commit a0399f95f80379b6954e7715b12051325e3b30b1
parent a55084f903e7db4ef4d3dc5d308d234625aec038
Author: Kyle Barlow <kb@kylebarlow.com>
Date: Tue, 14 Jul 2026 16:03:52 -0700
Advanced driving mode for fire truck
Diffstat:
5 files changed, 373 insertions(+), 7 deletions(-)
diff --git a/games/fire-truck/game.js b/games/fire-truck/game.js
@@ -82,6 +82,14 @@ function arrowLabel(dir) {
return dir ? (ARROWS[dir] || '') + ' ' + (NAMES[dir] || dir.toUpperCase()) : '';
}
+// Manual mode uses absolute compass directions (key = screen direction).
+const KEY_TO_HEADING = { ArrowUp: 'north', ArrowDown: 'south', ArrowLeft: 'west', ArrowRight: 'east' };
+
+function headingArrowLabel(heading) {
+ const LABELS = { north: '↑ UP', south: '↓ DOWN', west: '← LEFT', east: '→ RIGHT' };
+ return LABELS[heading] || '';
+}
+
// ── Shared cartoony art (hose minigame + endgame screen) ────────────────────
// Cartoon townspeople with a range of skin tones, hair colors, and hair
// styles. Baked once as textures; both the FireHoseScene street crowd and the
@@ -415,6 +423,15 @@ class FireTruckScene extends Phaser.Scene {
this.firesExtinguished = 0;
this.fireMode = false;
this._stallSince = 0;
+ // Manual control mode: truck moves only while an arrow key is held,
+ // cell-centre to cell-centre along the road graph.
+ this.controlMode = window.GAME_CONTROL_MODE ?? 'guided';
+ this.heldHeadings = []; // stack of held arrow-key headings, last wins
+ this.manualCell = null; // road cell the truck last centred on
+ this.manualTarget = null; // neighbouring road cell being driven toward
+ this.manualHintHeading = null;
+ this.fireDistances = null; // Map road cell -> graph distance to current fire
+ this._manualLastDist = null;
this.musicMuted = false;
this.sfxMuted = false;
this.sirenOn = false;
@@ -513,6 +530,7 @@ class FireTruckScene extends Phaser.Scene {
this.truck.rotation = this.targetRotation;
this.input.keyboard.on('keydown', this.onKeyDown, this);
+ this.input.keyboard.on('keyup', this.onKeyUp, this);
// This is a keyboard-driven game — players may never click the canvas, so
// start audio on the first key press too (a real keydown is a user gesture
// that satisfies the browser autoplay policy).
@@ -540,6 +558,7 @@ class FireTruckScene extends Phaser.Scene {
window.__FT_SCENE__ = this;
this._initFireDestination();
+ if (this.controlMode === 'manual') this._enterManualMode();
this._updateFireCounter();
this.refreshDebug();
}
@@ -1295,6 +1314,7 @@ class FireTruckScene extends Phaser.Scene {
updateTargetSpeed() {
const m = window.GAME_SPEED_MULTIPLIER ?? 1.0;
+ if (this.controlMode === 'manual') return; // manual speed follows held keys
if (this.state === 'driving') {
this.targetSpeed = (this.promptResolved ? MAX_SPEED : BASE_SPEED) * m;
} else if (this.state === 'waiting' || this.state === 'stopped') {
@@ -1313,6 +1333,12 @@ class FireTruckScene extends Phaser.Scene {
this.toggleLights();
return;
}
+ const held = KEY_TO_HEADING[event.key];
+ if (held && !event.repeat) {
+ this.heldHeadings = this.heldHeadings.filter((h) => h !== held);
+ this.heldHeadings.push(held);
+ }
+ if (this.controlMode === 'manual') return; // stepManual reads heldHeadings
const move = event.key === 'ArrowLeft' ? 'left' : event.key === 'ArrowRight' ? 'right' : event.key === 'ArrowUp' ? 'straight' : null;
if (!move || !this.promptDir) return;
if (move === this.promptDir) {
@@ -1334,6 +1360,11 @@ class FireTruckScene extends Phaser.Scene {
if (this.state === 'driving' || this.state === 'waiting') this.enterStopped();
}
+ onKeyUp(event) {
+ const held = KEY_TO_HEADING[event.key];
+ if (held) this.heldHeadings = this.heldHeadings.filter((h) => h !== held);
+ }
+
initAudio() {
if (this._audioInited) return;
this._audioInited = true;
@@ -1536,6 +1567,7 @@ class FireTruckScene extends Phaser.Scene {
step(dt) {
if (this.state === 'minigame') return; // FireHoseScene owns the action
+ if (this.controlMode === 'manual') { this.stepManual(dt); return; }
const m = window.GAME_SPEED_MULTIPLIER ?? 1.0;
if (this.speed < this.targetSpeed) this.speed = Math.min(this.targetSpeed, this.speed + ACCEL * m * dt);
if (this.speed > this.targetSpeed) this.speed = Math.max(this.targetSpeed, this.speed - BRAKE * m * dt);
@@ -1630,6 +1662,189 @@ class FireTruckScene extends Phaser.Scene {
this.refreshDebug();
}
+ // ── Manual control mode ────────────────────────────────────────────────
+ // The truck drives only while an arrow key is held, in that key's compass
+ // direction, snapping cell-centre to cell-centre along the road graph.
+ // Reversing (opposite key) is allowed mid-segment; turns happen at cell
+ // centres where the road graph has an exit that way.
+
+ setControlMode(mode) {
+ if (mode !== 'manual' && mode !== 'guided') return;
+ if (mode === this.controlMode) return;
+ this.controlMode = mode;
+ if (this.state === 'minigame') return; // applied when the minigame resumes us
+ if (mode === 'manual') this._enterManualMode();
+ else this._enterGuidedMode();
+ }
+
+ _nearestRoadCell() {
+ const gx = Math.round(this.truck.x / CELL_SIZE);
+ const gy = Math.round(this.truck.y / CELL_SIZE);
+ const cell = this.cellAt(gx, gy);
+ if (cell && cell.type === FT.CELL_TYPES.ROAD) return cell;
+ for (const card of ['N', 'E', 'S', 'W']) {
+ const n = this.cellAt(gx + FT.DIRS[card].dx, gy + FT.DIRS[card].dy);
+ if (n && n.type === FT.CELL_TYPES.ROAD) return n;
+ }
+ return this.island.grid[this.island.routeStart.y][this.island.routeStart.x];
+ }
+
+ _enterManualMode() {
+ this.promptDir = null;
+ this.promptResolved = false;
+ this.failVisible = false;
+ this.overlay.setFillStyle(0xe63946, 0);
+ this.manualCell = this._nearestRoadCell();
+ this.manualTarget = null;
+ const p = this.worldPoint(this.manualCell.x, this.manualCell.y);
+ this.truck.setPosition(p.x, p.y);
+ this.state = 'driving';
+ this.targetSpeed = 0;
+ this.speed = 0;
+ this._manualLastDist = this.fireDistances ? this.fireDistances.get(this.manualCell) : null;
+ this.statusText.setText('Hold an arrow key to drive to the fire!');
+ this._updateManualHint();
+ this.refreshDebug();
+ }
+
+ _enterGuidedMode() {
+ this.heldHeadings = [];
+ this.manualTarget = null;
+ this.manualHintHeading = null;
+ const fromCell = this._nearestRoadCell();
+ const p = this.worldPoint(fromCell.x, fromCell.y);
+ this.truck.setPosition(p.x, p.y);
+ let routed = false;
+ if (this.fireMode && this.fireCell) {
+ const targetCell = this.island.grid[this.fireCell.y][this.fireCell.x];
+ routed = this._applyRouteTo(fromCell, targetCell);
+ }
+ if (!routed) this.segmentEnd = { x: this.truck.x, y: this.truck.y };
+ this.promptDir = null;
+ this.promptResolved = false;
+ this.state = 'driving';
+ this.targetSpeed = BASE_SPEED * (window.GAME_SPEED_MULTIPLIER ?? 1.0);
+ this.statusText.setText('Watch for the next arrow.');
+ this.refreshDebug();
+ }
+
+ stepManual(dt) {
+ const m = window.GAME_SPEED_MULTIPLIER ?? 1.0;
+ if (!this.manualCell) this._enterManualMode();
+ const desired = this.heldHeadings.length ? this.heldHeadings[this.heldHeadings.length - 1] : null;
+
+ if (desired) {
+ if (!this.manualTarget) {
+ this._manualDepart(desired);
+ } else if (desired === FT.OPPOSITE[this.heading]) {
+ // Reverse mid-segment: head back toward the cell we just left.
+ const behind = this.manualCell;
+ this.manualCell = this.manualTarget;
+ this.manualTarget = behind;
+ this.heading = desired;
+ this.targetRotation = this.rotationForHeading(desired);
+ }
+ }
+
+ this.targetSpeed = (desired && this.manualTarget) ? MAX_SPEED * m : 0;
+ if (this.speed < this.targetSpeed) this.speed = Math.min(this.targetSpeed, this.speed + ACCEL * m * dt);
+ if (this.speed > this.targetSpeed) this.speed = Math.max(this.targetSpeed, this.speed - BRAKE * m * dt);
+
+ if (this.manualTarget && this.speed > 0) {
+ const end = this.worldPoint(this.manualTarget.x, this.manualTarget.y);
+ const dx = end.x - this.truck.x;
+ const dy = end.y - this.truck.y;
+ const dist = Math.hypot(dx, dy);
+ const travel = Math.min(dist, this.speed * dt);
+ if (dist > 0.001) {
+ this.truck.x += (dx / dist) * travel;
+ this.truck.y += (dy / dist) * travel;
+ this.debugDistance += travel;
+ }
+ if (dist - travel <= 0.8 * SCALE) {
+ this.truck.setPosition(end.x, end.y);
+ this._manualArrive();
+ }
+ }
+
+ // Fire proximity → enter firefighting
+ if (this.fireMode && this.fireCell && this.state === 'driving') {
+ const fdx = this.fireCell.x * CELL_SIZE - this.truck.x;
+ const fdy = this.fireCell.y * CELL_SIZE - this.truck.y;
+ if (Math.hypot(fdx, fdy) < FIRE_TRIGGER_DIST) {
+ this.enterFirefighting();
+ return;
+ }
+ }
+ if (this.fireGraphics) this._updateFireAnimation(this.time.now);
+
+ let diff = this.targetRotation - this.truck.rotation;
+ while (diff > Math.PI) diff -= 2 * Math.PI;
+ while (diff < -Math.PI) diff += 2 * Math.PI;
+ if (Math.abs(diff) > 0.005) {
+ this.truck.rotation += Math.sign(diff) * Math.min(Math.abs(diff), 5 * dt);
+ } else {
+ this.truck.rotation = this.targetRotation;
+ }
+
+ if (this.failVisible && this.time.now > this.warningUntil) {
+ this.failVisible = false;
+ this.overlay.setFillStyle(0xe63946, 0);
+ }
+ if (this.time.now > this.successUntil && this.successOverlay.alpha > 0) {
+ this.successOverlay.setAlpha(Math.max(0, this.successOverlay.alpha - 2 * dt));
+ }
+
+ this._updateLightsEffect();
+ this.refreshDebug();
+ }
+
+ _manualDepart(desired) {
+ const card = FT.HEADING_TO_CARD[desired];
+ const cell = this.manualCell;
+ if (cell.exits[card]) {
+ const next = this.cellAt(cell.x + FT.DIRS[card].dx, cell.y + FT.DIRS[card].dy);
+ if (next && next.type === FT.CELL_TYPES.ROAD) {
+ this.manualTarget = next;
+ this.heading = desired;
+ this.targetRotation = this.rotationForHeading(desired);
+ return;
+ }
+ }
+ this.statusText.setText('No road that way! Try another arrow.');
+ }
+
+ _manualArrive() {
+ this.manualCell = this.manualTarget;
+ this.manualTarget = null;
+ if (this.fireMode && this.fireDistances) {
+ const d = this.fireDistances.get(this.manualCell);
+ if (d != null && this._manualLastDist != null) {
+ if (d > this._manualLastDist) this._flagWrongWay();
+ else if (d < this._manualLastDist) this.statusText.setText('Great! Keep going!');
+ }
+ if (d != null) this._manualLastDist = d;
+ }
+ this._updateManualHint();
+ }
+
+ _flagWrongWay() {
+ this.failVisible = true;
+ this.warningUntil = this.time.now + 500;
+ this.overlay.setFillStyle(0xe63946, 0.15);
+ this.statusText.setText('Wrong way! Turn around — the fire is the other way!');
+ this.playFailSound();
+ }
+
+ _updateManualHint() {
+ if (!this.fireMode || !this.fireDistances || !this.manualCell) {
+ this.manualHintHeading = null;
+ return;
+ }
+ const card = FT.bestRoadDirection(this.island.grid, this.manualCell, this.fireDistances);
+ this.manualHintHeading = card ? FT.CARD_TO_HEADING[card] : null;
+ }
+
advancePhase() {
if (this.phase === 'approach') {
if (this.promptDir && !this.promptResolved) return;
@@ -1691,7 +1906,11 @@ class FireTruckScene extends Phaser.Scene {
this.debugDistancePx = this.debugDistance;
this.fps = this.game.loop.actualFps;
if (this.fpsText) this.fpsText.setText(`FPS: ${this.fps.toFixed(0)}`);
- this.promptText.setText(this.promptDir ? arrowLabel(this.promptDir) : '');
+ if (this.controlMode === 'manual') {
+ this.promptText.setText(this.manualHintHeading ? headingArrowLabel(this.manualHintHeading) : '');
+ } else {
+ this.promptText.setText(this.promptDir ? arrowLabel(this.promptDir) : '');
+ }
}
cellAt(x, y) {
@@ -1729,20 +1948,28 @@ class FireTruckScene extends Phaser.Scene {
this.fireBuildingCell = dest.buildingCell;
const targetCell = island.grid[this.fireCell.y][this.fireCell.x];
- const cellPath = FT.bfsRoadPath(island.grid, fromCell, targetCell);
- if (!cellPath) {
+ if (!this._applyRouteTo(fromCell, targetCell)) {
this.fireCell = null;
this.fireBuildingCell = null;
return false;
}
+ this.fireDistances = FT.bfsRoadDistances(island.grid, targetCell);
+ return true;
+ }
+
+ _applyRouteTo(fromCell, targetCell) {
+ const island = this.island;
+ const cellPath = FT.bfsRoadPath(island.grid, fromCell, targetCell);
+ if (!cellPath) return false;
const route = FT.pathToRouteSteps(island.grid, cellPath, 0);
this.route = route;
this.routeIndex = 0;
this.currentStep = route[0] ?? null;
+ this.phase = 'approach';
if (route.length === 0) {
- this.segmentEnd = { x: this.fireCell.x * CELL_SIZE, y: this.fireCell.y * CELL_SIZE };
+ this.segmentEnd = { x: targetCell.x * CELL_SIZE, y: targetCell.y * CELL_SIZE };
if (cellPath.length >= 2) this.heading = FT.headingFromTo(cellPath[0], cellPath[1]);
} else {
this.heading = this.currentStep.headingIn;
@@ -1863,6 +2090,9 @@ class FireTruckScene extends Phaser.Scene {
this.targetSpeed = 0;
this.speed = 0;
this.promptDir = null;
+ // Keyups delivered while this scene is paused are lost — drop held keys.
+ this.heldHeadings = [];
+ this.manualTarget = null;
this.statusText.setText('You made it! Put out the fire!');
this.promptText.setText('');
this.scene.launch('FireHoseScene', { fireNumber: this.firesExtinguished });
@@ -1914,12 +2144,14 @@ class FireTruckScene extends Phaser.Scene {
const ok = this._setupFireRoute(island, fromCell);
if (!ok) {
this.fireMode = false;
+ this.fireDistances = null;
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.');
+ if (this.controlMode === 'manual') this._enterManualMode();
+ else this.statusText.setText('Watch for the next arrow.');
return;
}
this.phase = 'approach';
@@ -1927,7 +2159,8 @@ class FireTruckScene extends Phaser.Scene {
this.promptResolved = false;
this.state = 'driving';
this.targetSpeed = BASE_SPEED * mult;
- this.statusText.setText('Watch for the next arrow.');
+ if (this.controlMode === 'manual') this._enterManualMode();
+ else this.statusText.setText('Watch for the next arrow.');
this._createFireGraphics();
}, 600);
}
diff --git a/games/fire-truck/index.html b/games/fire-truck/index.html
@@ -85,7 +85,7 @@
}
#settings-panel.open { display: block; }
#settings-panel label { margin-right: 0.5rem; font-weight: 600; }
- #speed-select, #distance-select {
+ #speed-select, #distance-select, #controls-select {
padding: 0.25rem 0.5rem;
font-family: inherit;
border-radius: 6px;
@@ -122,6 +122,13 @@
</select>
</div>
<div class="setting-row">
+ <label for="controls-select">Controls:</label>
+ <select id="controls-select">
+ <option value="guided" selected>Guided (Default)</option>
+ <option value="manual">Manual (hold arrows to drive)</option>
+ </select>
+ </div>
+ <div class="setting-row">
<label><input type="checkbox" id="music-toggle" checked> Music</label>
</div>
<div class="setting-row">
@@ -152,6 +159,11 @@
window.GAME_FIRE_DISTANCE = parseInt(e.target.value, 10);
e.target.blur();
});
+ document.getElementById('controls-select').addEventListener('change', (e) => {
+ window.GAME_CONTROL_MODE = e.target.value;
+ if (window.__FT_SCENE__) window.__FT_SCENE__.setControlMode(e.target.value);
+ e.target.blur();
+ });
document.getElementById('music-toggle').addEventListener('change', (e) => {
if (window.__FT_SCENE__) window.__FT_SCENE__.setMusicMuted(!e.target.checked);
});
diff --git a/games/fire-truck/lib.js b/games/fire-truck/lib.js
@@ -378,6 +378,23 @@
return dist;
}
+ // Cardinal ('N'/'E'/'S'/'W') that moves from `cell` to the road neighbour
+ // closest to the distMap origin (see bfsRoadDistances), or null if no
+ // neighbour improves on the current cell — e.g. when already at the origin.
+ function bestRoadDirection(grid, cell, distMap) {
+ let best = null;
+ let bestDist = distMap.has(cell) ? distMap.get(cell) : Infinity;
+ for (const card of CARDINALS) {
+ if (!cell.exits[card]) continue;
+ const row = grid[cell.y + DIRS[card].dy];
+ const next = row && row[cell.x + DIRS[card].dx];
+ if (!next || !distMap.has(next)) continue;
+ const d = distMap.get(next);
+ if (d < bestDist) { bestDist = d; best = card; }
+ }
+ return best;
+ }
+
function pickFireDestination(island, rng, opts) {
const rand = rng || createSeededRng();
const targetDistance = opts && opts.targetDistance != null ? opts.targetDistance : 0;
@@ -788,6 +805,7 @@
headingFromTo,
bfsRoadPath,
bfsRoadDistances,
+ bestRoadDirection,
pathToRouteSteps,
pickFireDestination,
hashCell,
diff --git a/tests/browser/runner.js b/tests/browser/runner.js
@@ -394,6 +394,63 @@ async function main() {
}
});
+ await test('fire-truck: manual mode drives only while a key is held', async (page, url) => {
+ await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' });
+ await page.waitForFunction(() => !!window.__FT_SCENE__ && !!window.__FT_SCENE__.truck, null, { timeout: 25000 });
+ await page.evaluate(() => window.__FT_SCENE__.setControlMode('manual'));
+ const before = await page.evaluate(() => window.__FT_SCENE__.debugDistancePx || 0);
+ // Pick any direction with a road exit from the anchored cell.
+ const key = await page.evaluate(() => {
+ const s = window.__FT_SCENE__;
+ const map = { N: 'ArrowUp', E: 'ArrowRight', S: 'ArrowDown', W: 'ArrowLeft' };
+ for (const c of ['N', 'E', 'S', 'W']) if (s.manualCell.exits[c]) return map[c];
+ return null;
+ });
+ if (!key) throw new Error('Manual anchor cell has no road exits');
+ await page.keyboard.down(key);
+ await page.waitForFunction(
+ (b) => (window.__FT_SCENE__.debugDistancePx || 0) > b + 50,
+ before,
+ { timeout: 5000 }
+ );
+ await page.keyboard.up(key);
+ // Truck brakes to a stop and then stays put (unless the held drive already
+ // reached the fire and launched the minigame, which also means it moved).
+ await page.waitForFunction(
+ () => window.__FT_SCENE__.speed === 0 || window.__FT_SCENE__.state === 'minigame',
+ null,
+ { timeout: 5000 }
+ );
+ const stopped = await page.evaluate(() => window.__FT_SCENE__.debugDistancePx);
+ await page.waitForTimeout(400);
+ const later = await page.evaluate(() => window.__FT_SCENE__.debugDistancePx);
+ if (later - stopped > 1) throw new Error(`Truck kept moving after key release (${(later - stopped).toFixed(1)}px)`);
+ });
+
+ await test('fire-truck: manual mode flags wrong-way driving', async (page, url) => {
+ await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' });
+ await page.waitForFunction(() => !!window.__FT_SCENE__ && !!window.__FT_SCENE__.truck, null, { timeout: 25000 });
+ const ok = await page.evaluate(() => {
+ const s = window.__FT_SCENE__;
+ s.setControlMode('manual');
+ if (!s.fireDistances || !s.manualCell) return false;
+ // Simulate arriving on a cell farther from the fire than the last one.
+ const grid = s.island.grid;
+ const here = s.manualCell;
+ let worse = null;
+ for (const c of ['N', 'E', 'S', 'W']) {
+ if (!here.exits[c]) continue;
+ const n = grid[here.y + window.FireTruckLib.DIRS[c].dy][here.x + window.FireTruckLib.DIRS[c].dx];
+ if (s.fireDistances.get(n) > s.fireDistances.get(here)) { worse = n; break; }
+ }
+ if (!worse) return false;
+ s.manualTarget = worse;
+ s._manualArrive();
+ return s.failVisible && s.statusText.text.includes('Wrong way');
+ });
+ if (!ok) throw new Error('Wrong-way feedback did not trigger');
+ });
+
await test('fire-truck: fire destination is set after load', async (page, url) => {
await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(1800);
diff --git a/tests/unit/fire-truck-manual.test.js b/tests/unit/fire-truck-manual.test.js
@@ -0,0 +1,46 @@
+'use strict';
+
+const test = require('node:test');
+const assert = require('node:assert');
+const FT = require('../../games/fire-truck/lib.js');
+
+function makeIsland(seed) {
+ return FT.buildIsland({ width: 50, height: 50, routeCount: 50, seed: seed ?? 1 });
+}
+
+function roads(island) {
+ const out = [];
+ for (const row of island.grid) for (const cell of row) {
+ if (cell.type === FT.CELL_TYPES.ROAD) out.push(cell);
+ }
+ return out;
+}
+
+test('bestRoadDirection always steps toward the BFS origin', () => {
+ const island = makeIsland(7);
+ const roadCells = roads(island);
+ const target = roadCells[Math.floor(roadCells.length / 2)];
+ const dist = FT.bfsRoadDistances(island.grid, target);
+
+ for (let i = 0; i < roadCells.length; i += 17) {
+ const cell = roadCells[i];
+ const card = FT.bestRoadDirection(island.grid, cell, dist);
+ if (cell === target) continue;
+ assert.ok(card, `direction exists from (${cell.x},${cell.y})`);
+ const next = island.grid[cell.y + FT.DIRS[card].dy][cell.x + FT.DIRS[card].dx];
+ assert.strictEqual(dist.get(next), dist.get(cell) - 1, 'moves one step closer');
+ }
+});
+
+test('bestRoadDirection is null at the origin itself', () => {
+ const island = makeIsland(3);
+ const target = roads(island)[0];
+ const dist = FT.bfsRoadDistances(island.grid, target);
+ assert.strictEqual(FT.bestRoadDirection(island.grid, target, dist), null);
+});
+
+test('bestRoadDirection is null when the distance map is empty', () => {
+ const island = makeIsland(3);
+ const cell = roads(island)[0];
+ assert.strictEqual(FT.bestRoadDirection(island.grid, cell, new Map()), null);
+});