AIゲームの作り方 約11分で読めます

ブラウザゲームの自動テストの方法|画面を出さないChromeで遊ばせて、壊れたゲームを見つける

「ゲームを自動でテスト」の文字と、背景に映る、テスト結果の表

ブラウザゲームの動作確認は、画面を出さないChromeに操作を送って、ゲームの変数を読めば自動にできます。この記事のスクリプトは、ページを開き、キーを押し、状態を確かめて、エラーを集めます。外部のライブラリは要らず、Nodeだけで動く形です。

スクリプトを、正常なゲームと、わざと壊した3つのゲームに実行して、見つけられるかを確かめました。確かめたのは、ゲームが動く範囲と、エラーの有無です。面白さは、確かめられません。

この記事で分かること

  • ゲームを自動で確かめるスクリプトのコード全体
  • テストの手順を書いた設定ファイルの例
  • わざと壊したゲーム3つを、見つけられたかの結果
  • アナリティクスに、テストのアクセスを数えさせない設定
  • 確かめられていないこと

テストにかけたのは、ブラウザゲームの作り方で作ったキャッチゲームです。AIにゲームを作らせたあとに、動きを確かめる手順としても使えます。作るところは、ChatGPT・Geminiでゲームを作る手順にあります。

この記事の目次
  1. 01自動で確かめる4段
  2. 02コード全体
    1. 手順を書く設定ファイル
  3. 03壊したゲームを見つけられるか
  4. 04AIにゲームを書かせたあとに使う
  5. 05確かめられていないこと
  6. 06うまくいかないとき
  7. 07よくある疑問
  8. 08この記事のまとめ

自動で確かめる4段

ゲームを自動で確かめる4段の図。1はページを開く。URLを渡して読み込みを待つ。2は操作を送る。キーを押す、離す、待つ。3は状態を確かめる。ゲームの変数を読んで期待と比べる。4はエラーを集める。例外とconsole.errorを全部記録する。正常1つと壊したもの3つの4回の実行は、どれも約2.7秒で終わった
画面を出さないChromeに、遊んでもらいます

ChromeにはDevTools Protocolという、外から操作する窓口があります。Chromeを、リモートデバッグのポートを開いて起動し、WebSocketでつなぐと、ページの移動、キーの入力、JavaScriptの実行ができます。ゲームの変数(かごの位置や、残り時間など)を読めば、画面を見なくても、状態を確かめられます。

コード全体

次のスクリプトを、run.mjsという名前で保存します。Node 24で動かしました。WebSocketとfetchを標準の機能で使うため、古いNodeでは動かないことがあります。ChromeのパスはWindowsの例なので、環境に合わせて書き換えます。

// ブラウザゲームの自動テスト。画面を出さないChromeでページを開き、操作して、結果を確かめる
// 実行: node run.mjs test.json
import { spawn } from 'node:child_process';
import { readFileSync } from 'node:fs';
const cfg = JSON.parse(readFileSync(process.argv[2], 'utf-8'));
const PORT = cfg.port || 9400;
const chrome = spawn('C:/Program Files/Google/Chrome/Application/chrome.exe', [
  '--headless=new', '--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader', `--remote-debugging-port=${PORT}`,
  `--window-size=${cfg.w || 400},${cfg.h || 700}`,
  // 自分のテストのアクセスが、アナリティクスに数えられないようにする
  '--host-resolver-rules=MAP www.googletagmanager.com 127.0.0.1, MAP *.google-analytics.com 127.0.0.1, MAP *.analytics.google.com 127.0.0.1',
  '--user-data-dir=' + process.env.TEMP + '\gametest-prof' + PORT, 'about:blank']);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
