用 Python 做一個 H5 遊戲機器人

lsvih發表於2018-03-15

用 Python 做一個 H5 遊戲機器人

**摘要:**我給遊戲 stabby.io 寫了一個機器人(bot),原始碼請參考: GitHub repo

幾周前,我在一個無聊的夜晚發現了一款遊戲:stabby.io。於是乎我的 IO 遊戲癮又犯了(曾經治好過)。在進入遊戲後,你會被送進一個小地圖中,場景裡有許多和你角色長得一樣的玩家,你可以殺死你身邊的任何一個人。你周圍的角色大多數都是電腦玩家,你需要設法弄清哪個才是人類玩家。我沉迷遊戲無法自拔,愉快地玩了幾個小時。

01-scrot

正當我放縱一夜時,Eric S. Raymond 先生提醒我 boredom and drudgery are evil(無聊和單調都是罪惡)……我還記得 LiveOverflow 的一位老師在視訊裡衝我叫喊 STOP WASTING YOUR TIME AND LEARN MORE HACKING!(多碼程式碼少睡覺)。因此,我打算把我的無聊與單調轉變成為一個有趣的程式設計專案,開始做一個為我玩 stabby 的 Python 機器人!

在開始前,先介紹一下 stabby 超酷的開發者:soulfoam,他在自己的 Twitch 頻道直播程式設計與遊戲開發。我得到了他的授權,允許我建立這個機器人並與大家分享。

我最開始的想法是用 autopy 捕獲螢幕,並根據影象分析傳送滑鼠的移動(作者在此悼念了曾經做過的 Runescape 機器人)。但很快我就放棄這種方式,因為這個遊戲有著更直接的互動方式 - WebSockets。由於 stabby 是一款多人實時 HTML5 遊戲,因此它使用了 WebSockets 在客戶端與伺服器之間建立了長連線,雙方都能隨時傳送資料。

01-websockets

所以我們只需要關注客戶端與伺服器間的 WebSocket 通訊就行了。如果可以理解從伺服器接收的訊息以及之後傳送給伺服器的訊息,那我們就能直接通過 WebSocket 通訊來玩遊戲。現在開始玩 stabby 遊戲,並開啟 Wireshark 檢視流量。

01-wireshark

**注意:**我對上面 stabby 的伺服器 IP 進行了打碼處理,避免它被攻擊。為了避免指令碼小子濫用這個機器人,我不會在 stabbybot 中提供這個 IP,你需要自行獲取。

接著說這美味的 WebSocket 資料包。在這兒看到了第一個表明我們正處於正確道路的標誌!我在開始遊戲時,將角色名設定為 chain,緊接著在發往伺服器的第二個 WebSocket 包的資料部分看到了 03chain。遊戲裡的其他人就這樣知道了我的名字!

通過對抓包進一步的分析,我確定了在建立連線時客戶端要傳送給服務端的東西。下面是我們需要在 Python 中重新復現的內容:

  • 連線至 stabby 的 WebSocket 伺服器
  • 傳送當前遊戲版本(000.0.4.3)
  • WebSocket Ping/Pong
  • 傳送我們的角色名
  • 監聽伺服器發來的訊息

我將使用 websocket-client 庫來讓 Python 連線 WebSocket 伺服器。下面編寫前文概述內容的程式碼:

# main.py

import websocket

# 建立一個 websocket 物件
ws = websocket.WebSocket()

# 連線到 stabby.io 伺服器
ws.connect('ws://%s:443' % server_ip, origin='http://stabby.io')

# 向伺服器傳送當前遊戲版本
ws.send('000.0.4.3')

# force a websocket ping/pong
ws.pong('')

# 傳送使用者名稱
ws.send('03%s' % 'stabbybot')

try:
    while True:
        # 監聽伺服器傳送的訊息
        print(ws.recv())
except KeyboardInterrupt:
    pass

ws.close()
複製程式碼

幸運的是,上面的程式沒有讓我們失望,收到了伺服器訊息!

