kgames

KGames — free keyboard games for kids
git clone https://www.keyboard.games/code/kgames.git
Log | Files | Refs | README | LICENSE

commit 0c3443e82a212334ed421ae55c6fb03db24d2bbb
parent e7bf30e6b3a6a28a20bf432eaa6b84ee10ae0298
Author: Kyle Barlow <kb@kylebarlow.com>
Date:   Mon, 13 Jul 2026 17:56:08 -0700

Basic phone access

Diffstat:
MCLAUDE.md | 33+++++++++++++++++++++++++++++----
Mgames/animal-letter/game.js | 2+-
Mgames/animal-letter/index.html | 2++
Mgames/animal-spell/game.js | 2+-
Mgames/animal-spell/index.html | 2++
Mgames/fire-truck/game.js | 2+-
Mgames/fire-truck/index.html | 2++
Mgames/letter-find/game.js | 3++-
Mgames/letter-find/index.html | 2++
Mgames/work/game.js | 2+-
Mgames/work/index.html | 2++
Mjs/shared/game-shared.js | 10+++++++++-
Ajs/shared/touch-controls.js | 242+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtests/browser/runner.js | 125+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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();