let tabs;
for (let i = 0; i < 40; i++) { try { tabs = await (await fetch(`http://127.0.0.1:${PORT}/json`)).json(); if (tabs.length) break; } catch (e) {} await sleep(250); }
const ws = new WebSocket(tabs.find((t) => t.type === 'page').webSocketDebuggerUrl);
await new Promise((r) => (ws.onopen = r));
let id = 0; const pend = {}; const errors = [];
ws.onmessage = (m) => {
  const d = JSON.parse(m.data);
  if (d.id && pend[d.id]) { pend[d.id](d); delete pend[d.id]; }
  if (d.method === 'Runtime.exceptionThrown') errors.push(d.params.exceptionDetails.exception?.description?.split('\n')[0] || d.params.exceptionDetails.text);
  if (d.method === 'Runtime.consoleAPICalled' && d.params.type === 'error') errors.push('console.error: ' + d.params.args.map((a) => a.value ?? a.description).join(' '));
};
const send = (method, params = {}) => new Promise((r) => { const i = ++id; pend[i] = r; ws.send(JSON.stringify({ id: i, method, params })); });
const ev = async (expr) => (await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true })).result?.result?.value;
await send('Runtime.enable'); await send('Page.enable');
const t0 = Date.now();
await send('Page.navigate', { url: cfg.url });
await sleep(cfg.load || 1500);
const results = [];
const KEYS = { ArrowRight: 39, ArrowLeft: 37, Space: 32 };
for (const s of cfg.steps) {
  if (s.wait) await sleep(s.wait);
  else if (s.keyDown) await send('Input.dispatchKeyEvent', { type: 'keyDown', code: s.keyDown, key: s.keyDown, windowsVirtualKeyCode: KEYS[s.keyDown] });
  else if (s.keyUp) await send('Input.dispatchKeyEvent', { type: 'keyUp', code: s.keyUp, key: s.keyUp, windowsVirtualKeyCode: KEYS[s.keyUp] });
  else if (s.eval) await ev(s.eval);
  else if (s.expect) {
    const v = await ev(s.expect);
    results.push({ name: s.name, ok: v === true, value: v });
  }
}
results.push({ name: 'コンソールにエラーが出ていない', ok: errors.length === 0, value: errors.slice(0, 3) });
for (const r of results) console.log(r.ok ? 'PASS' : 'FAIL', r.name, r.ok ? '' : JSON.stringify(r.value));
console.log('合計', results.filter((r) => r.ok).length + '/' + results.length, '所要', Date.now() - t0, 'ms');
ws.close(); chrome.kill();
process.exitCode = results.every((r) => r.ok) ? 0 : 1;

起動の引数には、アナリティクスの計測先を127.0.0.1に向ける指定が入っています。公開済みのゲームをテストするときに、テストのアクセスが、アクセス解析に混ざるのを防ぐためです。

手順を書く設定ファイル

テストの手順は、JSONで書きます。「expect」は、JavaScriptの式を評価して、結果がtrueなら合格です。名前は、結果の表示に使います。

{
 "url": "http://127.0.0.1:8791/tools/gametest-demo/ok.html",
 "port": 9400,
 "steps": [
  {
   "expect": "typeof player==='object' && document.querySelector('canvas').width===360",
   "name": "canvasが360幅で出ている"
  },
  {
   "eval": "player.x=100;0"
  },
  {
   "keyDown": "ArrowRight"
  },
  {
   "wait": 600
  },
  {
   "keyUp": "ArrowRight"
  },
  {
   "expect": "player.x>180",
   "name": "右キーを0.6秒押すと、かごが右へ動く"
  },
  {
   "eval": "left=0.3;0"
  },
  {
   "wait": 600
  },
  {
   "expect": "running===false",
   "name": "時間がなくなるとゲームが終わる"
  }
 ]
}

このテストは、4つを確かめます。canvasが出ていること、右キーを0.6秒押すとかごが右へ動くこと、残り時間がなくなるとゲームが終わること、コンソールにエラーがないことです。最後の項目は、設定に書かなくても、スクリプトが毎回足します。

壊したゲームを見つけられるか