030,day
15xx,60|stabbybot,0|
162,2,0
05+36551,186.7,131.0,walking,left|+58036,23.1,122.8,walking,right|_20986,55.2,71.7,idle,left|_47394,70.9,84.9,walking,right|_58354,10.4,16.2,walking,right|_81344,61.0,27.8,walking,left|+77108,107.5,8.9,walking,left|_96763,118.8,71.7,walking,left|_23992,104.4,24.1,walking,right|+30650,118.4,8.0,idle,left|+11693,186.7,35.5,walking,left|+34643,186.7,118.3,walking,left|+65406,83.9,33.3,idle,right|+24414,186.7,136.3,walking,left|+00863,75.2,35.3,walking,left|_57248,39.0,51.3,walking,right|_98132,165.2,10.0,walking,right|_45741,179.2,5.2,walking,right|+57840,186.7,45.3,walking,left|+70676,186.7,135.7,walking,left|+39478,90.8,63.3,walking,left|_51961,166.7,138.7,idle,right|+85034,148.4,7.7,idle,right|_72926,62.4,23.7,walking,left|_25474,9.6,58.0,idle,left|0,4.0,1.0,idle,left|_52426,61.0,128.4,walking,left|_00194,67.5,96.1,walking,left|+12906,170.7,33.7,walking,right|_67508,87.2,93.3,walking,left|+51085,140.3,34.2,idle,right|_67544,170.1,100.7,idle,right|_77761,158.5,127.6,idle,left|_25113,38.4,111.2,walking,left|
08100,20.5,227.68056,227.68056,0.0,0.0
18t,xx,250m or less
...
複製程式碼

以上是由伺服器傳給客戶端的訊息。我們可以在登入後得到關於遊戲中時間的資訊:030,day。接著會有一些資料不斷地產生: 05+36551,186.7,131.0,walking,left|+58036,23.1,122.8,walking,right|...,這些表達全域性狀況的資料看上去應該是:玩家 id、座標、狀態、臉對著的方向。現在可以試著除錯並對遊戲的通訊進行逆向工程,以理解客戶端、伺服器之間傳送的是什麼了。

例如,當在遊戲中殺人時會發生什麼?

01-kill

這次我使用了 Wireshark,特別設定了過濾器,僅抓取流向(ip.dst)伺服器的 WebSocket 流量。在殺死某人後,10 與玩家 id 被傳給伺服器。可能你還不太明白,我解釋一下:傳送給伺服器的一切東西都由兩位數字開頭,我將其稱為事件程式碼。總共有差不多 20 個不同的事件程式碼,我還沒完全弄清它們分別是做什麼的。不過,我可以找到一些比較重要的事件:

EVENTS = {
    '03': '登入',
    '05': '全域性狀況',
    '07': '移動',
    '09': '遊戲中的時間',
    '10': '殺',
    '13': '被殺',
    '14': '殺人資訊',
    '15': '狀態',
    '18': '目標'
}
複製程式碼

創造一個非常簡單的機器人

有了這些資訊,我們就能構建機器人啦!

.
├── main.py  - 機器人的入口檔案。在此檔案中會連線 stabby 的伺服器,
│              並定義主迴圈(main loop)。
├── comm.py  - 處理所有訊息的收發。
├── state.py - 跟蹤遊戲的當前狀態。
├── brain.py - 決定機器人要做什麼事。
└── log.py   - 提供機器人可能需要的日誌功能。
複製程式碼

main.py 中的主迴圈會做以下幾件事:

  • 接收伺服器訊息。
  • 將伺服器訊息傳給 comm.py 進行處理。
  • 處理過的資料會儲存在當前遊戲狀態(state.py)中。
  • 將當前遊戲狀態傳給 brain.py
  • 執行基於遊戲狀態做出的決策。

下面讓我們看看如何實現一個非常基本的會自己移動到上個玩家被殺的位置的機器人吧。當某人在遊戲中被殺害時,其餘的每個人都會受到一個類似 14+12906,120.2,64.8,seth 的廣播訊息。這個訊息中,14 是事件程式碼,後面是用逗號分隔的玩家 id、x 座標與 y 座標,最後是殺手的名稱。如果我們要走到這個位置區,要傳送事件程式碼 07,後面跟著用逗號分隔的 x 與 y 座標。

