2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

軽量判断AI Laya(Jevローカル動作クローン)にバブル崩しをプレイさせてみた

2
Last updated at Posted at 2026-09-22

はじめに

最終的には、ストリートファイター6で自分が到達できていないハイマスター帯をAIに狙ってもらいたい。そんな大きな目標をぼんやり考えています。

ただし、ゲーム画面をクラウドAPIへ送り続ける方式はレイテンシが気になります。そこでシルバーウィークに、ローカルで動く軽量判断AI Laya を試すことにしました。手始めの題材は、Windows 11に入っていたSolitaire & Casual Gamesのバブルパズルです。
image.png

ローカルAIを触っていると、つい「スクリーンショットを渡して、次にどこへ撃つか全部考えてもらおう」としたくなります。

しかし、それは案外重い。画像を毎フレーム渡すのは通信量も推論コストも大きく、出力も文章なのでゲーム制御には扱いづらい。

そこで今回は、処理を次のように分けました。

  1. 画面認識は従来の画像処理で行う
  2. 弾道計算と着地点候補の生成は物理シミュレータで行う
  3. 候補を撃った前後の盤面だけを、軽量な判断モデル Laya に採点させる
  4. 選ばれた候補をWindows側へマウス入力する
  5. もう一度画面を読み、予測と実測を比較する

最終的には、ゲームの目的である上部のマーク付きバブルをすべて消す、または支持を切って落とし、Level Completeまで自動で到達できました。

「LLMにゲームを丸ごと遊ばせた」というより、認識・物理・判断・操作・観測を分業させた、小さな自律ループの実験です。

Layaとは

LayaはConvai Innovationsが公開している、非自己回帰型のSystem 1判断エンジンです。

きっかけはTypeSafe AIのJevでした。Jevは、非構造な状態を受け取り、文章を生成する代わりに型付きの確率的判断を高速に返すモデルとして発表されています。面白そうでしたが、今回はローカル実行できる代替としてLayaを調べました。

一般的な生成LLMのように文章を1トークンずつ生成するのではなく、与えた状態に対して次のような型付きの判断を1回のforward passで返します。

  • choice: 選択肢から1つ選ぶ
  • score: 段階評価する
  • noul: trueである確率を返す

今回使った英語モデルはModernBERT-largeベースの421Mパラメータです。公式情報ではT4上で1問あたり約39.5msとされています。Apache 2.0で公開されており、ローカル実行できます。

なお、これは「ゲーム専用AI」ではありません。JSONなどで表現した状態に対し、指定した評価軸で高速にスコアを返す汎用の判断モデルです。

実行環境

  • Windows 11
  • WSL2 / Ubuntu 24.04
  • Core i7
  • メインメモリ 16GB
  • GeForce RTX 2070 Super 8GB
  • Python仮想環境 laya-env
  • Windows側Python: 画面キャプチャとマウス入力
  • WSL側Python: 画像認識、物理計算、Laya推論、全体制御

数年前、コロナ禍に購入した一般的なデスクトップPCです。最新GPUでなくても、どこまでローカルAIを動かせるのかにも興味がありました。

今回のゲームの目的

このゲームは、盤面上のバブルをすべて消すゲームではありません。狙うのは、上部に配置されたマーク付きのターゲットバブルです。

  • ターゲットと同色をつなげて直接消す
  • ターゲットを支えるバブルを消し、まとめて落とす
  • 最上部へ直接つながっているターゲットは、支持を切れないため直接消す

したがって、画面に通常バブルが残っていても、ターゲットをすべて処理できればクリアです。単純な「残りバブル数の最小化」と、ゲーム本来の勝利条件は一致しません。

この違いは、後述する観測ロジックとクリア判定を設計するうえで重要になりました。

最初に夢見たE2E方式は難しかった

当初は、画面を画像AIへ渡せば盤面理解から操作まで一気に解いてくれるのでは、と期待していました。

まずQwen系のモデルをRTX 2070 Superの8GBへ載せて試しました。推論自体は約2秒で動き、「このGPUでもQwen系のE2Eが動く」という体験は楽しかったのですが、画像から六角状のバブル配置とゲームルールを安定して読み解くのは難しく、E2E方式はいったん断念しました。

検討した選択肢は次の3つです。

  1. OpenCVで画面を解析し、必要な状態を自前で作る
  2. 画像認識モデルに画面の意味まで解釈させる
  3. ゲーム内部のロジックやパラメータを直接読む

