【GUIオートメーション】Pythonにゲームさせてみた【pyautogui】

Python教本の名著「退屈なことはPythonにやらせよう」の最終章「GUIオートメーション」をやってみたので、得た知見をまとめておきます。

練習課題としてFlashゲーム「Sushi go round」をプレイする関数を作ったので、ざっくりとしたアルゴリズムの仕組みとコードを解説したいと思います。

前置き:GUIオートメーションとは

GUIオートメーションとは、我々人間がキーボードとマウスでGUIを操作するのを自動化・代行するものです。

Pythonモジュールの一つであるpyautoguiをimportすると、マウスカーソルを指定した座標に動かしてクリックしたり、キーボードを押下したりする関数が使用できるようになります。

人間がコンピュータを使用するとき、ほぼすべての操作をマウスとキーボードから行うので、極論、pyautoguiですべての操作が自動化できるということになります。(マイクからの入力やUSBメモリを差し込むなどは除く)

またpyautoguiにはスクリーンショットを撮って、クリックすべきボタンの座標を割り出す関数が標準搭載されています。

これが目となって、画面の状況をプログラムが把握することができます。

画像処理に特化した別のライブラリや深層学習によるパターン認識、強化学習による意思決定と組み合わせることで、自由度の高い操作が実現できるでしょう。

pyautoguiの基礎知識

pyautoguiは大きく分けて3つの部類の関数群を提供します。

  • キーボード操作系(press, write, keyDown,keyUpなど)
  • マウス操作系(moveTo, click, doubleClick, scrollなど)
  • 画像マッチング系(locateOnScreen, locateCenterOnScreenなど)

これらの関数はJupyter上で実行するだけで目的の操作を実行するため、スクリプトを書くように操作を記述できます。

基本戦略は画面のスクリーンショットを取り、画面の状況に応じて条件分岐してキー入力やクリック操作を行うというアルゴリズムを作ることになります。

pyautoguiの関数についての詳しい情報は、私が勉強しながら作ったクイックリファレンスがありますので、よろしければこちらからダウンロードしてください。ipynbの形式で書かれているのでJupyter Lab等で開いてください。

公式サイトの説明も簡潔でわかりやすいのでおすすめです。

モジュール自体そこまで多くの機能を搭載しているわけではないので、ざっと流し読みするだけで関数の機能は理解できると思われます。

Sushi go roundを解く

Sushi Go Roundは、客の注文に応じて正しい素材を正しい数だけクリックし、最後に巻いて仕上げるというシンプルなゲームです。

素材が足りなくなったら、右の電話から自分で注文しなければいけません。制限時間内に一定の金額を稼ぎかつ客からの店の評価が一定以上だとクリアになります。

注文した品がなかなか出てこないと客は苛立ち始め最終的には店を出ていってしまいます。こうなると店の評価が下がるというわけです。

ここから先の内容を読む前に一度プレイしていただくと、内容がスムーズに理解できると思われます。(少々中毒性がありますが、忘れずに戻ってきてくださいね)

サンプルプログラムはこちらからダウンロードできます。

pythonにさせなければならない主な作業は以下の3つです。

  • 客の吹き出しから注文内容を読み取る
  • 正しく寿司を巻く(握る)
  • 材料の残量を把握し少なくなったら注文する

初めに以下のコードでpyautoguiをimportし、頻繁に使う機能を関数にしておきます。

import pyautogui as ag
import time

#クリックしたいボタンの画像ファイル名とスクリーンショットを渡す
def clickCenter(button,sc):
    button_box=ag.locate(button,sc,grayscale=True)
    if button_box!=None:
        ag.click(ag.center(button_box))
    else:
        print("button not found")
cc=clickCenter

#上の関数は与えられたボタンが見つからないと諦めるが、
#ボタンが現れるまでしばらく待ってほしいときに
def responsibleClickCenter(button,limit=60):
    button_box=None
    ti=time.time()
    while button_box==None and time.time()-ti<=10:
        button_box=ag.locateOnScreen(button,grayscale=True)
    if button_box!=None:
        ag.click(ag.center(button_box))
    else:
        print("button not found, time out")
rcc=responsibleClickCenter

#pyautoguiにあっても良さそうだが無い関数なので作っておく
#pyautoguiにはlocateCenterOnScreen()しか無い
def locateCenter(button,sc):
    return ag.center(ag.locate(button,sc,grayscale=True))

createSushi()

まずは作る寿司の名前を文字列で受け取り、その寿司を作る関数から見ていきましょう。

def createSushi(neta):
    global cordinate
    global material

    #何らかの手違いで巻きすに具材が残っていることを想定して一度クリックする
    ag.click(cordinate["mat"])
    rcc("mat.png")

    if neta==None:
        return 0
    elif neta=="onigiri":
        #材料の座標をクリックして使った分だけ材料を減らす
        ag.click(cordinate["rice"])
        ag.click(cordinate["rice"])
        ag.click(cordinate["nori"])
        materials["rice"]-=2
        materials["nori"]-=1
    elif neta=="gunkan":
        #同様
    elif neta=="roll":
        #同様
    ag.click(cordinate["mat"])