首先,我們建立一個跟蹤殺人資訊的遊戲狀態類:

# state.py

class GameState():
    """跟蹤 stabbybot 的當前遊戲狀態。"""

    def __init__(self):
        self.game_state = {
            'kill_info': {'uid': None, 'x': None, 'y': None, 'killer': None},
        }

    def kill_info(self, data):
        uid, x, y, killer = data.split(',')
        self.game_state['kill_info'] = {'uid': uid, 'x': x, 'y': y, 'killer': killer}
複製程式碼

接下來,我們建立通訊程式碼用以處理接收到的殺人資訊(然後將其傳給遊戲狀態類),以及將移動命令傳送出去:

# comm.py

def incoming(gs, raw_data):
    """處理收到的遊戲資料"""

    event_code = raw_data[:2]
    data = raw_data[2:]

    if event_code == '14':
        gs.kill_info(data)

class Outgoing(object):
    """處理要發出的遊戲資料。"""

    def move(self, x, y):
        x = x.split('.')[0]
        y = y.split('.')[0]
        self.ws.send('%s%s,%s' % ('07', x, y))
複製程式碼

下面為決策部分。程式將通過當前的遊戲狀態來進行決策,如果有人被殺了,它會將我們的角色移動到那個位置去:

# brain.py

class GenOne(object):
    """第一代 stabbybot。它現在還很蠢(笑"""

    def __init__(self, outgoing):
        self.outgoing = outgoing
        self.kill_info = {'uid': None, 'x': None, 'y': None, 'killer': None}

    def testA(self, game_state):
        """走到上個玩家被殺的地點去。"""
        if self.kill_info != game_state['kill_info']:
            self.kill_info = game_state['kill_info']

            if self.kill_info['killer']:
                print('New kill by %s! On the way to (%s, %s)!'
                    % (self.kill_info['killer'], self.kill_info['x'], self.kill_info['y']))
                self.outgoing.move(self.kill_info['x'], self.kill_info['y'])
複製程式碼

最後更新 main 檔案,它將連線伺服器,並執行上面概括的主迴圈:

# main.py

import websocket

import state
import comm
import brain

ws = websocket.WebSocket()
ws.connect('ws://%s:443' % server_ip, origin='http://stabby.io')
ws.send('000.0.4.3')
ws.pong('')
ws.send('03%s' % 'stabbybot')

# 將類例項化
gs = state.GameState()
outgoing = comm.Outgoing(ws)
bot = brain.GenOne(outgoing)

while True:
    # 接收伺服器訊息
    raw_data = ws.recv()

    # 處理收到的資料
    comm.incoming(gs, raw_data)

    # 進行決策
    bot.testA(gs.game_state)

ws.close()
複製程式碼

機器人執行時,將會如期執行。當有人死亡的時候,機器人會向那個死亡地點攻擊。雖然不夠刺激,但這是個不錯的開頭!現在,我們可以傳送與接收遊戲資料,並在遊戲中完成一些特定的任務。

創造一個體面的機器人

接下來為前面創造的簡單版機器人進行擴充,新增更多的功能。comm.pystate.py 檔案現在充滿了各種各樣的功能,詳情請檢視 stabbybot 的 GitHub repo

現在我們將做一個可以與普通人類玩家競爭的機器人。在 stabby 中最簡單的獲勝方式就是保持耐心,不斷走動,直到看見某人被殺,然後去殺掉那個殺人凶手。

因此,我們需要機器人做下面的事:

  • 隨機走動。
  • 檢查是否有人被殺(game_state['kill_info'])。
  • 如果有人被殺了,就檢查當前全域性狀況的資料(game_state['perception'])。
  • 確認是否某人是否離殺人地點夠近,以確定殺人凶手。
  • 為了分數和榮耀去殺了那個凶手!

開啟 brain.py 編寫一個 GenTwo 類(意為第二代)。第一步實現最簡單的部分,讓機器人隨機走動。

