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:
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">← 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);
+ });
});