#cordinateの中身はこのように定義されている
cordinate["mat"]=locateCenter("mat.png",sc)
cordinate["rice"]=locateCenter("left_rice.png",sc)
cordinate["nori"]=locateCenter("left_nori.png",sc)
cordinate["ikra"]=locateCenter("left_ikra.png",sc)
cordinate["phone"]=locateCenter("phone.png",sc)

初めにグローバル変数を使うことを宣言しています。cordinateは辞書型でボタンの名前をキーとして与えるとその座標を返します。いちいち画面から探していると処理が重くなる場合があるので、プログラムを起動した最初に座標を検出してこれをずっと使うようにします。

マッチングに使う画像はこんな感じでipynbファイルと同じフォルダに保存されているものとします。

left_rice.png

今後プログラム中に出てくるpngファイルはすべて同様です。

もう一つのグローバル変数materialsは材料の数を記憶しておくためのものです。ちなみに、寿司のレシピはゲーム内のレシピブックで確認できます。

寿司を作る際はメインループからこの関数を呼び出すものとします。

phone()

次に材料が足りなくなったときの電話注文のルーチンを作っていきます。

def phone(material,sc):
    global cordinate
    ag.click(cordinate["phone"])
    if material=="rice":
        rcc("phone_rice.png",sc)
        rcc("phone_rice2.png",sc)
        rcc("phone_normal.png",sc)
    if material=="nori":
        rcc("phone_topping.png",sc)
        rcc("phone_nori.png",sc)
        rcc("phone_normal.png",sc)
    if material=="ikra":
        rcc("phone_topping.png",sc)
        rcc("phone_ikra.png",sc)
        rcc("phone_normal.png",sc)

この関数は注文したい材料の名前とスクリーンショットを受け取ります。

createSushi()のように座標指定ではなく、毎回画像を検出させているのは所持金が足りないとボタンの色が変わって注文できないのにも関わらず、クリックだけして注文が完了したことになるのを防ぐためです。

注文できる状態の色のボタンが出るまでしばらく待たせます。この間に客の誰かが食べ終わって所持金が増えればOKです。

pyautoguiのマッチングは完全一致検索で、1ピクセルでも異なると領域を認識できないので当然上の2つの画像は全く別もの扱いされます。

current_order_check()

お次は、客が出す吹き出しを認識して注文をリスト形式で格納するモジュールです。

def current_order_check(sc):
    global wall_x
    current_order=[None]*6
    for neta in ["onigiri","gunkan","roll"]:
        box=ag.locateAll("order_"+neta+".png",sc,grayscale=True)
        if box!=None:
            for i in box:
                x=ag.center(i)[0]
                for j in range(5):
                    if wall_x[j]<x<wall_x[j+1]:
                        current_order[j]=neta
    return current_order

事前に軍艦やおにぎりの画像をorder_onigiri.pngなどのファイル名で用意しておき、その画像をスクリーンショットからlocateAllで検出します。

次に、マッチングした領域の中心のx座標からどの客の注文かを特定します。このときに使用しているwall_xは客と客の境界を事前にお盆の左下の位置を使ってリスト形式で用意したものになります。

dish_clean()

客が食べ終わった後はお皿を片付けないと次の客を呼ぶことはできません。空の皿を検出してこれをクリックするモジュールが必要です。

def dish_clean(sc):
    global wall_x
    dish_box=ag.locateAll("dish.png",sc,grayscale=True)
    if dish_box != None:
        for i in dish_box:
            ag.click(ag.center(i))
            for j in range(5):
                if wall_x[j]<i[0]<wall_x[j+1]:
                    seat_status[j]="wait"

ここでも、注文を検知するときに使ったのと同じ方法で、どのテーブルの皿を片付けたか判定しています。

実は注文内容の他にテーブルのステータス状態を記録しておくseat_statusというリストが用意してあり、そのテーブルの客の注文を処理したかどうかを把握させています。

こうしないと、作って回転台に並べても客が取るまで同じ寿司を作り続けて、材料を無駄遣いしてしまうからです。

皿を片付けたらそのテーブルは”wait”にしておき、次に客が注文を出したらそれを作るという仕組みです。この次に説明するメインループで、寿司を提供したら”done”という状態にしています。

main()

最後に、これまで作成したモジュールを呼び出すメインループについて説明します。

