0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

2048 を AI が自動プレイして VRM アバターが実況する「AI2048」を作った — OpenClaw でフルローカル・オフライン実況デモ

0
Last updated at Posted at 2026-06-09

ローカルの 2048 を AI が自動プレイし、その様子を VRM アバター「コテコ」(アルヨ調)が
実況する 完全オフライン のデモ AI2048 を作りました。トレードショーでの実演を想定し、
「AI が画面を見て考えて喋っている」絵で足を止めてもらうことを狙っています。

ポイントは「役割分担」です。

  • 手の決定は expectimax(純 Python・CPU) — 安定して 2048 に到達。LLM には手を選ばせない
  • 盤面は localStorage の gameState から読む(画像認識でなく誤差ゼロ)。
  • 制御ループの中心は OpenClaw(Docker)エージェント。毎ターン Qwen3 がスキルを呼ぶ。
  • 実況は VOICEVOX(ずんだもん声)→ three-vrm(VRM アバター)に同期。
  • 2048 の盤面を背景にライブ配信し、その前でアバターが喋る 1 画面の絵にする。

LLM(Qwen3.6)・音声合成(VOICEVOX)・VRM 表示(three-vrm)は
EarthTourGuide / AIassistant のスタックを流用し、
AMD GPU(Ryzen AI Max+ 395 / ROCm)上でフルローカルに動かしています。

リポジトリ: https://github.com/kotetsuy/AI2048

この記事の前半はセットアップ/使い方(リポジトリの READMEJ)、後半は仕組みと
設計判断(同 TECHNICALJ)をベースにまとめています。


第1部:セットアップと使い方

1. 動作環境

項目
マシン NucBox EVO X2 / Ryzen AI MAX+ 395 / gfx1151 / 48GB unified memory
OS Ubuntu 24.04
GPU ROCm 7.2.x(HSA_OVERRIDE_GFX_VERSION=11.5.1
表示/音声 xrdp + GNOME Remote Desktop、PipeWire → xrdp-sink

LLM/VLM は gfx1151(ROCm)、OpenClaw 本体・expectimax・ブラウザ制御は CPU で動きます。

2. 前提(リポジトリ外で用意するもの)

このリポジトリはオーケストレーション一式(スクリプト・OpenClaw 設定・three-vrm 同梱版)を
含みますが、以下の 大きいアセットは別途用意します(.gitignore 対象 / ライセンス上同梱しない)。

必要物 既定パス 入手方法
2048 ゲーム本体 ~/2048 git clone https://github.com/gabrielecirulli/2048
llama.cpp(ROCm ビルド) ~/llama.cpp/build/bin/llama-server gfx1151 向けに ROCm 対応でビルド
Qwen3 モデル ~/AIassistant/qwen3.6/...UD-Q4_K_XL.gguf GGUF を配置
VRM アバター ~/AIassistant/vroid/koteko.vrm VRM ファイルを配置
VOICEVOX Docker イメージ start_all.sh が自動 pull/起動

必須コマンド: docker(+ compose v2), tmux, curl, google-chrome, python3, xrandr

3. セットアップ(git clone → 実行)

# 1) クローン
cd ~
git clone https://github.com/kotetsuy/AI2048.git AI2048
cd AI2048

# 2) 2048 ゲーム本体を取得(オフライン配信元)
git clone https://github.com/gabrielecirulli/2048 ~/2048

# 3) 背景配信(bgcast)用の venv を作成(playwright + aiohttp。Chromium 本体は不要)
python3 -m venv .venv
./.venv/bin/pip install playwright aiohttp
#   ※ connect_over_cdp はホストの Chrome を使うので `playwright install` は不要。

# 4) Compose の .env を用意(パスとトークンを含む。.env はリポジトリに含まれない)
cd openclaw-demo
STATE="$(pwd)/state"
{ echo "OPENCLAW_IMAGE=openclaw-2048:local";
  echo "OPENCLAW_CONFIG_DIR=${STATE}";
  echo "OPENCLAW_WORKSPACE_DIR=${STATE}/workspace";
  echo "OPENCLAW_GATEWAY_PORT=18789";
  echo "OPENCLAW_GATEWAY_BIND=lan";
  echo "OPENCLAW_TZ=Asia/Tokyo";
  echo "OPENCLAW_GATEWAY_TOKEN=$(openssl rand -hex 32)"; } > .env

# 5) OpenClaw のコンテナ画像をビルド(公式画像 + Python + playwright)
docker compose build           # → openclaw-2048:local
cd ..

openclaw-demo/.envOPENCLAW_GATEWAY_TOKEN(シークレット)を含むため .gitignore 済みで、
リポジトリには含まれません。テンプレートは openclaw-demo/.env.example にあるので、手動で
用意する場合は cp .env.example .env してパス(絶対パス)とトークンを書き換えます。
上記 4) のワンライナーは現在地から .env を自動生成します。

