commit d758800ace6fe2c86af546e3c23f573bed724738
parent ec87ac35dbc46c70c22b81192caa056f01c7c7b8
Author: Kyle Barlow <kb@kylebarlow.com>
Date: Mon, 4 May 2026 13:30:02 -0700
fire-truck: endgame scene, fires counter, audio robustness, speed/distance defaults
- Add FireTruckEndScene: coastal celebration with dancing people, dolphins,
animated water fan, color-cycling 'You did it!' text, and Play Again button
- Track fires extinguished (🔥 0/4 HUD counter); transition to endgame after 4
- Celebration song: triumphant Em/Am/G melody at 170 BPM, triangle-wave synth
- Fix audio: initAudio() fires on any keydown/pointerdown, not just S/Space;
playSuccessSound/playFailSound defensively call initAudio() themselves
- Default fire distance 20 → 10 blocks; add 5-block option to dropdown
- Speed 2.5× faster (BASE 170→425, MAX 220→550, ACCEL 260→650, BRAKE 320→800)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat:
3 files changed, 384 insertions(+), 13 deletions(-)
diff --git a/games/fire-truck/game.js b/games/fire-truck/game.js
@@ -2,16 +2,17 @@ const FT = window.FireTruckLib;
const SCALE = 5;
const CELL_SIZE = 96 * SCALE;
-const BASE_SPEED = 170;
-const MAX_SPEED = 220;
-const ACCEL = 260;
-const BRAKE = 320;
+const BASE_SPEED = 425;
+const MAX_SPEED = 550;
+const ACCEL = 650;
+const BRAKE = 800;
const REACTION_TIME_S = 2.0;
const MIN_PROMPT_DIST = CELL_SIZE * 0.72;
const STOP_LINE_DIST = Math.round(CELL_SIZE * 0.45);
const ROUTE_EXTENSION_COUNT = 36;
const FIRE_TRIGGER_DIST = CELL_SIZE * 0.6;
const FIRE_EXTINGUISH_S = 3.0;
+const FIRES_TO_WIN = 4;
const WATER_COLOR = 0x6bc4e8;
const BEACH_COLOR = 0xf2e2b6;
@@ -35,6 +36,24 @@ const FT_CHORDS = [
['A2','C3','E3'], null, null, null,
];
+// Celebration song for the endgame screen — related to the main theme
+// (same Em/Am/G harmonic language, brighter octave, triumphant ascending phrases)
+const FT_END_BPM = 170;
+const FT_END_MELODY = [
+ 'E5','G5','B5','E6', 'D6','B5','G5','A5',
+ 'A5','C6','E6','A6', 'G6','E6','D6','E6',
+];
+const FT_END_BASS = [
+ 'E3','G3','B3','E4', 'D4','B3','G3','A3',
+ 'A3','C4','E4','A4', 'G4','E4','B3','E4',
+];
+const FT_END_CHORDS = [
+ ['E3','G3','B3'], null, ['B2','D3','G3'], null,
+ ['G2','B2','D3'], null, ['A2','E3','A3'], null,
+ ['A2','C3','E3'], null, ['E3','A3','C4'], null,
+ ['G2','B2','D3'], null, ['E3','G3','B3'], null,
+];
+
function arrowLabel(dir) {
const ARROWS = { left: '←', right: '→', straight: '↑' };
const NAMES = { left: 'LEFT', right: 'RIGHT', straight: 'STRAIGHT' };
@@ -70,6 +89,7 @@ class FireTruckScene extends Phaser.Scene {
this.fireGraphics = null;
this.waterGraphics = null;
this.fireExtinguishAccum = 0;
+ this.firesExtinguished = 0;
this.spaceHeld = false;
this.fireMode = false;
this._stallSince = 0;
@@ -141,13 +161,22 @@ class FireTruckScene extends Phaser.Scene {
padding: { x: 6, y: 3 },
}).setScrollFactor(0).setDepth(50);
+ this.fireCountText = this.add.text(this.scale.width / 2, 78, '🔥 0 / 4', {
+ fontFamily: 'Fredoka, sans-serif',
+ fontSize: '26px',
+ color: '#ffffff',
+ stroke: '#2f2f2f',
+ strokeThickness: 7,
+ align: 'center',
+ }).setOrigin(0.5, 0).setScrollFactor(0).setDepth(50);
+
this.uiCamera = this.cameras.add(0, 0, this.scale.width, this.scale.height);
this.renderIsland();
this.createTruck();
this.uiCamera.ignore([...this.cityChunks, this.truck]);
- this.cameras.main.ignore([this.overlay, this.successOverlay, this.promptText, this.statusText, this.fpsText]);
+ this.cameras.main.ignore([this.overlay, this.successOverlay, this.promptText, this.statusText, this.fpsText, this.fireCountText]);
this.currentStep = this.route[0];
const start = this.worldPoint(this.island.routeStart.x, this.island.routeStart.y);
@@ -159,6 +188,7 @@ class FireTruckScene extends Phaser.Scene {
this.input.keyboard.on('keydown', this.onKeyDown, this);
this.input.keyboard.on('keyup', this.onKeyUp, this);
+ this.input.once('pointerdown', () => this.initAudio(), this);
this.scale.on('resize', this.onResize, this);
this.onResize(this.scale.gameSize);
@@ -179,6 +209,7 @@ class FireTruckScene extends Phaser.Scene {
window.__FT_SCENE__ = this;
this._initFireDestination();
+ this._updateFireCounter();
this.refreshDebug();
}
@@ -314,6 +345,7 @@ class FireTruckScene extends Phaser.Scene {
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);
+ this.fireCountText.setPosition(gameSize.width / 2, 78);
if (this.uiCamera) this.uiCamera.setSize(gameSize.width, gameSize.height);
}
@@ -327,13 +359,12 @@ class FireTruckScene extends Phaser.Scene {
}
onKeyDown(event) {
+ this.initAudio();
if (event.code === 'Space') {
- this.initAudio();
if (this.state === 'firefighting') this.spaceHeld = true;
return;
}
if (event.key === 's' || event.key === 'S') {
- this.initAudio();
this.toggleSiren();
return;
}
@@ -343,7 +374,6 @@ class FireTruckScene extends Phaser.Scene {
}
const move = event.key === 'ArrowLeft' ? 'left' : event.key === 'ArrowRight' ? 'right' : event.key === 'ArrowUp' ? 'straight' : null;
if (!move || !this.promptDir) return;
- this.initAudio();
if (move === this.promptDir) {
this.promptResolved = true;
this.promptDir = null;
@@ -497,6 +527,7 @@ class FireTruckScene extends Phaser.Scene {
}
playSuccessSound() {
+ this.initAudio();
if (!this.audioCtx || this.sfxMuted) return;
try {
const ctx = this.audioCtx;
@@ -515,6 +546,7 @@ class FireTruckScene extends Phaser.Scene {
}
playFailSound() {
+ this.initAudio();
if (!this.audioCtx || this.sfxMuted) return;
try {
const ctx = this.audioCtx;
@@ -789,6 +821,10 @@ class FireTruckScene extends Phaser.Scene {
return true;
}
+ _updateFireCounter() {
+ this.fireCountText.setText(`🔥 ${this.firesExtinguished} / ${FIRES_TO_WIN}`);
+ }
+
_initFireDestination() {
const island = this.island;
const fromCell = island.grid[island.routeStart.y][island.routeStart.x];
@@ -909,6 +945,8 @@ class FireTruckScene extends Phaser.Scene {
// Guard against re-entrant calls: step() stays in firefighting state for 600ms
// while the delayedCall is pending, so this can fire every frame otherwise.
if (!this.fireBuildingCell) return;
+ this.firesExtinguished += 1;
+ this._updateFireCounter();
this.successOverlay.setAlpha(0.12);
this.successUntil = this.time.now + 200;
this.playSuccessSound();
@@ -919,6 +957,11 @@ class FireTruckScene extends Phaser.Scene {
this.fireBuildingCell = null;
this.statusText.setText('Fire out! Great job!');
+ if (this.firesExtinguished >= FIRES_TO_WIN) {
+ this.time.delayedCall(900, () => this.scene.start('FireTruckEndScene'));
+ return;
+ }
+
this.time.delayedCall(600, () => {
const island = this.island;
const mult = window.GAME_SPEED_MULTIPLIER ?? 1.0;
@@ -971,6 +1014,287 @@ function colorForCell(cell) {
return BUILDING_PALETTE[Math.abs((cell.x * 13 + cell.y * 17) % BUILDING_PALETTE.length)];
}
+class FireTruckEndScene extends Phaser.Scene {
+ constructor() {
+ super({ key: 'FireTruckEndScene' });
+ this._hue = 0;
+ this._W = 0;
+ this._H = 0;
+ }
+
+ create() {
+ const W = this._W = this.scale.width;
+ const H = this._H = this.scale.height;
+
+ // Sky gradient
+ const skyGfx = this.add.graphics();
+ skyGfx.fillGradientStyle(0x87ceeb, 0x87ceeb, 0x5bacd8, 0x5bacd8, 1);
+ skyGfx.fillRect(0, 0, W, H * 0.52);
+
+ // Dense urban buildings (left 40%, varying heights)
+ const bldGfx = this.add.graphics();
+ const bldColors = BUILDING_PALETTE;
+ const numBlds = 10;
+ const bldZoneW = W * 0.40;
+ for (let i = 0; i < numBlds; i++) {
+ const bw = bldZoneW / numBlds;
+ const bh = H * (0.10 + ((i * 7 + 3) % 9) / 9 * 0.38);
+ const bx = i * bw;
+ const by = H * 0.52 - bh;
+ bldGfx.fillStyle(bldColors[i % bldColors.length], 1);
+ bldGfx.fillRect(bx, by, bw - 3, bh);
+ bldGfx.lineStyle(2, 0x000000, 0.12);
+ bldGfx.strokeRect(bx + 1, by + 1, bw - 5, bh - 2);
+ // Windows
+ bldGfx.fillStyle(0xffe8a0, 0.7);
+ for (let wy = by + 8; wy < H * 0.52 - 10; wy += 14) {
+ for (let wx = bx + 5; wx < bx + bw - 10; wx += 12) {
+ bldGfx.fillRect(wx, wy, 7, 8);
+ }
+ }
+ }
+
+ // Road
+ const roadGfx = this.add.graphics();
+ roadGfx.fillStyle(ASPHALT_COLOR, 1);
+ roadGfx.fillRect(0, H * 0.52, W, H * 0.10);
+ // Yellow dashes
+ roadGfx.fillStyle(STRIPE_COLOR, 1);
+ const dashW = W * 0.06;
+ const dashGap = W * 0.04;
+ const dashY = H * 0.52 + H * 0.048;
+ for (let dx = 0; dx < W; dx += dashW + dashGap) {
+ roadGfx.fillRect(dx, dashY, dashW, 5);
+ }
+
+ // Beach
+ const beachGfx = this.add.graphics();
+ beachGfx.fillStyle(BEACH_COLOR, 1);
+ beachGfx.fillRect(0, H * 0.62, W, H * 0.06);
+
+ // Ocean fill
+ const oceanGfx = this.add.graphics();
+ oceanGfx.fillStyle(0x2a9d8f, 1);
+ oceanGfx.fillRect(0, H * 0.68, W, H * 0.32);
+
+ // Wave graphics (cleared/redrawn each frame)
+ this.waveGfx = this.add.graphics();
+
+ // Fire truck (static, parked on road, facing right)
+ const truckX = W * 0.32;
+ const truckY = H * 0.565;
+ const truckGfx = this.add.graphics();
+ const tw = W * 0.10, th = H * 0.065;
+ // Body
+ truckGfx.fillStyle(0xe63946, 1);
+ truckGfx.fillRect(truckX - tw / 2, truckY - th / 2, tw, th);
+ // Cab (right side)
+ truckGfx.fillStyle(0xc02030, 1);
+ truckGfx.fillRect(truckX + tw * 0.18, truckY - th / 2, tw * 0.32, th);
+ // Windows
+ truckGfx.fillStyle(0x88ccff, 0.8);
+ truckGfx.fillRect(truckX + tw * 0.22, truckY - th * 0.38, tw * 0.24, th * 0.38);
+ // White stripe
+ truckGfx.fillStyle(0xffffff, 0.6);
+ truckGfx.fillRect(truckX - tw / 2, truckY + th * 0.1, tw, th * 0.12);
+ // Wheels
+ truckGfx.fillStyle(0x222222, 1);
+ truckGfx.fillCircle(truckX - tw * 0.28, truckY + th / 2 + 4, H * 0.018);
+ truckGfx.fillCircle(truckX + tw * 0.28, truckY + th / 2 + 4, H * 0.018);
+
+ // Water fan (cleared/redrawn each frame)
+ this.waterFanGfx = this.add.graphics();
+ this._truckFrontX = truckX + tw * 0.5;
+ this._truckFrontY = truckY;
+
+ // Dolphins (3) — ellipses, start below ocean line
+ this._dolphins = [];
+ for (let i = 0; i < 3; i++) {
+ const dx = W * (0.55 + i * 0.14);
+ const d = this.add.ellipse(dx, H * 0.88, W * 0.035, H * 0.025, 0x1a7a7a);
+ d.setAlpha(0);
+ this._dolphins.push(d);
+ const launchDolphin = (dol) => {
+ dol.setAlpha(1).setAngle(-25);
+ this.tweens.add({
+ targets: dol, y: H * 0.72, angle: 0,
+ duration: 550, ease: 'Sine.easeOut',
+ onComplete: () => {
+ this.tweens.add({
+ targets: dol, y: H * 0.88, angle: 25,
+ duration: 550, ease: 'Sine.easeIn',
+ onComplete: () => {
+ dol.setAlpha(0);
+ this.time.delayedCall(900 + i * 300, () => launchDolphin(dol));
+ }
+ });
+ }
+ });
+ };
+ this.time.delayedCall(i * 1100, () => launchDolphin(d));
+ }
+
+ // Dancing people (5) on road
+ const personColors = [0xe63946, 0x1d7cf2, 0xffd23f, 0x2ec4b6, 0xff8c42];
+ for (let i = 0; i < 5; i++) {
+ const px = W * (0.50 + i * 0.09);
+ const py = H * 0.565;
+ const person = this.add.container(px, py);
+
+ const headR = H * 0.022;
+ const bodyH = H * 0.048;
+ const armW = H * 0.032;
+ const armH = H * 0.010;
+
+ const head = this.add.circle(0, -bodyH / 2 - headR, headR, personColors[i % personColors.length]);
+ const body = this.add.rectangle(0, 0, headR * 1.2, bodyH, personColors[i % personColors.length]);
+ const armL = this.add.rectangle(-headR * 0.6 - armW / 2, -bodyH * 0.2, armW, armH, personColors[i % personColors.length]);
+ const armR = this.add.rectangle(headR * 0.6 + armW / 2, -bodyH * 0.2, armW, armH, personColors[i % personColors.length]);
+
+ person.add([head, body, armL, armR]);
+
+ // Bounce person up/down
+ this.tweens.add({
+ targets: person, y: py - H * 0.015,
+ duration: 350 + i * 60, ease: 'Sine.easeInOut',
+ yoyo: true, repeat: -1,
+ });
+ // Wave arms
+ this.tweens.add({
+ targets: armL, angle: { from: -45, to: 10 },
+ duration: 380 + i * 60, ease: 'Sine.easeInOut',
+ yoyo: true, repeat: -1,
+ });
+ this.tweens.add({
+ targets: armR, angle: { from: 45, to: -10 },
+ duration: 380 + i * 60, ease: 'Sine.easeInOut',
+ yoyo: true, repeat: -1, delay: (380 + i * 60) / 2,
+ });
+ }
+
+ // "You did it!" text
+ this.didItText = this.add.text(W / 2, H * 0.30, 'You did it!', {
+ fontFamily: 'Fredoka, sans-serif',
+ fontSize: Math.round(W / 8) + 'px',
+ color: '#ffffff',
+ stroke: '#2f2f2f',
+ strokeThickness: 10,
+ align: 'center',
+ }).setOrigin(0.5).setDepth(60);
+
+ this.tweens.add({
+ targets: this.didItText,
+ scaleX: { from: 0.91, to: 1.09 },
+ scaleY: { from: 0.91, to: 1.09 },
+ angle: { from: -5, to: 5 },
+ duration: 650,
+ ease: 'Sine.easeInOut',
+ yoyo: true,
+ repeat: -1,
+ });
+
+ // "Play Again" button
+ const btnY = H * 0.91;
+ const btnBg = this.add.rectangle(W / 2, btnY, 210, 56, 0xe63946)
+ .setStrokeStyle(4, 0x9b1c25)
+ .setInteractive({ useHandCursor: true })
+ .setDepth(61);
+ const btnText = this.add.text(W / 2, btnY, 'Play Again', {
+ fontFamily: 'Fredoka, sans-serif',
+ fontSize: '26px',
+ color: '#ffffff',
+ }).setOrigin(0.5).setDepth(62);
+
+ btnBg.on('pointerover', () => btnBg.setFillStyle(0xff6b6b));
+ btnBg.on('pointerout', () => btnBg.setFillStyle(0xe63946));
+ btnBg.on('pointerdown', () => this.scene.start('FireTruckScene'));
+
+ // Celebration music — starts immediately since user has already been playing
+ try {
+ this._endGain = new Tone.Gain(0.7).toDestination();
+
+ this._endMelodySynth = new Tone.Synth({
+ oscillator: { type: 'triangle' },
+ envelope: { attack: 0.006, decay: 0.10, sustain: 0.22, release: 0.08 },
+ }).connect(this._endGain);
+ this._endMelodySynth.volume.value = -14;
+
+ this._endBassSynth = new Tone.Synth({
+ oscillator: { type: 'sawtooth' },
+ envelope: { attack: 0.005, decay: 0.08, sustain: 0.10, release: 0.05 },
+ }).connect(this._endGain);
+ this._endBassSynth.volume.value = -18;
+
+ this._endChordSynth = new Tone.PolySynth(Tone.Synth, {
+ oscillator: { type: 'square' },
+ envelope: { attack: 0.005, decay: 0.06, sustain: 0.08, release: 0.03 },
+ }).connect(this._endGain);
+ this._endChordSynth.volume.value = -26;
+
+ let step = 0;
+ new Tone.Sequence((time, note) => {
+ const i = step % FT_END_MELODY.length;
+ step++;
+ this._endMelodySynth.triggerAttackRelease(note, '8n', time);
+ this._endBassSynth.triggerAttackRelease(FT_END_BASS[i], '8n', time);
+ if (FT_END_CHORDS[i]) this._endChordSynth.triggerAttackRelease(FT_END_CHORDS[i], '8n', time);
+ }, FT_END_MELODY, '8n').start(0);
+
+ Tone.Transport.bpm.value = FT_END_BPM;
+ Tone.Transport.start();
+ } catch (_) {}
+
+ window.__FT_END_SCENE__ = this;
+ this.events.on('shutdown', this._shutdown, this);
+ this.events.on('destroy', this._shutdown, this);
+ }
+
+ _shutdown() {
+ try { Tone.Transport.stop(); } catch (_) {}
+ if (this._endGain) { try { this._endGain.dispose(); } catch (_) {} this._endGain = null; }
+ if (this._endMelodySynth) { try { this._endMelodySynth.dispose(); } catch (_) {} this._endMelodySynth = null; }
+ if (this._endBassSynth) { try { this._endBassSynth.dispose(); } catch (_) {} this._endBassSynth = null; }
+ if (this._endChordSynth) { try { this._endChordSynth.dispose(); } catch (_) {} this._endChordSynth = null; }
+ window.__FT_END_SCENE__ = null;
+ }
+
+ update(time, delta) {
+ const dt = delta / 1000;
+ const W = this._W;
+ const H = this._H;
+
+ // Color-cycle "You did it!" text
+ this._hue = (this._hue + dt * 0.18) % 1;
+ const c = Phaser.Display.Color.HSLToColor(this._hue, 0.85, 0.62);
+ this.didItText.setColor(Phaser.Display.Color.RGBToString(c.r, c.g, c.b));
+
+ // Ocean waves
+ this.waveGfx.clear();
+ for (let w = 0; w < 3; w++) {
+ const waveY = H * 0.70 + w * H * 0.055;
+ this.waveGfx.lineStyle(3, 0x5bc8d0, 0.45 - w * 0.1);
+ this.waveGfx.beginPath();
+ let first = true;
+ for (let x = 0; x <= W; x += 4) {
+ const y = waveY + Math.sin((x / W) * Math.PI * 6 + time * 0.002 + w * 1.2) * H * 0.012;
+ if (first) { this.waveGfx.moveTo(x, y); first = false; } else { this.waveGfx.lineTo(x, y); }
+ }
+ this.waveGfx.strokePath();
+ }
+
+ // Water fan from truck front
+ this.waterFanGfx.clear();
+ this.waterFanGfx.lineStyle(3, 0x88ddff, 0.7);
+ const tfx = this._truckFrontX;
+ const tfy = this._truckFrontY;
+ for (let r = 0; r < 7; r++) {
+ const angle = -0.28 + r * 0.09 + Math.sin(time * 0.004 + r) * 0.04;
+ const len = W * 0.09 + Math.sin(time * 0.003 + r * 0.7) * W * 0.01;
+ this.waterFanGfx.lineBetween(tfx, tfy, tfx + Math.cos(angle) * len, tfy + Math.sin(angle) * len);
+ }
+ }
+}
+
// preserveDrawingBuffer is required so Playwright tests can readPixels the
// canvas, but it disables compositor fast paths on some drivers and tanks
// framerate. Keep it on only for tests.
@@ -990,5 +1314,5 @@ new Phaser.Game({
antialias: false,
pixelArt: false,
},
- scene: [FireTruckScene],
+ scene: [FireTruckScene, FireTruckEndScene],
});
diff --git a/games/fire-truck/index.html b/games/fire-truck/index.html
@@ -114,8 +114,9 @@
<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="5">5 blocks</option>
+ <option value="10" selected>10 blocks (Default)</option>
+ <option value="20">20 blocks</option>
<option value="30">30 blocks</option>
<option value="50">50 blocks</option>
</select>
@@ -137,7 +138,7 @@
window.GAME_SPEED_MULTIPLIER = 1.0;
}
if (typeof window.GAME_FIRE_DISTANCE === 'undefined') {
- window.GAME_FIRE_DISTANCE = 20;
+ window.GAME_FIRE_DISTANCE = 10;
}
document.getElementById('settings-btn').addEventListener('click', () => {
document.getElementById('settings-panel').classList.toggle('open');
diff --git a/tests/browser/runner.js b/tests/browser/runner.js
@@ -208,7 +208,7 @@ 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);
+ await page.waitForTimeout(600);
const start = await page.evaluate(() => ({
x: window.__FT_SCENE__.truckX,
y: window.__FT_SCENE__.truckY,
@@ -414,6 +414,52 @@ async function main() {
if (!result.ok) throw new Error(`Fire distance ${result.d} not in [20,30] for targetDistance=25`);
});
+ await test('fire-truck: endgame scene renders, exposes __FT_END_SCENE__, no errors', async (page, url, errors) => {
+ await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' });
+ await page.waitForTimeout(1800);
+ if (errors.length) throw new Error(errors[0]);
+ await page.evaluate(() => { window.__FT_SCENE__.scene.start('FireTruckEndScene'); });
+ await page.waitForTimeout(1200);
+ if (errors.length) throw new Error(errors[0]);
+ const hasEndScene = await page.evaluate(() => !!window.__FT_END_SCENE__);
+ if (!hasEndScene) throw new Error('__FT_END_SCENE__ not exposed after scene start');
+ const colorCount = await page.evaluate(() => {
+ const cv = document.querySelector('canvas');
+ if (!cv) return 0;
+ const seen = new Set();
+ const sampleW = 200, sampleH = 200;
+ const gl = cv.getContext('webgl2') || cv.getContext('webgl');
+ if (gl) {
+ const pixels = new Uint8Array(sampleW * sampleH * 4);
+ gl.readPixels(0, 0, sampleW, sampleH, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
+ for (let i = 0; i < pixels.length; i += 16) seen.add(`${pixels[i]},${pixels[i+1]},${pixels[i+2]}`);
+ }
+ return seen.size;
+ });
+ if (colorCount < 5) throw new Error(`Endgame scene rendered only ${colorCount} distinct colors — may be blank`);
+ });
+
+ await test('fire-truck: play again button restarts FireTruckScene with reset counter', async (page, url, errors) => {
+ await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' });
+ await page.waitForTimeout(1800);
+ await page.evaluate(() => { window.__FT_SCENE__.scene.start('FireTruckEndScene'); });
+ await page.waitForTimeout(1200);
+ if (errors.length) throw new Error(errors[0]);
+ const hasEnd = await page.evaluate(() => !!window.__FT_END_SCENE__);
+ if (!hasEnd) throw new Error('FireTruckEndScene did not start');
+ const W = await page.evaluate(() => window.innerWidth);
+ const H = await page.evaluate(() => window.innerHeight);
+ await page.mouse.click(W / 2, H * 0.91);
+ await page.waitForTimeout(1400);
+ if (errors.length) throw new Error(errors[0]);
+ const result = await page.evaluate(() => {
+ const s = window.__FT_SCENE__;
+ return { hasScene: !!s, counter: s ? s.firesExtinguished : -1 };
+ });
+ if (!result.hasScene) throw new Error('FireTruckScene not restarted after Play Again');
+ if (result.counter !== 0) throw new Error(`firesExtinguished should be 0 after restart, got ${result.counter}`);
+ });
+
// ── Summary ───────────────────────────────────────────────────────────────
await browser.close();