エンジン別レシピ
どのレシピも、Keiko 自身のエンジンチェックで実行している最小限のゲームとテストです。ArrowRight を押している間は右に動き、クリック数を数える箱です。フックを自分のゲームにコピーして、ステートを合わせて書き換えてください。
テスト済みのバージョン:Phaser 4.2 と 3.x、PixiJS 8.22、KAPLAY 3001.0、Excalibur 0.32、three.js r186、debuff 0.3。
ひとつの共通テストがすべてで動きます。
// 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/ のフックを当てて、そこに置いてあるテストを実行します。
| ゲーム | エンジン | テストの内容 | 決定的 |
|---|---|---|---|
| Runner | Phaser 3 | スコア、ジャンプ、ゲームオーバー | ✔(同じシード) |
| Starshake | Phaser 3 | 開始、移動、射撃 | ✔(同じシード) |
| Puzzling Potions | PixiJS 8 | Play で盤面がすべて開く | ✔(同じシード) |
| Bubbo Bubbo | PixiJS 8 | Play でゲーム画面に到達する | ✘(理由) |
「同じシード」は、同じ 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を持ちます
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 つのルールで対応できました。
- 今の画面を知っているオブジェクトから始める。 Phaser なら
game.scene。PixiJS のゲームなら、たいていゲームがすでに持っているナビゲーションや画面マネージャーです。そのオブジェクトの現在の画面をsceneとして公開します。 - 画面ができあがるまでは
undefinedを返す。 そうすればplay({ ready: (g) => g.state !== undefined })がそれを待つので、フレーム数を当て推量せずに済みます。 - テストでアサートするものだけを公開する。 スコア、位置、フラグを 1、2 個。位置は丸めて、小さな浮動小数点の差が差分として出ないようにします。
- ボタンの位置はページピクセルで公開する。 ゲームは canvas をウィンドウに合わせて拡大縮小するので、
tap(x, y)はページ(CSS)ピクセルを使います。ボタンの範囲をステートに入れ、その真ん中をタップしましょう。
Phaser:アクティブなシーンを読む
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:画面マネージャーを読む
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 を押します。
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
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
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
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
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
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 コンテキストはあらゆる呼び出しを受け付けるので、シーン、マテリアル、レンダーループは動きます。何も描画されないだけです。
手書きのループ
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 の開発ツールを通してゲームを動かします。
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 のロジックテストを見てください。