kgames

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

commit ef51f2228ec65e52f4ef824080d9262990310913
parent 837cd9820a0d2aed03b3a1c63c9500c4a8b12279
Author: Kyle Barlow <kb@kylebarlow.com>
Date:   Sat,  2 May 2026 11:20:03 -0700

fire-truck: fix stuck bugs, clear prompt on correct key, distance-aware fire destination

- Guard null currentStep in step() and advancePhase() to fix the most
  common stuck case (TypeError after fire route is exhausted)
- Clear promptDir immediately on correct keypress so arrow vanishes
  at the moment the right key is pressed; gate retrigger on promptResolved
- Extract _setupFireRoute() helper shared by _initFireDestination and
  _extinguishFire respawn; always restores state='driving' on any failure
  path so firefighting state can never get stuck
- Add stall watchdog: resets targetSpeed if truck is driving but
  speed stays 0 for >2s without a prompt
- Add bfsRoadDistances() to lib.js and distance-aware opts to
  pickFireDestination (targetDistance/variance); fire now spawns ~20
  road-cells ahead by default
- Add Fire distance dropdown to settings panel (10/20/30/50 blocks)
- 8 new tests (unit + browser); all 45 unit + 21 browser tests pass

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

Diffstat:
Mgames/fire-truck/game.js | 100++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------------
Mgames/fire-truck/index.html | 39++++++++++++++++++++++++++++++---------
Mgames/fire-truck/lib.js | 63++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mtests/browser/runner.js | 42++++++++++++++++++++++++++++++++++++++++++
Mtests/unit/route.test.js | 66++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 259 insertions(+), 51 deletions(-)

