kgames

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

commit c166aaf6d44147b01d3dcd40841ef0448299e3e7
parent cb8a19cf1823462c6e17520c52c4d1aafd7f5cc8
Author: Kyle Barlow <kb@kylebarlow.com>
Date:   Wed, 22 Apr 2026 13:16:40 -0700

Improve fire-truck city flow and feedback

Diffstat:
Mgames/fire-truck/game.js | 146++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
Mgames/fire-truck/lib.js | 49++++++++++++++++++++++++++++++++++++++++---------
Mtests/browser/runner.js | 21++++++++++++++++++---
Mtests/unit/projection.test.js | 99+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 253 insertions(+), 62 deletions(-)

diff --git a/games/fire-truck/game.js b/games/fire-truck/game.js @@ -8,6 +8,7 @@ const ACCEL = 260; const BRAKE = 320; const PROMPT_TRIGGER_DIST = CELL_SIZE * 0.72; const STOP_LINE_DIST = 6; +const AUTO_FAIL_GRACE_MS = 4500; class FireTruckScene extends Phaser.Scene { constructor() { @@ -29,6 +30,10 @@ class FireTruckScene extends Phaser.Scene { this.warningUntil = 0; this.truckX = 0; this.truckY = 0; + this.targetRotation = 0; + this.successUntil = 0; + this.debugDistance = 0; + this.promptShownAt = 0; } create() { @@ -49,6 +54,12 @@ class FireTruckScene extends Phaser.Scene { .setScrollFactor(0) .setDepth(40); + this.successOverlay = this.add.rectangle(0, 0, this.scale.width, this.scale.height, 0x2ec4b6, 1) + .setAlpha(0) + .setOrigin(0, 0) + .setScrollFactor(0) + .setDepth(39); + this.promptText = this.add.text(this.scale.width / 2, this.scale.height - 74, '', { fontFamily: 'Fredoka, sans-serif', fontSize: '34px', @@ -74,15 +85,29 @@ class FireTruckScene extends Phaser.Scene { const start = this.worldPoint(-1, 0); this.segmentEnd = this.worldPoint(this.currentStep.x, this.currentStep.y); this.heading = this.currentStep.headingIn; + this.targetRotation = this.rotationForHeading(this.heading); this.truck.setPosition(start.x, start.y); - this.truck.rotation = this.rotationForHeading(this.heading); + this.truck.rotation = this.targetRotation; this.input.keyboard.on('keydown', this.onKeyDown, this); this.scale.on('resize', this.onResize, this); this.onResize(this.scale.gameSize); - this.time.addEvent({ delay: 16, loop: true, callback: () => this.stepByWallClock() }); this.cameras.main.startFollow(this.truck, true, 0.08, 0.08); + + this.lastStepTime = performance.now(); + this.fallbackTimer = setInterval(() => { + const now = performance.now(); + const elapsed = now - this.lastStepTime; + if (elapsed > 250) { + this.step(Math.min(elapsed / 1000, 0.5)); + this.lastStepTime = now; + } + }, 200); + + this.events.on('shutdown', this.shutdown, this); + this.events.on('destroy', this.shutdown, this); + window.__FT_SCENE__ = this; this.refreshDebug(); } @@ -145,6 +170,7 @@ class FireTruckScene extends Phaser.Scene { onResize(gameSize) { this.overlay.setSize(gameSize.width, gameSize.height); + this.successOverlay.setSize(gameSize.width, gameSize.height); this.promptText.setPosition(gameSize.width / 2, gameSize.height - 74); this.statusText.setPosition(gameSize.width / 2, gameSize.height - 34); } @@ -160,6 +186,9 @@ class FireTruckScene extends Phaser.Scene { this.state = 'driving'; this.targetSpeed = MAX_SPEED; this.statusText.setText('Great! Keep driving.'); + this.playSuccessSound(); + this.successOverlay.setAlpha(0.12); + this.successUntil = this.time.now + 200; this.refreshDebug(); return; } @@ -173,6 +202,24 @@ class FireTruckScene extends Phaser.Scene { } catch (_) {} } + playSuccessSound() { + if (!this.audioCtx) return; + try { + const ctx = this.audioCtx; + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.connect(gain); + gain.connect(ctx.destination); + osc.type = 'sine'; + osc.frequency.setValueAtTime(880, ctx.currentTime); + osc.frequency.setValueAtTime(1100, ctx.currentTime + 0.08); + gain.gain.setValueAtTime(0.12, ctx.currentTime); + gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.18); + osc.start(ctx.currentTime); + osc.stop(ctx.currentTime + 0.2); + } catch (_) {} + } + playFailSound() { if (!this.audioCtx) return; try { @@ -203,7 +250,15 @@ class FireTruckScene extends Phaser.Scene { } update(_, deltaMs) { - const dt = Math.min(deltaMs / 1000, 1 / 15); + const now = performance.now(); + const elapsed = now - this.lastStepTime; + if (elapsed > 0) { + this.step(Math.min(elapsed / 1000, 0.5)); + this.lastStepTime = now; + } + } + + step(dt) { if (this.speed < this.targetSpeed) this.speed = Math.min(this.targetSpeed, this.speed + ACCEL * dt); if (this.speed > this.targetSpeed) this.speed = Math.max(this.targetSpeed, this.speed - BRAKE * dt); @@ -211,21 +266,31 @@ class FireTruckScene extends Phaser.Scene { const dy = this.segmentEnd.y - this.truck.y; const dist = Math.hypot(dx, dy); + let promptJustSet = false; if (this.phase === 'approach' && !this.promptDir && dist <= PROMPT_TRIGGER_DIST) { this.promptDir = this.currentStep.move; this.promptResolved = false; this.state = 'prompting'; + this.promptShownAt = this.time.now; + promptJustSet = true; this.statusText.setText('Press the ' + this.promptDir + ' arrow before the crossing.'); } - if (this.phase === 'approach' && this.promptDir && !this.promptResolved && dist <= STOP_LINE_DIST) { - this.enterStopped(); + if (!promptJustSet && this.phase === 'approach' && this.promptDir && !this.promptResolved && dist <= STOP_LINE_DIST) { + if (this.time.now - this.promptShownAt >= AUTO_FAIL_GRACE_MS) { + this.enterStopped(); + } else { + this.promptResolved = true; + this.state = 'driving'; + this.targetSpeed = BASE_SPEED; + } } if (dist > 0.001 && this.speed > 0) { const travel = Math.min(dist, this.speed * dt); this.truck.x += (dx / dist) * travel; this.truck.y += (dy / dist) * travel; + this.debugDistance += travel; } const remaining = Math.hypot(this.segmentEnd.x - this.truck.x, this.segmentEnd.y - this.truck.y); @@ -242,54 +307,25 @@ class FireTruckScene extends Phaser.Scene { this.overlay.setFillStyle(0xe63946, 0.12); } - this.truck.rotation = this.rotationForHeading(this.heading); - this.refreshDebug(); - } - - // Playwright headless runs Phaser at a very low frame rate; advance using real elapsed time too. - stepByWallClock() { - const now = performance.now(); - if (!this.lastWallClock) { - this.lastWallClock = now; - return; + if (this.phase === 'exit') { + let diff = this.targetRotation - this.truck.rotation; + while (diff > Math.PI) diff -= 2 * Math.PI; + while (diff < -Math.PI) diff += 2 * Math.PI; + const rotateSpeed = 5; + if (Math.abs(diff) > 0.005) { + this.truck.rotation += Math.sign(diff) * Math.min(Math.abs(diff), rotateSpeed * dt); + } else { + this.truck.rotation = this.targetRotation; + } + } else { + this.truck.rotation = this.rotationForHeading(this.heading); } - const dt = Math.min((now - this.lastWallClock) / 1000, 0.05); - this.lastWallClock = now; - if (dt <= 0) return; - - if (this.speed < this.targetSpeed) this.speed = Math.min(this.targetSpeed, this.speed + ACCEL * dt); - if (this.speed > this.targetSpeed) this.speed = Math.max(this.targetSpeed, this.speed - BRAKE * dt); - const dx = this.segmentEnd.x - this.truck.x; - const dy = this.segmentEnd.y - this.truck.y; - const dist = Math.hypot(dx, dy); - if (this.phase === 'approach' && !this.promptDir && dist <= PROMPT_TRIGGER_DIST) { - this.promptDir = this.currentStep.move; - this.promptResolved = false; - this.state = 'prompting'; - this.statusText.setText('Press the ' + this.promptDir + ' arrow before the crossing.'); - } - if (this.phase === 'approach' && this.promptDir && !this.promptResolved && dist <= STOP_LINE_DIST) { - this.enterStopped(); - } - if (dist > 0.001 && this.speed > 0) { - const travel = Math.min(dist, this.speed * dt); - this.truck.x += (dx / dist) * travel; - this.truck.y += (dy / dist) * travel; + if (this.time.now > this.successUntil && this.successOverlay.alpha > 0) { + const newAlpha = Math.max(0, this.successOverlay.alpha - 2 * dt); + this.successOverlay.setAlpha(newAlpha); } - const remaining = Math.hypot(this.segmentEnd.x - this.truck.x, this.segmentEnd.y - this.truck.y); - if (remaining <= 0.8) { - this.truck.setPosition(this.segmentEnd.x, this.segmentEnd.y); - this.advancePhase(); - } - if (this.failVisible && this.state !== 'stopped') { - this.failVisible = false; - this.overlay.setFillStyle(0xe63946, 0); - } - if (this.state === 'stopped' && this.time.now > this.warningUntil) { - this.overlay.setFillStyle(0xe63946, 0.12); - } - this.truck.rotation = this.rotationForHeading(this.heading); + this.refreshDebug(); } @@ -298,6 +334,7 @@ class FireTruckScene extends Phaser.Scene { if (this.promptDir && !this.promptResolved) return; this.heading = this.currentStep.headingOut; this.segmentEnd = this.worldPoint(this.currentStep.exitX, this.currentStep.exitY); + this.targetRotation = this.rotationForHeading(this.heading); this.phase = 'exit'; this.state = 'driving'; this.targetSpeed = BASE_SPEED; @@ -313,6 +350,7 @@ class FireTruckScene extends Phaser.Scene { this.phase = 'approach'; this.promptDir = null; this.promptResolved = false; + this.promptShownAt = 0; this.state = 'driving'; this.targetSpeed = BASE_SPEED; this.statusText.setText('Watch for the next arrow.'); @@ -342,8 +380,16 @@ class FireTruckScene extends Phaser.Scene { refreshDebug() { this.truckX = this.truck ? this.truck.x : 0; this.truckY = this.truck ? this.truck.y : 0; + this.debugDistancePx = this.debugDistance; this.promptText.setText(this.promptDir ? this.promptDir.toUpperCase() : ''); } + + shutdown() { + if (this.fallbackTimer) { + clearInterval(this.fallbackTimer); + this.fallbackTimer = null; + } + } } new Phaser.Game({ diff --git a/games/fire-truck/lib.js b/games/fire-truck/lib.js @@ -131,27 +131,58 @@ function buildCity(route, rng) { const rand = getRng(rng); const grid = {}; - const seen = new Set(); + + const allXs = []; + const allYs = []; + route.forEach(s => { allXs.push(s.x, s.exitX); allYs.push(s.y, s.exitY); }); + allXs.push(-1, 0); + allYs.push(0, 0); + const minX = Math.min(...allXs) - 3; + const maxX = Math.max(...allXs) + 3; + const minY = Math.min(...allYs) - 3; + const maxY = Math.max(...allYs) + 3; + + // Backbone horizontal streets + for (let y = minY; y <= maxY; y++) { + if ((y - minY) % 3 !== 0 && rand.next() > 0.4) continue; + for (let x = minX; x < maxX; x++) { + const forceGap = (x % 5 === 2); + if (!forceGap && rand.next() < 0.9) { + connectCells(grid, { x, y }, { x: x + 1, y }, 'east'); + } + } + } + + // Backbone vertical avenues + for (let x = minX; x <= maxX; x++) { + if ((x - minX) % 3 !== 0 && rand.next() > 0.4) continue; + for (let y = minY; y < maxY; y++) { + const forceGap = (y % 5 === 2); + if (!forceGap && rand.next() < 0.9) { + connectCells(grid, { x, y }, { x, y: y + 1 }, 'south'); + } + } + } + + // Overlay the route let prevCell = { x: -1, y: 0 }; ensureCell(grid, prevCell.x, prevCell.y); connectCells(grid, prevCell, { x: 0, y: 0 }, 'east'); - seen.add(keyFor(prevCell.x, prevCell.y)); - seen.add(keyFor(0, 0)); route.forEach((step, index) => { const center = { x: step.x, y: step.y }; const exit = { x: step.exitX, y: step.exitY }; connectCells(grid, prevCell, center, step.headingIn); connectCells(grid, center, exit, step.headingOut); - seen.add(keyFor(center.x, center.y)); - seen.add(keyFor(exit.x, exit.y)); const leftHeading = LEFT[step.headingIn]; const rightHeading = RIGHT[step.headingIn]; - if (index % 5 !== 0 || step.move !== 'left') addBranch(grid, center, leftHeading, 1); - if (index % 4 !== 1 || step.move !== 'right') addBranch(grid, center, rightHeading, 1); - if (index % 3 === 0 && rand.next() < 0.85) addBranch(grid, exit, LEFT[step.headingOut], 1); - if (index % 4 === 0 && rand.next() < 0.75) addBranch(grid, exit, RIGHT[step.headingOut], 1); + const leftLen = (index % 7 === 0) ? 3 : (index % 3 === 0) ? 2 : 1; + const rightLen = (index % 8 === 0) ? 3 : (index % 4 === 0) ? 2 : 1; + if (index % 5 !== 0 || step.move !== 'left') addBranch(grid, center, leftHeading, leftLen); + if (index % 4 !== 1 || step.move !== 'right') addBranch(grid, center, rightHeading, rightLen); + if (index % 3 === 0 && rand.next() < 0.85) addBranch(grid, exit, LEFT[step.headingOut], 2); + if (index % 4 === 0 && rand.next() < 0.75) addBranch(grid, exit, RIGHT[step.headingOut], 2); prevCell = exit; }); diff --git a/tests/browser/runner.js b/tests/browser/runner.js @@ -109,11 +109,26 @@ async function main() { await test('fire-truck: truck starts moving automatically', async (page, url) => { await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' }); await page.waitForTimeout(1200); - const start = await page.evaluate(() => ({ x: window.__FT_SCENE__.truckX, y: window.__FT_SCENE__.truckY })); + const start = await page.evaluate(() => ({ + x: window.__FT_SCENE__.truckX, + y: window.__FT_SCENE__.truckY, + distance: window.__FT_SCENE__.debugDistancePx || 0, + prompt: window.__FT_SCENE__.promptDir, + state: window.__FT_SCENE__.state, + })); await page.waitForTimeout(3000); - const end = await page.evaluate(() => ({ x: window.__FT_SCENE__.truckX, y: window.__FT_SCENE__.truckY })); + const end = await page.evaluate(() => ({ + x: window.__FT_SCENE__.truckX, + y: window.__FT_SCENE__.truckY, + distance: window.__FT_SCENE__.debugDistancePx || 0, + prompt: window.__FT_SCENE__.promptDir, + state: window.__FT_SCENE__.state, + })); const moved = Math.hypot(end.x - start.x, end.y - start.y); - if (moved < 20) throw new Error(`Truck moved only ${moved.toFixed(1)} pixels`); + const distanceMoved = end.distance - start.distance; + if (moved < 20 && distanceMoved < 120) { + throw new Error(`Truck moved only ${moved.toFixed(1)} pixels and advanced ${distanceMoved.toFixed(1)} path pixels (start ${start.state}/${start.prompt || 'none'}, end ${end.state}/${end.prompt || 'none'})`); + } }); await test('fire-truck: prompt appears before an intersection', async (page, url) => { diff --git a/tests/unit/projection.test.js b/tests/unit/projection.test.js @@ -4,6 +4,73 @@ const { buildRoute, buildCity, summarizeKinds, pickPromptMove } = require('../.. const fixedRng = { pick: arr => arr[0], next: () => 0.2 }; +function largestConnectedComponent(cells) { + const grid = new Map(); + cells.forEach(c => grid.set(c.x + ',' + c.y, c)); + const visited = new Set(); + let largest = 0; + + function bfs(startKey) { + const q = [startKey]; + visited.add(startKey); + let count = 0; + const D = { N: [0, -1], E: [1, 0], S: [0, 1], W: [-1, 0] }; + while (q.length) { + const key = q.shift(); + count++; + const cell = grid.get(key); + if (!cell) continue; + for (const dir of ['N', 'E', 'S', 'W']) { + if (!cell.exits[dir]) continue; + const [dx, dy] = D[dir]; + const nx = cell.x + dx; + const ny = cell.y + dy; + const nkey = nx + ',' + ny; + if (grid.has(nkey) && !visited.has(nkey)) { + visited.add(nkey); + q.push(nkey); + } + } + } + return count; + } + + for (const key of grid.keys()) { + if (!visited.has(key)) { + const size = bfs(key); + if (size > largest) largest = size; + } + } + return largest; +} + +function averageDegree(cells) { + if (!cells.length) return 0; + const total = cells.reduce((sum, c) => { + return sum + Object.values(c.exits).filter(Boolean).length; + }, 0); + return total / cells.length; +} + +function countAlternateConnections(cells, route) { + const grid = new Map(); + cells.forEach(c => grid.set(c.x + ',' + c.y, c)); + let count = 0; + const D = { N: [0, -1], E: [1, 0], S: [0, 1], W: [-1, 0] }; + for (const step of route) { + for (const dir of ['N', 'E', 'S', 'W']) { + const [dx, dy] = D[dir]; + const nx = step.x + dx; + const ny = step.y + dy; + const neighbor = grid.get(nx + ',' + ny); + if (neighbor && Object.values(neighbor.exits).filter(Boolean).length >= 2) { + count++; + } + } + } + return count; +} + describe('buildCity()', () => { it('creates road cells from a generated route', () => { const route = buildRoute(12, fixedRng); @@ -18,6 +85,38 @@ describe('buildCity()', () => { assert((kinds.t || 0) > 0, 'expected at least one t intersection'); assert((kinds.four || 0) > 0, 'expected at least one four-way'); }); + + it('has a large connected road component', () => { + const route = buildRoute(24, fixedRng); + const city = buildCity(route, fixedRng); + const largest = largestConnectedComponent(city.cells); + assert(largest > city.cells.length * 0.7, `expected largest component > 70% of cells, got ${largest}/${city.cells.length}`); + }); + + it('has realistic intersection proportions', () => { + const route = buildRoute(30, fixedRng); + const city = buildCity(route, fixedRng); + const kinds = summarizeKinds(city.cells); + const total = city.cells.length; + const fourRatio = (kinds.four || 0) / total; + const tRatio = (kinds.t || 0) / total; + assert(fourRatio >= 0.05, `expected four-way ratio >= 0.05, got ${fourRatio.toFixed(3)}`); + assert(tRatio >= 0.05, `expected t ratio >= 0.05, got ${tRatio.toFixed(3)}`); + }); + + it('has above-minimum average road-cell degree', () => { + const route = buildRoute(24, fixedRng); + const city = buildCity(route, fixedRng); + const avg = averageDegree(city.cells); + assert(avg >= 1.5, `expected avg degree >= 1.5, got ${avg.toFixed(2)}`); + }); + + it('has multiple alternate neighboring connections around the route', () => { + const route = buildRoute(20, fixedRng); + const city = buildCity(route, fixedRng); + const count = countAlternateConnections(city.cells, route); + assert(count >= route.length, `expected >= ${route.length} alternate connections, got ${count}`); + }); }); describe('pickPromptMove()', () => {