はじめに
ABEJAでデータサイエンティストをしている清田です。今回は強化学習で何かゲームの学習をやってみたいと考え、その題材としてスイカゲームを扱えるようにしました。「強化学習で攻略したい」と銘打っているのですが、この記事で扱うのはその準備までです。 スイカゲームは Nintendo Switch (以下 Switch)用ゲームとして発売された落ち物パズルゲームです。箱の中に果物を落としていき、同じ種類の果物同士を接触させると合わさって一段階上の果物に変化します。より上位の果物を作るほど高い点数が得られます。箱から果物が飛び出してしまうとゲームオーバーです。2048 パズル + 落ち物パズルといった雰囲気のゲームです。ゲームとしてはシンプルながら、スイカの作成や高得点を目指す際には目先のスコア獲得だけではなく長期的に盤面を整える必要があるため強化学習向けの題材と言えます(言えると思います)。
このゲームは現在(2024年8月5日)は Nintendo Switch 版しかないため、PC 上で動かしてプログラムから直接操作を行うことができません。そこで、Nintendo Switch自動化 【2021年9月最新】 #C++ - Qiitaなどで使われている Arduino を用いて Switch への入力を行いつつ、キャプチャーボードで映像出力を PC に出力することで Switch 実機を用いて学習ができると考えたので試しました。
ハードウェアの準備
Switch と PC を双方向のやり取りさせるために必要なものは 2 つです。
- PC からの出力を Switch に入力する仕組み
- Switch からの映像出力を PC が受け取る仕組み
今回は Aruduino で前者の仕組みを、キャプチャーボードで後者の仕組みを実現しました。
Arduino の準備
Switch に Aruduino をNintendo Switch Pro コントローラー(プロコン)として認識させることで PC の出力を Switch に入力できるようにしました。Arduino を用いて Nintendo Switch を操作する方法については前述の Nintento Switch 自動化など既に多くの記事があるため、それらを参考にしました。
- PC からの USB 出力をシリアル出力に変換するやつ(KeeYees FTDI FT232RL USB-TTLシリアル変換アダプターモジュール 5V/3.3V切り替え)
- シリアル出力を Switch への出力に変換するやつ(テルモ(Terumo) 開発ボード Arduino Leonardo ソケット・ヘッダ付)
Arduino のボードを Switch にプロコンとして認識させる方法は以下の記事の内容ほぼそのまま真似しました。
Arduino を PC と Switch の両方に接続した状態
Switch -> PC
Switch の映像出力をキャプチャーボード(AverMedia 2Plus)を用いてPCに出力し、OBS Studio の仮想カメラで取り込みました。画像のようにキャプチャーボードからの入力を表示して仮想カメラを ON にすることで、OpenCV の VideoCapture が利用可能なカメラとして認識されます。
VideoCapture 側でもちゃんと画像を取得できています。
環境の実装
(S. Sutton 著の強化学習より引用)
強化学習における環境はエージェントからの入力(行動 )を受け取り、状態 を1ステップ進め、次の状態 と報酬 をエージェントにフィードバックする機能を持ちます。また、エピソードタスクにおいてはエピソードの終了判定を行い、エピソード終了時には適切に次のエピソードを開始できる機能も必要です。 スイカゲームの場合次のようなメソッドが必要となります。
- step メソッド
- reset メソッド
これらのメソッドの実装に際して、PCからボタン入力を行うためのコードとして SwitchControlLibrary を参考にしました。
作成したコードは以下のリポジトリに置いています。
stepメソッド
step メソッドには以下の機能が必要です。
- 行動 を受け取って対応するボタン入力情報を Switch に入力し、入力後の次の状態 に遷移させる
- 前の状態 と次の状態 から報酬 を計算する
- がゲームオーバー状態であるかどうかを判別する
状態の遷移
スイカゲームではプレイヤーは3種類の操作ができます。
- 果物を落とす
- 左に移動する
- 右に移動する
これらを以下のように実装しました。A ボタンを押した場合のみ 1 秒間待機するようにしています。スイカゲームではAボタンを押した後 A ボタンの再入力を受け付けない時間があるため、エージェントが A ボタンを連打しているが実際に果物を落とす操作は 1 回しか反映されていないといった事態を防ぐ意図で入れています。
if action == 0: # Aを押す self.key.input([Button.A]) time.sleep(0.1) self.key.inputEnd([Button.A]) time.sleep(1) elif action == 1: # 左を押す self.key.input([Hat.LEFT]) time.sleep(0.1) self.key.inputEnd([Hat.LEFT]) elif action == 2: # 右を押す self.key.input([Hat.RIGHT]) time.sleep(0.1) self.key.inputEnd([Hat.RIGHT]) else: raise ValueError('action must be 0, 1 or 2')
報酬 を計算する
状態の遷移から報酬を計算して返す関数が必要となります。スイカゲームにおけるゲーム目標は以下のように色々考えられますが、今回は一般的な目線で見て上手いプレイを目指したいので、高いスコアを目標とするのが良いでしょう。
- 高スコア
- スイカ作ること
- なるべく短時間でスイカを作ること
- 低スコア
プレイ画面からスコアを読み取れるようにしたかったので EasyOCR を使いました。
赤枠の領域の文字を読み取って整数に変換しています。
def get_score(self): # 現在のスコアを取得するメソッド frame = self.get_frame() score_image = frame[SCORE_YMIN:SCORE_YMAX, SCORE_XMIN:SCORE_XMAX, :] text = self.reader.readtext(score_image) try: score = int(text[0][1]) except: score = -1 return score
ちゃんと読み取ることができています。
報酬は前ステップと現在のステップとの間のスコアの変化量としました。ただし、EasyOCR で読み取る際、稀に読み取りのミスで実際の値より一桁大きな値が出ることがあったため、そのようなケースにおいては報酬を0とするようにしています。
# 点数の取得 score = self.get_score() if score >= 0: reward = score - self.current_score if 0 < reward < 500: # スイカゲームの仕様上1秒間に500点以上増えることは通常ない self.current_score = score else: reward = 0 else: reward = 0
終了判定
ゲームオーバー状態の判定にはプレイ画面右下のシンカの輪を利用しました。シンカの輪の部分の画像を使って OpenCV の matchTemplate 関数でテンプレートマッチングを行い、マッチングスコアの最大値が一定以下となったらゲームプレイ状態ではない(=ゲームオーバー状態)という判定を行うようにしました。
frame = self.get_frame() # 通常のプレイ画面かどうかの判定 res = cv2.matchTemplate( frame[RING_EVOLUTION_YMIN:RING_EVOLUTION_YMAX, RING_EVOLUTION_XMIN:RING_EVOLUTION_XMAX, :], self.ring_evolution_image, cv2.TM_CCOEFF ) min_val, max_val, min_loc, max_loc = cv2.minMaxLoc(res) if max_val > RING_EVOLUTION_THRESHOLD: return "Playing"
reset メソッド
reset メソッドには以下の機能が必要です。
- ゲームオーバー画面でリトライボタンを押して次のゲームを開始する
- 現在のスコアを 0 に初期化し、初期状態のプレイ画面を取得する
これらの機能のため、ゲームオーバー状態の時に自動的にリトライを行う処理を作成しました。
ゲームのリトライ
スイカゲームではゲームオーバー時に獲得していたスコアに応じて異なる画面に遷移します。ゲームオーバー後、リトライしてゲームの移行するための手順は次のようになります。
スコアがランキング入りしている場合
↓
Aボタンを押す
↓
↓
左ボタンを押し、その後Aボタンを押して数秒待つ
スコアがランキング入りしていない場合
↓
左ボタンを押し、その後Aボタンを押して数秒待つ
ゲームオーバー時に画像の赤枠の領域を使って終了判定と同様のテンプレートマッチングを行ってどのゲームオーバー状態にあるかを判別し、その後リトライに必要なボタン入力を行うことで新しいゲームを開始できるようにしました。
コードとしては以下のような形にしました。
if game_state == "Rankin": # Aを押して1秒待機 self.key.input([Button.A]) time.sleep(0.1) self.key.inputEnd([Button.A]) time.sleep(2) # 左を押す self.key.input([Hat.LEFT]) time.sleep(0.2) self.key.inputEnd([Hat.LEFT]) # Aを押す self.key.input([Button.A]) time.sleep(0.1) self.key.inputEnd([Button.A]) elif game_state == "Rankin_2": self.key.input([Button.A]) time.sleep(0.1) self.key.inputEnd([Button.A]) time.sleep(2) # 左を押す self.key.input([Hat.LEFT]) time.sleep(0.2) self.key.inputEnd([Hat.LEFT]) # Aを押す self.key.input([Button.A]) time.sleep(0.1) self.key.inputEnd([Button.A]) elif game_state == "Gameover": # 左を押す self.key.input([Hat.LEFT]) time.sleep(1) self.key.inputEnd([Hat.LEFT]) # Aを押す self.key.input([Button.A]) time.sleep(0.1) self.key.inputEnd([Button.A]) time.sleep()
自動プレイのテスト
Aと左と右を 8:1:1 の割合で押すエージェントを作成してプレイさせてみました。ちゃんとゲームオーバー後に復帰処理ができています。