kgames

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

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:
Mgames/fire-truck/game.js | 245+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mgames/fire-truck/index.html | 14+++++++++++++-
Mgames/fire-truck/lib.js | 18++++++++++++++++++
Mtests/browser/runner.js | 57+++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atests/unit/fire-truck-manual.test.js | 46++++++++++++++++++++++++++++++++++++++++++++++
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); +});