本文へスキップ

エンジン別レシピ ​

どのレシピも、Keiko 自身のエンジンチェックで実行している最小限のゲームとテストです。ArrowRight を押している間は右に動き、クリック数を数える箱です。フックを自分のゲームにコピーして、ステートを合わせて書き換えてください。

テスト済みのバージョン:Phaser 4.2 と 3.x、PixiJS 8.22、KAPLAY 3001.0、Excalibur 0.32、three.js r186、debuff 0.3。

ひとつの共通テストがすべてで動きます。

ts
// tests/engine.test.ts
import { expect, play, test } from "keiko";

const ready = (g) => g.state !== undefined;

test("moves right while ArrowRight is held", async () => {
  const game = await play({ ready });
  const start = game.state.x;
  game.hold("ArrowRight", "1s");
  expect(game.state.x - start).toBeBetween(100, 130);
});

test("a click reaches the game", async () => {
  const game = await play({ ready });
  game.tap(160, 90).wait(2);
  expect(game.state.clicks).toBe(1);
});

test.deterministic("plays the same twice", (game) => game.hold("ArrowRight", 20).wait(10), { play: { ready } });

エンジンは起動の 1、2 フレーム後にシーンを組み立てることが多いので、ready でフックがステートを報告するまで待ちます。

実際のゲーム ​

Keiko は、Keiko のために書かれたわけではないオープンソースのゲームも、小さな window.__KEIKO__ フックをひとつずつ足すだけでプレイできます。下の表を再現するには、debuff リポジトリで一度 npm run compat を実行し、続いて node compat/real.mjs を実行します。各ゲームを固定したコミットで clone し、compat/real/ のフックを当てて、そこに置いてあるテストを実行します。

ゲームエンジンテストの内容決定的
RunnerPhaser 3スコア、ジャンプ、ゲームオーバー✔(同じシード)
StarshakePhaser 3開始、移動、射撃✔(同じシード)
Puzzling PotionsPixiJS 8Play で盤面がすべて開く✔(同じシード)
Bubbo BubboPixiJS 8Play でゲーム画面に到達する✘(理由)

「同じシード」は、同じ Math.random() シードでの 2 回の実行がステップ単位で一致する(sameSeed: true)という意味です。これらのゲームは出現や盤面に Math.random() を使っているので、シードが違えば違うゲームになります。

非同期の UI フロー(Play を押す、トゥイーンが終わる、次の画面がアセットを読み込む)を持つゲームでは run() と eventually() を使います。非同期のフローを見てください。

フック不要:Phaser と PixiJS ​

Phaser 3 か PixiJS 8 なら、Keiko はゲームにコードを足さずに、エンジン自体から game.engine を読み取ります。

  • scene:一番上のアクティブなシーン(Phaser)または画面(PixiJS)
  • scenes:そのすべて
  • targets:プレイヤーが押せるもの。tap(x, y) 用に、ページピクセルでの中心座標が付きます。表示されていて、入力を受け付けていて、画面内にあるオブジェクトで、type、text、name を持ちます
ts
const game = await play({ ready: (g) => g.engine?.scene === "HomeScreen" });
await game.tapWhenReady((g) => g.engine?.targets.find((t) => t.text === "Start Game"));
await game.eventually(() => game.engine?.scene === "GameScreen");

tapWhenReady はターゲットが現れて動きが止まるまで待つので、フェードやスライドで登場するメニューも、ボタンが落ち着いた位置で押されます。上の実際のゲームには、どれもこの書き方のテストもあります。テストでゲーム独自のステート(スコア、盤面)が必要なときは、下のフックを追加してください。Phaser 4 の ES モジュールビルドにはグローバルがないので、常にフックが必要です。

既存のゲームにつなぐ ​

上のエンジン別レシピは新規のゲームです。自分で書いていないゲームなら、足すコードはフックだけです。上の実際のゲームは、すべてこの 4 つのルールで対応できました。

  1. 今の画面を知っているオブジェクトから始める。 Phaser なら game.scene。PixiJS のゲームなら、たいていゲームがすでに持っているナビゲーションや画面マネージャーです。そのオブジェクトの現在の画面を scene として公開します。
  2. 画面ができあがるまでは undefined を返す。 そうすれば play({ ready: (g) => g.state !== undefined }) がそれを待つので、フレーム数を当て推量せずに済みます。
  3. テストでアサートするものだけを公開する。 スコア、位置、フラグを 1、2 個。位置は丸めて、小さな浮動小数点の差が差分として出ないようにします。
  4. ボタンの位置はページピクセルで公開する。 ゲームは canvas をウィンドウに合わせて拡大縮小するので、tap(x, y) はページ(CSS)ピクセルを使います。ボタンの範囲をステートに入れ、その真ん中をタップしましょう。