3はハック寄りで汎用性に欠けます。2はうまくいけば理想的ですが、今回のPCとモデルでは精度が足りません。そこで、泥臭いものの確実な1を選びました。

Laya自体の導入は現在のパッケージでは次の形です。

python3 -m venv ~/laya-env
source ~/laya-env/bin/activate
pip install laya pillow
import laya

agent = laya.load("convaiinnovations/laya")

初回はHugging Faceからモデル一式がダウンロードされます。今回のログでは38ファイルを取得した後、モデルを常駐させて再利用しました。

Layaのリリース直後に試した内容です。Python要件や依存パッケージは更新される可能性があるため、実行時点のPyPIも確認してください。

全体構成

処理の流れは次の通りです。並べてみると、認知・判断・制御の構成で、仕事で扱っているADASにも少し似ています。

コード上はおおむね次の役割に分かれています。

モジュール 役割
capture.py 対象ウィンドウの位置取得と画面保存
vision.py 円・色・発射位置・現在球・次球の認識
physics_v53.py 壁反射を含む弾道計算と着地点候補生成
laya_decision.py 候補ごとの盤面遷移をLayaへ入力して採点
click.py Windows側でドラッグ、リリース、クリック
play.py 一連の処理、再観測、安全停止の制御

ポイントは、画像そのものをLayaへ渡していないことです。

1. 画面を状態へ変換する

まずWindows側で対象ウィンドウをキャプチャし、WSL側で盤面を認識します。

実行ログの一例です。

Window:
 left  = 2623
 top   = 32
 width = 1038
 height= 1187
saved: C:\temp\game.png

current_ball: blue
next_ball: yellow
launcher: (519, 839)
board bubbles: 90
colors: {'green': 24, 'yellow': 19, 'blue': 15, 'red': 31, 'gray': 1}
time: 169.4 ms

バブルは千鳥配置なので、単純な直交格子ではなく六角格子として扱います。認識した各バブルの画像座標を、最近傍の (row, col) へ変換しました。

これで「赤い円が画像のどこにあるか」から、「六角格子のどのセルに何色があるか」へ表現を落とせます。

ゲーム画像は自然画像と違い、色とコントラストがはっきりしています。そのためOpenCVによる円検出や色分類は比較的安定しました。一方で、バブルは互いに接した六角配置なので、単純な円検出だけでは行・列の対応が崩れます。最終的には2D画像上で軌跡をレイキャストし、壁反射も含めて処理しました。

image.png

途中では灰色のバブルを色分類へ入れ忘れ、軌跡が貫通するバグも出ました。人間なら前提設計が崩れたと分かった瞬間に波及範囲を考えてげっそりするところですが、AIは淡々と修正案を出してきます。この温度差は精神衛生上かなり助かりました。

2. 物理シミュレータで行動候補を作る

Layaに角度をゼロから考えさせるのではなく、まず物理側で実行可能な候補を作ります。

候補には次の情報があります。

{
  "angle": 20.0,
  "landing_row": 9,
  "landing_col": 6,
  "strategy": "POP",
  "cluster_size": 7
}

候補は大きく次の3種類に分類しました。

  • POP: 3個以上つながって消える
  • BUILD_PAIR: 同色を2個にして次へつなぐ
  • SAFE_PLACE: すぐには消えないが比較的安全な配置

左右の壁反射も含め、複数の角度を走査して着地点を予測します。

ここまでは完全にルールベースです。Layaの役割は、候補生成ではなく候補評価に限定しました。
image.png

3. Layaの使い方を最初から間違えた

最初は複数の角度をchoiceの選択肢として並べ、Layaへ「どれがよいか」を直接聞きました。ところが、数字を外しても順番を入れ替えても、なぜか必ず最初の選択肢を返します。

「本当に考えているのか」と疑いましたが、問題は使い方でした。Layaは文章生成モデルというより、状態に対して型付きの確率やスコアを返す判断エンジンです。候補をまとめて丸投げするのではなく、各候補を個別に評価して、返されたスコアをこちらで比較する必要がありました。

そこで、画像キャプチャから物理的に異なる6候補を作り、それぞれについてLayaを呼び出す方式へ変更しました。候補数を増やせば探索は細かくなりますが、その分だけ処理時間が増えるため、今回は6候補を基本としました。

4. Layaには「前後の差」だけを見せる