4. 起動(これ一発)

cd ~/AI2048
./start_all.sh

start_all.sh が以下を順に立ち上げます(既に上がっていればスキップ=idempotent)。

  1. 2048 静的サーバ :8009(tmux)
  2. VOICEVOX(docker):50021
  3. llama-server :8080-c 65536 --parallel 2、AIzunda/EarthTourGuide と共用)
  4. three-vrm :8000(VRM 表示 + /speak + 背景中継)
  5. 2048 表示用 Chrome(CDP :9222、headed)
  6. OpenClaw gateway(docker):18789
  7. VRM 全画面表示 + 背景配信(start_phase2_display.sh

ホスト側プロセスは tmux セッション ai2048 で走ります。ログは tmux attach -t ai2048
Chrome/bgcast は /tmp/chrome-*.log /tmp/bgcast.log を見ます。

動作確認(1 ゲームだけ実況)

cd openclaw-demo
docker compose run --rm -T openclaw-cli \
  agent --agent main --session-key demo$(date +%s) \
  --message "play2048 スキルで新規ゲームを始め、step→narrate で実況して報告して。"

exit 0 かつホスト画面の盤面が変化し、アバターが喋れば OK です。

5. 連続稼働(デモ本番)

cd ~/AI2048
./demo_loop.sh

外側ループが毎回フレッシュな --session-key で OpenClaw エージェントを呼び、
「数手 stepsnarrate(間引き)」→ 終局で勝敗演出 → newgame を繰り返します。

調整用の環境変数:

env 既定 意味
DEMO_MOVES 8 1 セッションあたりの手数
DEMO_GAP 2 セッション間の待ち(秒)
DEMO_FRESH 1 開始時に newgame するか(0 で継続)

停止方法:

  • アバター画面の 「⏹ デモ停止」ボタン(即時停止。進行中セッションも docker stop)。
  • 端末で Ctrl-C(現セッション完了後に停止)。
  • 起動 PID は /tmp/demo_loop.pid に記録されます。

6. 停止

./stop_all.sh                 # デモ一式を停止
./stop_all.sh --keep-shared   # 共用の llama / VOICEVOX は残す(推奨)

llama-serverVOICEVOX は EarthTourGuide / AIzunda と共用なので、
共用環境では --keep-shared を使ってください。

7. ポート一覧

サービス ポート 用途
2048 静的サーバ 8009 ゲーム配信
three-vrm 8000 VRM 表示 / /speak / 背景中継
llama-server 8080 Qwen3(実況テキスト生成・共用)
Chrome CDP 9222 ボット操作 / 背景 screencast 元
OpenClaw gateway 18789 エージェント制御プレーン
VOICEVOX 50021 音声合成

8. トラブルシュート

症状 対処
アバターが喋らない curl localhost:8000/statusclients が 0 → 表示 Chrome が /ws 未接続。start_phase2_display.sh を再実行し /tmp/chrome-vrm.log を確認
背景に 2048 が出ない /tmp/bgcast.log/bg_ingest connected が無い → bgcast 未起動。.venv に playwright+aiohttp があるか確認(NO_BGCAST=1 で無効化も可)
エージェントが overflow llama は -c 65536 --parallel 2 で起動(per-slot 32768 が必要)
CDP に繋がらない Chrome は /tmp/chrome-cdp-2048 プロファイルで headed 起動。起動前に rm -f /tmp/chrome-cdp-2048/SingletonLock
Cannot continue from message role: assistant セッション main の使い回し → 毎回ユニークな --session-key を渡す

第2部:仕組みと設計判断

ここからは「どう動いているか」と「なぜそう作ったか」を解説します。

1. 全体構成

architecture.png

レイヤ 担当 配置
オーケストレーション OpenClaw Gateway + エージェント(Qwen3) Docker(network_mode: host)
手の決定 expectimax(純 Python) コンテナ内 CPU
ブラウザ制御 Playwright connect_over_cdp コンテナ → ホスト Chrome
盤面表示 gabrielecirulli/2048 + Chrome(headed) ホスト
実況テキスト整形 llama-server / Qwen3.6-35B-A3B ホスト gfx1151(共用)
音声合成 VOICEVOX ホスト(docker)
アバター/字幕/背景 three-vrm + zundamon.html(three.js + three-vrm) ホスト Chrome

設計の核は、OpenClaw を「より agentic に見せる」ために毎ターンの制御ループの中心
置きつつ(方式A)、手の質と速度は expectimax に固定して LLM には手を選ばせない、という分業です。
LLM(弱い・遅い)に手を選ばせると 2048 に届きません。LLM は実況テキストの生成と制御ループの駆動だけを担います。

確定した設計判断

  1. OpenClaw を本デモのオーケストレーション本体とし、Docker(Compose)で動かす。
  2. ゲームは gabrielecirulli/2048(本家)を python -m http.server でローカル配信(オフライン)。
  3. 盤面は localStorage の gameState から JSON で読む。敗北は .game-message のクラス、
    勝利(2048)は state["won"] で判定。
  4. 手の決定は expectimax(純 Python・CPU)固定。LLM には手を選ばせない。
  5. llama-server は AIzunda / EarthTourGuide と共用--parallel 2 以上で起動し、
    会話と実況呼び出しが直列化しないようにする。
  6. 重い推論(LLM)は gfx1151(ROCm)、OpenClaw 本体・expectimax・ブラウザ制御は CPU。
  7. 「絵」方式は VRM アバター同居 + 背景化。2048 の画面を VRM の scene.background に流し、
    その前でアバターが実況する 1 画面の絵にする。

2. 盤面の取得(認識誤差ゼロ)

2048 の GameManager は毎ターン完全な状態を localStorage["gameState"] に JSON で保存します。
これを CDP 経由で読むので、画像認識も DOM パースも不要で誤差ゼロです。

window.localStorage.getItem('gameState')
// → {"grid":{"cells":[[col0...],[col1...]]}, "score":N, "won":bool, ...}
  • cells[x][y]x=列・y=行parse_board()board[y][x] に転置して 4×4 行列にする。
  • ゲームオーバー時は gameState が消える仕様 → 敗北判定は .game-message のクラス
    game-over を含むか)で行う。勝利(2048)は state["won"]
  • コンテナ越しの高レイテンシで gameState が一瞬欠落する(None)ことがある →
    settle_status() が board 確定 or over まで読み直して過渡状態を吸収する。

