commit 5ea25da83ea4ef07a1f1b15728cda67d3c6860de
parent 231201da5967788ebf93121d698acf9182f034cb
Author: Kyle Barlow <kb@kylebarlow.com>
Date: Tue, 14 Jul 2026 08:06:32 -0700
fire truck midgame
Diffstat:
7 files changed, 1160 insertions(+), 253 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
@@ -75,6 +75,24 @@ added to `cameras.main.ignore(...)`.
boats/buoys < peds < bikes < cars/buses < burning overlay < fire glow < fire <
spray < smoke < cloud shadows < truck < birds < HUD.
+**Hose minigame** (`FireHoseScene`): when the truck reaches the fire, the
+driving scene enters state `minigame`, pauses itself, and launches a
+street-view scene — building facade with a window grid (`FT.buildFacade` in
+lib.js, pure + unit-tested), arrow keys move an aim reticle, SPACE sprays from
+the truck's rotating ladder. Quenched windows reveal waving residents/a cat;
+when all are out the scene resumes the driving scene and calls
+`_onMinigameComplete()`. **Headless-RAF rule**: gameplay logic lives in
+`_step()` driven from both `update()` and a wall-clock `setInterval` fallback
+(same trick as the driving scene's `fallbackTimer`), and all scene transitions
+use wall-clock `setTimeout` — never `this.time.delayedCall`, which starves in
+throttled/headless browsers and hangs the tests.
+
+**Shared cartoony art** (file-scope helpers in game.js): `bakePeopleTextures`
+(`kg-person-0..7`, racially diverse cartoon townspeople), `bakeSideTruckTexture`
+(`kg-truck-side`), `bakeLadderTexture`, `bakeCritterTextures` (dog/cat/parrot/
+iguana), `drawSidePalm`. Used by both `FireHoseScene` and `FireTruckEndScene` —
+reuse these rather than drawing new people/trucks.
+
**Ambient life** (`_initAmbient` / `_updateAmbient`, driven from `update()` —
never `step()`, which tests call directly): baked-texture Images for traffic
(`FT.advanceCarPlan` graph walk), pedestrians, cloud shadows, boats, birds.
diff --git a/games/fire-truck/game.js b/games/fire-truck/game.js
@@ -22,7 +22,6 @@ const PED_COUNT = 160;
const BIKE_COUNT = 48;
const BUS_COUNT = 6;
const CAR_COUNT = 4;
-const FIRE_EXTINGUISH_S = 3.0;
const FIRES_TO_WIN = 4;
// Tropical-island palette. Water stays saturated; asphalt stays dark for
@@ -83,6 +82,309 @@ function arrowLabel(dir) {
return dir ? (ARROWS[dir] || '') + ' ' + (NAMES[dir] || dir.toUpperCase()) : '';
}
+// ── 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
+// FireTruckEndScene dancers draw from the same set.
+const KG_SKINS = [0xffe0bd, 0xf1c27d, 0xd9995b, 0xc68642, 0x8d5524, 0x5e3a1c];
+const KG_LOOKS = [
+ { skin: KG_SKINS[4], shirt: 0xe63946, pants: 0x2f4858, hair: 0x1b1b1b, hairStyle: 2 },
+ { skin: KG_SKINS[0], shirt: 0x1d7cf2, pants: 0x6b4a2b, hair: 0xd9b380, hairStyle: 0 },
+ { skin: KG_SKINS[3], shirt: 0xffd23f, pants: 0x30333c, hair: 0x2f2a26, hairStyle: 1 },
+ { skin: KG_SKINS[1], shirt: 0x2ec4b6, pants: 0x8d5524, hair: 0x4a3728, hairStyle: 3 },
+ { skin: KG_SKINS[5], shirt: 0xff8c42, pants: 0x1d3557, hair: 0x1b1b1b, hairStyle: 0 },
+ { skin: KG_SKINS[2], shirt: 0xf68fb8, pants: 0x2f4858, hair: 0x2f2a26, hairStyle: 2 },
+ { skin: KG_SKINS[3], shirt: 0xa8d96c, pants: 0x30333c, hair: 0x1b1b1b, hairStyle: 3 },
+ { skin: KG_SKINS[1], shirt: 0x7f6bd6, pants: 0x445266, hair: 0x8a5a2b, hairStyle: 1 },
+];
+
+function bakePersonTexture(scene, key, look) {
+ if (scene.textures.exists(key)) return;
+ const g = scene.make.graphics({ add: false });
+ // shadow + legs + shoes
+ g.fillStyle(0x000000, 0.15); g.fillEllipse(28, 91, 34, 8);
+ g.fillStyle(look.pants, 1);
+ g.fillRoundedRect(19, 60, 8, 26, 4);
+ g.fillRoundedRect(29, 60, 8, 26, 4);
+ g.fillStyle(0x3a3f4a, 1);
+ g.fillEllipse(23, 88, 12, 7); g.fillEllipse(33, 88, 12, 7);
+ // arms + hands
+ g.fillStyle(look.shirt, 1);
+ g.fillRoundedRect(8, 42, 8, 24, 4);
+ g.fillRoundedRect(40, 42, 8, 24, 4);
+ g.fillStyle(look.skin, 1); g.fillCircle(12, 67, 4); g.fillCircle(44, 67, 4);
+ // torso with a sheen highlight
+ g.fillStyle(look.shirt, 1); g.fillRoundedRect(14, 38, 28, 28, 10);
+ g.fillStyle(0xffffff, 0.25); g.fillRoundedRect(17, 41, 10, 6, 3);
+ // big cartoon head: hair circle behind, face circle in front
+ g.fillStyle(look.hair, 1); g.fillCircle(28, 17, 15);
+ g.fillStyle(look.skin, 1); g.fillCircle(28, 21, 13);
+ if (look.hairStyle === 1) { // long hair down to the shoulders
+ g.fillStyle(look.hair, 1);
+ g.fillRoundedRect(11, 14, 8, 26, 4);
+ g.fillRoundedRect(37, 14, 8, 26, 4);
+ } else if (look.hairStyle === 2) { // curly
+ g.fillStyle(look.hair, 1);
+ g.fillCircle(17, 12, 7); g.fillCircle(24, 7, 8);
+ g.fillCircle(33, 8, 8); g.fillCircle(40, 14, 6);
+ } else if (look.hairStyle === 3) { // top bun
+ g.fillStyle(look.hair, 1); g.fillCircle(28, 5, 6);
+ }
+ g.fillStyle(look.hair, 1); g.fillEllipse(28, 11, 24, 10); // fringe
+ // face: eyes, pupils, cheeks, smile
+ g.fillStyle(0xffffff, 1); g.fillCircle(23, 21, 3.4); g.fillCircle(33, 21, 3.4);
+ g.fillStyle(0x2b2b2b, 1); g.fillCircle(23.6, 21.6, 1.7); g.fillCircle(33.6, 21.6, 1.7);
+ g.fillStyle(0xff8c8c, 0.35); g.fillCircle(20, 26, 2.6); g.fillCircle(36, 26, 2.6);
+ g.lineStyle(2, 0x7a4a3a, 1);
+ g.beginPath(); g.arc(28, 26, 5.5, Math.PI * 0.15, Math.PI * 0.85, false); g.strokePath();
+ g.generateTexture(key, 56, 96);
+ g.destroy();
+}
+
+function bakePeopleTextures(scene) {
+ KG_LOOKS.forEach((look, i) => bakePersonTexture(scene, 'kg-person-' + i, look));
+}
+
+// Side-view fire truck (facing right) — lockers, ladder turntable, crewed cab.
+function bakeSideTruckTexture(scene) {
+ const key = 'kg-truck-side';
+ if (scene.textures.exists(key)) return key;
+ const g = scene.make.graphics({ add: false });
+ g.fillStyle(0x000000, 0.18); g.fillEllipse(130, 102, 230, 14);
+ // rear body
+ g.fillStyle(0xe63946, 1); g.fillRoundedRect(6, 36, 168, 52, 8);
+ g.lineStyle(3, 0x9b1c25, 1); g.strokeRoundedRect(6, 36, 168, 52, 8);
+ // silver roll-up equipment lockers
+ const locker = (x) => {
+ g.fillStyle(0xd7dde6, 1); g.fillRoundedRect(x, 44, 34, 30, 4);
+ g.lineStyle(1.5, 0x9aa4b2, 1);
+ for (let ly = 49; ly <= 69; ly += 5) g.lineBetween(x + 2, ly, x + 32, ly);
+ };
+ locker(28); locker(70); locker(112);
+ // hose reel + rear step
+ g.fillStyle(0x9aa0a8, 1); g.fillCircle(16, 58, 9);
+ g.fillStyle(0x30333c, 1); g.fillCircle(16, 58, 4);
+ g.fillStyle(0x30333c, 1); g.fillRect(0, 82, 8, 8);
+ // white stripe + gold band along the body
+ g.fillStyle(0xffd23f, 1); g.fillRect(6, 77, 168, 2);
+ g.fillStyle(0xffffff, 0.9); g.fillRect(6, 79, 168, 6);
+ // ladder turntable on top
+ g.fillStyle(0x9aa0a8, 1); g.fillRoundedRect(50, 26, 40, 12, 4);
+ g.fillStyle(0xf4b400, 1); g.fillCircle(70, 31, 8);
+ // cab
+ g.fillStyle(0xe63946, 1); g.fillRoundedRect(172, 26, 76, 62, { tl: 14, tr: 22, bl: 0, br: 8 });
+ g.lineStyle(3, 0x9b1c25, 1); g.strokeRoundedRect(172, 26, 76, 62, { tl: 14, tr: 22, bl: 0, br: 8 });
+ // cab window with a firefighter at the wheel
+ g.fillStyle(0x9fdcff, 1); g.fillRoundedRect(186, 34, 52, 24, { tl: 8, tr: 14, bl: 4, br: 4 });
+ g.fillStyle(0x8d5524, 1); g.fillCircle(206, 50, 7);
+ g.fillStyle(0xffd23f, 1); g.fillEllipse(206, 43, 18, 8);
+ g.fillStyle(0x2b2b2b, 1); g.fillCircle(208.5, 50, 1.4);
+ // stripe continues on the cab, door seam + handle
+ g.fillStyle(0xffd23f, 1); g.fillRect(172, 77, 76, 2);
+ g.fillStyle(0xffffff, 0.9); g.fillRect(172, 79, 76, 6);
+ g.lineStyle(2, 0x9b1c25, 1); g.lineBetween(184, 60, 184, 86);
+ g.fillStyle(0xffd23f, 1); g.fillRect(176, 64, 7, 3);
+ // light bar
+ g.fillStyle(0x30333c, 1); g.fillRoundedRect(196, 18, 34, 9, 3);
+ g.fillStyle(0xe63946, 1); g.fillRoundedRect(198, 15, 12, 7, 2);
+ g.fillStyle(0x1d7cf2, 1); g.fillRoundedRect(214, 15, 12, 7, 2);
+ // bumper + headlight
+ g.fillStyle(0xd7dde6, 1); g.fillRoundedRect(244, 74, 14, 14, 3);
+ g.fillStyle(0xffe9a8, 1); g.fillCircle(250, 70, 4);
+ // wheels
+ const wheel = (x) => {
+ g.fillStyle(0x22262e, 1); g.fillCircle(x, 88, 15);
+ g.fillStyle(0x9aa0a8, 1); g.fillCircle(x, 88, 7.5);
+ g.fillStyle(0x22262e, 1); g.fillCircle(x, 88, 2.5);
+ };
+ wheel(46); wheel(120); wheel(210);
+ g.generateTexture(key, 260, 112);
+ g.destroy();
+ return key;
+}
+
+// Extending ladder with a firefighter + nozzle in the tip basket. Pivot is at
+// local (14, 15); the water jet leaves from local (206, 15).
+const KG_LADDER_PIVOT_X = 14;
+const KG_LADDER_TIP_X = 206;
+function bakeLadderTexture(scene) {
+ const key = 'kg-ladder';
+ if (scene.textures.exists(key)) return key;
+ const g = scene.make.graphics({ add: false });
+ g.fillStyle(0xffd23f, 1); g.fillRect(14, 7, 160, 5); g.fillRect(14, 18, 160, 5);
+ g.fillStyle(0xcf9b1e, 1);
+ for (let x = 22; x <= 166; x += 12) g.fillRect(x, 9, 3, 12);
+ g.fillStyle(0x9aa0a8, 1); g.fillCircle(14, 15, 10);
+ g.fillStyle(0x30333c, 1); g.fillCircle(14, 15, 4);
+ // tip basket with firefighter (helmet + face) holding the nozzle
+ g.fillStyle(0xe63946, 1); g.fillRoundedRect(170, 3, 20, 24, 5);
+ g.fillStyle(0xc68642, 1); g.fillCircle(181, 12, 5);
+ g.fillStyle(0xffd23f, 1); g.fillEllipse(181, 8, 13, 6);
+ g.fillStyle(0x2b2b2b, 1); g.fillCircle(182.6, 12, 1.1);
+ g.fillStyle(0xd7dde6, 1); g.fillRect(188, 12, 14, 6);
+ g.fillStyle(0x30333c, 1); g.fillRect(201, 11, 6, 8);
+ g.generateTexture(key, 210, 30);
+ g.destroy();
+ return key;
+}
+
+// Pets and tropical wildlife.
+function bakeCritterTextures(scene) {
+ const mk = () => scene.make.graphics({ add: false });
+ if (!scene.textures.exists('kg-dog')) {
+ const g = mk();
+ g.fillStyle(0x000000, 0.15); g.fillEllipse(24, 35, 36, 6);
+ g.fillStyle(0xb5773a, 1); g.fillTriangle(4, 16, 12, 20, 4, 26); // tail
+ g.fillStyle(0xc98a4b, 1); g.fillRoundedRect(8, 14, 28, 14, 7); // body
+ g.fillStyle(0xb5773a, 1); // legs
+ g.fillRect(11, 26, 4, 8); g.fillRect(19, 26, 4, 8); g.fillRect(29, 26, 4, 8);
+ g.fillStyle(0xc98a4b, 1); g.fillCircle(38, 13, 8); // head
+ g.fillStyle(0x8a5a2b, 1); g.fillTriangle(32, 6, 38, 4, 36, 12); // ear
+ g.fillStyle(0xe63946, 1); g.fillRect(31, 18, 12, 3); // collar
+ g.fillStyle(0xffffff, 1); g.fillCircle(40, 12, 2.6); // eye
+ g.fillStyle(0x2b2b2b, 1); g.fillCircle(40.8, 12.4, 1.3);
+ g.fillStyle(0x2b2b2b, 1); g.fillCircle(45, 15, 2); // nose
+ g.generateTexture('kg-dog', 52, 40); g.destroy();
+ }
+ if (!scene.textures.exists('kg-cat')) {
+ const g = mk();
+ g.fillStyle(0x000000, 0.15); g.fillEllipse(18, 37, 26, 5);
+ g.lineStyle(4, 0x8a6f52, 1);
+ g.beginPath(); g.arc(28, 30, 8, -Math.PI * 0.5, Math.PI * 0.4); g.strokePath(); // tail
+ g.fillStyle(0xa8886a, 1); g.fillRoundedRect(9, 18, 18, 20, 8); // sitting body
+ g.fillStyle(0xa8886a, 1); g.fillCircle(18, 12, 9); // head
+ g.fillStyle(0x8a6f52, 1);
+ g.fillTriangle(10, 8, 14, 2, 16, 8); g.fillTriangle(20, 8, 22, 2, 26, 8); // ears
+ g.fillStyle(0x7ecb62, 1); g.fillCircle(14.5, 12, 2.4); g.fillCircle(21.5, 12, 2.4);
+ g.fillStyle(0x2b2b2b, 1); g.fillCircle(14.5, 12, 1.1); g.fillCircle(21.5, 12, 1.1);
+ g.fillStyle(0xf3b0c3, 1); g.fillTriangle(16.6, 15, 19.4, 15, 18, 17); // nose
+ g.generateTexture('kg-cat', 40, 40); g.destroy();
+ }
+ if (!scene.textures.exists('kg-parrot')) {
+ const g = mk();
+ g.fillStyle(0x1d7cf2, 1); g.fillTriangle(2, 26, 16, 20, 12, 30); // tail feathers
+ g.fillStyle(0x2ec4b6, 1); g.fillTriangle(4, 22, 16, 18, 12, 26);
+ g.fillStyle(0xe63946, 1); g.fillEllipse(22, 22, 22, 18); // body
+ g.fillStyle(0xe63946, 1); g.fillCircle(33, 13, 8); // head
+ g.fillStyle(0xffffff, 1); g.fillCircle(35, 12, 4); // face patch
+ g.fillStyle(0x2b2b2b, 1); g.fillCircle(35.5, 12, 1.4);
+ g.fillStyle(0xffd23f, 1); g.fillTriangle(40, 12, 46, 16, 39, 18); // beak
+ g.fillStyle(0xffd23f, 1); g.fillTriangle(14, 16, 30, 22, 16, 28); // wing
+ g.fillStyle(0xa8d96c, 1); g.fillTriangle(16, 18, 27, 22, 17, 26);
+ g.generateTexture('kg-parrot', 48, 36); g.destroy();
+ }
+ if (!scene.textures.exists('kg-iguana')) {
+ const g = mk();
+ g.fillStyle(0x000000, 0.12); g.fillEllipse(32, 23, 52, 5);
+ g.fillStyle(0x5ba64f, 1); g.fillTriangle(0, 18, 20, 12, 20, 20); // tail
+ g.fillStyle(0x6fbf5d, 1); g.fillEllipse(32, 16, 30, 12); // body
+ g.fillStyle(0x6fbf5d, 1); g.fillCircle(50, 13, 7); // head
+ g.fillStyle(0x5ba64f, 1); // legs
+ g.fillRect(24, 20, 4, 5); g.fillRect(38, 20, 4, 5);
+ g.fillStyle(0x8fd97e, 1); // back spines
+ for (let sx = 20; sx <= 46; sx += 6) g.fillTriangle(sx, 11, sx + 3, 6, sx + 6, 11);
+ g.fillStyle(0xffffff, 1); g.fillCircle(52, 12, 2.2);
+ g.fillStyle(0x2b2b2b, 1); g.fillCircle(52.6, 12.2, 1.1);
+ g.generateTexture('kg-iguana', 60, 26); g.destroy();
+ }
+}
+
+// Fire/smoke FX textures shared by the driving scene and the hose minigame.
+function bakeFxTextures(scene) {
+ if (!scene.textures.exists('smoke-puff')) {
+ const g = scene.make.graphics({ add: false });
+ g.fillStyle(0xb9bcc2, 0.5); g.fillCircle(14, 14, 12);
+ g.fillStyle(0xe4e6ea, 0.5); g.fillCircle(11, 11, 7);
+ g.generateTexture('smoke-puff', 28, 28); g.destroy();
+ }
+ if (!scene.textures.exists('glow')) {
+ const g = scene.make.graphics({ add: false });
+ for (let r = 32; r > 0; r -= 4) {
+ g.fillStyle(0xffa733, 0.06);
+ g.fillCircle(32, 32, r);
+ }
+ g.generateTexture('glow', 64, 64); g.destroy();
+ }
+}
+
+// Side-view palm (curved trunk + drooping fronds), shared by the hose
+// minigame street and the endgame beach.
+function drawSidePalm(gfx, x, groundY, height) {
+ gfx.lineStyle(Math.max(4, height * 0.09), PALM_TRUNK, 1);
+ gfx.beginPath();
+ gfx.moveTo(x, groundY);
+ gfx.lineTo(x - height * 0.08, groundY - height * 0.5);
+ gfx.lineTo(x + height * 0.06, groundY - height);
+ gfx.strokePath();
+ const topX = x + height * 0.06;
+ const topY = groundY - height;
+ const frondL = height * 0.55;
+ const angles = [-2.5, -1.9, -1.3, -0.7, -0.1, 0.5];
+ for (const a of angles) {
+ gfx.lineStyle(Math.max(3, height * 0.05), PALM_FROND, 1);
+ gfx.beginPath();
+ gfx.moveTo(topX, topY);
+ gfx.lineTo(topX + Math.cos(a) * frondL, topY + Math.sin(a) * frondL * 0.6 + frondL * 0.2);
+ gfx.strokePath();
+ }
+ gfx.fillStyle(darken(PALM_TRUNK, 20), 1);
+ gfx.fillCircle(topX, topY, height * 0.05);
+}
+
+// ── Web Audio SFX helpers (shared by scenes; ctx comes from the drive scene) ─
+function startWaterNoise(ctx) {
+ if (!ctx) return null;
+ try {
+ const buffer = ctx.createBuffer(1, ctx.sampleRate, ctx.sampleRate);
+ const data = buffer.getChannelData(0);
+ for (let i = 0; i < data.length; i++) data[i] = Math.random() * 2 - 1;
+ const source = ctx.createBufferSource();
+ source.buffer = buffer;
+ source.loop = true;
+ const filter = ctx.createBiquadFilter();
+ filter.type = 'bandpass';
+ filter.frequency.value = 1200;
+ filter.Q.value = 0.6;
+ const gain = ctx.createGain();
+ gain.gain.value = 0.14;
+ source.connect(filter);
+ filter.connect(gain);
+ gain.connect(ctx.destination);
+ source.start();
+ return { source, gain };
+ } catch (_) { return null; }
+}
+
+function stopWaterNoise(ctx, handle) {
+ if (!handle) return;
+ try {
+ handle.gain.gain.setValueAtTime(handle.gain.gain.value, ctx.currentTime);
+ handle.gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.08);
+ handle.source.stop(ctx.currentTime + 0.08);
+ } catch (_) {}
+}
+
+// Short melodic blips: notes = [[freq, startOffsetSec, durSec], ...]
+function playJingle(ctx, muted, notes) {
+ if (!ctx || muted) return;
+ try {
+ for (const [freq, at, dur] of notes) {
+ const osc = ctx.createOscillator();
+ const gain = ctx.createGain();
+ osc.type = 'triangle';
+ osc.frequency.value = freq;
+ osc.connect(gain);
+ gain.connect(ctx.destination);
+ const t0 = ctx.currentTime + at;
+ gain.gain.setValueAtTime(0.0001, t0);
+ gain.gain.linearRampToValueAtTime(0.14, t0 + 0.02);
+ gain.gain.exponentialRampToValueAtTime(0.001, t0 + dur);
+ osc.start(t0);
+ osc.stop(t0 + dur + 0.05);
+ }
+ } catch (_) {}
+}
+
class FireTruckScene extends Phaser.Scene {
constructor() {
super({ key: 'FireTruckScene' });
@@ -110,14 +412,9 @@ class FireTruckScene extends Phaser.Scene {
this.fireCell = null;
this.fireBuildingCell = null;
this.fireGraphics = null;
- this.waterGraphics = null;
- this.fireExtinguishAccum = 0;
this.firesExtinguished = 0;
- this.spaceHeld = false;
this.fireMode = false;
this._stallSince = 0;
- this.waterSoundSource = null;
- this.waterSoundGain = null;
this.musicMuted = false;
this.sfxMuted = false;
this.sirenOn = false;
@@ -216,7 +513,6 @@ 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).
@@ -785,21 +1081,7 @@ class FireTruckScene extends Phaser.Scene {
g.generateTexture('bird', 24, 12); g.destroy();
}
- if (!this.textures.exists('smoke-puff')) {
- const g = mk();
- g.fillStyle(0xb9bcc2, 0.5); g.fillCircle(14, 14, 12);
- g.fillStyle(0xe4e6ea, 0.5); g.fillCircle(11, 11, 7);
- g.generateTexture('smoke-puff', 28, 28); g.destroy();
- }
-
- if (!this.textures.exists('glow')) {
- const g = mk();
- for (let r = 32; r > 0; r -= 4) {
- g.fillStyle(0xffa733, 0.06);
- g.fillCircle(32, 32, r);
- }
- g.generateTexture('glow', 64, 64); g.destroy();
- }
+ bakeFxTextures(this);
if (!this.textures.exists('burning-overlay')) {
const g = mk();
@@ -1022,10 +1304,7 @@ class FireTruckScene extends Phaser.Scene {
onKeyDown(event) {
this.initAudio();
- if (event.code === 'Space') {
- if (this.state === 'firefighting') this.spaceHeld = true;
- return;
- }
+ if (event.code === 'Space') return; // spraying lives in FireHoseScene now
if (event.key === 's' || event.key === 'S') {
this.toggleSiren();
return;
@@ -1041,7 +1320,7 @@ class FireTruckScene extends Phaser.Scene {
this.promptDir = null;
this.failVisible = false;
this.overlay.setFillStyle(0xe63946, 0);
- if (this.state !== 'firefighting') {
+ if (this.state !== 'minigame') {
this.state = 'driving';
this.targetSpeed = MAX_SPEED * (window.GAME_SPEED_MULTIPLIER ?? 1.0);
}
@@ -1055,10 +1334,6 @@ class FireTruckScene extends Phaser.Scene {
if (this.state === 'driving' || this.state === 'waiting') this.enterStopped();
}
- onKeyUp(event) {
- if (event.code === 'Space') this.spaceHeld = false;
- }
-
initAudio() {
if (this._audioInited) return;
this._audioInited = true;
@@ -1185,7 +1460,6 @@ class FireTruckScene extends Phaser.Scene {
if (this.sirenGainNode && this.audioCtx) {
this.sirenGainNode.gain.setValueAtTime(muted ? 0.001 : 0.12, this.audioCtx.currentTime);
}
- if (muted) this._stopWaterSound();
}
playSuccessSound() {
@@ -1261,6 +1535,7 @@ class FireTruckScene extends Phaser.Scene {
}
step(dt) {
+ if (this.state === 'minigame') return; // FireHoseScene owns the action
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);
@@ -1315,18 +1590,6 @@ class FireTruckScene extends Phaser.Scene {
return;
}
}
- // Firefighting update
- if (this.state === 'firefighting') {
- if (this.spaceHeld) {
- this.fireExtinguishAccum += dt;
- if (this.fireExtinguishAccum >= FIRE_EXTINGUISH_S) this._extinguishFire();
- }
- this._updateFireAnimation(this.time.now);
- this._updateWaterSpray();
- this._updateLightsEffect();
- this.refreshDebug();
- return;
- }
// Always animate fire while driving toward it
if (this.fireGraphics) this._updateFireAnimation(this.time.now);
@@ -1554,10 +1817,7 @@ class FireTruckScene extends Phaser.Scene {
const cx = this.fireBuildingCell.x * CELL_SIZE;
const cy = this.fireBuildingCell.y * CELL_SIZE;
const baseY = cy + CELL_SIZE * 0.36;
-
- // Shrink as the fire is put out.
- const progress = Math.min(1, this.fireExtinguishAccum / FIRE_EXTINGUISH_S);
- const scale = 1 - progress * 0.75;
+ const scale = 1; // full blaze until the hose minigame puts it out
// Pulsing glow underneath.
if (this.fireGlow) {
@@ -1595,124 +1855,31 @@ class FireTruckScene extends Phaser.Scene {
}
}
- _updateWaterSpray() {
- if (!this.spaceHeld || !this.fireCell || !this.fireBuildingCell) {
- if (this.waterGraphics) this.waterGraphics.clear();
- this._stopWaterSound();
- return;
- }
- this._startWaterSound();
- if (!this.waterGraphics) {
- this.waterGraphics = this.add.graphics();
- this.waterGraphics.setDepth(11);
- this.uiCamera.ignore(this.waterGraphics);
- }
- const gfx = this.waterGraphics;
- gfx.clear();
- const tx = this.fireBuildingCell.x * CELL_SIZE;
- const ty = this.fireBuildingCell.y * CELL_SIZE;
- const sx = this.truck.x;
- const sy = this.truck.y;
- const t = this.time.now;
- const dx = tx - sx, dy = ty - sy;
- const len = Math.hypot(dx, dy) || 1;
- // Perpendicular unit vector for wiggle + strand spread.
- const px = -dy / len, py = dx / len;
-
- // Two tapered strands (bright core over a wider translucent stream).
- const strands = [
- { color: 0x44aaff, alpha: 0.5, width: 10, wig: 22 },
- { color: 0x9fdcff, alpha: 0.85, width: 5, wig: 16 },
- ];
- const SEG = 10;
- for (const s of strands) {
- for (let i = 0; i < SEG; i++) {
- const t0 = i / SEG, t1 = (i + 1) / SEG;
- const w0 = s.wig * Math.sin(t0 * Math.PI * 3 + t * 0.02);
- const w1 = s.wig * Math.sin(t1 * Math.PI * 3 + t * 0.02);
- gfx.lineStyle((s.width * (1 - t0 * 0.6)) * SCALE * 0.5, s.color, s.alpha);
- gfx.lineBetween(
- sx + dx * t0 + px * w0, sy + dy * t0 + py * w0,
- sx + dx * t1 + px * w1, sy + dy * t1 + py * w1
- );
- }
- }
- // Jittered droplets near the far end.
- gfx.fillStyle(0xcdeeff, 0.85);
- for (let i = 0; i < 6; i++) {
- const tt = 0.7 + Math.random() * 0.3;
- const jx = sx + dx * tt + px * (Math.random() - 0.5) * 40 * SCALE * 0.4;
- const jy = sy + dy * tt + py * (Math.random() - 0.5) * 40 * SCALE * 0.4;
- gfx.fillCircle(jx, jy, (2 + Math.random() * 3) * SCALE * 0.5);
- }
- // Expanding splash ring at the building.
- const ring = (Math.sin(t * 0.012) * 0.5 + 0.5) * CELL_SIZE * 0.3 + CELL_SIZE * 0.1;
- gfx.lineStyle(3 * SCALE * 0.5, 0x9fdcff, 0.5);
- gfx.strokeCircle(tx, ty, ring);
- }
-
- _startWaterSound() {
- if (this.waterSoundSource || !this.audioCtx || this.sfxMuted) return;
- try {
- const ctx = this.audioCtx;
- const bufferSize = ctx.sampleRate;
- const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
- const data = buffer.getChannelData(0);
- for (let i = 0; i < bufferSize; i++) data[i] = Math.random() * 2 - 1;
- const source = ctx.createBufferSource();
- source.buffer = buffer;
- source.loop = true;
- const filter = ctx.createBiquadFilter();
- filter.type = 'bandpass';
- filter.frequency.value = 1200;
- filter.Q.value = 0.6;
- const gain = ctx.createGain();
- gain.gain.value = 0.14;
- source.connect(filter);
- filter.connect(gain);
- gain.connect(ctx.destination);
- source.start();
- this.waterSoundSource = source;
- this.waterSoundGain = gain;
- } catch (_) {}
- }
-
- _stopWaterSound() {
- if (!this.waterSoundSource) return;
- try {
- const ctx = this.audioCtx;
- this.waterSoundGain.gain.setValueAtTime(this.waterSoundGain.gain.value, ctx.currentTime);
- this.waterSoundGain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.08);
- this.waterSoundSource.stop(ctx.currentTime + 0.08);
- } catch (_) {}
- this.waterSoundSource = null;
- this.waterSoundGain = null;
- }
-
+ // The truck has arrived at the fire: hand off to the street-level hose
+ // minigame. This scene pauses (its HUD and world stay rendered underneath,
+ // fully covered by the minigame backdrop) until _onMinigameComplete.
enterFirefighting() {
- this.state = 'firefighting';
+ this.state = 'minigame';
this.targetSpeed = 0;
this.speed = 0;
- this.fireExtinguishAccum = 0;
- this.spaceHeld = false;
this.promptDir = null;
- this.statusText.setText('Hold SPACE to spray water!');
+ this.statusText.setText('You made it! Put out the fire!');
this.promptText.setText('');
+ this.scene.launch('FireHoseScene', { fireNumber: this.firesExtinguished });
+ this.scene.pause();
}
- _extinguishFire() {
- // Guard against re-entrant calls: step() stays in firefighting state for 600ms
- // while the delayedCall is pending, so this can fire every frame otherwise.
+ // Called by FireHoseScene right after it resumes this scene.
+ _onMinigameComplete() {
if (!this.fireBuildingCell) return;
+ this.lastStepTime = performance.now();
this.firesExtinguished += 1;
this._updateFireCounter();
this.successOverlay.setAlpha(0.12);
this.successUntil = this.time.now + 200;
this.playSuccessSound();
- this._stopWaterSound();
this._stopSmoke();
if (this.fireGraphics) { this.fireGraphics.destroy(); this.fireGraphics = null; }
- if (this.waterGraphics) { this.waterGraphics.destroy(); this.waterGraphics = null; }
if (this.fireGlow) { this.fireGlow.destroy(); this.fireGlow = null; }
// Leave a brief scorch mark that fades out.
if (this.burnOverlay) {
@@ -1724,12 +1891,17 @@ class FireTruckScene extends Phaser.Scene {
this.fireBuildingCell = null;
this.statusText.setText('Fire out! Great job!');
+ // Wall-clock timeouts (not scene delayedCalls): headless/throttled
+ // browsers can starve the scene clock, and these transitions must happen.
if (this.firesExtinguished >= FIRES_TO_WIN) {
- this.time.delayedCall(900, () => this.scene.start('FireTruckEndScene'));
+ setTimeout(() => {
+ if (this.scene && this.scene.isActive()) this.scene.start('FireTruckEndScene');
+ }, 900);
return;
}
- this.time.delayedCall(600, () => {
+ setTimeout(() => {
+ if (!this.scene || !this.scene.isActive() || this.state !== 'minigame') return;
const island = this.island;
const mult = window.GAME_SPEED_MULTIPLIER ?? 1.0;
const truckX = Math.round(this.truck.x / CELL_SIZE);
@@ -1757,7 +1929,7 @@ class FireTruckScene extends Phaser.Scene {
this.targetSpeed = BASE_SPEED * mult;
this.statusText.setText('Watch for the next arrow.');
this._createFireGraphics();
- });
+ }, 600);
}
shutdown() {
@@ -1765,12 +1937,10 @@ class FireTruckScene extends Phaser.Scene {
clearInterval(this.fallbackTimer);
this.fallbackTimer = null;
}
- this._stopWaterSound();
this._stopSiren();
this._stopSmoke();
if (this.bgGain) { try { Tone.Transport.stop(); } catch (_) {} }
if (this.fireGraphics) { this.fireGraphics.destroy(); this.fireGraphics = null; }
- if (this.waterGraphics) { this.waterGraphics.destroy(); this.waterGraphics = null; }
if (this.fireGlow) { this.fireGlow.destroy(); this.fireGlow = null; }
if (this.burnOverlay) { this.burnOverlay.destroy(); this.burnOverlay = null; }
this.ambientReady = false;
@@ -1807,6 +1977,622 @@ function darken(color, amt) {
Math.max(0, c.red - amt), Math.max(0, c.green - amt), Math.max(0, c.blue - amt));
}
+// ── Street-level hose minigame ───────────────────────────────────────────────
+// Launched (over the paused driving scene) when the truck reaches the fire.
+// A building facade fills the view with several windows ablaze; arrow keys
+// steer an aim reticle, holding SPACE sprays water from the truck's ladder
+// nozzle. Dousing a window long enough quenches it — a resident (or cat!)
+// appears waving thanks. When every window is out the crowd celebrates and
+// control returns to the driving scene.
+const HOSE_QUENCH_S = 1.4;
+
+class FireHoseScene extends Phaser.Scene {
+ constructor() {
+ super({ key: 'FireHoseScene' });
+ }
+
+ init(data) {
+ this.fireNumber = (data && data.fireNumber) || 0;
+ this.done = false;
+ this.spraying = false;
+ this.hoverFire = null;
+ this.waterNoise = null;
+ this._steamAccum = 0;
+ this._catUsed = false;
+ }
+
+ create() {
+ const W = this._W = this.scale.width;
+ const H = this._H = this.scale.height;
+ this.drive = this.scene.get('FireTruckScene');
+ this.rng = FT.createSeededRng(((Date.now() & 0xffff) + 1) * (this.fireNumber + 3));
+
+ bakePeopleTextures(this);
+ bakeSideTruckTexture(this);
+ bakeLadderTexture(this);
+ bakeCritterTextures(this);
+ bakeFxTextures(this);
+ if (window.KGames && KGames.bakeFireworksAtlas) {
+ try { KGames.bakeFireworksAtlas(this); } catch (_) {}
+ }
+
+ this.streetY = H * 0.76;
+ this._drawBackdrop(W, H);
+ this._buildFacade(W, H);
+ this._spawnCrowd(W, H);
+ this._placeTruck(W, H);
+
+ this.fireGfx = this.add.graphics().setDepth(20);
+ this.waterGfx = this.add.graphics().setDepth(30);
+ this.ringGfx = this.add.graphics().setDepth(41);
+ this.lightGfx = this.add.graphics().setDepth(27);
+
+ // Aim reticle: white outer ring + yellow inner ring + crosshair ticks.
+ // The green ring lights up when the aim is over a burning window.
+ this.aimX = (this.bldX0 + this.bldX1) / 2;
+ this.aimY = (this.bldTop + this.streetY) / 2;
+ const retGfx = this.add.graphics();
+ retGfx.lineStyle(4, 0xffffff, 0.9); retGfx.strokeCircle(0, 0, 26);
+ retGfx.lineStyle(3, 0xffd23f, 1); retGfx.strokeCircle(0, 0, 20);
+ retGfx.lineStyle(4, 0xffffff, 0.9);
+ retGfx.lineBetween(-34, 0, -12, 0); retGfx.lineBetween(12, 0, 34, 0);
+ retGfx.lineBetween(0, -34, 0, -12); retGfx.lineBetween(0, 12, 0, 34);
+ this.hoverRing = this.add.graphics();
+ this.hoverRing.lineStyle(5, 0x7dff9a, 0.95);
+ this.hoverRing.strokeCircle(0, 0, 30);
+ this.hoverRing.setVisible(false);
+ this.reticle = this.add.container(this.aimX, this.aimY, [retGfx, this.hoverRing]).setDepth(40);
+ this.tweens.add({
+ targets: this.reticle, scale: { from: 0.92, to: 1.08 },
+ duration: 500, yoyo: true, repeat: -1, ease: 'Sine.easeInOut',
+ });
+
+ this.cursors = this.input.keyboard.createCursorKeys();
+
+ this.hudText = this.add.text(W / 2, 66, '', {
+ fontFamily: 'Fredoka, sans-serif',
+ fontSize: '24px',
+ color: '#ffffff',
+ stroke: '#2f2f2f',
+ strokeThickness: 7,
+ align: 'center',
+ }).setOrigin(0.5, 0).setDepth(60);
+ this._updateHud();
+
+ this._smokeTimer = this.time.addEvent({ delay: 320, loop: true, callback: () => this._emitFireSmoke() });
+
+ // Headless/throttled browsers can starve RAF (same workaround as the
+ // driving scene): gameplay-critical logic lives in _step, driven from
+ // update() and from this wall-clock fallback. _finishAtWall is checked
+ // there too, so the return-to-driving handoff never relies on the scene
+ // clock.
+ this._finishAtWall = 0;
+ this._finished = false;
+ this.lastStepTime = performance.now();
+ this.fallbackTimer = setInterval(() => {
+ const now = performance.now();
+ if (now - this.lastStepTime > 250) {
+ this._step(Math.min((now - this.lastStepTime) / 1000, 0.5));
+ this.lastStepTime = now;
+ }
+ }, 200);
+
+ window.__FT_HOSE_SCENE__ = this;
+ this.events.on('shutdown', this._shutdown, this);
+ this.events.on('destroy', this._shutdown, this);
+ }
+
+ _drawBackdrop(W, H) {
+ const g = this.add.graphics().setDepth(0);
+ // Sky: cool blue up top warming toward the horizon.
+ g.fillGradientStyle(0x8fe0f2, 0x8fe0f2, 0xffe3a3, 0xffd08a, 1);
+ g.fillRect(0, 0, W, this.streetY);
+ g.fillStyle(0xfff2b0, 1); g.fillCircle(W * 0.07, H * 0.10, H * 0.045);
+ // Distant pastel skyline peeking out on both sides of the facade.
+ const silhouettes = [
+ [0.00, 0.13, 0.34], [0.08, 0.09, 0.26], [0.84, 0.10, 0.30], [0.92, 0.09, 0.38],
+ ];
+ silhouettes.forEach(([fx, fw, fh], i) => {
+ const color = lighten(BUILDING_PALETTE[(i * 2 + 1) % BUILDING_PALETTE.length], 40);
+ g.fillStyle(color, 1);
+ g.fillRect(W * fx, this.streetY - H * fh, W * fw, H * fh);
+ g.fillStyle(0xffffff, 0.35);
+ for (let wy = this.streetY - H * fh + 12; wy < this.streetY - 12; wy += 22) {
+ for (let wx = W * fx + 8; wx < W * (fx + fw) - 10; wx += 20) g.fillRect(wx, wy, 8, 10);
+ }
+ });
+ // Sidewalk band + curb.
+ g.fillStyle(SIDEWALK_COLOR, 1); g.fillRect(0, this.streetY, W, H * 0.07);
+ g.fillStyle(darken(SIDEWALK_COLOR, 40), 1); g.fillRect(0, this.streetY + H * 0.07 - 3, W, 3);
+ // Road with the painted bike lane nearest the curb.
+ g.fillStyle(ASPHALT_COLOR, 1); g.fillRect(0, this.streetY + H * 0.07, W, H * 0.23);
+ g.fillStyle(BIKE_LANE_COLOR, 0.6); g.fillRect(0, this.streetY + H * 0.075, W, H * 0.035);
+ g.fillStyle(0xffffff, 0.7);
+ for (let dx = 8; dx < W; dx += 46) g.fillRect(dx, this.streetY + H * 0.075 + H * 0.035, 22, 3);
+ g.fillStyle(STRIPE_COLOR, 0.9);
+ for (let dx = 0; dx < W; dx += 70) g.fillRect(dx, this.streetY + H * 0.16, 38, 5);
+ // Street palms framing the block.
+ drawSidePalm(g, W * 0.075, this.streetY + 6, H * 0.17);
+ drawSidePalm(g, W * 0.945, this.streetY + 6, H * 0.20);
+ }
+
+ _buildFacade(W, H) {
+ const facade = FT.buildFacade(this.rng, { fireNumber: this.fireNumber });
+ const x0 = this.bldX0 = W * 0.18;
+ const x1 = this.bldX1 = W * 0.82;
+ const top = this.bldTop = H * 0.10;
+ const bw = x1 - x0;
+ const bh = this.streetY - top;
+ const color = BUILDING_PALETTE[Math.floor(this.rng.next() * BUILDING_PALETTE.length)];
+ const g = this.add.graphics().setDepth(5);
+
+ // Body with drop shadow, outline, parapet.
+ g.fillStyle(0x000000, 0.12);
+ g.fillRoundedRect(x0 + 10, top + 10, bw, bh, { tl: 14, tr: 14, bl: 0, br: 0 });
+ g.fillStyle(color, 1);
+ g.fillRoundedRect(x0, top, bw, bh, { tl: 14, tr: 14, bl: 0, br: 0 });
+ g.lineStyle(4, darken(color, 50), 1);
+ g.strokeRoundedRect(x0, top, bw, bh, { tl: 14, tr: 14, bl: 0, br: 0 });
+ g.fillStyle(darken(color, 25), 1);
+ g.fillRoundedRect(x0 - 8, top - 6, bw + 16, 18, 8);
+
+ // Entrance: awning-striped doorway at street level.
+ const doorW = Math.min(bw * 0.16, 96);
+ const doorH = H * 0.09;
+ const doorX = (x0 + x1) / 2 - doorW / 2;
+ g.fillStyle(darken(color, 60), 1);
+ g.fillRoundedRect(doorX, this.streetY - doorH, doorW, doorH, { tl: 10, tr: 10, bl: 0, br: 0 });
+ g.fillStyle(0xffe9a8, 0.9);
+ g.fillRoundedRect(doorX + doorW * 0.2, this.streetY - doorH * 0.85, doorW * 0.6, doorH * 0.55, 6);
+ const awnY = this.streetY - doorH - 14;
+ for (let i = 0; i < 6; i++) {
+ g.fillStyle(i % 2 ? 0xffffff : 0xe63946, 1);
+ g.fillRect(doorX - 8 + i * ((doorW + 16) / 6), awnY, (doorW + 16) / 6, 14);
+ }
+
+ // Window grid (fire windows start dark + ablaze; the rest get lit/curtain
+ // interiors and the odd sill plant).
+ const cols = facade.cols, rows = facade.rows;
+ const areaX = x0 + bw * 0.07, areaW = bw * 0.86;
+ const areaY = top + 32;
+ const areaH = (this.streetY - doorH - 22) - areaY;
+ const gapX = areaW * 0.06, gapY = areaH * 0.10;
+ const winW = (areaW - gapX * (cols - 1)) / cols;
+ const winH = (areaH - gapY * (rows - 1)) / rows;
+ const fireSet = new Set(facade.fires.map((f) => f.index));
+ this.windows = [];
+ for (let r = 0; r < rows; r++) {
+ for (let c = 0; c < cols; c++) {
+ const index = r * cols + c;
+ const wx = areaX + c * (winW + gapX);
+ const wy = areaY + r * (winH + gapY);
+ g.fillStyle(darken(color, 45), 1);
+ g.fillRoundedRect(wx - 5, wy - 5, winW + 10, winH + 10, 6);
+ g.fillStyle(darken(color, 30), 1);
+ g.fillRect(wx - 9, wy + winH + 5, winW + 18, 6);
+ const burning = fireSet.has(index);
+ if (burning) {
+ g.fillStyle(0x2a1a12, 1);
+ g.fillRect(wx, wy, winW, winH);
+ } else {
+ g.fillStyle(this.rng.next() < 0.45 ? 0xffe9a8 : 0xbcd8e8, 1);
+ g.fillRect(wx, wy, winW, winH);
+ g.lineStyle(2, darken(color, 45), 0.8);
+ g.lineBetween(wx + winW / 2, wy, wx + winW / 2, wy + winH);
+ g.lineBetween(wx, wy + winH / 2, wx + winW, wy + winH / 2);
+ if (this.rng.next() < 0.3) {
+ g.fillStyle(0x46b063, 1);
+ g.fillCircle(wx + winW * 0.82, wy + winH - 7, 6);
+ g.fillStyle(0xd97b52, 1);
+ g.fillRect(wx + winW * 0.82 - 5, wy + winH - 4, 10, 5);
+ }
+ }
+ const win = {
+ index, x: wx, y: wy, w: winW, h: winH,
+ cx: wx + winW / 2, cy: wy + winH / 2,
+ burning, progress: 0, out: false, glow: null, flicker: this.rng.next() * 7,
+ };
+ if (burning) {
+ win.glow = this.add.image(win.cx, win.cy, 'glow')
+ .setDepth(19).setBlendMode(Phaser.BlendModes.ADD).setScale(winW / 34);
+ }
+ this.windows.push(win);
+ }
+ }
+ this.fires = this.windows.filter((w) => w.burning);
+ }
+
+ _spawnCrowd(W, H) {
+ // Onlookers gathered on the sidewalk clear of the truck, with pets and a
+ // little tropical wildlife in the mix.
+ this.crowd = [];
+ const scale = (H * 0.115) / 96;
+ const baseY = this.streetY + H * 0.05;
+ const n = 6 + Math.floor(this.rng.next() * 3);
+ for (let i = 0; i < n; i++) {
+ const px = W * (0.42 + (i + this.rng.next() * 0.6) * (0.53 / n));
+ const p = this.add.image(px, baseY + (this.rng.next() - 0.5) * H * 0.012, 'kg-person-' + (i % KG_LOOKS.length))
+ .setOrigin(0.5, 1).setScale(scale).setDepth(12);
+ if (this.rng.next() < 0.5) p.setFlipX(true);
+ this.tweens.add({
+ targets: p, y: p.y - H * 0.008,
+ duration: 420 + i * 60, yoyo: true, repeat: -1, ease: 'Sine.easeInOut',
+ });
+ this.crowd.push(p);
+ }
+ const dog = this.add.image(W * 0.47, baseY, 'kg-dog').setOrigin(0.5, 1).setScale(scale * 1.1).setDepth(12);
+ this.tweens.add({ targets: dog, y: dog.y - H * 0.015, duration: 300, yoyo: true, repeat: -1, ease: 'Sine.easeInOut' });
+ this.crowd.push(dog);
+ const cat = this.add.image(W * 0.90, baseY - 2, 'kg-cat').setOrigin(0.5, 1).setScale(scale).setDepth(12);
+ this.crowd.push(cat);
+ const iguana = this.add.image(W * 0.955, baseY - 2, 'kg-iguana').setOrigin(0.5, 1).setScale(scale).setDepth(12);
+ if (this.rng.next() < 0.5) iguana.setFlipX(true);
+ this.crowd.push(iguana);
+ // A parrot cruises back and forth across the sky.
+ this.parrot = this.add.image(-60, H * 0.14, 'kg-parrot').setScale(scale * 1.3).setDepth(8);
+ this.tweens.add({
+ targets: this.parrot, x: W + 60,
+ duration: 11000, repeat: -1, yoyo: true,
+ onYoyo: () => this.parrot.setFlipX(true),
+ onRepeat: () => this.parrot.setFlipX(false),
+ });
+ this.tweens.add({ targets: this.parrot, y: H * 0.18, duration: 900, yoyo: true, repeat: -1, ease: 'Sine.easeInOut' });
+ }
+
+ _placeTruck(W, H) {
+ const scale = Math.min((W * 0.30) / 260, (H * 0.24) / 112);
+ this.truckImg = this.add.image(W * 0.155, this.streetY + H * 0.145, 'kg-truck-side')
+ .setOrigin(0.5, 1).setScale(scale).setDepth(25);
+ // Ladder pivots on the turntable (local 70,31 in the 260×112 texture).
+ const px = this.truckImg.x + (70 - 130) * scale;
+ const py = this.truckImg.y + (31 - 112) * scale;
+ this.ladderPivot = { x: px, y: py };
+ const ladderScale = scale * 1.25;
+ this.ladder = this.add.image(px, py, 'kg-ladder')
+ .setOrigin(KG_LADDER_PIVOT_X / 210, 0.5).setScale(ladderScale).setDepth(26);
+ this.ladderLen = (KG_LADDER_TIP_X - KG_LADDER_PIVOT_X) * ladderScale;
+ // Light-bar flash position (local 212,17).
+ this._lightX = this.truckImg.x + (212 - 130) * scale;
+ this._lightY = this.truckImg.y + (17 - 112) * scale;
+ }
+
+ _updateHud() {
+ const left = this.fires ? this.fires.filter((f) => !f.out).length : 0;
+ this.hudText.setText(left > 0
+ ? `🔥 ${left} window${left === 1 ? '' : 's'} on fire! ◀ ▲ ▼ ▶ aim · hold SPACE to spray`
+ : '✨ All the fires are out! ✨');
+ }
+
+ // Gameplay-critical logic (aim, quench, finish handoff). Runs from update()
+ // and from the wall-clock fallback interval, so it keeps working even when
+ // the render loop is throttled.
+ _step(dt) {
+ const H = this._H;
+ if (!this.done) {
+ const mdt = Math.min(dt, 0.05); // don't teleport the aim on a long tick
+ const spd = H * 0.62;
+ if (this.cursors.left.isDown) this.aimX -= spd * mdt;
+ if (this.cursors.right.isDown) this.aimX += spd * mdt;
+ if (this.cursors.up.isDown) this.aimY -= spd * mdt;
+ if (this.cursors.down.isDown) this.aimY += spd * mdt;
+ this.aimX = Phaser.Math.Clamp(this.aimX, this.bldX0 + 16, this.bldX1 - 16);
+ this.aimY = Phaser.Math.Clamp(this.aimY, this.bldTop + 16, this.streetY - 20);
+ this.reticle.setPosition(this.aimX, this.aimY);
+ this.spraying = this.cursors.space.isDown;
+ } else {
+ this.spraying = false;
+ }
+
+ // Which burning window (if any) is under the aim?
+ this.hoverFire = null;
+ for (const f of this.fires) {
+ if (f.out) continue;
+ const mx = f.w * 0.35, my = f.h * 0.35;
+ if (this.aimX >= f.x - mx && this.aimX <= f.x + f.w + mx &&
+ this.aimY >= f.y - my && this.aimY <= f.y + f.h + my) {
+ this.hoverFire = f;
+ break;
+ }
+ }
+ this.hoverRing.setVisible(!!this.hoverFire);
+
+ // Quenching: douse a burning window to fill its progress ring.
+ if (this.spraying && this.hoverFire) {
+ const f = this.hoverFire;
+ f.progress += dt / HOSE_QUENCH_S;
+ this._steamAccum += dt;
+ if (this._steamAccum > 0.12) {
+ this._steamAccum = 0;
+ this._emitSteam(f);
+ }
+ if (f.progress >= 1) this._quenchWindow(f);
+ }
+
+ if (this._finishAtWall && performance.now() >= this._finishAtWall) this._finish();
+ }
+
+ update(time) {
+ const now = performance.now();
+ if (now > this.lastStepTime) {
+ this._step(Math.min((now - this.lastStepTime) / 1000, 0.5));
+ this.lastStepTime = now;
+ }
+
+ // Ladder tracks the aim; water pours from its tip.
+ const ang = Math.atan2(this.aimY - this.ladderPivot.y, this.aimX - this.ladderPivot.x);
+ this.ladder.rotation = ang;
+ const tipX = this.ladderPivot.x + Math.cos(ang) * this.ladderLen;
+ const tipY = this.ladderPivot.y + Math.sin(ang) * this.ladderLen;
+
+ this.waterGfx.clear();
+ if (this.spraying) {
+ if (!this.waterNoise && this.drive.audioCtx && !this.drive.sfxMuted) {
+ this.waterNoise = startWaterNoise(this.drive.audioCtx);
+ }
+ this._drawWater(time, tipX, tipY);
+ }
+ if ((!this.spraying || this.drive.sfxMuted) && this.waterNoise) {
+ stopWaterNoise(this.drive.audioCtx, this.waterNoise);
+ this.waterNoise = null;
+ }
+
+ this.ringGfx.clear();
+ const hf = this.hoverFire;
+ if (hf && !hf.out && hf.progress > 0) {
+ this.ringGfx.lineStyle(7, 0x7ddcff, 0.95);
+ this.ringGfx.beginPath();
+ this.ringGfx.arc(this.aimX, this.aimY, 38, -Math.PI / 2, -Math.PI / 2 + hf.progress * Math.PI * 2);
+ this.ringGfx.strokePath();
+ }
+
+ this._drawFires(time);
+
+ // Emergency lights strobing on the cab.
+ this.lightGfx.clear();
+ const phase = Math.floor(time / 166) % 2;
+ this.lightGfx.fillStyle(phase ? 0xe63946 : 0x1d7cf2, 0.9);
+ this.lightGfx.fillCircle(this._lightX + (phase ? -8 : 8), this._lightY, 6);
+ }
+
+ _drawWater(time, tx, ty) {
+ const gfx = this.waterGfx;
+ const ax = this.aimX, ay = this.aimY;
+ const dist = Math.hypot(ax - tx, ay - ty) || 1;
+ // Quadratic arc from the nozzle, sagging control point lifted above the chord.
+ const mx = (tx + ax) / 2, my = (ty + ay) / 2 - dist * 0.18;
+ const pt = (t) => ({
+ x: (1 - t) * (1 - t) * tx + 2 * (1 - t) * t * mx + t * t * ax,
+ y: (1 - t) * (1 - t) * ty + 2 * (1 - t) * t * my + t * t * ay,
+ });
+ const SEG = 14;
+ const strands = [
+ { color: 0x44aaff, alpha: 0.45, width: 14 },
+ { color: 0x9fdcff, alpha: 0.9, width: 7 },
+ ];
+ for (const s of strands) {
+ let prev = pt(0);
+ for (let i = 1; i <= SEG; i++) {
+ const t = i / SEG;
+ const p = pt(t);
+ const wig = Math.sin(t * 9 + time * 0.02) * 3;
+ gfx.lineStyle(s.width * (1 - t * 0.45), s.color, s.alpha);
+ gfx.lineBetween(prev.x, prev.y + wig, p.x, p.y + wig);
+ prev = p;
+ }
+ }
+ // Droplets and an expanding splash ring at the point of impact.
+ gfx.fillStyle(0xcdeeff, 0.9);
+ for (let i = 0; i < 5; i++) {
+ gfx.fillCircle(ax + (Math.random() - 0.5) * 36, ay + (Math.random() - 0.5) * 30, 2 + Math.random() * 3);
+ }
+ const ring = (Math.sin(time * 0.014) * 0.5 + 0.5) * 14 + 10;
+ gfx.lineStyle(3, 0x9fdcff, 0.6);
+ gfx.strokeCircle(ax, ay, ring);
+ // Water running down the wall when the spray isn't on a fire.
+ if (!this.hoverFire) {
+ gfx.fillStyle(0x9fdcff, 0.5);
+ for (let i = 0; i < 3; i++) {
+ gfx.fillRect(ax - 14 + i * 12, ay + 8 + ((time * 0.06 + i * 20) % 26), 3, 10);
+ }
+ }
+ }
+
+ _drawFires(t) {
+ const gfx = this.fireGfx;
+ gfx.clear();
+ const layers = [
+ { color: 0xff7a18, half: 0.34, h: 1.15, phase: 0.0 },
+ { color: 0xff3b1f, half: 0.24, h: 0.90, phase: 1.7 },
+ { color: 0xffd23f, half: 0.14, h: 0.60, phase: 3.1 },
+ ];
+ for (const f of this.fires) {
+ if (f.out) continue;
+ const scale = 1 - f.progress * 0.75;
+ const baseY = f.y + f.h - 2;
+ if (f.glow) {
+ const pulse = 1 + Math.sin(t * 0.006 + f.flicker) * 0.15;
+ f.glow.setScale((f.w / 34) * pulse * (0.5 + scale * 0.5));
+ f.glow.setAlpha(0.35 + scale * 0.5);
+ }
+ for (const L of layers) {
+ const half = f.w * L.half * scale;
+ for (let i = 0; i < 2; i++) {
+ const off = Math.sin(t * 0.004 + i * 1.9 + L.phase + f.flicker) * f.w * 0.12 * scale;
+ const h = (f.h * L.h + Math.sin(t * 0.006 + i * 2.3 + L.phase + f.flicker) * f.h * 0.2) * scale;
+ gfx.fillStyle(L.color, 0.78 + Math.sin(t * 0.005 + i + L.phase) * 0.2);
+ gfx.fillTriangle(
+ f.cx + off - half, baseY,
+ f.cx + off + half, baseY,
+ f.cx + off, baseY - h
+ );
+ }
+ }
+ }
+ }
+
+ _emitFireSmoke() {
+ if (!this.fires) return;
+ for (const f of this.fires) {
+ if (f.out || Math.random() > 0.6) continue;
+ const puff = this.add.image(f.cx + (Math.random() - 0.5) * f.w * 0.5, f.y, 'smoke-puff')
+ .setScale(f.w / 40).setAlpha(0.7).setDepth(21);
+ this.tweens.add({
+ targets: puff,
+ x: puff.x - 20 - Math.random() * 30, y: puff.y - 70 - Math.random() * 40,
+ scale: f.w / 18, alpha: 0,
+ duration: 1400, ease: 'Sine.easeOut',
+ onComplete: () => puff.destroy(),
+ });
+ }
+ }
+
+ _emitSteam(f) {
+ const puff = this.add.image(this.aimX + (Math.random() - 0.5) * f.w * 0.4, f.cy, 'smoke-puff')
+ .setScale(f.w / 60).setAlpha(0.95).setTint(0xf2f8ff).setDepth(31);
+ this.tweens.add({
+ targets: puff,
+ y: puff.y - 40 - Math.random() * 25, scale: f.w / 26, alpha: 0,
+ duration: 700, ease: 'Sine.easeOut',
+ onComplete: () => puff.destroy(),
+ });
+ }
+
+ _quenchWindow(f) {
+ if (f.out) return;
+ f.out = true;
+ f.progress = 1;
+ if (f.glow) { f.glow.destroy(); f.glow = null; }
+
+ // Relight the window and pop in a grateful resident (one lucky window
+ // reveals a cat instead).
+ const g = this.add.graphics().setDepth(6);
+ g.fillStyle(0xffe9a8, 1);
+ g.fillRect(f.x, f.y, f.w, f.h);
+ const useCat = !this._catUsed && (this.rng.next() < 0.3 || this.fires.every((fi) => fi.out || fi === f));
+ if (useCat) {
+ this._catUsed = true;
+ const cat = this.add.image(f.cx, f.y + f.h, 'kg-cat').setOrigin(0.5, 1).setDepth(7);
+ cat.setScale(Math.min(f.w / 60, f.h / 48));
+ cat.setAlpha(0);
+ this.tweens.add({ targets: cat, alpha: 1, duration: 250 });
+ } else {
+ this._addWaver(f);
+ }
+
+ // Feedback burst: steam, chime, popup, confetti, HUD.
+ for (let i = 0; i < 6; i++) this._emitSteam(f);
+ playJingle(this.drive.audioCtx, this.drive.sfxMuted, [[880, 0, 0.18], [1320, 0.09, 0.26]]);
+ this._popup(f.cx, f.y - 8, 'Fire out!');
+ if (window.KGames && KGames.burstConfetti) {
+ try { KGames.burstConfetti(this, f.cx, f.cy); } catch (_) {}
+ }
+ this._updateHud();
+
+ if (this.fires.every((fi) => fi.out)) this._celebrate();
+ }
+
+ // A cartoon head-and-shoulders resident waving from inside the window.
+ _addWaver(f) {
+ const look = KG_LOOKS[Math.floor(this.rng.next() * KG_LOOKS.length)];
+ const s = Math.min(f.w, f.h) / 60;
+ const c = this.add.container(f.cx, f.y + f.h).setDepth(7);
+ const body = this.add.graphics();
+ body.fillStyle(look.shirt, 1); body.fillRoundedRect(-16 * s, -22 * s, 32 * s, 22 * s, 8 * s);
+ body.fillStyle(look.hair, 1); body.fillCircle(0, -34 * s, 14 * s);
+ body.fillStyle(look.skin, 1); body.fillCircle(0, -31 * s, 12 * s);
+ body.fillStyle(look.hair, 1); body.fillEllipse(0, -40 * s, 22 * s, 9 * s);
+ body.fillStyle(0xffffff, 1); body.fillCircle(-4.5 * s, -31 * s, 3 * s); body.fillCircle(4.5 * s, -31 * s, 3 * s);
+ body.fillStyle(0x2b2b2b, 1); body.fillCircle(-4 * s, -30.4 * s, 1.5 * s); body.fillCircle(5 * s, -30.4 * s, 1.5 * s);
+ body.lineStyle(2 * s, 0x7a4a3a, 1);
+ body.beginPath(); body.arc(0, -27 * s, 5 * s, Math.PI * 0.15, Math.PI * 0.85); body.strokePath();
+ const hand = this.add.graphics();
+ hand.fillStyle(look.skin, 1); hand.fillCircle(0, 0, 4.5 * s);
+ hand.setPosition(19 * s, -34 * s);
+ c.add([body, hand]);
+ c.setAlpha(0);
+ this.tweens.add({ targets: c, alpha: 1, duration: 250 });
+ this.tweens.add({
+ targets: hand, y: -42 * s,
+ duration: 260, yoyo: true, repeat: -1, ease: 'Sine.easeInOut',
+ });
+ }
+
+ _popup(x, y, text) {
+ const t = this.add.text(x, y, text, {
+ fontFamily: 'Fredoka, sans-serif',
+ fontSize: '26px',
+ color: '#ffffff',
+ stroke: '#2f2f2f',
+ strokeThickness: 7,
+ }).setOrigin(0.5, 1).setDepth(62);
+ this.tweens.add({
+ targets: t, y: y - 46, alpha: 0,
+ duration: 1100, ease: 'Sine.easeOut',
+ onComplete: () => t.destroy(),
+ });
+ }
+
+ _celebrate() {
+ if (this.done) return;
+ this.done = true;
+ if (this.waterNoise) {
+ stopWaterNoise(this.drive.audioCtx, this.waterNoise);
+ this.waterNoise = null;
+ }
+ this.reticle.setVisible(false);
+ this._updateHud();
+
+ const W = this._W, H = this._H;
+ const banner = this.add.text(W / 2, H * 0.34, 'You saved the building!', {
+ fontFamily: 'Fredoka, sans-serif',
+ fontSize: Math.round(W / 16) + 'px',
+ color: '#ffffff',
+ stroke: '#2f2f2f',
+ strokeThickness: 10,
+ align: 'center',
+ }).setOrigin(0.5).setScale(0.2).setDepth(63);
+ this.tweens.add({ targets: banner, scale: 1, duration: 420, ease: 'Back.easeOut' });
+
+ for (let i = 0; i < this.crowd.length; i++) {
+ const p = this.crowd[i];
+ this.tweens.killTweensOf(p);
+ this.tweens.add({
+ targets: p, y: p.y - H * 0.045,
+ duration: 260 + (i % 3) * 50, yoyo: true, repeat: 5, ease: 'Sine.easeOut', delay: i * 60,
+ });
+ }
+ playJingle(this.drive.audioCtx, this.drive.sfxMuted,
+ [[523, 0, 0.15], [659, 0.12, 0.15], [784, 0.24, 0.15], [1047, 0.36, 0.45]]);
+ if (window.KGames && KGames.burstConfetti) {
+ try {
+ KGames.burstConfetti(this, W * 0.35, H * 0.3);
+ KGames.burstConfetti(this, W * 0.65, H * 0.35);
+ } catch (_) {}
+ }
+
+ this._finishAtWall = performance.now() + 2600;
+ }
+
+ _finish() {
+ if (this._finished) return;
+ this._finished = true;
+ const drive = this.drive;
+ this.scene.resume('FireTruckScene');
+ drive._onMinigameComplete();
+ this.scene.stop();
+ }
+
+ _shutdown() {
+ if (this.fallbackTimer) { clearInterval(this.fallbackTimer); this.fallbackTimer = null; }
+ if (this._smokeTimer) { this._smokeTimer.remove(false); this._smokeTimer = null; }
+ if (this.waterNoise) {
+ stopWaterNoise(this.drive && this.drive.audioCtx, this.waterNoise);
+ this.waterNoise = null;
+ }
+ if (window.__FT_HOSE_SCENE__ === this) window.__FT_HOSE_SCENE__ = null;
+ }
+}
+
class FireTruckEndScene extends Phaser.Scene {
constructor() {
super({ key: 'FireTruckEndScene' });
@@ -1824,6 +2610,10 @@ class FireTruckEndScene extends Phaser.Scene {
const W = this._W = this.scale.width;
const H = this._H = this.scale.height;
+ bakePeopleTextures(this);
+ bakeSideTruckTexture(this);
+ bakeCritterTextures(this);
+
// Warm tropical sky gradient
const skyGfx = this.add.graphics();
skyGfx.fillGradientStyle(0xffe3a3, 0xffd08a, 0x8fe0f2, 0x62d9e8, 1);
@@ -1885,7 +2675,7 @@ class FireTruckEndScene extends Phaser.Scene {
// Side-view palms tucked between skyline and beach.
for (let i = 0; i < 3; i++) {
- this._drawSidePalm(bldGfx, bldZoneW + i * W * 0.05 + W * 0.02, H * 0.52, H * 0.10);
+ drawSidePalm(bldGfx, bldZoneW + i * W * 0.05 + W * 0.02, H * 0.52, H * 0.10);
}
// Road
@@ -1944,32 +2734,17 @@ class FireTruckEndScene extends Phaser.Scene {
// 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)
+ // Fire truck (the shared cartoony side-view, parked on the road facing right)
+ const truckX = W * 0.30;
+ const truckY = H * 0.615;
+ const truckScale = (W * 0.15) / 260;
+ this.add.image(truckX, truckY, 'kg-truck-side')
+ .setOrigin(0.5, 1).setScale(truckScale).setDepth(2);
+
+ // Water fan (cleared/redrawn each frame) sprays from the front of the truck
this.waterFanGfx = this.add.graphics();
- this._truckFrontX = truckX + tw * 0.5;
- this._truckFrontY = truckY;
+ this._truckFrontX = truckX + 130 * truckScale;
+ this._truckFrontY = truckY - 50 * truckScale;
// Dolphins (3) — Twemoji SVG, jump above ocean surface
this._dolphins = [];
@@ -2011,43 +2786,53 @@ class FireTruckEndScene extends Phaser.Scene {
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
+ // Dancing crowd on the road — the same diverse cartoon townspeople as the
+ // hose minigame, bouncing and swaying, joined by a dog, cat, iguana, and a
+ // parrot doing laps across the sky.
+ const dancerScale = (H * 0.105) / 96;
+ for (let i = 0; i < 6; i++) {
+ const px = W * (0.46 + i * 0.075);
+ const py = H * 0.615;
+ const person = this.add.image(px, py, 'kg-person-' + (i % KG_LOOKS.length))
+ .setOrigin(0.5, 1).setScale(dancerScale).setDepth(3);
+ if (i % 2) person.setFlipX(true);
this.tweens.add({
- targets: person, y: py - H * 0.015,
- duration: 350 + i * 60, ease: 'Sine.easeInOut',
+ targets: person, y: py - H * 0.028,
+ duration: 330 + i * 55, 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',
+ targets: person, angle: { from: -7, to: 7 },
+ duration: 400 + i * 50, 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,
- });
}
+ const endDog = this.add.image(W * 0.42, H * 0.615, 'kg-dog')
+ .setOrigin(0.5, 1).setScale(dancerScale * 1.2).setDepth(3);
+ this.tweens.add({
+ targets: endDog, y: H * 0.615 - H * 0.035,
+ duration: 300, ease: 'Sine.easeOut', yoyo: true, repeat: -1,
+ });
+ const endCat = this.add.image(W * 0.94, H * 0.61, 'kg-cat')
+ .setOrigin(0.5, 1).setScale(dancerScale).setDepth(3);
+ this.tweens.add({
+ targets: endCat, angle: { from: -5, to: 5 },
+ duration: 500, ease: 'Sine.easeInOut', yoyo: true, repeat: -1,
+ });
+ this.add.image(W * 0.48, H * 0.675, 'kg-iguana')
+ .setOrigin(0.5, 1).setScale(dancerScale).setDepth(3);
+ const endParrot = this.add.image(-50, H * 0.12, 'kg-parrot')
+ .setScale(dancerScale * 1.4).setDepth(3);
+ this.tweens.add({
+ targets: endParrot, x: W + 50,
+ duration: 12000, repeat: -1, yoyo: true,
+ onYoyo: () => endParrot.setFlipX(true),
+ onRepeat: () => endParrot.setFlipX(false),
+ });
+ this.tweens.add({
+ targets: endParrot, y: H * 0.16,
+ duration: 850, yoyo: true, repeat: -1, ease: 'Sine.easeInOut',
+ });
// "You did it!" text
this.didItText = this.add.text(W / 2, H * 0.30, 'You did it!', {
@@ -2143,29 +2928,6 @@ class FireTruckEndScene extends Phaser.Scene {
this.events.on('destroy', this._shutdown, this);
}
- _drawSidePalm(gfx, x, groundY, height) {
- // Curved trunk + drooping fronds, seen from the side.
- gfx.lineStyle(Math.max(4, height * 0.09), PALM_TRUNK, 1);
- gfx.beginPath();
- gfx.moveTo(x, groundY);
- gfx.lineTo(x - height * 0.08, groundY - height * 0.5);
- gfx.lineTo(x + height * 0.06, groundY - height);
- gfx.strokePath();
- const topX = x + height * 0.06;
- const topY = groundY - height;
- const frondL = height * 0.55;
- const angles = [-2.5, -1.9, -1.3, -0.7, -0.1, 0.5];
- for (const a of angles) {
- gfx.lineStyle(Math.max(3, height * 0.05), PALM_FROND, 1);
- gfx.beginPath();
- gfx.moveTo(topX, topY);
- gfx.lineTo(topX + Math.cos(a) * frondL, topY + Math.sin(a) * frondL * 0.6 + frondL * 0.2);
- gfx.strokePath();
- }
- gfx.fillStyle(darken(PALM_TRUNK, 20), 1);
- gfx.fillCircle(topX, topY, height * 0.05);
- }
-
_shutdown() {
if (this._fwTimer) { this._fwTimer.remove(false); this._fwTimer = null; }
try { Tone.Transport.stop(); } catch (_) {}
@@ -2220,13 +2982,13 @@ class FireTruckEndScene extends Phaser.Scene {
this.waveGfx.strokePath();
}
- // Water fan from truck front
+ // Celebratory water fan arcing up from the 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 angle = -1.05 + 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);
}
@@ -2252,5 +3014,5 @@ window.__KG_GAME__ = new Phaser.Game({
antialias: false,
pixelArt: false,
},
- scene: [FireTruckScene, FireTruckEndScene],
+ scene: [FireTruckScene, FireHoseScene, FireTruckEndScene],
});
diff --git a/games/fire-truck/index.html b/games/fire-truck/index.html
@@ -130,7 +130,7 @@
</div>
<div id="hud">
<div class="title">Arrow Key Fire Truck</div>
- <div class="hint">Left arrow = left, up arrow = straight, right arrow = right | S = siren | L = lights</div>
+ <div class="hint">Arrows = drive (and aim at the fire!) | SPACE = spray | S = siren | L = lights</div>
</div>
<div id="game-container"></div>
<script>
diff --git a/games/fire-truck/lib.js b/games/fire-truck/lib.js
@@ -723,8 +723,34 @@
}, rng);
}
+ // ── Fire-hose minigame facade ───────────────────────────────────────────
+ // Pure layout for the street-view hose minigame: a cols×rows window grid
+ // plus which windows start on fire. fireNumber (0-based, how many fires the
+ // player has already put out) ramps the count up gently.
+ function buildFacade(rng, opts) {
+ const o = opts || {};
+ const rand = rng || createSeededRng(o.seed);
+ const cols = o.cols || randInt(rand, 4, 6);
+ const rows = o.rows || randInt(rand, 3, 4);
+ const total = cols * rows;
+ const maxFires = Math.max(2, Math.floor(total / 2));
+ const want = Math.min(3 + (o.fireNumber || 0), maxFires);
+
+ const indices = [];
+ for (let i = 0; i < total; i++) indices.push(i);
+ for (let i = indices.length - 1; i > 0; i--) {
+ const j = Math.floor(rand.next() * (i + 1));
+ const tmp = indices[i]; indices[i] = indices[j]; indices[j] = tmp;
+ }
+ const fires = indices.slice(0, want).sort((a, b) => a - b)
+ .map((i) => ({ index: i, col: i % cols, row: Math.floor(i / cols) }));
+
+ return { cols, rows, fires };
+ }
+
const api = {
HEADINGS,
+ buildFacade,
CELL_TYPES,
turnHeading,
getRelativeMove,
diff --git a/js/shared/touch-controls.js b/js/shared/touch-controls.js
@@ -77,9 +77,10 @@
if (layout === 'drive') {
return [
[
- { label: '◀', key: 'ArrowLeft', code: 'ArrowLeft', keyCode: 37, grow: 1, kind: 'arrow', aria: 'Turn left' },
- { label: '▲', key: 'ArrowUp', code: 'ArrowUp', keyCode: 38, grow: 1, kind: 'arrow', aria: 'Go straight' },
- { label: '▶', key: 'ArrowRight', code: 'ArrowRight', keyCode: 39, grow: 1, kind: 'arrow', aria: 'Turn right' },
+ { label: '◀', key: 'ArrowLeft', code: 'ArrowLeft', keyCode: 37, grow: 1, kind: 'arrow', hold: true, aria: 'Turn left / aim left' },
+ { label: '▲', key: 'ArrowUp', code: 'ArrowUp', keyCode: 38, grow: 1, kind: 'arrow', hold: true, aria: 'Go straight / aim up' },
+ { label: '▼', key: 'ArrowDown', code: 'ArrowDown', keyCode: 40, grow: 1, kind: 'arrow', hold: true, aria: 'Aim down' },
+ { label: '▶', key: 'ArrowRight', code: 'ArrowRight', keyCode: 39, grow: 1, kind: 'arrow', hold: true, aria: 'Turn right / aim right' },
],
[
{ label: '🔊 Siren', key: 's', code: 'KeyS', keyCode: 83, grow: 1, kind: 'special', aria: 'Siren' },
diff --git a/tests/browser/runner.js b/tests/browser/runner.js
@@ -414,10 +414,10 @@ async function main() {
if (!info.adjacent) throw new Error('fireCell and fireBuildingCell are not adjacent');
});
- await test('fire-truck: spacebar accumulates extinguish in firefighting state', async (page, url) => {
+ await test('fire-truck: reaching the fire launches the hose minigame', async (page, url, errors) => {
await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(1800);
- // Force into firefighting by teleporting truck to fireCell
+ // Teleport the truck onto fireCell so the fire trigger fires
await page.evaluate(() => {
const s = window.__FT_SCENE__;
if (s.fireCell) {
@@ -428,13 +428,57 @@ async function main() {
s.step(0.016);
}
});
- await page.waitForFunction(() => window.__FT_SCENE__.state === 'firefighting', null, { timeout: 5000 });
- const before = await page.evaluate(() => window.__FT_SCENE__.fireExtinguishAccum);
+ await page.waitForFunction(
+ () => window.__FT_HOSE_SCENE__ && window.__FT_HOSE_SCENE__.fires && window.__FT_HOSE_SCENE__.fires.length > 0,
+ null, { timeout: 5000 });
+ if (errors.length) throw new Error(errors[0]);
+ const state = await page.evaluate(() => window.__FT_SCENE__.state);
+ if (state !== 'minigame') throw new Error(`drive scene should be in minigame state, got: ${state}`);
+ // Aim at the first burning window and hold SPACE → quench progress rises
+ await page.evaluate(() => {
+ const h = window.__FT_HOSE_SCENE__;
+ const f = h.fires[0];
+ h.aimX = f.cx;
+ h.aimY = f.cy;
+ });
await page.keyboard.down('Space');
- await page.waitForTimeout(400);
- const after = await page.evaluate(() => window.__FT_SCENE__.fireExtinguishAccum);
+ await page.waitForTimeout(500);
+ const progress = await page.evaluate(() => window.__FT_HOSE_SCENE__.fires[0].progress);
await page.keyboard.up('Space');
- if (after <= before) throw new Error(`Extinguish did not accumulate: ${before} -> ${after}`);
+ if (!(progress > 0)) throw new Error(`Quench progress did not accumulate: ${progress}`);
+ });
+
+ await test('fire-truck: quenching every window returns to driving with counter +1', async (page, url, errors) => {
+ await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' });
+ await page.waitForTimeout(1800);
+ await page.evaluate(() => {
+ const s = window.__FT_SCENE__;
+ if (s.fireCell) {
+ s.state = 'driving';
+ s.speed = 0;
+ s.targetSpeed = 0;
+ s.truck.setPosition(s.fireCell.x * s.cellSize, s.fireCell.y * s.cellSize);
+ s.step(0.016);
+ }
+ });
+ await page.waitForFunction(
+ () => window.__FT_HOSE_SCENE__ && window.__FT_HOSE_SCENE__.fires && window.__FT_HOSE_SCENE__.fires.length > 0,
+ null, { timeout: 5000 });
+ await page.evaluate(() => {
+ const h = window.__FT_HOSE_SCENE__;
+ for (const f of h.fires) h._quenchWindow(f);
+ });
+ // Celebration plays (~2.6 s), then the drive scene resumes and re-routes (~0.6 s)
+ await page.waitForFunction(
+ () => window.__FT_SCENE__.firesExtinguished === 1 && window.__FT_SCENE__.state === 'driving',
+ null, { timeout: 10000 });
+ if (errors.length) throw new Error(errors[0]);
+ const info = await page.evaluate(() => ({
+ hoseGone: !window.__FT_HOSE_SCENE__,
+ hasNextFire: !!window.__FT_SCENE__.fireCell,
+ }));
+ if (!info.hoseGone) throw new Error('FireHoseScene still active after completion');
+ if (!info.hasNextFire) throw new Error('next fire destination was not set after minigame');
});
await test('fire-truck: correct arrow clears prompt immediately', async (page, url) => {
@@ -673,16 +717,23 @@ async function main() {
s.step(0.016);
}
});
- await page.waitForFunction(() => window.__FT_SCENE__.state === 'firefighting', null, { timeout: 6000 });
- const before = await page.evaluate(() => window.__FT_SCENE__.fireExtinguishAccum);
+ await page.waitForFunction(
+ () => window.__FT_HOSE_SCENE__ && window.__FT_HOSE_SCENE__.fires && window.__FT_HOSE_SCENE__.fires.length > 0,
+ null, { timeout: 6000 });
+ await page.evaluate(() => {
+ const h = window.__FT_HOSE_SCENE__;
+ const f = h.fires[0];
+ h.aimX = f.cx;
+ h.aimY = f.cy;
+ });
const spray = await touchKey(page, '💦 Spray');
const box = await spray.boundingBox();
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await page.waitForTimeout(400);
- const after = await page.evaluate(() => window.__FT_SCENE__.fireExtinguishAccum);
+ const after = await page.evaluate(() => window.__FT_HOSE_SCENE__.fires[0].progress);
await page.mouse.up();
- if (after <= before) throw new Error(`Spray hold did not accumulate: ${before} -> ${after}`);
+ if (after <= 0) throw new Error(`Spray hold did not accumulate quench progress: ${after}`);
});
// ── Summary ───────────────────────────────────────────────────────────────
diff --git a/tests/unit/fire-truck-facade.test.js b/tests/unit/fire-truck-facade.test.js
@@ -0,0 +1,49 @@
+'use strict';
+
+const test = require('node:test');
+const assert = require('node:assert');
+const FT = require('../../games/fire-truck/lib.js');
+
+test('buildFacade is deterministic for a given seed', () => {
+ const a = FT.buildFacade(FT.createSeededRng(42), { fireNumber: 1 });
+ const b = FT.buildFacade(FT.createSeededRng(42), { fireNumber: 1 });
+ assert.deepStrictEqual(a, b);
+});
+
+test('buildFacade produces a sane window grid', () => {
+ for (let seed = 0; seed < 50; seed++) {
+ const f = FT.buildFacade(FT.createSeededRng(seed), { fireNumber: 0 });
+ assert.ok(f.cols >= 4 && f.cols <= 6, `cols in range: ${f.cols}`);
+ assert.ok(f.rows >= 3 && f.rows <= 4, `rows in range: ${f.rows}`);
+ }
+});
+
+test('buildFacade fire windows are unique and inside the grid', () => {
+ for (let seed = 0; seed < 50; seed++) {
+ const f = FT.buildFacade(FT.createSeededRng(seed), { fireNumber: 3 });
+ const seen = new Set();
+ for (const fire of f.fires) {
+ assert.ok(!seen.has(fire.index), 'no duplicate fire windows');
+ seen.add(fire.index);
+ assert.ok(fire.col >= 0 && fire.col < f.cols, 'col in grid');
+ assert.ok(fire.row >= 0 && fire.row < f.rows, 'row in grid');
+ assert.strictEqual(fire.index, fire.row * f.cols + fire.col, 'index matches col/row');
+ }
+ }
+});
+
+test('buildFacade fire count ramps with fireNumber but stays ≤ half the windows', () => {
+ for (let seed = 0; seed < 30; seed++) {
+ for (let n = 0; n < 6; n++) {
+ const f = FT.buildFacade(FT.createSeededRng(seed * 7 + 1), { fireNumber: n });
+ const cap = Math.max(2, Math.floor((f.cols * f.rows) / 2));
+ assert.strictEqual(f.fires.length, Math.min(3 + n, cap));
+ }
+ }
+});
+
+test('buildFacade honors explicit cols/rows', () => {
+ const f = FT.buildFacade(FT.createSeededRng(9), { cols: 5, rows: 4, fireNumber: 0 });
+ assert.strictEqual(f.cols, 5);
+ assert.strictEqual(f.rows, 4);
+});