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:
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()', () => {