diff --git a/games/fire-truck/game.js b/games/fire-truck/game.js @@ -56,6 +56,7 @@ class FireTruckScene extends Phaser.Scene { this.fireExtinguishAccum = 0; this.spaceHeld = false; this.fireMode = false; + this._stallSince = 0; } create() { @@ -304,10 +305,13 @@ class FireTruckScene extends Phaser.Scene { this.initAudio(); if (move === this.promptDir) { this.promptResolved = true; + this.promptDir = null; this.failVisible = false; this.overlay.setFillStyle(0xe63946, 0); - this.state = 'driving'; - this.targetSpeed = MAX_SPEED * (window.GAME_SPEED_MULTIPLIER ?? 1.0); + if (this.state !== 'firefighting') { + this.state = 'driving'; + this.targetSpeed = MAX_SPEED * (window.GAME_SPEED_MULTIPLIER ?? 1.0); + } this.statusText.setText('Great! Keep driving.'); this.playSuccessSound(); this.successOverlay.setAlpha(0.12); @@ -404,8 +408,19 @@ class FireTruckScene extends Phaser.Scene { const promptTriggerDist = Math.max(MIN_PROMPT_DIST, this.speed * REACTION_TIME_S); + // F6: stall watchdog + if (this.state === 'driving' && this.targetSpeed > 0 && this.speed < 1 && !this.promptDir) { + if (!this._stallSince) this._stallSince = this.time.now; + if (this.time.now - this._stallSince > 2000) { + this.targetSpeed = BASE_SPEED * (window.GAME_SPEED_MULTIPLIER ?? 1.0); + this._stallSince = 0; + } + } else { + this._stallSince = 0; + } + let promptJustSet = false; - if (this.phase === 'approach' && !this.promptDir && dist <= promptTriggerDist) { + if (this.phase === 'approach' && !this.promptDir && !this.promptResolved && this.currentStep && dist <= promptTriggerDist) { this.promptDir = this.currentStep.move; this.promptResolved = false; this.promptShownAt = this.time.now; @@ -490,6 +505,13 @@ class FireTruckScene extends Phaser.Scene { advancePhase() { if (this.phase === 'approach') { if (this.promptDir && !this.promptResolved) return; + if (!this.currentStep) { + // Straight-line final segment to fire cell — fire trigger handles the rest + this.phase = 'exit'; + this.state = 'driving'; + this.targetSpeed = BASE_SPEED * (window.GAME_SPEED_MULTIPLIER ?? 1.0); + return; + } this.heading = this.currentStep.headingOut; this.segmentEnd = this.worldPoint(this.currentStep.exitX, this.currentStep.exitY); this.targetRotation = this.rotationForHeading(this.heading); @@ -569,33 +591,44 @@ class FireTruckScene extends Phaser.Scene { this.refreshDebug(); } - _initFireDestination() { - const island = this.island; + _setupFireRoute(island, fromCell) { const rng = FT.createSeededRng(Date.now()); - const dest = FT.pickFireDestination(island, rng); - if (!dest) return; + const targetDistance = window.GAME_FIRE_DISTANCE ?? 20; + const variance = window.GAME_FIRE_VARIANCE ?? 5; + const dest = FT.pickFireDestination(island, rng, { fromCell, targetDistance, variance }); + if (!dest) return false; this.fireCell = dest.roadCell; this.fireBuildingCell = dest.buildingCell; - const startCell = island.grid[island.routeStart.y][island.routeStart.x]; const targetCell = island.grid[this.fireCell.y][this.fireCell.x]; - const cellPath = FT.bfsRoadPath(island.grid, startCell, targetCell); - if (!cellPath) return; + const cellPath = FT.bfsRoadPath(island.grid, fromCell, targetCell); + if (!cellPath) { + this.fireCell = null; + this.fireBuildingCell = null; + return false; + } const route = FT.pathToRouteSteps(island.grid, cellPath, 0); this.route = route; this.routeIndex = 0; - this.currentStep = route[0]; + this.currentStep = route[0] ?? null; if (route.length === 0) { this.segmentEnd = { x: this.fireCell.x * CELL_SIZE, y: this.fireCell.y * CELL_SIZE }; - this.heading = FT.headingFromTo(cellPath[0], cellPath[1]); + if (cellPath.length >= 2) this.heading = FT.headingFromTo(cellPath[0], cellPath[1]); } else { this.heading = this.currentStep.headingIn; this.segmentEnd = this.worldPoint(this.currentStep.x, this.currentStep.y); } - this.targetRotation = this.rotationForHeading(this.heading); + return true; + } + + _initFireDestination() { + const island = this.island; + const fromCell = island.grid[island.routeStart.y][island.routeStart.x]; + const ok = this._setupFireRoute(island, fromCell); + if (!ok) return; this._createFireGraphics(); this.fireMode = true; } @@ -676,37 +709,30 @@ class FireTruckScene extends Phaser.Scene { this.time.delayedCall(600, () => { const island = this.island; - const rng = FT.createSeededRng(Date.now()); - const dest = FT.pickFireDestination(island, rng); - if (!dest) return; - this.fireCell = dest.roadCell; - this.fireBuildingCell = dest.buildingCell; - - // BFS from current truck position cell + const mult = window.GAME_SPEED_MULTIPLIER ?? 1.0; const truckX = Math.round(this.truck.x / CELL_SIZE); const truckY = Math.round(this.truck.y / CELL_SIZE); - const startCell = island.grid[truckY] && island.grid[truckY][truckX]; - if (!startCell || startCell.type !== FT.CELL_TYPES.ROAD) return; - const targetCell = island.grid[this.fireCell.y][this.fireCell.x]; - const cellPath = FT.bfsRoadPath(island.grid, startCell, targetCell); - if (!cellPath) return; - - const route = FT.pathToRouteSteps(island.grid, cellPath, 0); - this.route = route; - this.routeIndex = 0; - this.currentStep = route[0]; - - if (route.length === 0) { - this.segmentEnd = { x: this.fireCell.x * CELL_SIZE, y: this.fireCell.y * CELL_SIZE }; - } else { - this.heading = this.currentStep.headingIn; - this.segmentEnd = this.worldPoint(this.currentStep.x, this.currentStep.y); + const fromCell = (island.grid[truckY] && island.grid[truckY][truckX] && + island.grid[truckY][truckX].type === FT.CELL_TYPES.ROAD) + ? island.grid[truckY][truckX] + : island.grid[island.routeStart.y][island.routeStart.x]; + + const ok = this._setupFireRoute(island, fromCell); + if (!ok) { + this.fireMode = false; + this.phase = 'approach'; + this.promptDir = null; + this.promptResolved = false; + this.state = 'driving'; + this.targetSpeed = BASE_SPEED * mult; + this.statusText.setText('Watch for the next arrow.'); + return; } this.phase = 'approach'; this.promptDir = null; this.promptResolved = false; this.state = 'driving'; - this.targetSpeed = BASE_SPEED * (window.GAME_SPEED_MULTIPLIER ?? 1.0); + this.targetSpeed = BASE_SPEED * mult; this.statusText.setText('Watch for the next arrow.'); this._createFireGraphics(); }); diff --git a/games/fire-truck/index.html b/games/fire-truck/index.html @@ -85,12 +85,15 @@ } #settings-panel.open { display: block; } #settings-panel label { margin-right: 0.5rem; font-weight: 600; } - #speed-select { + #speed-select, #distance-select { padding: 0.25rem 0.5rem; font-family: inherit; border-radius: 6px; border: 1px solid #ccc; } + #settings-panel .setting-row { + margin-top: 0.6rem; + } #game-container { flex: 1; width: 100%; height: 100%; } </style> </head> @@ -98,14 +101,25 @@ <nav><a href="../../index.html">&larr; Back to KGames</a></nav> <div id="settings-btn">⚙️</div> <div id="settings-panel"> - <label for="speed-select">Speed:</label> - <select id="speed-select"> - <option value="0.5">0.5x</option> - <option value="1" selected>1x (Default)</option> - <option value="2">2x</option> - <option value="3">3x</option> - <option value="5">5x</option> - </select> + <div> + <label for="speed-select">Speed:</label> + <select id="speed-select"> + <option value="0.5">0.5x</option> + <option value="1" selected>1x (Default)</option> + <option value="2">2x</option> + <option value="3">3x</option> + <option value="5">5x</option> + </select> + </div> + <div class="setting-row"> + <label for="distance-select">Fire distance:</label> + <select id="distance-select"> + <option value="10">10 blocks</option> + <option value="20" selected>20 blocks (Default)</option> + <option value="30">30 blocks</option> + <option value="50">50 blocks</option> + </select> + </div> </div> <div id="hud"> <div class="title">Arrow Key Fire Truck</div> @@ -116,6 +130,9 @@ if (typeof window.GAME_SPEED_MULTIPLIER === 'undefined') { window.GAME_SPEED_MULTIPLIER = 1.0; } + if (typeof window.GAME_FIRE_DISTANCE === 'undefined') { + window.GAME_FIRE_DISTANCE = 20; + } document.getElementById('settings-btn').addEventListener('click', () => { document.getElementById('settings-panel').classList.toggle('open'); }); @@ -124,6 +141,10 @@ if (window.__FT_SCENE__) window.__FT_SCENE__.updateTargetSpeed(); e.target.blur(); }); + document.getElementById('distance-select').addEventListener('change', (e) => { + window.GAME_FIRE_DISTANCE = parseInt(e.target.value, 10); + e.target.blur(); + }); </script> <script src="https://cdn.jsdelivr.net/npm/phaser@3.80.1/dist/phaser.min.js"></script> <script src="lib.js"></script> diff --git a/games/fire-truck/lib.js b/games/fire-truck/lib.js @@ -357,8 +357,33 @@ return steps; } - function pickFireDestination(island, rng) { + function bfsRoadDistances(grid, startCell) { + const dist = new Map(); + dist.set(startCell, 0); + const queue = [startCell]; + while (queue.length) { + const cell = queue.shift(); + const d = dist.get(cell); + for (const card of ['N', 'E', 'S', 'W']) { + if (!cell.exits[card]) continue; + const nx = cell.x + DIRS[card].dx; + const ny = cell.y + DIRS[card].dy; + const next = grid[ny] && grid[ny][nx]; + if (!next || next.type !== CELL_TYPES.ROAD) continue; + if (dist.has(next)) continue; + dist.set(next, d + 1); + queue.push(next); + } + } + return dist; + } + + function pickFireDestination(island, rng, opts) { const rand = rng || createSeededRng(); + const targetDistance = opts && opts.targetDistance != null ? opts.targetDistance : 0; + const variance = opts && opts.variance != null ? opts.variance : 5; + const fromCell = opts && opts.fromCell; + const candidates = island.roads.filter(cell => { for (const card of ['N', 'E', 'S', 'W']) { const nx = cell.x + DIRS[card].dx; @@ -368,13 +393,40 @@ } return false; }); - const roadCell = randPick(rand, candidates); + if (!candidates.length) return null; + + let chosen; + if (fromCell && targetDistance > 0) { + const dists = bfsRoadDistances(island.grid, fromCell); + const minD = targetDistance - variance; + const maxD = targetDistance + variance; + const inRange = candidates.filter(c => { + const d = dists.get(c); + return d != null && d >= minD && d <= maxD; + }); + if (inRange.length) { + chosen = randPick(rand, inRange); + } else { + let bestDiff = Infinity; + for (const c of candidates) { + const d = dists.get(c); + if (d == null) continue; + const diff = Math.abs(d - targetDistance); + if (diff < bestDiff) { bestDiff = diff; chosen = c; } + } + if (!chosen) chosen = randPick(rand, candidates); + } + } else { + chosen = randPick(rand, candidates); + } + + if (!chosen) return null; for (const card of ['N', 'E', 'S', 'W']) { - const nx = roadCell.x + DIRS[card].dx; - const ny = roadCell.y + DIRS[card].dy; + const nx = chosen.x + DIRS[card].dx; + const ny = chosen.y + DIRS[card].dy; const neighbor = island.grid[ny] && island.grid[ny][nx]; if (neighbor && neighbor.type === CELL_TYPES.BUILDING) { - return { roadCell, buildingCell: neighbor }; + return { roadCell: chosen, buildingCell: neighbor }; } } return null; @@ -593,6 +645,7 @@ pickRandomRouteStart, headingFromTo, bfsRoadPath, + bfsRoadDistances, pathToRouteSteps, pickFireDestination, }; diff --git a/tests/browser/runner.js b/tests/browser/runner.js @@ -372,6 +372,48 @@ async function main() { if (after <= before) throw new Error(`Extinguish did not accumulate: ${before} -> ${after}`); }); + await test('fire-truck: correct arrow clears prompt immediately', async (page, url) => { + await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' }); + await page.waitForTimeout(1800); + // Teleport near segmentEnd so prompt triggers, then press the correct 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: 5000 }); + const correctKey = await page.evaluate(() => { + const dir = window.__FT_SCENE__.promptDir; + return dir === 'left' ? 'ArrowLeft' : dir === 'right' ? 'ArrowRight' : 'ArrowUp'; + }); + await page.keyboard.press(correctKey); + // promptDir must be null on the very next evaluate (no round-trip delay) + const afterPromptDir = await page.evaluate(() => window.__FT_SCENE__.promptDir); + if (afterPromptDir !== null) throw new Error(`promptDir should be null immediately, got: ${afterPromptDir}`); + const promptText = await page.evaluate(() => window.__FT_SCENE__.promptText.text); + if (promptText !== '') throw new Error(`promptText should be empty, got: "${promptText}"`); + }); + + await test('fire-truck: fire distance setting is honored at startup', async (page, url) => { + await page.addInitScript(() => { window.GAME_FIRE_DISTANCE = 25; }); + await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' }); + await page.waitForTimeout(1800); + const result = await page.evaluate(() => { + const s = window.__FT_SCENE__; + if (!s.fireCell) return { ok: false, reason: 'no fireCell' }; + const fromCell = s.island.grid[s.island.routeStart.y][s.island.routeStart.x]; + const FireTruckLib = window.FireTruckLib; + const dists = FireTruckLib.bfsRoadDistances(s.island.grid, fromCell); + const d = dists.get(s.island.grid[s.fireCell.y][s.fireCell.x]); + return { ok: d != null && d >= 20 && d <= 30, d }; + }); + if (!result.ok) throw new Error(`Fire distance ${result.d} not in [20,30] for targetDistance=25`); + }); + // ── Summary ─────────────────────────────────────────────────────────────── await browser.close(); diff --git a/tests/unit/route.test.js b/tests/unit/route.test.js @@ -11,6 +11,7 @@ const { HEADING_TO_CARD, pickRandomRouteStart, bfsRoadPath, + bfsRoadDistances, pathToRouteSteps, pickFireDestination, headingFromTo, @@ -202,4 +203,69 @@ describe('pickFireDestination()', () => { assert.equal(dest.roadCell.type, 'road'); assert.equal(dest.buildingCell.type, 'building'); }); + + it('respects targetDistance within variance', () => { + const island = buildIsland({ seed: 8, routeCount: 10 }); + const fromCell = island.grid[island.routeStart.y][island.routeStart.x]; + const dists = bfsRoadDistances(island.grid, fromCell); + for (let seed = 1; seed <= 5; seed++) { + const dest = pickFireDestination(island, createSeededRng(seed), { + fromCell, + targetDistance: 20, + variance: 5, + }); + assert.ok(dest, `seed ${seed}: dest should exist`); + const d = dists.get(dest.roadCell); + assert.ok(d != null, 'destination must be reachable'); + assert.ok(d >= 15 && d <= 25, `seed ${seed}: distance ${d} should be in [15,25]`); + } + }); + + it('falls back to closest when no candidates in range', () => { + const island = buildIsland({ seed: 8, routeCount: 10 }); + const fromCell = island.grid[island.routeStart.y][island.routeStart.x]; + const dest = pickFireDestination(island, createSeededRng(1), { + fromCell, + targetDistance: 1000, + variance: 0, + }); + assert.ok(dest, 'should return a fallback result rather than null'); + assert.equal(dest.roadCell.type, 'road'); + }); +}); + +describe('bfsRoadDistances()', () => { + it('assigns distance 0 to start cell and positive distances to others', () => { + const island = buildIsland({ seed: 5, routeCount: 10 }); + const start = island.roads[0]; + const dists = bfsRoadDistances(island.grid, start); + assert.equal(dists.get(start), 0); + for (const [cell, d] of dists) { + assert.ok(d >= 0, 'all distances non-negative'); + } + }); + + it('neighbors are at most 1 hop from each other', () => { + const island = buildIsland({ seed: 5, routeCount: 10 }); + const start = island.roads[0]; + const dists = bfsRoadDistances(island.grid, start); + for (const [cell] of dists) { + if (cell.type !== 'road') continue; + const d = dists.get(cell); + for (const card of ['N', 'E', 'S', 'W']) { + if (!cell.exits[card]) continue; + const { dx, dy } = { N: { dx: 0, dy: -1 }, E: { dx: 1, dy: 0 }, S: { dx: 0, dy: 1 }, W: { dx: -1, dy: 0 } }[card]; + const neighbor = island.grid[cell.y + dy] && island.grid[cell.y + dy][cell.x + dx]; + if (!neighbor || !dists.has(neighbor)) continue; + assert.ok(Math.abs(dists.get(neighbor) - d) <= 1, 'adjacent cells differ by at most 1'); + } + } + }); + + it('covers all road cells (connected graph)', () => { + const island = buildIsland({ seed: 5, routeCount: 10 }); + const start = island.roads[0]; + const dists = bfsRoadDistances(island.grid, start); + assert.equal(dists.size, island.roads.length); + }); });