class GenTwo(object):
    """第二代 stabbybot。看著這個小傢伙到處走動吧!"""

    def __init__(self, outgoing):
        self.outgoing = outgoing
        self.walk_lock = False
        self.walk_count = 0
        self.max_step_count = 600

    def main(self, game_state):
        self.random_walk(game_state)

    def is_locked(self):
        # 檢查是否加鎖
        if (self.walk_lock): # 一個鎖
            return True
        return False

    def random_walk(self, game_state):
        # 檢查是否加鎖
        if not self.is_locked():
            # 得到隨機的 x、y 座標
            rand_x = random.randint(40, 400)
            rand_y = random.randint(40, 400)
            # 開始向隨機的 x、y 座標移動
            self.outgoing.move(str(rand_x), str(rand_y))
            # 上鎖
            self.walk_lock = True

        # 檢查移動是否完成
        if self.max_step_count < self.walk_count:
            # 解鎖
            self.walk_lock = False
            self.walk_count = 0

        # 增加走路計數器
        self.walk_count += 1
複製程式碼

上面做的是一件很重要的事情:建立了一個鎖機制。由於機器人要進行許多的操作,我不希望看到機器人變得困惑,在隨機走動的途中去殺人。當我們的角色開始隨機行走時,會等待 600 個“步驟”(即收到的事件),然後才會再次開始隨機行走。600 是通過計算得出的,從地圖一角走到另一角的最大步數。

接下來為我們的小狗準備肉。檢查最近的殺人事件,然後與當前的全域性狀況資料進行比較。

import collections

class GenTwo(object):

    def __init__(self, outgoing):
        self.outgoing = outgoing

        # 跟蹤最近發生的殺人事件
        self.kill_info = {'uid': None, 'x': None, 'y': None, 'killer': None}

    def main(self, game_state):
        # 優先執行
        self.go_for_kill(game_state)
        self.random_walk(game_state)

    def go_for_kill(self, game_state):
        # 檢查是否有新的殺人事件發生
        if self.kill_info != game_state['kill_info']:
            self.kill_info = game_state['kill_info']

            # 殺人事件發生的 x、y 座標
            kill_x = float(game_state['kill_info']['x'])
            kill_y = float(game_state['kill_info']['y'])

            # 用周圍角色的 id、x 座標、y 座標建立一個 OrderedDict
            player_coords = collections.OrderedDict()
            for i in game_state['perception']:
                player_x = float(i['x'])
                player_y = float(i['y'])
                player_uid = i['uid']
                player_coords[player_uid] = (player_x, player_y)
複製程式碼

現在在 go_for_kill 中,有一個 kill_xkill_y 座標,表明了最近一次殺人時間的發生地點。另外還有一個由玩家 ID、玩家 x、y 座標組成的有序字典。當遊戲中有人被殺時,有序字典將會如下所示:OrderedDict([('+56523', (315.8, 197.5)), ('+93735', (497.4, 130.7)), ...])。下面找出離殺人地點最近的玩家就行了。如果有玩家離殺人座標足夠近,機器人將把他們找出來!

所以現在任務很清晰了,我們需要在一組座標中找到最接近的座標。這個方法被稱為最鄰近查詢,我們可以用 k-d trees 實現。我使用了 SciPy 這個超帥的 Python 庫,用它的 scipy.spatial.KDTree.query 方法實現了這個功能。

from scipy import spatial

    # ...

    def go_for_kill(self, game_state):
        if self.kill_info != game_state['kill_info']:
            self.kill_info = game_state['kill_info']
            self.kill_lock = True

            kill_x = float(game_state['kill_info']['x'])
            kill_y = float(game_state['kill_info']['y'])

            player_coords = collections.OrderedDict()
            for i in game_state['perception']:
                player_x = float(i['x'])
                player_y = float(i['y'])
                player_uid = i['uid']
                player_coords[player_uid] = (player_x, player_y)

            # 找到距擊殺座標最近的玩家
            tree = spatial.KDTree(list(player_coords.values()))
            distance, index = tree.query([(kill_x, kill_y)])

            # 當距離某玩家足夠近時進行擊殺
            if distance < 10:
                kill_uid = list(player_coords.keys())[int(index)]
                self.outgoing.kill(kill_uid)