3. expectimax ソルバ(play2048_bot.py

手の決定ロジックは純粋関数なので、そのまま流用します(play2048_cdp.pychoose_move を import)。

  • move_board(board, dir): 0=上/1=右/2=下/3=左。_compress_merge_left() で 1 列を詰めてマージ。
  • 評価関数 evaluate():
    • 蛇行(snake)重み: 最大タイルを左上に集めて単調性を保つ古典ヒューリスティック(4**k で重み付け)。
    • 空きマスボーナス EMPTY_BONUS = 4**13(詰まり防止)。
  • expectimax(board, depth, is_chance): プレイヤーノードは max、チャンスノードは
    空きマスに 2(0.9)/4(0.1) を置いた期待値。in-place 更新で deepcopy を回避。
  • 可変探索深さ _depth_for(): 空きが少ないほど分岐が減るので深く読む(空き ≥6→3、≥3→4、それ未満→5)。

4. OpenClaw 統合

4.1 スキルの形(重要な設計含意)

OpenClaw のスキルは「独自の typed tool を JS で登録する」ものでは ありません
スキル = SKILL.md(YAML フロントマター + markdown 本文の指示書)で、本文がエージェントに
「いつ・どの組み込みツールを呼ぶか」を教えます。同梱スクリプトは組み込み exec ツールで実行します。

  • 配置: state/workspace/skills/play2048/SKILL.md + play2048_cdp.py + play2048_bot.py
  • フロントマター: name, description, metadata.openclaw.requires.bins: [python3], os: [linux]
  • エージェントは python3 {baseDir}/play2048_cdp.py <subcommand>exec で呼び、stdout の 1 行 JSON を読む。

4.2 CLI サブコマンド(play2048_cdp.py

各コマンドは毎回 connect_over_cdp し、状態はブラウザの localStorage に持ちます(ステートレス)。
stdout は機械可読の 1 行 JSON、人間向けログは stderr です。

サブコマンド 役割 主な出力
read 盤面と状態を返す board/score/won/over/max_tile/empty
solve expectimax で次の手 direction/key/dir_ja
press <dir> 矢印キー送出 pressed
step read+solve+press を 1 回に束ねる event: move/won/over/stuck/wait
steps --count N 複数手を 1 コールで一気に着手(最速・間引き用の主役) event, moves, score, max_tile, board
narrate 実況(three-vrm /speak へ送る) event:narrate, spoken, duration_sec, waited_sec
newgame 新規ゲーム開始 event:newgame
play モノリシック自動プレイ(保険) result: won/lost/stuck

steps がデモの主役です。1 セッション内で最大 N 手を着手し、途中で won/over/stuck/wait なら
そこまでで止めて返します。これで エージェントの LLM 往復を手ごとに発生させず数手まとめて進め、
実況を間引き
ます(実測 8 手 ≈ 1.5s。旧 step 方式は 4 手で約 70s でした)。

4.3 制御フロー(方式A・連続稼働)

control-flow.png

demo_loop.sh が外側ループで、毎回フレッシュな --session-key で OpenClaw エージェントを呼びます。
SKILL.md の手順は次の通りです。

  1. セッション開始時に newgame しない(前セッションのゲームを継続。ブラウザに状態が残る)。
  2. 指定手数(既定 8)まで steps --count 4narrate(バッチごとに 1 回)を繰り返す。
  3. event が wonnarrate --speaker 1(あまあま声で勝利演出)→ newgame
  4. over/stuck → 締めの実況 → newgame
  5. 手数・score・max を一言で報告して終了 → 次セッションが続きを回す。

数手ごとに新セッションにすることで、コンテキスト溢れも無限ゲームも回避しつつ
OpenClaw を制御の中心に保てます。

5. ネットワークとコンテキストの確定構成

実機検証で当初の仮説をいくつも更新しました。要点だけ挙げます。

5.1 network_mode: host(bridge ではない)

  • Chrome 149 は remote-debugging を 127.0.0.1 にしか bind しない
    --remote-debugging-address=0.0.0.0 を無視)。→ bridge + host.docker.internal では CDP に届かない。
  • host ネットワークなら localhost で CDP(9222)/2048(8009)/llama(8080)/VOICEVOX(50021)/three-vrm(8000)
    すべてに到達。Chrome の DNS-rebinding(Host ヘッダ)制限も localhost で回避できる。
  • このため provider baseUrl も http://localhost:8080/v1(host net では host.docker.internal は解決不可)。
  • Chrome 起動には --remote-allow-origins=* も付与し、CDP 接続前にホスト名を IP 解決する(DNS-rebinding 対策)。

5.2 コンテキスト・サイズの規則(最重要)

  • OpenClaw は 「プロンプトトークン ≤ contextWindow ÷ 2」 を要求します(残り半分を応答用に予約)。
  • ガードが許す最大使用量 = contextWindow/2 + maxTokens = 24576 + 1024 = 25600 トークン
    これが llama の per-slot n_ctx に収まれば良いcontextWindow を n_ctx に揃える必要はない)。
  • プロンプト縮小策:
    • skills.allowBundled: [] で bundled スキル約 57 個をシステムプロンプトから除外。
    • plugins.deny: [...] でツール供給プラグイン 7 個を無効化(gateway ログが 0 plugins に)。
    • 効果: 初回プロンプト P = 16,902 トークン(旧 ~21k から約 20% 減)。
  • 確定構成: llama -c 65536 --parallel 2(per-slot 32768)/ openclaw.json contextWindow: 49152,
    maxTokens: 1024。検証で 2 手 step→narrate がピーク n_past=19,610(truncated=0)、VRAM 24.8GB/48GB。
    --parallel 2 を保ったまま overflow なし
  • KV キャッシュのプレフィックス再利用が効き、2 手目以降の prompt eval は ~150-730 トークンのみ。