キャッチゲームのコードを、3通りに壊しました。

  • 起動時に、存在しない関数を呼ぶ
  • キーの状態を記録する行を消して、キーで動かないようにする
  • 残り時間を減らす行を消して、ゲームが終わらないようにする
わざと壊したゲームを見つけたかの結果。正常なゲームは4つとも合格。起動時にエラーが出るゲームは、4つとも不合格で、エラーの内容が記録された。キーで動かないゲームは、キーで動くチェックだけが不合格。終わらないゲームは、時間で終わるチェックだけが不合格
壊した場所に応じて、落ちるチェックが変わりました

正常なゲームは4つとも合格しました。起動時にエラーが出るゲームは、4つとも不合格で、エラーの内容(「ReferenceError: undefinedFunction is not defined」)も記録されました。キーで動かないゲームで落ちたのは、「キーで動く」の1つだけでした。終わらないゲームも、「時間で終わる」の1つだけです。4回とも、実行は約2.7秒で終わりました。

スクリプトは、1つでも不合格があると、終了コードを1にして終わります。自動で動かす仕組みに組み込めば、壊れたゲームを公開する前に止められます。

AIにゲームを書かせたあとに使う

AIが書いたゲームは、一見動いても、細かい部分が抜けていることがあります。この記事のスクリプトに、確認したい項目を足すと、毎回同じ手順で確かめられます。たとえば次のような項目です。

  • スコアが、操作に応じて増える
  • 終わったあとに、もう一度遊べる
  • 画面の大きさを変えても、エラーが出ない

これらの項目を、AIが書いたコードに実際に当てた結果は、確かめていません。

確かめられていないこと

  • ゲームの面白さや分かりやすさ自動テストは、動作とエラーしか確かめられません。
  • 実機のスマートフォンでの操作(画面の大きさだけは、起動の引数で変えられます)
  • Chrome以外のブラウザ
  • 画像を比べて、見た目のずれを見つける方法
  • macOSやLinuxでのChromeのパスと、起動の引数
  • キーを短く連続で押す操作(この記事は、押す・待つ・離す、の形だけです)

確かめていないものは、動かして確かめてから、別の記事で扱います。

うまくいかないとき

症状 確認すること
Chromeが起動しない スクリプトの先頭にあるChromeのパスが、実際の場所と合っているかを確かめます
つながらない ポート番号が、ほかの実行や、別のChromeと重なっていないかを確かめます
expectが不合格になる 式を、ブラウザのコンソールで評価して、値を確かめます。ゲームの変数が、letやconstで宣言されていると、window.の形では読めません。名前だけで書きます
キーを送っても動かない keyDownのあと、少し待ってからkeyUpを送っているかを確かめます
実行が終わらない ゲーム側で、ページの読み込みが止まっていないかを確かめます。loadの待ち時間を増やします

関連記事ブラウザゲームの作り方|HTML1枚で動くキャッチゲームと、当たり判定が抜ける条件

よくある疑問

PlaywrightやPuppeteerは、使わないのですか

この記事では、ライブラリなしで動くスクリプトを書きました。ほかの道具との比較は、確かめていません。

画面を出すこともできますか

起動の引数から、ヘッドレスの指定を外せば、画面が出ます。この記事では、画面を出さない形だけを確かめました。

公開したゲームも、テストできますか

URLを書き換えれば、公開済みのページも開けます。ただし、相手のサービスの規約や負荷を確かめてから、使ってください。この記事では、手元のサーバーでだけ確かめています。

この記事のまとめ

この記事のまとめ

  • 画面を出さないChromeに、キーを送り、ゲームの変数を読めば、動作を自動で確かめられる
  • わざと壊したゲーム3つは、壊した場所に応じたチェックで見つかった
  • テストの起動には、アナリティクスに数えられない設定を入れる
  • 面白さや、実機のスマートフォンでの操作は、確かめていない