複製程式碼

如果你想看完整的策略,這兒是 stabbybot 中 brain.py 的完整程式碼.

現在讓我們執行機器人,看看它表現如何:

$ python stabbybot/main.py -s <server_ip> -u stabbybot

[+] MOVE: (228, 56)
[+] STAT: [('sam5', '2146'), ('jjkiller', '397'), ('QWERTY', '393'), ('N-chan', '240'), ('stabbybot', '0')]
[+] KILL: jjkiller (62.798412, 16.391998)
[+] STAT: [('sam5', '2146'), ('jjkiller', '407'), ('QWERTY', '393'), ('N-chan', '240'), ('stabbybot', '0')]
[+] KILL: N-chan (322.9627, 235.68994)
[+] STAT: [('sam5', '2146'), ('jjkiller', '407'), ('QWERTY', '393'), ('N-chan', '250'), ('stabbybot', '0')]
[+] KILL: jjkiller (79.39742, 11.73037)
[+] STAT: [('sam5', '2146'), ('jjkiller', '417'), ('QWERTY', '393'), ('N-chan', '250'), ('stabbybot', '0')]
[+] KILL: QWERTY (241.24649, 253.66882)
[+] STAT: [('sam5', '2146'), ('QWERTY', '505'), ('jjkiller', '417'), ('stabbybot', '0')]
[+] KILL: sam5 (91.02979, 41.00656)
[+] STAT: [('sam5', '2156'), ('QWERTY', '505'), ('jjkiller', '417'), ('stabbybot', '0')]
[+] MOVE: (287, 236)
[+] KILL: jjkiller (100.214806, 36.986927)
[+] STAT: [('jjkiller', '1006'), ('QWERTY', '505'), ('stabbybot', '0')]

... snip (10 minutes later)

[+] ASSA: _95181
[+] STAT: [('Mr.Stabb', '778'), ('QWERTY', '687'), ('stabbybot', '565'), ('fire', '408'), ('ff', '0'), ('Guest72571', '0'), ('shako', '0')]
[+] KILL: stabbybot (159.09984, 218.41016)
[+] ASSA: 0
[+] STAT: [('Mr.Stabb', '778'), ('stabbybot', '717'), ('QWERTY', '687'), ('ff', '0'), ('Guest72571', '0'), ('shako', '0')]
[+] STAT: [('Mr.Stabb', '778'), ('stabbybot', '717'), ('QWERTY', '687'), ('fire', '306'), ('ff', '0'), ('Guest72571', '0'), ('shako', '0')]
[+] STAT: [('Mr.Stabb', '778'), ('stabbybot', '717'), ('QWERTY', '687'), ('fire', '306'), ('z', '37'), ('ff', '0'), ('Guest72571', '0'), ('shako', '0')]
[+] MOVE: (245, 287)
[+] KILL: fire (194.04352, 68.50006)
[+] STAT: [('Mr.Stabb', '778'), ('stabbybot', '717'), ('QWERTY', '687'), ('fire', '316'), ('z', '37'), ('ff', '0'), ('Guest72571', '0'), ('shako', '0')]
[+] TOD: night
[+] KILL: Guest72571 (212.10252, 150.89288)
[+] STAT: [('Mr.Stabb', '778'), ('stabbybot', '717'), ('QWERTY', '687'), ('fire', '316'), ('z', '37'), ('Guest72571', '10'), ('ff', '0'), ('shako', '0')]
[-] You have been killed.
close status: 12596
複製程式碼

結果還不錯。機器人大約存活了 10 分鐘,已經很了不起了。它得了 717 分,在被殺掉的時候排行第二!

以上就是本文的全部內容!如果你想找個有趣的程式設計專案,可以去做做 HTML5 遊戲的機器人,你將獲得無窮的樂趣,並能很好地練習網路分析、逆向工程、程式設計、演算法、AI 等各種能力。希望能看到你的創作!


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章