用 Python 作一個 H5 遊戲機器人

用 Python 作一個 H5 遊戲機器人

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

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

01-scrot

正當我放縱一晚上時,Eric S. Raymond 先生提醒我 boredom and drudgery are evil(無聊和單調都是罪惡)……我還記得 LiveOverflow 的一位老師在視頻裏衝我叫喊 STOP WASTING YOUR TIME AND LEARN MORE HACKING!(多碼代碼少睡覺)。所以,我打算把個人無聊與單調轉變成爲一個有趣的編程項目,開始作一個爲我玩 stabby 的 Python 機器人!html5

在開始前,先介紹一下 stabby 超酷的開發者:soulfoam,他在本身的 Twitch 頻道直播編程與遊戲開發。我獲得了他的受權,容許我建立這個機器人並與你們分享。python

我最開始的想法是用 autopy 捕獲屏幕,並根據圖像分析發送鼠標的移動(做者在此悼念了曾經作過的 Runescape 機器人)。但很快我就放棄這種方式,由於這個遊戲有着更直接的交互方式 - WebSockets。因爲 stabby 是一款多人實時 HTML5 遊戲,所以它使用了 WebSockets 在客戶端與服務器之間創建了長鏈接,雙方都能隨時發送數據。android

01-websockets

因此咱們只須要關注客戶端與服務器間的 WebSocket 通信就好了。若是能夠理解從服務器接收的消息以及以後發送給服務器的消息,那咱們就能直接經過 WebSocket 通信來玩遊戲。如今開始玩 stabby 遊戲,並打開 Wireshark 查看流量。ios

01-wireshark

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

接着說這美味的 WebSocket 數據包。在這兒看到了第一個代表咱們正處於正確道路的標誌!我在開始遊戲時,將角色名設定爲 chain,緊接着在發往服務器的第二個 WebSocket 包的數據部分看到了 03chain。遊戲裏的其餘人就這樣知道了個人名字!github

經過對抓包進一步的分析,我肯定了在創建鏈接時客戶端要發送給服務端的東西。下面是咱們須要在 Python 中從新復現的內容:web

  • 鏈接至 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前端後端區塊鏈產品設計人工智能等領域,想要查看更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章
相關標籤/搜索