候補の着地点へ現在球を仮置きし、同色クラスタが3個以上なら消去します。さらに、天井との接続が切れて落下するバブルも診断用に計算しました。

ただしLayaには、次のラベルを渡しませんでした。

  • POP / BUILD_PAIR / SAFE_PLACE
  • 物理側のスコア
  • 消えるクラスタ数
  • 同色近傍数

これらを渡すと、Layaが盤面を評価したのか、物理側の答えを復唱しただけなのか分からなくなるためです。

Layaへ渡したのは、着地点周辺2周分のローカル盤面のBefore/Afterだけです。

{
  "game": "bubble_shooter",
  "ball": "B",
  "before": [
    "0,0:.", "-1,0:B", "-1,1:Y", "0,-1:.", "0,1:."
  ],
  "after": [
    "0,0:.", "-1,0:.", "-1,1:Y", "0,-1:.", "0,1:."
  ]
}

表現は以下の通りです。

  • R/G/B/Y: 赤・緑・青・黄
  • X: 灰色
  • .: 空き
  • ?: 不明
  • dr,dc: 着地点からの相対セル位置

質問はscore型にしました。

questions = {
    "quality": {
        "type": "score",
        "instructions": (
            "Rate this bubble-shooter state transition. "
            "Prefer transitions that improve the board and make progress; "
            "avoid transitions that leave the board worse or provide little benefit."
        ),
        "criteria": [
            "very bad",
            "bad",
            "reasonable",
            "good",
            "excellent",
        ],
    }
}

result = agent.predict(state, questions)

返り値は文章ではなく、スコアと確率分布です。

score       : 1.6113
confidence  : 0.0271
input tokens: 374

Layaの英語モデルはコンテキスト長が512トークンです。盤面全体や説明を詰め込むのではなく、判断に必要な局所状態へ圧縮し、当初は200〜300トークン程度を目標にしました。実際のログでは候補により300台後半まで使っていますが、上限へ張り付かないよう情報量を抑えています。

開発初期には、クラスタ数や付随して落下するバブル数も明示的なパラメータとして渡しました。その後、Layaが物理側の答えを復唱しているだけではないかを確かめるため、POPなどのラベルを隠し、ローカル盤面のBefore/Afterから評価させる方式へ進めました。

この実験で一番効いたのは、モデルそのものより判断に必要な周辺情報をどう表現するかでした。

5. 1回ロードして常駐させる

最初は候補を評価するたびにモデルをロードする形になり、ゲーム用途としては待ち時間が大きすぎました。

そこでLayaを制御プロセス内で一度だけロードし、各ショットで使い回す形へ変更しました。

print("Loading Laya (one-time)...")
agent = laya.load("convaiinnovations/laya")
print("Laya ready. Model will stay resident.")

for candidate in candidates:
    result = agent.predict(
        make_laya_state(candidate),
        questions,
    )

小型モデルでも、毎回ロードすれば遅い。モデルサイズより先にライフサイクルを直すという、GPUプログラムではおなじみの話でした。

6. Layaだけに任せると、意外と素直には勝てない

実際に試すと、Layaの最高スコアが必ずしも「最も多く消える手」と一致しませんでした。

ある局面では、物理側が7個消えると予測した候補が複数ある中で、Layaは次のように評価しました。

 #   ANGLE    CELL    PHYS        SIM  POP  DROP  TOTAL   LAYA
 1     0.0    (9,5)   POP           7    7     1      8   1.073
 2   -20.0    (9,3)   SAFE_PLACE    1    0     0      0   1.252
 3    -2.5    (9,4)   POP           7    7     1      8   1.575
 4    20.0    (9,6)   POP           7    7     1      8   1.611
 5    35.0    (9,7)   POP           7    7     1      8   1.650
 6   -45.0    (9,1)   POP           3    3     0      3   1.510

同じ「7個消える」候補でも、周辺配置によってスコアが変わるのは面白いところです。

一方で、実験中のLayaのconfidenceはかなり低く、多くが0.02〜0.17程度でした。ゼロショットで独自ゲームの局面を評価させているので、これはむしろ正直な出力です。

そこで最終的には、Layaを絶対的な司令塔にはしませんでした。

  • 確実に消せる候補があるなら、まずPOP候補を優先する
  • 大きく消せる候補は即時戦術として扱う
  • 消せない局面では、中央を詰まらせにくい構造的な候補を選ぶ
  • 条件が同程度の候補のタイブレークにLayaを使う