def main():
    global wall_x
    global cordinate
    global materials
    global seat_status
    time.sleep(0.5)
    sc=ag.screenshot()
    obon_box=ag.locateAll("obon.png",sc,grayscale=True)
    wall_x=[i.left for i in obon_box]
    wall_x+=[10**5]
    cordinate["mat"]=locateCenter("mat.png",sc)
    cordinate["rice"]=locateCenter("left_rice.png",sc)
    cordinate["nori"]=locateCenter("left_nori.png",sc)
    cordinate["ikra"]=locateCenter("left_ikra.png",sc)
    cordinate["phone"]=locateCenter("phone.png",sc)
    
    while True:
        #スクリーンショットの取得
        sc=ag.screenshot()
        dish_clean(sc)
        
        #注文確認
        order_list=current_order_check(sc)

        #寿司を作る
        for i in range(6):
            if seat_status[i]=="wait" and order_list[i]!=None:
                createSushi(order_list[i])
                seat_status[i]="done"
                for i in range(3):
                    sc=ag.screenshot()
                    dish_clean(sc)
                
        #足りない材料の発注
        for mate, num in materials.items():
            if num<4:
                phone(mate,sc)
                materials[mate]+=10
                if materials[mate]<10:
                    materials[mate]=10
        
        #クリア/ゲームオーバー後の処理
        if ag.locate("btn_continue.png",sc)!=None:
            cc("btn_continue.png",sc)
            time.sleep(1)
            sc=ag.screenshot()
            if ag.locate("btn_continue.png",sc)!=None:
                cc("btn_continue.png",sc)
                seat_status=["wait"]*6
                materials={"rice":10,"nori":10,"ikra":10}    
            if ag.locate("btn_continue2.png",sc)!=None:
                cc("btn_continue2.png",sc)
                rcc("btn_yes.png")
                seat_status=["wait"]*6
                materials={"rice":10,"nori":10,"ikra":10}   

無限ループに入る前に、その後様々な関数で使用するグローバル変数の中身を定義してます。

ループの中はこれまで紹介した各種モジュールを適切な条件制御の下呼び出しているだけなので、さほど難しくありません。

例えば、寿司を作る関数は、seat_statusが”wait”かつ、注文がNoneでは無い(客の吹き出しが出ている状態)のときだけ呼び出されるようになっています。

ゲームをクリアするか失敗するとコンテニューボタンが現れるので、ループごとに画面にこのボタンが有るか無いかを確認してします。

クリアの場合と失敗の場合で次に出てくるボタンが異なるので、もう一度画面上でボタンの検出を行っています。

どちらの場合も必要な変数を初期値にリセットしてループに戻るので、放っておけばクリアするまでループし続けます。

game_start()

ゲーム開始後のこの画面の状態でプログラムを起動することにします。

playボタン、continueボタンをクリックするところのプログラムを作っておきます。

def game_start():
    rcc("btn_play.png")
    rcc("btn_continue.png")
    rcc("btn_skip.png")
    rcc("btn_continue.png")

テストプレイ

wall_x=[]
cordinate={}
materials={"rice":10,"nori":10,"ikra":10}
seat_status=["wait"]*6

game_start()
main()

ゲームを起動した状態で上のコードを実行してみました。

結論から言うとなかなかクリアで来ませんでした。

どうも、右側の客の注文で作った寿司が上流の客に取られてしまい、下流の人が一向にありつけないという自体が発生しているようです。

↑左から3番目の客に軍艦を取られてお怒りの少女。この後一番右の少年もおにぎりを取られた。

というわけで、コードを次のように改善してみました。

     #スクリーンショットの取得
        sc=ag.screenshot()
        dish_clean(sc)
        
        #注文確認
        order_list=current_order_check(sc)

        
#追加分ここから
        seat_status[4]="wait"
        seat_status[5]="wait"
#ここまで


        #寿司を作る
        for i in range(6):
            if seat_status[i]=="wait" and order_list[i]!=None:
                createSushi(order_list[i])
                seat_status[i]="done"
                for i in range(3):
                    sc=ag.screenshot()
                    dish_clean(sc)

メインループ内の注文を確認した後に、seat_statusをいじっているのにお気づきでしょうか。一番右の二人については吹き出しが出ている限り、常に注文を処理するように改変しました。

これで上流の客に取られても、この二人の分については寿司が作られ続けるという仕組みです。

これでもう一度テストプレイをすると、無事クリアする事ができました。

それでも立て続けに同じ注文の客が上流に来続けると寿司を食べられなくで帰ってしまいますが、店の評判はクリアの水準を維持できます。

次のステージでは寿司の種類が一つ増え、クリアに必要な売上も上がります。

また色の異なる皿も出てくるため、各モジュールに追記が必要になりますが、原理的には根幹のメインループの構造は変えなくても大丈夫だと考えています。

今回は第一ステージをクリアしたということでこれで目標達成としますが、まだまだ改良できそうなところはたくさんあるので、より良いアルゴリズムができたらまた記事にしたいと思います。

今回記事で紹介したコードはこちらからダウンロードできます。

参考文献