ABEJA Tech Blog

中の人の興味のある情報を発信していきます

スイカゲームを強化学習で攻略したい(環境構築編)

はじめに

ABEJAでデータサイエンティストをしている清田です。今回は強化学習で何かゲームの学習をやってみたいと考え、その題材としてスイカゲームを扱えるようにしました。「強化学習で攻略したい」と銘打っているのですが、この記事で扱うのはその準備までです。 スイカゲームは Nintendo Switch (以下 Switch)用ゲームとして発売された落ち物パズルゲームです。箱の中に果物を落としていき、同じ種類の果物同士を接触させると合わさって一段階上の果物に変化します。より上位の果物を作るほど高い点数が得られます。箱から果物が飛び出してしまうとゲームオーバーです。2048 パズル + 落ち物パズルといった雰囲気のゲームです。ゲームとしてはシンプルながら、スイカの作成や高得点を目指す際には目先のスコア獲得だけではなく長期的に盤面を整える必要があるため強化学習向けの題材と言えます(言えると思います)。

store-jp.nintendo.com

このゲームは現在(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 自動化など既に多くの記事があるため、それらを参考にしました。

qiita.com

Arduino のボードを Switch にプロコンとして認識させる方法は以下の記事の内容ほぼそのまま真似しました。

zenn.dev

Arduino を PC と Switch の両方に接続した状態

Switch -> PC

Switch の映像出力をキャプチャーボード(AverMedia 2Plus)を用いてPCに出力し、OBS Studio の仮想カメラで取り込みました。画像のようにキャプチャーボードからの入力を表示して仮想カメラを ON にすることで、OpenCV の VideoCapture が利用可能なカメラとして認識されます。

VideoCapture 側でもちゃんと画像を取得できています。

環境の実装

(S. Sutton 著の強化学習より引用)

強化学習における環境はエージェントからの入力(行動  A_t)を受け取り、状態  S_t を1ステップ進め、次の状態  S_{t+1} と報酬  R_{t+1} をエージェントにフィードバックする機能を持ちます。また、エピソードタスクにおいてはエピソードの終了判定を行い、エピソード終了時には適切に次のエピソードを開始できる機能も必要です。 スイカゲームの場合次のようなメソッドが必要となります。

  1. step メソッド
  2. reset メソッド

これらのメソッドの実装に際して、PCからボタン入力を行うためのコードとして SwitchControlLibrary を参考にしました。

github.com

作成したコードは以下のリポジトリに置いています。

github.com

stepメソッド

step メソッドには以下の機能が必要です。

  • 行動  A_t を受け取って対応するボタン入力情報を Switch に入力し、入力後の次の状態  S_{t+1} に遷移させる
  • 前の状態  S_t と次の状態  S_{t+1} から報酬  R_{t+1} を計算する
  •  S_{t+1} がゲームオーバー状態であるかどうかを判別する

状態の遷移

スイカゲームではプレイヤーは3種類の操作ができます。

  1. 果物を落とす
  2. 左に移動する
  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')

報酬  R_{t+1} を計算する

状態の遷移から報酬を計算して返す関数が必要となります。スイカゲームにおけるゲーム目標は以下のように色々考えられますが、今回は一般的な目線で見て上手いプレイを目指したいので、高いスコアを目標とするのが良いでしょう。

  • 高スコア
  • スイカ作ること
  • なるべく短時間でスイカを作ること
  • 低スコア

プレイ画面からスコアを読み取れるようにしたかったので EasyOCR を使いました。

github.com

赤枠の領域の文字を読み取って整数に変換しています。

    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 の割合で押すエージェントを作成してプレイさせてみました。ちゃんとゲームオーバー後に復帰処理ができています。

youtu.be