5.3 LLM プロバイダ設定(openclaw.json

models.providers."llama-host": {
  baseUrl: "http://localhost:8080/v1",
  apiKey: "dummy-key",            // llama-server は認証しない
  api: "openai-completions",      // vLLM/SGLang と同じ
  models: [{ id: "qwen3", reasoning: false,
             contextWindow: 49152, maxTokens: 1024,
             compat: { thinkingFormat: "qwen-chat-template" } }]   // thinking 無効
}
agents.defaults.model.primary: "llama-host/qwen3"
gateway.mode: "local"             // 無いと起動ブロック

6. 実況パイプライン(narrate → three-vrm → VOICEVOX → VRM)

narration-pipeline.png

narrate (play2048_cdp.py) ──POST /speak──► three-vrm(:8000)
   ──VOICEVOX 合成──► ──ws /ws──► zundamon.html(口パク + 字幕 + 再生)
  • narrate は実況テキストを three-vrm の POST /speak(:8000) へ送ります(依存追加なし・urllib のみ)。
    three-vrm が VOICEVOX で合成し、/ws 経由で VRM 表示ページへ viseme 付きで配信します。
  • 音声は VRM 表示 Chrome → PipeWire → xrdp-sink → Mac 側で再生(コンテナ内では再生しない)。
  • /speak 失敗時はテキストのみで継続(ループは止めない=フォールバック)。

テンポ同期

  • /speak のレスポンスに duration_sec(合成 WAV の再生秒数) を追加(WAV をパースして算出。後方互換)。
  • narrateduration_sec ぶん待ってから返るので、step→narrate ループが「喋り終わってから次手」になり
    実況が被りません。PLAY2048_NARRATE_WAIT(0 で無効) / ..._FACTOR / ..._MAX_WAIT で調整可能。
  • 最速・間引きモード(デモ本番): compose で PLAY2048_NARRATE_WAIT=0 + PLAY2048_MOVE_DELAY=0.2
    narrate は発話を待たず即返り、実況は数手まとめて 1 回だけにしてテンポを優先します。

キャラクター(コテコ)

実況キャラはずんだもん(〜のだ)から コテコ(アルヨ調: 〜アル/アルヨ/アルネ) に変更しました。
声は VOICEVOX ずんだもん声(id=3) のまま、VRM は koteko.vrm を使います。

7. 背景ライブ配信(play2048_bgcast.py

2048 の画面を VRM の scene.background に流し、その前でアバターが実況する 1 画面の絵を作ります
(EarthTourGuide の earth-controller と同方式)。

2048 Chrome(:9222) ──Playwright CDP screencast──► bgcast
   ──ws /bg_ingest──► three-vrm ──/bg──► zundamon.html scene.background
  • 生 CDP を自前 WS で扱わず Playwright 経由にするのが肝です(aiohttp はブリッジ送信のみ)。
  • Page.startScreencast(JPEG) でフレーム取得 → screencastFrameAck を最優先(怠ると止まる)。
  • three-vrm は /bg_ingest(投入)と /bg(購読)の WS を持ち、最新フレームをキャッシュして新規購読者へ即送る。
  • ズーム fit: 2048 ページを縮小してウィンドウに収め盤面の下切れを防ぐ。do_newgame の page.reload 後も
    効くよう CDP addScriptToEvaluateOnNewDocument で再適用(実測 1024×768 で zoom≈0.81)。

8. デモ運用機能

  • 停止ボタン: アバター画面の「⏹ デモ停止」→ three-vrm POST /stop_demo/tmp/demo_loop.pid
    対象を厳密特定し SIGINT。さらに進行中のエージェントセッション(コンテナ)も docker stop即時停止
  • 字幕: 3 行で頭打ち(-webkit-line-clamp:3)。/speak は各発話前に turn_start を送り
    バッファをリセット(送らないと字幕が累積し更新が止まって見える)。
  • アバター正面化: koteko.vrm は VRM1.0。VRM 読込後に gltf.scene.rotation.y += atan2(...)
    カメラ方向へ向け、オフセット/距離が変わっても正面を保つ。
  • 連続稼働 + 自動リスタート: demo_loop.sh(方式A)。サービス断は wait 再確認、セッション失敗は次へ
    (フォールバック)。各セッションは timeout 付き。

9. 現状ステータス

フェーズ 状態
Phase 0(CDP 接続フィージビリティ) ✅ 完了
Phase 1(OpenClaw コンテナ化 + スキル + エージェント駆動) ✅ 完了
Phase 2(実況の実音声化: narrate→VOICEVOX→three-vrm) ✅ 完了
Phase 3(磨き込み: 背景化 / 一括起動停止 / 連続稼働 / 勝敗演出 / 停止ボタン / 字幕 / 正面化) ✅ ほぼ完了

直近の 5 分間デモテストでは 4 セッション・失敗 0、自動リスタート・アルヨ調実況・テンポ同期を確認、
VRAM 24.93→25.06GB(リークなし)。残るは長時間(1〜2 時間相当)連続稼働の耐久確認のみです。


おわりに

「LLM を agentic な制御ループの中心に置きつつ、勝率を左右する手の決定は古典的な expectimax に
固定する」という分業がこのデモの肝でした。盤面を localStorage から読んで認識誤差をゼロにしたこと、
steps で LLM 往復を間引いてテンポを稼いだこと、共用 llama-server の per-slot コンテキストに
収まるようプロンプトを削ったこと——あたりが頭を悩ませたポイントです。

コードは https://github.com/kotetsuy/AI2048 にあります。
音声・VRM パイプラインのベースは kotetsuy/EarthTourGuide /
kotetsuy/AIassistant です。

0
0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?