Phaser:アクティブなシーンを読む ​

ts
const game = new Phaser.Game(config);

if (import.meta.env.DEV) {
  window.__KEIKO__ = {
    scene: () => game.scene.getScenes(true)[0]?.scene.key,
    state: () => {
      const s = game.scene.getScene("game");
      if (!s?.player?.body) return undefined; // まだできていない
      return { score: s.score, y: Math.round(s.player.y), onGround: s.player.body.blocked.down };
    },
  };
}

PixiJS:画面マネージャーを読む ​

ts
if (import.meta.env.DEV) {
  window.__KEIKO__ = {
    scene: () => navigation.currentScreen?.constructor.name,
    state: () => {
      const screen = navigation.currentScreen;
      const toPage = app.canvas.clientWidth / app.renderer.width; // ステージのピクセル → ページのピクセル
      const b = screen?.playButton?.getBounds();
      return {
        screen: screen?.constructor.name,
        playButton: b && { x: (b.x + b.width / 2) * toPage, y: (b.y + b.height / 2) * toPage },
        score: screen?.game?.score ?? null,
      };
    },
  };
}

あとは、プレイヤーと同じようにテストが Play を押します。

ts
const game = await play({ ready: (g) => g.state?.playButton != null });
const { x, y } = game.state.playButton;
game.tap(x, y);
await game.eventually((s) => s.screen === "GameScreen", { within: "5s" });

ここでは until() ではなく eventually() を使います。画面は表示される前に、遷移のトゥイーンを await したり、アセットを読み込んだりすることが多いからです。

Phaser ​

ts
import Phaser from "phaser";

class Play extends Phaser.Scene {
  constructor() { super("play"); }
  create() {
    this.player = this.add.rectangle(160, 90, 16, 16, 0xcffb2d);
    this.cursors = this.input.keyboard.createCursorKeys();
    this.clicks = 0;
    this.input.on("pointerdown", () => this.clicks++);
  }
  update(_time, delta) {
    if (this.cursors.right.isDown) this.player.x += (120 * delta) / 1000;
  }
}

const game = new Phaser.Game({ type: Phaser.AUTO, width: 320, height: 180, scene: [Play] });

if (import.meta.env.DEV) {
  window.__KEIKO__ = {
    state: () => {
      const s = game.scene.getScene("play");
      return s?.player ? { x: Math.round(s.player.x), clicks: s.clicks } : undefined;
    },
    scene: () => game.scene.getScenes(true)[0]?.scene.key,
  };
}

Phaser はポインターの位置を pageX から読みます。Keiko がこの値を埋めるので、game.tap(x, y) はシーン内の (x, y) に届きます。乱数を決定的にするには、Phaser の生成器にシードを入れます:Phaser.Math.RND.sow(["seed"])。

PixiJS ​

ts
import { Application, Graphics } from "pixi.js";

const app = new Application();
await app.init({ width: 320, height: 180 });
document.querySelector("#app").appendChild(app.canvas);

const box = new Graphics().rect(0, 0, 16, 16).fill(0xcffb2d);
box.x = 160;
app.stage.addChild(box);
app.stage.eventMode = "static";
app.stage.hitArea = app.screen;
let clicks = 0;
app.stage.on("pointerdown", () => clicks++);

const keys = new Set();
addEventListener("keydown", (e) => keys.add(e.code));
addEventListener("keyup", (e) => keys.delete(e.code));
app.ticker.add((t) => {
  if (keys.has("ArrowRight")) box.x += (120 * t.deltaMS) / 1000;
});

if (import.meta.env.DEV) window.__KEIKO__ = { state: () => ({ x: Math.round(box.x), clicks }) };

PixiJS は最後に描画した内容に対して当たり判定をします。Keiko はテスト開始前に最初のフレームをプレイするので、タップはすぐに効きます。実行中の最初の PixiJS テストは、モジュールの読み込みで数秒かかります。

