Swell Foopを自動でプレイする

ここ最近はコンピュータービジョンという領域に足を踏み入れようとしていましたが、もともと私がやりたかったことからだんだん遠ざかっていました。そこで、今回は書籍もUdemyも一旦忘れてもう一度ゲームをプレイする方向に集中してみました。

# テンプレートマッチングでマスを検出する

OpenCVにはテンプレートマッチングという手法があり、これはあらかじめ用意した画像ファイルをもとに、画面上に映っている箇所を検出してくれるというものです。

img = ImageGrab.grab(bbox)
imp = np.array(img)
img_rgb = cv2.cvtColor(imp, cv2.COLOR_BGR2RGB)
img_gray = cv2.cvtColor(imp, cv2.COLOR_RGB2GRAY)
template_y = cv2.imread("y.png", 0)
w, h = template_y.shape[::-1]
res = cv2.matchTemplate(img_gray, template_y, cv2.TM_CCOEFF_NORMED)
threshold = 0.9  # スレッショルド
loc = np.where(res >= threshold)
for pt in zip(*loc[::-1]):  # pt == (x, y)
    cv2.rectangle(img_rgb, pt, (pt[0] + w, pt[1] + h), (0, 0, 255), 2)

ここに使われているcv2.matchTemplateという関数を使うことで、複数の値が返ってくるので、np.where(res >= threshold)で指定した閾値で画像の座標を取得することができました。threasholdの値は画像によって異なっているので、黄色いマスは0.9で問題ありませんでしたが、青色と緑色のマスは0.97などに指定する必要がありました。

Pythonの文法ですが、zip(*loc[::-1])という記述があります。最初このコードをそのまま検索するとフリーザーバッグのZiplocがヒットしたりしましたが、一見すると難解な記法に見えます。

最初にzipは次のように動きます:

list(zip([1,2,3], [4,5,6]))
[(1, 4), (2, 5), (3, 6)]

加えて、*locという記述はloc = [[1,2,3], [4,5,6]]このような二次元配列を、zip(loc[0], loc[1]) == zip(*loc)に変形することができます。 また、loc[::-1]となっている箇所は-1でリストを反転するので、zip(loc[1], loc[0])のように逆順に変形しています。

# [[y1, y2, y3, ...], [x1, x2, x3, ...]]
loc = np.where(res >= threshold)

# [[x1, y1], [x2, y2], [x3, y3], ...]
for pt in zip(*loc[::-1]):
    pt  # == (x, y)

以上のことを踏まえるとmatchTemplateのデータを上記のようなコードで理解しやすい座標の形式で返してくれるようです。すると以下のような画像が出力できるようになりました。

黄色マスを検出したところ

# 取得した座標を二次元配列に変換する

ここからはOpenCVを使わないので地味な作業ですが、取得したそれぞれのマスをpxから二次元配列の座標に計算して表示してみました。次のキャプチャから二次元配列に変換します。また、最初に欲張りていたので小さいサイズに変更しました。

サイズをSmallに変更した
B G B Y Y Y
Y G B B B G
Y Y B Y B B
G G G G B Y
Y G Y B G B

コード例は省略しますが、こんな形で取得できるようになったので、次にどこをクリックするか考えます。このゲームの戦略は鮫亀のように最終的にすべての色を消すことよりも、いかに高いスコアを取るかが重要だったりします。高いスコアを取るには当然消せるだけ多くのマスを消すことも重要なのですが、何度かプレイしているうちにいかに同時に多くのマスを消せるかが重要なことに気が付きました。

この手のパズルゲームには先読みの技術も必要だったりするのですが、ひとまずは最も多く消せるマスを選択することにしました。上の例では青色のマスが8個繋がっているので、コンピューターにこのマスを選択させればよいです。

0 1 12 2 1 0
2 0  9 6 4 0
1 0  0 0 2 0
5 3  1 0 0 0
0 0  0 0 0 0

単純な重みづけの計算を再帰関数で実装してみました。2マス以上隣接している場合に+1していき、接続先が自分の色と異なる場合は無視します。左上を起点に開始するので最も高い値も左上に表示されています(ただし12が出ているのはバグっぽいですが)。これで、一段目の左から3つめのマスをクリックさせればゲームを進めることは可能です。

# マウスのクリックを実装する

私が普段使っているのはLinuxなので、WindowsではなくX Window Systemというプロトコルを利用しています。OpenCVが人間の目(入力)にあたるならば、Xでマウスを動かすのはさながら人間の手(出力)といえるでしょう。ここではxdotoolというコマンドを利用します。

sudo apt install xdotool

こういったプログラムをインストールすると、マウスが遠隔で操作できるので若干恐ろしい気もしないでもありませんが、Python側で使う分には特に何も難しい操作は必要ありません。

import os
import time

def click(x, y, secs=0.1):
    os.system("xdotool mousemove {} {}".format(x, y))
    time.sleep(secs)
    os.system("xdotool click 1")
    time.sleep(1)

Pythonのプログラミングに詳しい人からすると、なかなか意識低い書き方で苛立ちを覚えるかもしれません。単にマウスを動かすのにわざわざ返り値やWebのようにインジェクションを心配する必要もないでしょう。使い方は簡単で、指定した場所にマウスを動かしてクリックボタンを押すだけです。

あとはwhileループを使ってマスが2個以上隣接している間はクリックを繰り返します。重みづけの処理がうまくいっただけでも感動しましたが、こうして一通りゲームをプレイできるようになると嬉しいですね。もちろん人間がプレイするよりも下手なので、改良の余地は十分にあります。このままこのプログラムを改良してもいいのですが、私のプログラミングの能力的にも数手先を予想して最善手を選択するアルゴリズムがうまく実装できるかはまだまだ怪しいです。それなら他のゲームを似たような形でプレイできるようにするだけでも面白いのかなぁと思ってみたり。いずれにせよ、ずっとやりたかったことの第一歩が実現できてよかったです。

# 参考にしたページ

Difference between zip(list) and zip(*list) [duplicate]

Is there a terminal command that will click the mouse?