kgames

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

commit 5ea25da83ea4ef07a1f1b15728cda67d3c6860de
parent 231201da5967788ebf93121d698acf9182f034cb
Author: Kyle Barlow <kb@kylebarlow.com>
Date:   Tue, 14 Jul 2026 08:06:32 -0700

fire truck midgame

Diffstat:
MCLAUDE.md | 18++++++++++++++++++
Mgames/fire-truck/game.js | 1238++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
Mgames/fire-truck/index.html | 2+-
Mgames/fire-truck/lib.js | 26++++++++++++++++++++++++++
Mjs/shared/touch-controls.js | 7++++---
Mtests/browser/runner.js | 73++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Atests/unit/fire-truck-facade.test.js | 49+++++++++++++++++++++++++++++++++++++++++++++++++
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 &nbsp;|&nbsp; S = siren &nbsp;|&nbsp; L = lights</div> + <div class="hint">Arrows = drive (and aim at the fire!) &nbsp;|&nbsp; SPACE = spray &nbsp;|&nbsp; S = siren &nbsp;|&nbsp; 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); +});