つまり、ルールで安全領域を作り、その中でAIに選ばせる構成です。

これはゲームに限らず、実システムへAI判断を入れる際にも使いやすい形だと思います。

7. 操作より難しかったのは「撃った後を正しく見る」こと

ショット後すぐにキャプチャすると、アニメーション途中の状態を読んでしまいます。また、次球表示と現在球の切り替わりが画面認識上安定するまで時間がかかります。

そこで、最大6回まで再キャプチャし、盤面と現在球が連続して同じ状態になったら「settled」とみなすobserverを追加しました。

POST-SHOT OBSERVER: probe=1/6 current=green expected=green stable=1/2 bubbles=75
POST-SHOT OBSERVER: probe=2/6 current=green expected=green stable=2/2 bubbles=75
POST-SHOT OBSERVER: state settled

さらに、ショット前後を比較して次を記録しました。

  • 予測着地点と観測着地点の距離
  • 着地点セルが一致したか
  • POP予測と実際の消去が一致したか
  • 消えた色と個数
  • 盤面が変化しなかったか
  • 次球が現在球へ遷移したか

予測が外れた例もあります。

predicted landing: (5, 3) (446.0, 513.0)
observed landing : (4, 3) (422, 472)
landing error    : 47.51

pop predicted / actual: True / False

これを失敗として残すことで、次回以降のキャリブレーション材料にできます。

8. WindowsとWSLをまたぐ入力で詰まった

画面認識と推論はWSL側で動かせますが、対象ゲームへのマウス入力はWindows側で行いました。

最初のショットでは、単純なクリックだけでは弾道ガイドが初期化されず、次の手順が必要でした。

  1. 発射位置から照準位置へドラッグ
  2. リリース
  3. 少し待つ
  4. 照準位置を明示的にクリック

しかも、これらを別プロセスに分けると状態が切れることがあったため、1つのWindows Pythonプロセス内で実行しました。

FIRST SHOT:
drag -> release -> wait -> explicit click

INIT AIM: drag -> release
wait 0.30s

FIRST FIRE:
MOUSEDOWN -> MOUSEUP -> FIRE

推論アルゴリズムより、GUIの状態機械を特定する方が泥臭くて時間がかかります。AIへ入れる情報を作るのも大変ですが、AIの出力を現実の操作へつなぐインターフェースも同じくらい手間でした。ここもADASっぽいところです。

結果:ターゲットを落としてLevel Completeへ

残りバブル数は勝利条件ではない

あるランでは、画像認識上のバブル数が90個から7個まで減りました。

途中には、21個の同色クラスタに加えて17個の落下を予測し、合計38個を除去する候補を選ぶ局面もありました。

angle        : -35.0
landing      : (9, 2)
sim cluster  : 21
pop removed  : 21
drop removed : 17
total removed: 38
Physics best same: False

ただし、ここで「残り7個だから未クリア」とは判断できません。このゲームの目的はマーク付きターゲットの消去または落下であり、通常バブルは残っていても構いません。

このランの制御プログラムは、ターゲット達成やLevel Complete画面をまだ正しく認識できず、盤面が変化しない状態を3回観測して安全停止しました。

NO-EFFECT SHOT: board unchanged; record as failure and continue (3/3)
STOP: too many consecutive unchanged-board shots

つまり問題は、プレイ戦略だけではなくゲーム本来の終了条件を状態として持っていなかったことです。それでも無限に同じ操作を繰り返さず停止できた点は有効でした。この時点で、少なくとも次の一連の流れは自律化できました。

  • 実画面の読み取り
  • 実行可能な行動候補の生成
  • AIによる候補評価
  • GUI操作
  • 結果の再観測
  • 異常時の安全停止

そして、ついにクリアした

その後のランでは、開始時に画面上部を埋めていたバブルを順に崩し、約40秒のところで大きな連鎖落下が発生しました。マーク付きターゲットを直接消す、または支持しているバブルごと落とすことで条件を満たし、通常バブルを残したまま**Level Complete!** のダイアログへ到達しました。

録画は約54秒です。完了画面では次を確認できました。

  • Score: 40,060
  • Level Complete!
  • Reward: XP +100
  • Reward: Moves +5

実際のプレイ映像はこちらです。

動画ハイライト
00:00 初期盤面
00:30 盤面下部が大きく空く
00:35 連鎖落下直前
00:38 大量落下後
00:43 最終局面
00:46 Level Complete!

