commit 0c3443e82a212334ed421ae55c6fb03db24d2bbb
parent e7bf30e6b3a6a28a20bf432eaa6b84ee10ae0298
Author: Kyle Barlow <kb@kylebarlow.com>
Date: Mon, 13 Jul 2026 17:56:08 -0700
Basic phone access
Diffstat:
14 files changed, 421 insertions(+), 10 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
@@ -29,6 +29,8 @@ rendering that syntax checks miss (see the projection-bug incident).
| `games/<slug>/index.html` | HTML shell: CDN scripts, HUD buttons, game container |
| `games/<slug>/lib.js` | Pure logic (projection math, route gen) — dual-export for unit tests |
| `games/<slug>/game.js` | Phaser scene — all graphics via `generateTexture`, audio via Web Audio / Tone.js |
+| `js/shared/game-shared.js` | Shared `KGames.*` helpers (AudioSystem, win screen, confetti, progress bar) |
+| `js/shared/touch-controls.js` | On-screen keyboard / D-pad for phones — see "Mobile / touch controls" |
| `assets/thumbnails/<slug>.svg` | 160×130 viewBox, rx=18, stroke from palette, `#FFF5F5` fill |
| `tests/unit/` | `node --test` unit tests — no browser, no Phaser |
| `tests/browser/runner.js` | Playwright smoke tests — no JS errors + canvas renders |
@@ -55,18 +57,41 @@ sy = horizonY + scale × (CAM_Y_WORLD − worldY) × H/2
intentional and correct. The `sy = horizonY − worldY × scale` formula from
the first implementation was wrong (collapsed everything to the horizon).
+## Mobile / touch controls
+
+Phones have no physical keyboard and a Phaser canvas can't summon the native
+one, so `js/shared/touch-controls.js` renders an on-screen control bar whose
+buttons dispatch **synthetic `KeyboardEvent`s to `window`**. Phaser's keyboard
+plugin already listens on `window`, so every game's existing key handling works
+unchanged — game logic never references this module.
+
+- Opt in from the HTML shell, after `game.js`:
+ `<script>KGames.initTouchControls({ layout: 'alphabet' });</script>`
+- Layouts: `alphabet` (A–Z), `text` (A–Z + Space/Backspace/Enter, e.g. work),
+ `drive` (arrows + Siren/Lights/Spray, e.g. fire-truck).
+- Shown only on touch / coarse-pointer devices. Force with `?kbd=1` / `?kbd=0`
+ or `window.__FORCE_TOUCH_CONTROLS__` (tests use the latter).
+- The bar is a flex child below `#game-container`, so the canvas shrinks to fit
+ above it. Phaser's `Scale.RESIZE` ignores a synthetic window `resize` when the
+ window size is unchanged, so the module calls `game.scale.refresh()` directly.
+ **This requires the game instance on `window.__KG_GAME__`** — assign it where
+ you call `new Phaser.Game(...)`.
+
## Adding a new game
1. Create `games/<slug>/index.html` (clone from `games/fire-truck/index.html`)
2. Create `games/<slug>/lib.js` for pure (unit-testable) logic
3. Create `games/<slug>/game.js` for the Phaser scene
4. Add `render: { preserveDrawingBuffer: true }` to the `Phaser.Game` config
- (required for the Playwright pixel-reading tests to work)
+ (required for the Playwright pixel-reading tests to work), and assign the game
+ to `window.__KG_GAME__` so touch controls can resize it
5. Create `assets/thumbnails/<slug>.svg` (160×130, rx=18 rounded rect)
6. Replace a `Coming Soon` placeholder in `js/main.js`
-7. Add unit tests in `tests/unit/<slug>-*.test.js`
-8. Add browser test cases to `tests/browser/runner.js`
-9. Run `npm test` — all must pass before committing
+7. Wire up touch controls: `KGames.initTouchControls({ layout: ... })` in the
+ HTML shell (pick or add the layout that matches the game's keys)
+8. Add unit tests in `tests/unit/<slug>-*.test.js`
+9. Add browser test cases to `tests/browser/runner.js`
+10. Run `npm test` — all must pass before committing
## Color palette
diff --git a/games/animal-letter/game.js b/games/animal-letter/game.js
@@ -398,7 +398,7 @@ class AnimalLetterScene extends Phaser.Scene {
}
}
-new Phaser.Game({
+window.__KG_GAME__ = new Phaser.Game({
type: Phaser.AUTO,
parent: 'game-container',
backgroundColor: '#1A1A2E',
diff --git a/games/animal-letter/index.html b/games/animal-letter/index.html
@@ -78,5 +78,7 @@
<script src="../../js/shared/game-shared.js"></script>
<script src="lib.js"></script>
<script src="game.js"></script>
+ <script src="../../js/shared/touch-controls.js"></script>
+ <script>KGames.initTouchControls({ layout: 'alphabet' });</script>
</body>
</html>
diff --git a/games/animal-spell/game.js b/games/animal-spell/game.js
@@ -447,7 +447,7 @@ class AnimalSpellScene extends Phaser.Scene {
}
}
-new Phaser.Game({
+window.__KG_GAME__ = new Phaser.Game({
type: Phaser.AUTO,
parent: 'game-container',
backgroundColor: '#1A1A2E',
diff --git a/games/animal-spell/index.html b/games/animal-spell/index.html
@@ -79,5 +79,7 @@
<script src="../animal-letter/lib.js"></script>
<script src="lib.js"></script>
<script src="game.js"></script>
+ <script src="../../js/shared/touch-controls.js"></script>
+ <script>KGames.initTouchControls({ layout: 'alphabet' });</script>
</body>
</html>
diff --git a/games/fire-truck/game.js b/games/fire-truck/game.js
@@ -1318,7 +1318,7 @@ class FireTruckEndScene extends Phaser.Scene {
// framerate. Keep it on only for tests.
const IS_TEST = !!window.__TEST_MODE__;
-new Phaser.Game({
+window.__KG_GAME__ = new Phaser.Game({
type: Phaser.AUTO,
parent: 'game-container',
backgroundColor: '#6BC4E8',
diff --git a/games/fire-truck/index.html b/games/fire-truck/index.html
@@ -163,5 +163,7 @@
<script src="https://cdn.jsdelivr.net/npm/tone@14.7.77/build/Tone.js"></script>
<script src="lib.js"></script>
<script src="game.js"></script>
+ <script src="../../js/shared/touch-controls.js"></script>
+ <script>KGames.initTouchControls({ layout: 'drive' });</script>
</body>
</html>
diff --git a/games/letter-find/game.js b/games/letter-find/game.js
@@ -37,6 +37,7 @@ class LetterFindScene extends Phaser.Scene {
}
create() {
+ window.__LF_SCENE__ = this;
const W = this.scale.width;
const H = this.scale.height;
@@ -231,7 +232,7 @@ class LetterFindScene extends Phaser.Scene {
}
}
-new Phaser.Game({
+window.__KG_GAME__ = new Phaser.Game({
type: Phaser.AUTO,
parent: 'game-container',
backgroundColor: '#1A1A2E',
diff --git a/games/letter-find/index.html b/games/letter-find/index.html
@@ -56,5 +56,7 @@
<script src="https://cdn.jsdelivr.net/npm/tone@14.7.77/build/Tone.js"></script>
<script src="../../js/shared/game-shared.js"></script>
<script src="game.js"></script>
+ <script src="../../js/shared/touch-controls.js"></script>
+ <script>KGames.initTouchControls({ layout: 'alphabet' });</script>
</body>
</html>
diff --git a/games/work/game.js b/games/work/game.js
@@ -538,7 +538,7 @@ class WorkScene extends Phaser.Scene {
}
}
-new Phaser.Game({
+window.__KG_GAME__ = new Phaser.Game({
type: Phaser.AUTO,
parent: 'game-container',
backgroundColor: '#E8E8EC',
diff --git a/games/work/index.html b/games/work/index.html
@@ -56,5 +56,7 @@
<script src="../../js/shared/game-shared.js"></script>
<script src="lib.js"></script>
<script src="game.js"></script>
+ <script src="../../js/shared/touch-controls.js"></script>
+ <script>KGames.initTouchControls({ layout: 'text' });</script>
</body>
</html>
diff --git a/js/shared/game-shared.js b/js/shared/game-shared.js
@@ -200,6 +200,7 @@
this._seqStep = 0;
this._audioCtx = null;
this._speakTimer = null;
+ this._lastFailTime = null;
}
initMusic() {
@@ -261,8 +262,15 @@
playFailLick() {
if (!this.musicReady) return;
- const start = Tone.Transport.nextSubdivision('8n');
const s = 60 / this._bpm / 4;
+ // Two near-simultaneous wrong inputs (e.g. a two-finger tap on the touch
+ // keyboard) can resolve to the same subdivision; the monophonic failSynth
+ // then throws "start time must be strictly greater". Keep starts increasing.
+ let start = Tone.Transport.nextSubdivision('8n');
+ if (this._lastFailTime != null && start <= this._lastFailTime) {
+ start = this._lastFailTime + s;
+ }
+ this._lastFailTime = start + 3 * s;
['E4', 'Eb4', 'D4', 'Db4'].forEach((note, i) => {
this.failSynth.triggerAttackRelease(note, '16n', start + i * s);
});
diff --git a/js/shared/touch-controls.js b/js/shared/touch-controls.js
@@ -0,0 +1,242 @@
+// On-screen touch controls for phones / tablets.
+//
+// Games drive all their input through the keyboard (Phaser's keyboard plugin
+// listens on `window`). Phones have no physical keyboard and a Phaser canvas
+// can't summon the native one, so this module renders a DOM control bar whose
+// buttons dispatch synthetic KeyboardEvents to `window`. Every game's existing
+// key handling then works unchanged — no game logic touches this file.
+//
+// Usage (in a game's index.html, after game-shared.js + game.js):
+// <script src="../../js/shared/touch-controls.js"></script>
+// <script>KGames.initTouchControls({ layout: 'alphabet' });</script>
+//
+// Layouts: 'alphabet' (A–Z), 'text' (A–Z + Space/Backspace/Enter), 'drive'
+// (arrows + Siren/Lights/Spray for fire-truck).
+//
+// Shown only on touch / coarse-pointer devices. Force on/off with the URL
+// param ?kbd=1 / ?kbd=0 or window.__FORCE_TOUCH_CONTROLS__ (used by tests).
+
+(function (root) {
+ 'use strict';
+
+ root.KGames = root.KGames || {};
+
+ // ── Visibility gate ─────────────────────────────────────────────────────────
+
+ function shouldShow() {
+ try {
+ const params = new URLSearchParams(root.location.search);
+ if (params.get('kbd') === '1') return true;
+ if (params.get('kbd') === '0') return false;
+ } catch (_) {}
+ if (root.__FORCE_TOUCH_CONTROLS__ === true) return true;
+ if (root.__FORCE_TOUCH_CONTROLS__ === false) return false;
+ const coarse = root.matchMedia && root.matchMedia('(pointer: coarse)').matches;
+ const touch = 'ontouchstart' in root || (root.navigator && root.navigator.maxTouchPoints > 0);
+ return !!(coarse || touch);
+ }
+
+ // ── Synthetic key dispatch ──────────────────────────────────────────────────
+
+ function dispatchKey(type, d) {
+ const ev = new KeyboardEvent(type, {
+ key: d.key,
+ code: d.code,
+ keyCode: d.keyCode,
+ which: d.keyCode,
+ bubbles: true,
+ cancelable: true,
+ });
+ root.dispatchEvent(ev);
+ }
+
+ // ── Key descriptors ─────────────────────────────────────────────────────────
+
+ function letterKey(ch) {
+ return { label: ch, key: ch, code: 'Key' + ch, keyCode: ch.charCodeAt(0) };
+ }
+
+ function alphabetRows() {
+ const A = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('').map(letterKey);
+ return [A.slice(0, 9), A.slice(9, 18), A.slice(18, 26)];
+ }
+
+ function layoutRows(layout) {
+ if (layout === 'alphabet') return alphabetRows();
+
+ if (layout === 'text') {
+ const rows = alphabetRows();
+ rows.push([
+ { label: '⌫', key: 'Backspace', code: 'Backspace', keyCode: 8, grow: 1.6, kind: 'special', aria: 'Backspace' },
+ { label: 'space', key: ' ', code: 'Space', keyCode: 32, grow: 4, kind: 'special', aria: 'Space' },
+ { label: '⏎', key: 'Enter', code: 'Enter', keyCode: 13, grow: 1.6, kind: 'special', aria: 'Enter' },
+ ]);
+ return rows;
+ }
+
+ if (layout === 'drive') {
+ return [
+ [
+ { label: '◀', key: 'ArrowLeft', code: 'ArrowLeft', keyCode: 37, grow: 1, kind: 'arrow', aria: 'Turn left' },
+ { label: '▲', key: 'ArrowUp', code: 'ArrowUp', keyCode: 38, grow: 1, kind: 'arrow', aria: 'Go straight' },
+ { label: '▶', key: 'ArrowRight', code: 'ArrowRight', keyCode: 39, grow: 1, kind: 'arrow', aria: 'Turn right' },
+ ],
+ [
+ { label: '🔊 Siren', key: 's', code: 'KeyS', keyCode: 83, grow: 1, kind: 'special', aria: 'Siren' },
+ { label: '💡 Lights', key: 'l', code: 'KeyL', keyCode: 76, grow: 1, kind: 'special', aria: 'Lights' },
+ { label: '💦 Spray', key: ' ', code: 'Space', keyCode: 32, grow: 1, kind: 'special', hold: true, aria: 'Spray water' },
+ ],
+ ];
+ }
+
+ return [];
+ }
+
+ // ── DOM construction ────────────────────────────────────────────────────────
+
+ function makeKey(d) {
+ const btn = document.createElement('button');
+ btn.type = 'button';
+ btn.tabIndex = -1; // keep focus off these — game listens on window
+ btn.className = 'kg-key'
+ + (d.kind === 'special' ? ' kg-key-special' : '')
+ + (d.kind === 'arrow' ? ' kg-key-arrow' : '');
+ btn.textContent = d.label;
+ btn.setAttribute('aria-label', d.aria || d.label);
+ if (d.grow) btn.style.flexGrow = String(d.grow);
+
+ if (d.hold) {
+ const down = (e) => {
+ e.preventDefault();
+ if (btn._down) return;
+ btn._down = true;
+ btn.classList.add('kg-active');
+ dispatchKey('keydown', d);
+ };
+ const up = () => {
+ if (!btn._down) return;
+ btn._down = false;
+ btn.classList.remove('kg-active');
+ dispatchKey('keyup', d);
+ };
+ btn.addEventListener('pointerdown', down);
+ btn.addEventListener('pointerup', up);
+ btn.addEventListener('pointerleave', up);
+ btn.addEventListener('pointercancel', up);
+ } else {
+ // Tap: fire on pointerdown for snappy response (a full key press + release).
+ btn.addEventListener('pointerdown', (e) => {
+ e.preventDefault();
+ btn.classList.add('kg-active');
+ dispatchKey('keydown', d);
+ dispatchKey('keyup', d);
+ setTimeout(() => btn.classList.remove('kg-active'), 110);
+ });
+ }
+ return btn;
+ }
+
+ function injectStyle() {
+ if (document.getElementById('kg-touch-style')) return;
+ const css = `
+#game-container { flex: 1 1 auto !important; height: auto !important; min-height: 0 !important; }
+#kg-touch {
+ flex: 0 0 auto;
+ width: 100%;
+ background: rgba(18, 18, 32, 0.92);
+ padding: 6px 5px calc(6px + env(safe-area-inset-bottom, 0px));
+ display: flex;
+ flex-direction: column;
+ gap: 5px;
+ user-select: none;
+ -webkit-user-select: none;
+ touch-action: none;
+ z-index: 40;
+}
+#kg-touch .kg-row { display: flex; gap: 5px; justify-content: center; }
+#kg-touch .kg-key {
+ flex: 1 1 0;
+ min-width: 0;
+ max-width: 72px;
+ height: clamp(40px, 7.5vh, 60px);
+ border: none;
+ border-radius: 9px;
+ background: #353553;
+ color: #fff;
+ font-family: 'Fredoka', sans-serif;
+ font-weight: 600;
+ font-size: clamp(17px, 4.4vw, 25px);
+ line-height: 1;
+ padding: 0;
+ cursor: pointer;
+ -webkit-tap-highlight-color: transparent;
+ touch-action: none;
+ transition: background 0.06s, transform 0.06s;
+}
+#kg-touch .kg-key.kg-key-special { background: #2c3a52; max-width: none; font-size: clamp(14px, 3.6vw, 20px); }
+#kg-touch .kg-key.kg-key-arrow { max-width: none; height: clamp(52px, 11vh, 84px); font-size: clamp(26px, 8vw, 44px); }
+#kg-touch .kg-key.kg-active { background: #2EC4B6; transform: translateY(1px); }
+`;
+ const style = document.createElement('style');
+ style.id = 'kg-touch-style';
+ style.textContent = css;
+ document.head.appendChild(style);
+ }
+
+ function build(layout) {
+ if (document.getElementById('kg-touch')) return;
+ injectStyle();
+
+ const bar = document.createElement('div');
+ bar.id = 'kg-touch';
+
+ layoutRows(layout).forEach((row) => {
+ const rowEl = document.createElement('div');
+ rowEl.className = 'kg-row';
+ row.forEach((d) => rowEl.appendChild(makeKey(d)));
+ bar.appendChild(rowEl);
+ });
+
+ // Block context menu (long-press) and scrolling on the bar itself.
+ bar.addEventListener('contextmenu', (e) => e.preventDefault());
+
+ document.body.appendChild(bar);
+
+ // The bar shrinks #game-container. Phaser's Scale.RESIZE ignores a synthetic
+ // window 'resize' when the window dimensions are unchanged, so it won't notice
+ // the smaller parent on its own — call scale.refresh() directly. refresh()
+ // re-measures the parent and emits the scale 'resize' event the scenes already
+ // listen to, so the game canvas and layout both follow the keyboard.
+ const nudge = () => {
+ const game = root.__KG_GAME__;
+ if (game && game.scale) { try { game.scale.refresh(); } catch (_) {} }
+ root.dispatchEvent(new Event('resize'));
+ };
+ nudge();
+ requestAnimationFrame(nudge);
+ setTimeout(nudge, 250);
+ setTimeout(nudge, 600);
+
+ // The on-screen keyboard can change height across orientation changes; keep
+ // the canvas sized to the space above it.
+ if (root.ResizeObserver) {
+ try { new ResizeObserver(nudge).observe(bar); } catch (_) {}
+ }
+ root.addEventListener('orientationchange', () => setTimeout(nudge, 100));
+ }
+
+ // ── Public entry ────────────────────────────────────────────────────────────
+
+ function initTouchControls(opts) {
+ const layout = (opts && opts.layout) || 'alphabet';
+ if (!shouldShow()) return;
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', () => build(layout));
+ } else {
+ build(layout);
+ }
+ }
+
+ root.KGames.initTouchControls = initTouchControls;
+
+}(window));
diff --git a/tests/browser/runner.js b/tests/browser/runner.js
@@ -522,6 +522,131 @@ async function main() {
if (result.counter !== 0) throw new Error(`firesExtinguished should be 0 after restart, got ${result.counter}`);
});
+ // ── On-screen touch controls ────────────────────────────────────────────────
+
+ // Helper: returns an ElementHandle for the touch key whose label === text.
+ async function touchKey(page, text) {
+ const h = await page.evaluateHandle(
+ (t) => [...document.querySelectorAll('#kg-touch .kg-key')].find(b => b.textContent === t),
+ text
+ );
+ const el = h.asElement();
+ if (!el) throw new Error(`On-screen key "${text}" not found`);
+ return el;
+ }
+
+ await test('touch: controls hidden by default on non-touch browser', async (page, url) => {
+ await page.goto(`${url}/games/letter-find/`, { waitUntil: 'domcontentloaded' });
+ await page.waitForTimeout(800);
+ const bar = await page.$('#kg-touch');
+ if (bar) throw new Error('Touch bar should not render on a non-touch device');
+ });
+
+ await test('touch: ?kbd=1 forces the on-screen keyboard to render', async (page, url) => {
+ await page.goto(`${url}/games/letter-find/?kbd=1`, { waitUntil: 'domcontentloaded' });
+ await page.waitForSelector('#kg-touch', { timeout: 5000 });
+ const keys = await page.$$eval('#kg-touch .kg-key', els => els.length);
+ if (keys !== 26) throw new Error(`Expected 26 alphabet keys, got ${keys}`);
+ });
+
+ await test('touch: letter-find on-screen key advances score', async (page, url) => {
+ await page.addInitScript(() => { window.__FORCE_TOUCH_CONTROLS__ = true; });
+ await page.goto(`${url}/games/letter-find/`, { waitUntil: 'domcontentloaded' });
+ await page.waitForSelector('#kg-touch');
+ await page.waitForFunction(() => window.__LF_SCENE__ && window.__LF_SCENE__.accepting, null, { timeout: 5000 });
+ const target = await page.evaluate(() => window.__LF_SCENE__.targetChar);
+ const before = await page.evaluate(() => window.__LF_SCENE__.lettersFound);
+ const key = await touchKey(page, target);
+ await key.click();
+ await page.waitForTimeout(300);
+ const after = await page.evaluate(() => window.__LF_SCENE__.lettersFound);
+ if (after !== before + 1) throw new Error(`Touch key did not advance score: ${before} -> ${after}`);
+ });
+
+ await test('touch: animal-spell on-screen key advances letter index', async (page, url) => {
+ await page.addInitScript(() => { window.__FORCE_TOUCH_CONTROLS__ = true; });
+ await page.goto(`${url}/games/animal-spell/`, { waitUntil: 'domcontentloaded' });
+ await page.waitForSelector('#kg-touch');
+ await page.waitForFunction(() => window.__AS_SCENE__ && window.__AS_SCENE__.accepting, null, { timeout: 6000 });
+ const letter = await page.evaluate(() => window.__AS_SCENE__.wordLetters[0]);
+ const before = await page.evaluate(() => window.__AS_SCENE__.letterIndex);
+ const key = await touchKey(page, letter);
+ await key.click();
+ await page.waitForTimeout(400);
+ const after = await page.evaluate(() => window.__AS_SCENE__.letterIndex);
+ if (after !== before + 1) throw new Error(`Touch key did not advance letter index: ${before} -> ${after}`);
+ });
+
+ await test('touch: work text keyboard types name and submits on Enter', async (page, url) => {
+ await page.addInitScript(() => { window.__FORCE_TOUCH_CONTROLS__ = true; });
+ await page.goto(`${url}/games/work/`, { waitUntil: 'domcontentloaded' });
+ await page.waitForSelector('#kg-touch');
+ await page.evaluate(() => window.__WORK_SCENE__.enterMode('name'));
+ await page.waitForFunction(() => window.__WORK_SCENE__.acceptInput === true, null, { timeout: 5000 });
+ for (const ch of ['A', 'B', 'C']) {
+ const k = await touchKey(page, ch);
+ await k.click();
+ await page.waitForTimeout(50);
+ }
+ const enter = await touchKey(page, '⏎');
+ await enter.click();
+ await page.waitForTimeout(200);
+ const name = await page.evaluate(() => window.__WORK_SCENE__.playerName);
+ if (name !== 'ABC') throw new Error(`Expected playerName "ABC" via touch keyboard, got "${name}"`);
+ });
+
+ await test('touch: work keyboard has Space and Backspace keys', async (page, url) => {
+ await page.goto(`${url}/games/work/?kbd=1`, { waitUntil: 'domcontentloaded' });
+ await page.waitForSelector('#kg-touch');
+ const labels = await page.$$eval('#kg-touch .kg-key', els => els.map(e => e.textContent));
+ if (!labels.includes('space')) throw new Error('Space key missing from work keyboard');
+ if (!labels.includes('⌫')) throw new Error('Backspace key missing from work keyboard');
+ });
+
+ await test('touch: fire-truck drive pad clears prompt on correct arrow', async (page, url) => {
+ await page.addInitScript(() => { window.__FORCE_TOUCH_CONTROLS__ = true; });
+ await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' });
+ await page.waitForSelector('#kg-touch .kg-key-arrow');
+ await page.evaluate(() => {
+ const s = window.__FT_SCENE__;
+ const end = s.segmentEnd;
+ s.state = 'driving'; s.speed = 0; s.targetSpeed = 0;
+ s.truck.setPosition(end.x - 260, end.y);
+ s.step(0.016);
+ });
+ await page.waitForFunction(() => !!window.__FT_SCENE__.promptDir, null, { timeout: 6000 });
+ const dir = await page.evaluate(() => window.__FT_SCENE__.promptDir);
+ const label = dir === 'left' ? '◀' : dir === 'right' ? '▶' : '▲';
+ const arrow = await touchKey(page, label);
+ await arrow.click();
+ const after = await page.evaluate(() => window.__FT_SCENE__.promptDir);
+ if (after !== null) throw new Error(`promptDir should be null after touch arrow, got ${after}`);
+ });
+
+ await test('touch: fire-truck spray button accumulates extinguish (press-hold)', async (page, url) => {
+ await page.addInitScript(() => { window.__FORCE_TOUCH_CONTROLS__ = true; });
+ await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' });
+ await page.waitForSelector('#kg-touch');
+ await page.evaluate(() => {
+ const s = window.__FT_SCENE__;
+ if (s.fireCell) {
+ s.state = 'driving'; s.speed = 0; s.targetSpeed = 0;
+ s.truck.setPosition(s.fireCell.x * s.cellSize, s.fireCell.y * s.cellSize);
+ s.step(0.016);
+ }
+ });
+ await page.waitForFunction(() => window.__FT_SCENE__.state === 'firefighting', null, { timeout: 6000 });
+ const before = await page.evaluate(() => window.__FT_SCENE__.fireExtinguishAccum);
+ const spray = await touchKey(page, '💦 Spray');
+ const box = await spray.boundingBox();
+ await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
+ await page.mouse.down();
+ await page.waitForTimeout(400);
+ const after = await page.evaluate(() => window.__FT_SCENE__.fireExtinguishAccum);
+ await page.mouse.up();
+ if (after <= before) throw new Error(`Spray hold did not accumulate: ${before} -> ${after}`);
+ });
+
// ── Summary ───────────────────────────────────────────────────────────────
await browser.close();