KAPLAY ​

ts
import kaplay from "kaplay";

const k = kaplay({ width: 320, height: 180, global: false });
const box = k.add([k.rect(16, 16), k.pos(160, 90)]);
let clicks = 0;
k.onMousePress(() => clicks++);
k.onUpdate(() => {
  if (k.isKeyDown("right")) box.pos.x += 120 * k.dt();
});

if (import.meta.env.DEV) window.__KEIKO__ = { state: () => ({ x: Math.round(box.pos.x), clicks }) };

KAPLAY は自分の canvas でキーを受け取ります。Keiko は、ほかに何もフォーカスされていなければキーをそこへ送ります。KAPLAY は起動時に組み込みアセットを読み込みますが、play() はそれを待ちます。

Excalibur ​

ts
import * as ex from "excalibur";

const game = new ex.Engine({ width: 320, height: 180, suppressPlayButton: true, displayMode: ex.DisplayMode.Fixed });
const box = new ex.Actor({ x: 160, y: 90, width: 16, height: 16, color: ex.Color.fromHex("#cffb2d") });
game.add(box);
let clicks = 0;
game.input.pointers.primary.on("down", () => clicks++);
box.on("preupdate", (e) => {
  if (game.input.keyboard.isHeld(ex.Keys.Right)) box.pos.x += (120 * e.elapsed) / 1000;
});
await game.start();

if (import.meta.env.DEV) window.__KEIKO__ = { state: () => ({ x: Math.round(box.pos.x), clicks }) };

suppressPlayButton は Excalibur の「クリックしてスタート」画面を飛ばします。これがないと、テストがその画面をクリックしなければなりません。

three.js ​

ts
import * as THREE from "three";

const renderer = new THREE.WebGLRenderer();
renderer.setSize(320, 180);
document.querySelector("#app").appendChild(renderer.domElement);
const scene = new THREE.Scene();
const camera = new THREE.OrthographicCamera(0, 320, 180, 0, -1, 1);
const box = new THREE.Mesh(new THREE.PlaneGeometry(16, 16), new THREE.MeshBasicMaterial({ color: 0xcffb2d }));
box.position.x = 160;
scene.add(box);
let clicks = 0;
renderer.domElement.addEventListener("pointerdown", () => clicks++);

const keys = new Set();
addEventListener("keydown", (e) => keys.add(e.code));
addEventListener("keyup", (e) => keys.delete(e.code));
let last;
renderer.setAnimationLoop((t) => {
  const dt = last === undefined ? 0 : (t - last) / 1000;
  last = t;
  if (keys.has("ArrowRight")) box.position.x += 120 * dt;
  renderer.render(scene, camera);
});

if (import.meta.env.DEV) window.__KEIKO__ = { state: () => ({ x: Math.round(box.position.x), clicks }) };

three.js には WebGL が必要です。Keiko の WebGL コンテキストはあらゆる呼び出しを受け付けるので、シーン、マテリアル、レンダーループは動きます。何も描画されないだけです。

手書きのループ ​

ts
const canvas = document.querySelector("canvas");
const ctx = canvas.getContext("2d");
const keys = new Set();
addEventListener("keydown", (e) => keys.add(e.key));
addEventListener("keyup", (e) => keys.delete(e.key));
let x = 160;
let last;

function frame(now) {
  const dt = last === undefined ? 0 : (now - last) / 1000;
  last = now;
  if (keys.has("ArrowRight")) x += 120 * dt;
  ctx.fillRect(x, 90, 16, 16);
  requestAnimationFrame(frame);
}
requestAnimationFrame(frame);

if (import.meta.env.DEV) window.__KEIKO__ = { state: () => ({ x: Math.round(x) }) };

debuff ​

debuff のゲームには何も追加する必要がありません。Keiko は package.json から debuff を検出し、どのテンプレートにも入っている debuff の開発ツールを通してゲームを動かします。

ts
if (import.meta.env.DEV) game.use(devTools({ input }));

game.state はアクティブなシーンの inspect() で、ステップは debuff の固定ステップです。パラメータとコマンドも debuff のもの(game.params.define、devTools({ commands }))を使います。テストは npx debuff test で実行し、エージェントは MCP サーバーの run_tests ツールから実行します。新規プロジェクトにはサンプルテストが入っています。debuff のロジックテストを見てください。