ここで紹介した90個→7個のテキストログと、Level Completeを記録した動画は同じランです。なお、そもそも残存数だけでは成功・失敗を判定できません。最後に7個残っていますが、ターゲットのバブルを落とせてクリアとなっています。

分かったこと

1. 画像をそのままAIへ渡す必要はない

ゲーム画面をPicture to TextやVLMへ毎回渡すより、ローカルの画像処理で状態へ圧縮した方が軽く、再現性も高くなります。

今回Layaへ渡したのは数百トークン程度の局所盤面です。クラウドへ巨大な画像を送り続ける必要もありません。

2. 小型の判断モデルは「候補の採点」に向く

Layaは角度探索も衝突計算もしません。得意な仕事だけを渡すことで、421Mモデルでもループに組み込めます。

3. ゼロショットのスコアを盲信しない

今回のscore出力はconfidenceが低く、物理的に明らかな最善手と一致しない場面もありました。

Laya自身の公開情報でも、ordinalなscoreは弱めのプリミティブと説明されています。ゲーム固有データでfine-tuningしていない以上、ヒューリスティックとの併用が妥当です。

4. 本番ループでは「観測」と「停止条件」が主役になる

AIが良い手を選べても、画面が遷移中だったり入力が無視されたりすれば壊れます。

再観測、状態安定判定、no-effect検出、連続失敗時停止の方が、デモを実動作へ変える上では重要でした。

今回さらに分かったのは、盤面のバブル数だけを見ても勝利条件は分からないことです。マーク付きターゲットの状態、またはLevel Complete表示そのものを観測する必要があります。

5. AIとのデバッグは頼れる先輩と作業する感覚だった

ChatGPTのASTRAには、一晩中トライアンドエラーへ付き合ってもらいました。面倒になったと弱音を吐くと、ログ整理やデバッグ効率を上げる仕組みを追加してくれます。

一方で、やり取りが長くなると自分がAIの手先になったような感覚もありました。コードが長くなるにつれて部分修正の取り違えも起きます。便利だからこそ、人間側が設計意図と検証条件を握り続ける必要があります。

6. 軽量E2Eの夢はまだ遠いが、分業なら動く

万能な画像AIへ画面を入れるだけ、という形にはなりませんでした。OpenCV、六角格子化、レイキャスト、物理候補、Windows入力という泥臭いセットアップが必要です。

既存ゲームを外から操作する場合は状態センシングが難所になります。一方、ゲーム内部に組み込めるなら、オブジェクト位置やルールを最初から取得できるため、Layaのような軽量判断モデルは使いやすくなります。

次にやりたいこと

  • 実測した着地点誤差から弾道パラメータをオンライン補正する
  • Layaへの入力を局所盤面だけでなく、天井までの支持構造も含める
  • scoreではなく、候補を直接比較するchoice方式も試す
  • プレイログからゲーム固有データを作り、Layaをfine-tuningする
  • マーク付きターゲットを個別認識し、ゲーム本来の達成条件を状態へ入れる
  • Level Complete画面を画像認識し、制御側でもクリアを自動確定する
  • 物理のみ、Layaのみ、ハイブリッドの勝率と処理時間を比較する
  • 同じ初期条件で複数回実行し、クリア率と平均ショット数を測る

おわりに

今回の一番の発見は、「AIに全部考えさせる」より、状態を作る処理、候補を作る処理、候補を選ぶ処理を分けた方が面白いということでした。

Layaは生成AIというより、高速な評価関数に近い使い心地です。ゲームの全制御を任せるにはまだ工夫が必要ですが、既存ロジックの横に置いて判断を少し賢くする部品としてはかなり興味深いものでした。

そしてRTX 2070 Super 8GBでも、画面認識・物理計算・ローカルAI判断をつないだ実験は十分できます。

途中では、残りバブル数を見て「あと7個」と解釈していました。しかし、それ自体がゲーム目的の取り違えでした。重要なのはマーク付きターゲットを消すか落とすことです。AIへ判断させる以前に、人間が目的関数を正しく定義しなければなりません。

1回の成功で性能を断言するつもりはありません。しかし、RTX 2070 Super上の小型判断モデルを、認識・物理・GUI操作のループへ組み込み、実ゲームをLevel Completeまで進められたのはなかなか面白い結果でした。

参考

※実験ログとメモからChatGPT ASTRAにQiita記事を作成してもらいました。

2
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?