kgames

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

game.js (133081B)


      1 const FT = window.FireTruckLib;
      2 
      3 const SCALE = 5;
      4 const CELL_SIZE = 96 * SCALE;
      5 const BAKE_CELL = 96; // px per cell in the baked chunk textures (CELL_SIZE / SCALE)
      6 const BASE_SPEED = 425;
      7 const MAX_SPEED = 550;
      8 const ACCEL = 650;
      9 const BRAKE = 800;
     10 const REACTION_TIME_S  = 2.0;
     11 const MIN_PROMPT_DIST  = CELL_SIZE * 0.72;
     12 const STOP_LINE_DIST = Math.round(CELL_SIZE * 0.45);
     13 const ROUTE_EXTENSION_COUNT = 36;
     14 const FIRE_TRIGGER_DIST   = CELL_SIZE * 0.6;
     15 const CAR_SPEED = 300;   // world u/s for ambient traffic
     16 const PED_SPEED = 90;    // world u/s for pedestrians
     17 const BIKE_SPEED = 170;  // world u/s for bicyclists
     18 const BUS_SPEED = 230;   // world u/s for buses
     19 // A people-first city: streets are full of walkers, bikes, and buses;
     20 // private cars are a rare sight.
     21 const PED_COUNT = 160;
     22 const BIKE_COUNT = 48;
     23 const BUS_COUNT = 6;
     24 const CAR_COUNT = 4;
     25 const FIRES_TO_WIN        = 4;
     26 
     27 // Tropical-island palette. Water stays saturated; asphalt stays dark for
     28 // stripe/crosswalk contrast. Building bodies are bright candy colors.
     29 const WATER_COLOR = 0x2bb5d8;
     30 const WATER_SHALLOW = 0x62d9e8;
     31 const WATER_FLECK = 0xc9f2f7;
     32 const BEACH_COLOR = 0xf9e3a9;
     33 const BEACH_WET = 0xeccd8a;
     34 const ASPHALT_COLOR = 0x30333c;
     35 const SIDEWALK_COLOR = 0xded5c3;
     36 const CROSSWALK_COLOR = 0xf4f2ec;
     37 const STRIPE_COLOR = 0xffd23f;
     38 const BUILDING_PALETTE = [0xff8a70, 0xffb347, 0x53d3c4, 0xffd76e, 0xf68fb8, 0xa8d96c];
     39 const ROOF_TERRACOTTA = 0xd97b52;
     40 const PARK_GRASS = 0x7ecb62;
     41 const PALM_TRUNK = 0x9a6b43;
     42 const PALM_FROND = 0x46b063;
     43 const BIKE_LANE_COLOR = 0x3e8e5a; // painted protected bike lanes
     44 
     45 // Both songs are ~1-minute arrangements: 16-step phrases (eighth notes)
     46 // concatenated so the loop is long enough not to grate. 'A' is the original
     47 // theme; the other phrases are new sections in the same E-minor character.
     48 // '.' = rest in the string form; chord rows place a triad every 4 steps
     49 // (main theme) or every 2 steps (end fanfare).
     50 const ftN = s => s.trim().split(/\s+/).map(t => (t === '.' ? null : t));
     51 const FTC = {
     52   Em: ['E3','G3','B3'], Am: ['A2','C3','E3'], G: ['G2','B2','D3'],
     53   C:  ['C3','E3','G3'], D:  ['D3','F#3','A3'], Bm: ['B2','D3','F#3'],
     54 };
     55 const ftChd4 = (a, b, c, d) =>
     56   [a, null, null, null, b, null, null, null, c, null, null, null, d, null, null, null];
     57 const ftChd8 = (...cs) => cs.flatMap(c => [c, null]);
     58 
     59 const FT_BPM = 158;
     60 const FT_MEL = {
     61   A: ftN('E5 G5 A5 B5 G5 E5 D5 E5  A4 C5 D5 E5 C5 A4 G4 A4'),
     62   B: ftN('B4 D5 E5 G5 E5 D5 B4 D5  E5 G5 B5 A5 G5 E5 D5 B4'),
     63   C: ftN('E4 G4 A4 B4 A4 G4 E4 G4  A4 B4 C5 B4 A4 G4 F#4 E4'),
     64   D: ftN('B5 A5 G5 A5 B5 E6 D6 B5  C6 B5 A5 G5 A5 B5 A5 G5'),
     65   E: ftN('G5 B5 D6 B5 G5 D5 B4 D5  C5 E5 G5 E5 D5 F#5 A5 F#5'),
     66   F: ftN('E5 D5 C5 B4 A4 B4 C5 A4  B4 C5 D5 E5 F#5 D5 B4 E5'),
     67 };
     68 const FT_BAS = {
     69   A: ftN('E2 B2 E2 B2 A2 C3 E3 A2  G2 B2 G2 D3 A2 E2 A2 E2'),
     70   B: ftN('E2 B2 E2 B2 G2 B2 G2 B2  C3 G2 C3 G2 D3 A2 D3 B2'),
     71   C: ftN('E2 E3 E2 E3 E2 E3 E2 B2  A2 A3 A2 A3 B2 F#2 B2 E2'),
     72   D: ftN('E2 B2 E3 B2 E2 B2 E3 B2  A2 E3 A2 E3 D3 A2 D3 A2'),
     73   E: ftN('G2 D3 G2 D3 G2 B2 G2 B2  C3 G2 C3 G2 D3 A2 D3 C3'),
     74   F: ftN('A2 E3 A2 E3 A2 C3 A2 C3  B2 F#2 B2 F#2 B2 D3 B2 E2'),
     75 };
     76 const FT_CHD = {
     77   A: ftChd4(FTC.Em, FTC.Am, FTC.G, FTC.Am),
     78   B: ftChd4(FTC.Em, FTC.G, FTC.C, FTC.D),
     79   C: ftChd4(FTC.Em, FTC.Em, FTC.Am, FTC.Bm),
     80   D: ftChd4(FTC.Em, FTC.Em, FTC.Am, FTC.D),
     81   E: ftChd4(FTC.G, FTC.G, FTC.C, FTC.D),
     82   F: ftChd4(FTC.Am, FTC.Am, FTC.Bm, FTC.Bm),
     83 };
     84 const FT_ORDER = ['A','B','A','C','D','B','E','F','A','C',
     85                   'D','E','B','F','C','D','A','B','F','A'];
     86 const FT_MELODY = FT_ORDER.flatMap(k => FT_MEL[k]);
     87 const FT_BASS   = FT_ORDER.flatMap(k => FT_BAS[k]);
     88 const FT_CHORDS = FT_ORDER.flatMap(k => FT_CHD[k]);
     89 
     90 // Celebration song for the endgame screen — related to the main theme
     91 // (same Em/Am/G harmonic language, brighter octave, triumphant ascending phrases)
     92 const FT_END_BPM = 170;
     93 const FT_END_MEL = {
     94   A: ftN('E5 G5 B5 E6 D6 B5 G5 A5  A5 C6 E6 A6 G6 E6 D6 E6'),
     95   B: ftN('B5 G5 E5 G5 B5 D6 E6 D6  C6 A5 E5 A5 C6 E6 D6 B5'),
     96   C: ftN('G5 B5 D6 G6 E6 C6 G5 C6  D6 B5 G5 B5 E6 D6 B5 A5'),
     97   D: ftN('E6 D6 B5 G5 A5 B5 C6 A5  B5 G5 E5 G5 B5 D6 E6 E6'),
     98 };
     99 const FT_END_BAS = {
    100   A: ftN('E3 G3 B3 E4 D4 B3 G3 A3  A3 C4 E4 A4 G4 E4 B3 E4'),
    101   B: ftN('E3 B3 G3 B3 E4 B3 G3 B3  A3 E4 C4 E4 D4 A3 D4 B3'),
    102   C: ftN('G3 B3 D4 G4 C4 E4 G4 C4  D4 B3 G3 B3 E4 B3 G3 A3'),
    103   D: ftN('E4 B3 G3 B3 A3 E3 A3 C4  B3 E3 G3 E3 B3 G3 E4 E3'),
    104 };
    105 const FT_END_CHD = {
    106   A: ftChd8(FTC.Em, ['B2','D3','G3'], FTC.G, ['A2','E3','A3'],
    107             FTC.Am, ['E3','A3','C4'], FTC.G, FTC.Em),
    108   B: ftChd8(FTC.Em, FTC.Em, FTC.G, FTC.Em, FTC.Am, FTC.Am, FTC.Am, FTC.G),
    109   C: ftChd8(FTC.G, FTC.G, FTC.C, FTC.C, FTC.G, FTC.Em, FTC.Em, FTC.Am),
    110   D: ftChd8(FTC.Em, FTC.Em, FTC.Am, FTC.Am, FTC.Em, FTC.Em, FTC.G, FTC.Em),
    111 };
    112 const FT_END_ORDER = ['A','B','A','C','A','B','D','C','A','B',
    113                       'C','D','A','C','B','D','A','B','D','A'];
    114 const FT_END_MELODY = FT_END_ORDER.flatMap(k => FT_END_MEL[k]);
    115 const FT_END_BASS   = FT_END_ORDER.flatMap(k => FT_END_BAS[k]);
    116 const FT_END_CHORDS = FT_END_ORDER.flatMap(k => FT_END_CHD[k]);
    117 
    118 function arrowLabel(dir) {
    119   const ARROWS = { left: '←', right: '→', straight: '↑' };
    120   const NAMES  = { left: 'LEFT',  right: 'RIGHT',  straight: 'STRAIGHT' };
    121   return dir ? (ARROWS[dir] || '') + ' ' + (NAMES[dir] || dir.toUpperCase()) : '';
    122 }
    123 
    124 // Manual mode uses absolute compass directions (key = screen direction).
    125 const KEY_TO_HEADING = { ArrowUp: 'north', ArrowDown: 'south', ArrowLeft: 'west', ArrowRight: 'east' };
    126 
    127 function headingArrowLabel(heading) {
    128   const LABELS = { north: '↑ UP', south: '↓ DOWN', west: '← LEFT', east: '→ RIGHT' };
    129   return LABELS[heading] || '';
    130 }
    131 
    132 // ── Shared cartoony art (hose minigame + endgame screen) ────────────────────
    133 // Cartoon townspeople with a range of skin tones, hair colors, and hair
    134 // styles. Baked once as textures; both the FireHoseScene street crowd and the
    135 // FireTruckEndScene dancers draw from the same set.
    136 const KG_SKINS = [0xffe0bd, 0xf1c27d, 0xd9995b, 0xc68642, 0x8d5524, 0x5e3a1c];
    137 const KG_LOOKS = [
    138   { skin: KG_SKINS[4], shirt: 0xe63946, pants: 0x2f4858, hair: 0x1b1b1b, hairStyle: 2 },
    139   { skin: KG_SKINS[0], shirt: 0x1d7cf2, pants: 0x6b4a2b, hair: 0xd9b380, hairStyle: 0 },
    140   { skin: KG_SKINS[3], shirt: 0xffd23f, pants: 0x30333c, hair: 0x2f2a26, hairStyle: 1 },
    141   { skin: KG_SKINS[1], shirt: 0x2ec4b6, pants: 0x8d5524, hair: 0x4a3728, hairStyle: 3 },
    142   { skin: KG_SKINS[5], shirt: 0xff8c42, pants: 0x1d3557, hair: 0x1b1b1b, hairStyle: 0 },
    143   { skin: KG_SKINS[2], shirt: 0xf68fb8, pants: 0x2f4858, hair: 0x2f2a26, hairStyle: 2 },
    144   { skin: KG_SKINS[3], shirt: 0xa8d96c, pants: 0x30333c, hair: 0x1b1b1b, hairStyle: 3 },
    145   { skin: KG_SKINS[1], shirt: 0x7f6bd6, pants: 0x445266, hair: 0x8a5a2b, hairStyle: 1 },
    146 ];
    147 
    148 function bakePersonTexture(scene, key, look) {
    149   if (scene.textures.exists(key)) return;
    150   const g = scene.make.graphics({ add: false });
    151   // shadow + legs + shoes
    152   g.fillStyle(0x000000, 0.15); g.fillEllipse(28, 91, 34, 8);
    153   g.fillStyle(look.pants, 1);
    154   g.fillRoundedRect(19, 60, 8, 26, 4);
    155   g.fillRoundedRect(29, 60, 8, 26, 4);
    156   g.fillStyle(0x3a3f4a, 1);
    157   g.fillEllipse(23, 88, 12, 7); g.fillEllipse(33, 88, 12, 7);
    158   // arms + hands
    159   g.fillStyle(look.shirt, 1);
    160   g.fillRoundedRect(8, 42, 8, 24, 4);
    161   g.fillRoundedRect(40, 42, 8, 24, 4);
    162   g.fillStyle(look.skin, 1); g.fillCircle(12, 67, 4); g.fillCircle(44, 67, 4);
    163   // torso with a sheen highlight
    164   g.fillStyle(look.shirt, 1); g.fillRoundedRect(14, 38, 28, 28, 10);
    165   g.fillStyle(0xffffff, 0.25); g.fillRoundedRect(17, 41, 10, 6, 3);
    166   // big cartoon head: hair circle behind, face circle in front
    167   g.fillStyle(look.hair, 1); g.fillCircle(28, 17, 15);
    168   g.fillStyle(look.skin, 1); g.fillCircle(28, 21, 13);
    169   if (look.hairStyle === 1) {          // long hair down to the shoulders
    170     g.fillStyle(look.hair, 1);
    171     g.fillRoundedRect(11, 14, 8, 26, 4);
    172     g.fillRoundedRect(37, 14, 8, 26, 4);
    173   } else if (look.hairStyle === 2) {   // curly
    174     g.fillStyle(look.hair, 1);
    175     g.fillCircle(17, 12, 7); g.fillCircle(24, 7, 8);
    176     g.fillCircle(33, 8, 8); g.fillCircle(40, 14, 6);
    177   } else if (look.hairStyle === 3) {   // top bun
    178     g.fillStyle(look.hair, 1); g.fillCircle(28, 5, 6);
    179   }
    180   g.fillStyle(look.hair, 1); g.fillEllipse(28, 11, 24, 10); // fringe
    181   // face: eyes, pupils, cheeks, smile
    182   g.fillStyle(0xffffff, 1); g.fillCircle(23, 21, 3.4); g.fillCircle(33, 21, 3.4);
    183   g.fillStyle(0x2b2b2b, 1); g.fillCircle(23.6, 21.6, 1.7); g.fillCircle(33.6, 21.6, 1.7);
    184   g.fillStyle(0xff8c8c, 0.35); g.fillCircle(20, 26, 2.6); g.fillCircle(36, 26, 2.6);
    185   g.lineStyle(2, 0x7a4a3a, 1);
    186   g.beginPath(); g.arc(28, 26, 5.5, Math.PI * 0.15, Math.PI * 0.85, false); g.strokePath();
    187   g.generateTexture(key, 56, 96);
    188   g.destroy();
    189 }
    190 
    191 function bakePeopleTextures(scene) {
    192   KG_LOOKS.forEach((look, i) => bakePersonTexture(scene, 'kg-person-' + i, look));
    193 }
    194 
    195 // Side-view fire truck (facing right) — lockers, ladder turntable, crewed cab.
    196 function bakeSideTruckTexture(scene) {
    197   const key = 'kg-truck-side';
    198   if (scene.textures.exists(key)) return key;
    199   const g = scene.make.graphics({ add: false });
    200   g.fillStyle(0x000000, 0.18); g.fillEllipse(130, 102, 230, 14);
    201   // rear body
    202   g.fillStyle(0xe63946, 1); g.fillRoundedRect(6, 36, 168, 52, 8);
    203   g.lineStyle(3, 0x9b1c25, 1); g.strokeRoundedRect(6, 36, 168, 52, 8);
    204   // silver roll-up equipment lockers
    205   const locker = (x) => {
    206     g.fillStyle(0xd7dde6, 1); g.fillRoundedRect(x, 44, 34, 30, 4);
    207     g.lineStyle(1.5, 0x9aa4b2, 1);
    208     for (let ly = 49; ly <= 69; ly += 5) g.lineBetween(x + 2, ly, x + 32, ly);
    209   };
    210   locker(28); locker(70); locker(112);
    211   // hose reel + rear step
    212   g.fillStyle(0x9aa0a8, 1); g.fillCircle(16, 58, 9);
    213   g.fillStyle(0x30333c, 1); g.fillCircle(16, 58, 4);
    214   g.fillStyle(0x30333c, 1); g.fillRect(0, 82, 8, 8);
    215   // white stripe + gold band along the body
    216   g.fillStyle(0xffd23f, 1); g.fillRect(6, 77, 168, 2);
    217   g.fillStyle(0xffffff, 0.9); g.fillRect(6, 79, 168, 6);
    218   // ladder turntable on top
    219   g.fillStyle(0x9aa0a8, 1); g.fillRoundedRect(50, 26, 40, 12, 4);
    220   g.fillStyle(0xf4b400, 1); g.fillCircle(70, 31, 8);
    221   // cab
    222   g.fillStyle(0xe63946, 1); g.fillRoundedRect(172, 26, 76, 62, { tl: 14, tr: 22, bl: 0, br: 8 });
    223   g.lineStyle(3, 0x9b1c25, 1); g.strokeRoundedRect(172, 26, 76, 62, { tl: 14, tr: 22, bl: 0, br: 8 });
    224   // cab window with a firefighter at the wheel
    225   g.fillStyle(0x9fdcff, 1); g.fillRoundedRect(186, 34, 52, 24, { tl: 8, tr: 14, bl: 4, br: 4 });
    226   g.fillStyle(0x8d5524, 1); g.fillCircle(206, 50, 7);
    227   g.fillStyle(0xffd23f, 1); g.fillEllipse(206, 43, 18, 8);
    228   g.fillStyle(0x2b2b2b, 1); g.fillCircle(208.5, 50, 1.4);
    229   // stripe continues on the cab, door seam + handle
    230   g.fillStyle(0xffd23f, 1); g.fillRect(172, 77, 76, 2);
    231   g.fillStyle(0xffffff, 0.9); g.fillRect(172, 79, 76, 6);
    232   g.lineStyle(2, 0x9b1c25, 1); g.lineBetween(184, 60, 184, 86);
    233   g.fillStyle(0xffd23f, 1); g.fillRect(176, 64, 7, 3);
    234   // light bar
    235   g.fillStyle(0x30333c, 1); g.fillRoundedRect(196, 18, 34, 9, 3);
    236   g.fillStyle(0xe63946, 1); g.fillRoundedRect(198, 15, 12, 7, 2);
    237   g.fillStyle(0x1d7cf2, 1); g.fillRoundedRect(214, 15, 12, 7, 2);
    238   // bumper + headlight
    239   g.fillStyle(0xd7dde6, 1); g.fillRoundedRect(244, 74, 14, 14, 3);
    240   g.fillStyle(0xffe9a8, 1); g.fillCircle(250, 70, 4);
    241   // wheels
    242   const wheel = (x) => {
    243     g.fillStyle(0x22262e, 1); g.fillCircle(x, 88, 15);
    244     g.fillStyle(0x9aa0a8, 1); g.fillCircle(x, 88, 7.5);
    245     g.fillStyle(0x22262e, 1); g.fillCircle(x, 88, 2.5);
    246   };
    247   wheel(46); wheel(120); wheel(210);
    248   g.generateTexture(key, 260, 112);
    249   g.destroy();
    250   return key;
    251 }
    252 
    253 // Extending ladder with a firefighter + nozzle in the tip basket. Pivot is at
    254 // local (14, 15); the water jet leaves from local (206, 15).
    255 const KG_LADDER_PIVOT_X = 14;
    256 const KG_LADDER_TIP_X = 206;
    257 function bakeLadderTexture(scene) {
    258   const key = 'kg-ladder';
    259   if (scene.textures.exists(key)) return key;
    260   const g = scene.make.graphics({ add: false });
    261   g.fillStyle(0xffd23f, 1); g.fillRect(14, 7, 160, 5); g.fillRect(14, 18, 160, 5);
    262   g.fillStyle(0xcf9b1e, 1);
    263   for (let x = 22; x <= 166; x += 12) g.fillRect(x, 9, 3, 12);
    264   g.fillStyle(0x9aa0a8, 1); g.fillCircle(14, 15, 10);
    265   g.fillStyle(0x30333c, 1); g.fillCircle(14, 15, 4);
    266   // tip basket with firefighter (helmet + face) holding the nozzle
    267   g.fillStyle(0xe63946, 1); g.fillRoundedRect(170, 3, 20, 24, 5);
    268   g.fillStyle(0xc68642, 1); g.fillCircle(181, 12, 5);
    269   g.fillStyle(0xffd23f, 1); g.fillEllipse(181, 8, 13, 6);
    270   g.fillStyle(0x2b2b2b, 1); g.fillCircle(182.6, 12, 1.1);
    271   g.fillStyle(0xd7dde6, 1); g.fillRect(188, 12, 14, 6);
    272   g.fillStyle(0x30333c, 1); g.fillRect(201, 11, 6, 8);
    273   g.generateTexture(key, 210, 30);
    274   g.destroy();
    275   return key;
    276 }
    277 
    278 // Pets and tropical wildlife.
    279 function bakeCritterTextures(scene) {
    280   const mk = () => scene.make.graphics({ add: false });
    281   if (!scene.textures.exists('kg-dog')) {
    282     const g = mk();
    283     g.fillStyle(0x000000, 0.15); g.fillEllipse(24, 35, 36, 6);
    284     g.fillStyle(0xb5773a, 1); g.fillTriangle(4, 16, 12, 20, 4, 26); // tail
    285     g.fillStyle(0xc98a4b, 1); g.fillRoundedRect(8, 14, 28, 14, 7);  // body
    286     g.fillStyle(0xb5773a, 1);                                       // legs
    287     g.fillRect(11, 26, 4, 8); g.fillRect(19, 26, 4, 8); g.fillRect(29, 26, 4, 8);
    288     g.fillStyle(0xc98a4b, 1); g.fillCircle(38, 13, 8);              // head
    289     g.fillStyle(0x8a5a2b, 1); g.fillTriangle(32, 6, 38, 4, 36, 12); // ear
    290     g.fillStyle(0xe63946, 1); g.fillRect(31, 18, 12, 3);            // collar
    291     g.fillStyle(0xffffff, 1); g.fillCircle(40, 12, 2.6);            // eye
    292     g.fillStyle(0x2b2b2b, 1); g.fillCircle(40.8, 12.4, 1.3);
    293     g.fillStyle(0x2b2b2b, 1); g.fillCircle(45, 15, 2);              // nose
    294     g.generateTexture('kg-dog', 52, 40); g.destroy();
    295   }
    296   if (!scene.textures.exists('kg-cat')) {
    297     const g = mk();
    298     g.fillStyle(0x000000, 0.15); g.fillEllipse(18, 37, 26, 5);
    299     g.lineStyle(4, 0x8a6f52, 1);
    300     g.beginPath(); g.arc(28, 30, 8, -Math.PI * 0.5, Math.PI * 0.4); g.strokePath(); // tail
    301     g.fillStyle(0xa8886a, 1); g.fillRoundedRect(9, 18, 18, 20, 8);   // sitting body
    302     g.fillStyle(0xa8886a, 1); g.fillCircle(18, 12, 9);               // head
    303     g.fillStyle(0x8a6f52, 1);
    304     g.fillTriangle(10, 8, 14, 2, 16, 8); g.fillTriangle(20, 8, 22, 2, 26, 8); // ears
    305     g.fillStyle(0x7ecb62, 1); g.fillCircle(14.5, 12, 2.4); g.fillCircle(21.5, 12, 2.4);
    306     g.fillStyle(0x2b2b2b, 1); g.fillCircle(14.5, 12, 1.1); g.fillCircle(21.5, 12, 1.1);
    307     g.fillStyle(0xf3b0c3, 1); g.fillTriangle(16.6, 15, 19.4, 15, 18, 17); // nose
    308     g.generateTexture('kg-cat', 40, 40); g.destroy();
    309   }
    310   if (!scene.textures.exists('kg-parrot')) {
    311     const g = mk();
    312     g.fillStyle(0x1d7cf2, 1); g.fillTriangle(2, 26, 16, 20, 12, 30);  // tail feathers
    313     g.fillStyle(0x2ec4b6, 1); g.fillTriangle(4, 22, 16, 18, 12, 26);
    314     g.fillStyle(0xe63946, 1); g.fillEllipse(22, 22, 22, 18);          // body
    315     g.fillStyle(0xe63946, 1); g.fillCircle(33, 13, 8);                // head
    316     g.fillStyle(0xffffff, 1); g.fillCircle(35, 12, 4);                // face patch
    317     g.fillStyle(0x2b2b2b, 1); g.fillCircle(35.5, 12, 1.4);
    318     g.fillStyle(0xffd23f, 1); g.fillTriangle(40, 12, 46, 16, 39, 18); // beak
    319     g.fillStyle(0xffd23f, 1); g.fillTriangle(14, 16, 30, 22, 16, 28); // wing
    320     g.fillStyle(0xa8d96c, 1); g.fillTriangle(16, 18, 27, 22, 17, 26);
    321     g.generateTexture('kg-parrot', 48, 36); g.destroy();
    322   }
    323   if (!scene.textures.exists('kg-iguana')) {
    324     const g = mk();
    325     g.fillStyle(0x000000, 0.12); g.fillEllipse(32, 23, 52, 5);
    326     g.fillStyle(0x5ba64f, 1); g.fillTriangle(0, 18, 20, 12, 20, 20);  // tail
    327     g.fillStyle(0x6fbf5d, 1); g.fillEllipse(32, 16, 30, 12);          // body
    328     g.fillStyle(0x6fbf5d, 1); g.fillCircle(50, 13, 7);                // head
    329     g.fillStyle(0x5ba64f, 1);                                         // legs
    330     g.fillRect(24, 20, 4, 5); g.fillRect(38, 20, 4, 5);
    331     g.fillStyle(0x8fd97e, 1);                                         // back spines
    332     for (let sx = 20; sx <= 46; sx += 6) g.fillTriangle(sx, 11, sx + 3, 6, sx + 6, 11);
    333     g.fillStyle(0xffffff, 1); g.fillCircle(52, 12, 2.2);
    334     g.fillStyle(0x2b2b2b, 1); g.fillCircle(52.6, 12.2, 1.1);
    335     g.generateTexture('kg-iguana', 60, 26); g.destroy();
    336   }
    337 }
    338 
    339 // Fire/smoke FX textures shared by the driving scene and the hose minigame.
    340 function bakeFxTextures(scene) {
    341   if (!scene.textures.exists('smoke-puff')) {
    342     const g = scene.make.graphics({ add: false });
    343     g.fillStyle(0xb9bcc2, 0.5); g.fillCircle(14, 14, 12);
    344     g.fillStyle(0xe4e6ea, 0.5); g.fillCircle(11, 11, 7);
    345     g.generateTexture('smoke-puff', 28, 28); g.destroy();
    346   }
    347   if (!scene.textures.exists('glow')) {
    348     const g = scene.make.graphics({ add: false });
    349     for (let r = 32; r > 0; r -= 4) {
    350       g.fillStyle(0xffa733, 0.06);
    351       g.fillCircle(32, 32, r);
    352     }
    353     g.generateTexture('glow', 64, 64); g.destroy();
    354   }
    355 }
    356 
    357 // Side-view palm (curved trunk + drooping fronds), shared by the hose
    358 // minigame street and the endgame beach.
    359 function drawSidePalm(gfx, x, groundY, height) {
    360   gfx.lineStyle(Math.max(4, height * 0.09), PALM_TRUNK, 1);
    361   gfx.beginPath();
    362   gfx.moveTo(x, groundY);
    363   gfx.lineTo(x - height * 0.08, groundY - height * 0.5);
    364   gfx.lineTo(x + height * 0.06, groundY - height);
    365   gfx.strokePath();
    366   const topX = x + height * 0.06;
    367   const topY = groundY - height;
    368   const frondL = height * 0.55;
    369   const angles = [-2.5, -1.9, -1.3, -0.7, -0.1, 0.5];
    370   for (const a of angles) {
    371     gfx.lineStyle(Math.max(3, height * 0.05), PALM_FROND, 1);
    372     gfx.beginPath();
    373     gfx.moveTo(topX, topY);
    374     gfx.lineTo(topX + Math.cos(a) * frondL, topY + Math.sin(a) * frondL * 0.6 + frondL * 0.2);
    375     gfx.strokePath();
    376   }
    377   gfx.fillStyle(darken(PALM_TRUNK, 20), 1);
    378   gfx.fillCircle(topX, topY, height * 0.05);
    379 }
    380 
    381 // ── Web Audio SFX helpers (shared by scenes; ctx comes from the drive scene) ─
    382 function startWaterNoise(ctx) {
    383   if (!ctx) return null;
    384   try {
    385     const buffer = ctx.createBuffer(1, ctx.sampleRate, ctx.sampleRate);
    386     const data = buffer.getChannelData(0);
    387     for (let i = 0; i < data.length; i++) data[i] = Math.random() * 2 - 1;
    388     const source = ctx.createBufferSource();
    389     source.buffer = buffer;
    390     source.loop = true;
    391     const filter = ctx.createBiquadFilter();
    392     filter.type = 'bandpass';
    393     filter.frequency.value = 1200;
    394     filter.Q.value = 0.6;
    395     const gain = ctx.createGain();
    396     gain.gain.value = 0.14;
    397     source.connect(filter);
    398     filter.connect(gain);
    399     gain.connect(ctx.destination);
    400     source.start();
    401     return { source, gain };
    402   } catch (_) { return null; }
    403 }
    404 
    405 function stopWaterNoise(ctx, handle) {
    406   if (!handle) return;
    407   try {
    408     handle.gain.gain.setValueAtTime(handle.gain.gain.value, ctx.currentTime);
    409     handle.gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.08);
    410     handle.source.stop(ctx.currentTime + 0.08);
    411   } catch (_) {}
    412 }
    413 
    414 // Short melodic blips: notes = [[freq, startOffsetSec, durSec], ...]
    415 function playJingle(ctx, muted, notes) {
    416   if (!ctx || muted) return;
    417   try {
    418     for (const [freq, at, dur] of notes) {
    419       const osc = ctx.createOscillator();
    420       const gain = ctx.createGain();
    421       osc.type = 'triangle';
    422       osc.frequency.value = freq;
    423       osc.connect(gain);
    424       gain.connect(ctx.destination);
    425       const t0 = ctx.currentTime + at;
    426       gain.gain.setValueAtTime(0.0001, t0);
    427       gain.gain.linearRampToValueAtTime(0.14, t0 + 0.02);
    428       gain.gain.exponentialRampToValueAtTime(0.001, t0 + dur);
    429       osc.start(t0);
    430       osc.stop(t0 + dur + 0.05);
    431     }
    432   } catch (_) {}
    433 }
    434 
    435 class FireTruckScene extends Phaser.Scene {
    436   constructor() {
    437     super({ key: 'FireTruckScene' });
    438     this.state = 'driving';
    439     this.speed = BASE_SPEED * (window.GAME_SPEED_MULTIPLIER ?? 1.0);
    440     this.targetSpeed = BASE_SPEED * (window.GAME_SPEED_MULTIPLIER ?? 1.0);
    441     this.promptDir = null;
    442     this.failVisible = false;
    443     this.route = [];
    444     this.island = null;
    445     this.routeIndex = 0;
    446     this.phase = 'approach';
    447     this.currentStep = null;
    448     this.heading = 'east';
    449     this.segmentEnd = { x: 0, y: 0 };
    450     this.audioCtx = null;
    451     this.promptResolved = false;
    452     this.warningUntil = 0;
    453     this.truckX = 0;
    454     this.truckY = 0;
    455     this.targetRotation = 0;
    456     this.successUntil = 0;
    457     this.debugDistance = 0;
    458     this.promptShownAt = 0;
    459     this.fireCell = null;
    460     this.fireBuildingCell = null;
    461     this.fireGraphics = null;
    462     this.firesExtinguished = 0;
    463     this.fireMode = false;
    464     this._stallSince = 0;
    465     // Manual control mode: truck moves only while an arrow key is held,
    466     // cell-centre to cell-centre along the road graph.
    467     this.controlMode = window.GAME_CONTROL_MODE ?? 'guided';
    468     this.heldHeadings = [];      // stack of held arrow-key headings, last wins
    469     this.manualCell = null;      // road cell the truck last centred on
    470     this.manualTarget = null;    // neighbouring road cell being driven toward
    471     this.manualHintHeading = null;
    472     this.fireDistances = null;   // Map road cell -> graph distance to current fire
    473     this._manualLastDist = null;
    474     this.musicMuted    = false;
    475     this.sfxMuted      = false;
    476     this.sirenOn       = false;
    477     this.lightsOn      = false;
    478     this._audioInited  = false;
    479     this.bgGain        = null;
    480     this.melodySynth   = null;
    481     this.bassSynth     = null;
    482     this.chordSynth    = null;
    483     this.sirenOsc      = null;
    484     this.sirenLfo      = null;
    485     this.sirenGainNode = null;
    486     this.truckLight    = null;
    487   }
    488 
    489   create() {
    490     this.island = FT.buildIsland({ width: 50, height: 50, routeCount: 200 });
    491     this.route = this.island.route;
    492     this.cellSize = CELL_SIZE;
    493 
    494     // Decoration precompute: building blocks (one rect per road-bounded block)
    495     // and park tagging. Must run before renderIsland + fire destination pick.
    496     this.blocks = FT.findBuildingBlocks(this.island.grid);
    497     FT.assignParks(this.island, { fraction: 0.15 });
    498 
    499     this.cameras.main.setBackgroundColor('#2BB5D8');
    500     this.cameras.main.setZoom(1 / SCALE);
    501     this.cameras.main.setBounds(
    502       this.island.bounds.minX * CELL_SIZE,
    503       this.island.bounds.minY * CELL_SIZE,
    504       this.island.bounds.width * CELL_SIZE,
    505       this.island.bounds.height * CELL_SIZE
    506     );
    507 
    508     this.overlay = this.add.rectangle(0, 0, this.scale.width, this.scale.height, 0xe63946, 0)
    509       .setOrigin(0, 0)
    510       .setScrollFactor(0)
    511       .setDepth(40);
    512 
    513     this.successOverlay = this.add.rectangle(0, 0, this.scale.width, this.scale.height, 0x2ec4b6, 1)
    514       .setAlpha(0)
    515       .setOrigin(0, 0)
    516       .setScrollFactor(0)
    517       .setDepth(39);
    518 
    519     this.promptText = this.add.text(this.scale.width / 2, this.scale.height - 74, '', {
    520       fontFamily: 'Fredoka, sans-serif',
    521       fontSize: '34px',
    522       color: '#ffffff',
    523       stroke: '#2f2f2f',
    524       strokeThickness: 8,
    525       align: 'center',
    526     }).setOrigin(0.5).setScrollFactor(0).setDepth(50);
    527 
    528     this.statusText = this.add.text(this.scale.width / 2, this.scale.height - 34, 'Watch for the next arrow.', {
    529       fontFamily: 'Fredoka, sans-serif',
    530       fontSize: '20px',
    531       color: '#213321',
    532       backgroundColor: '#ffffff',
    533       padding: { x: 10, y: 4 },
    534     }).setOrigin(0.5).setScrollFactor(0).setDepth(50);
    535     this.statusText.setAlpha(0.88);
    536 
    537     this.fpsText = this.add.text(8, 8, '', {
    538       fontFamily: 'monospace',
    539       fontSize: '14px',
    540       color: '#2e372d',
    541       backgroundColor: 'rgba(255,255,255,0.7)',
    542       padding: { x: 6, y: 3 },
    543     }).setScrollFactor(0).setDepth(50);
    544 
    545     this.fireCountText = this.add.text(this.scale.width / 2, 78, '🔥 0 / 4', {
    546       fontFamily: 'Fredoka, sans-serif',
    547       fontSize: '26px',
    548       color: '#ffffff',
    549       stroke: '#2f2f2f',
    550       strokeThickness: 7,
    551       align: 'center',
    552     }).setOrigin(0.5, 0).setScrollFactor(0).setDepth(50);
    553 
    554     this.uiCamera = this.cameras.add(0, 0, this.scale.width, this.scale.height);
    555 
    556     this._addOcean();
    557     this.renderIsland();
    558     this.createTruck();
    559 
    560     this.uiCamera.ignore([...this.cityChunks, this.truck]);
    561     this.cameras.main.ignore([this.overlay, this.successOverlay, this.promptText, this.statusText, this.fpsText, this.fireCountText]);
    562 
    563     this.currentStep = this.route[0];
    564     const start = this.worldPoint(this.island.routeStart.x, this.island.routeStart.y);
    565     this.segmentEnd = this.worldPoint(this.currentStep.x, this.currentStep.y);
    566     this.heading = this.currentStep.headingIn;
    567     this.targetRotation = this.rotationForHeading(this.heading);
    568     this.truck.setPosition(start.x, start.y);
    569     this.truck.rotation = this.targetRotation;
    570 
    571     this.input.keyboard.on('keydown', this.onKeyDown, this);
    572     this.input.keyboard.on('keyup', this.onKeyUp, this);
    573     // This is a keyboard-driven game — players may never click the canvas, so
    574     // start audio on the first key press too (a real keydown is a user gesture
    575     // that satisfies the browser autoplay policy).
    576     this.input.keyboard.once('keydown', () => this.initAudio(), this);
    577     this.input.once('pointerdown', () => this.initAudio(), this);
    578     this.scale.on('resize', this.onResize, this);
    579     this.onResize(this.scale.gameSize);
    580 
    581     this.cameras.main.startFollow(this.truck, false, 1, 1);
    582 
    583     this._initAmbient();
    584 
    585     this.lastStepTime = performance.now();
    586     this.fallbackTimer = setInterval(() => {
    587       const now = performance.now();
    588       const elapsed = now - this.lastStepTime;
    589       if (elapsed > 250) {
    590         this.step(Math.min(elapsed / 1000, 0.5));
    591         this.lastStepTime = now;
    592       }
    593     }, 200);
    594 
    595     this.events.on('shutdown', this.shutdown, this);
    596     this.events.on('destroy', this.shutdown, this);
    597 
    598     window.__FT_SCENE__ = this;
    599     this._initFireDestination();
    600     if (this.controlMode === 'manual') this._enterManualMode();
    601     this._updateFireCounter();
    602     this.refreshDebug();
    603   }
    604 
    605   worldPoint(x, y) {
    606     return { x: x * CELL_SIZE, y: y * CELL_SIZE };
    607   }
    608 
    609   renderIsland() {
    610     // Bake the static island into 16-cell chunk textures at BAKE_CELL px/cell,
    611     // drawn ×SCALE (camera zoom 1/SCALE) → ~1:1 on screen. Per-frame cost drops
    612     // to a handful of textured quads and Phaser culls offscreen chunks for free.
    613     //
    614     // Chunk-seam rule: every pass iterates a 1-cell apron around the chunk
    615     // (clamped to the grid) so overhanging art — SE shadows, palm fronds,
    616     // block-spanning buildings — that crosses a chunk border is re-drawn by the
    617     // neighbouring chunk. generateTexture clips whatever falls outside the tile.
    618     const CHUNK_CELLS = 16;
    619     const CHUNK_PX = CHUNK_CELLS * BAKE_CELL;
    620     const chunkCountX = Math.ceil(this.island.width / CHUNK_CELLS);
    621     const chunkCountY = Math.ceil(this.island.height / CHUNK_CELLS);
    622 
    623     this.cityChunks = [];
    624 
    625     for (let chunkY = 0; chunkY < chunkCountY; chunkY++) {
    626       for (let chunkX = 0; chunkX < chunkCountX; chunkX++) {
    627         const ox = chunkX * CHUNK_CELLS;
    628         const oy = chunkY * CHUNK_CELLS;
    629         const cellEndX = Math.min(this.island.width, ox + CHUNK_CELLS);
    630         const cellEndY = Math.min(this.island.height, oy + CHUNK_CELLS);
    631         const ax0 = Math.max(0, ox - 1);
    632         const ay0 = Math.max(0, oy - 1);
    633         const ax1 = Math.min(this.island.width, cellEndX + 1);
    634         const ay1 = Math.min(this.island.height, cellEndY + 1);
    635 
    636         const gfx = this.make.graphics({ add: false });
    637         this._bakeGround(gfx, ax0, ay0, ax1, ay1, ox, oy);
    638         this._bakeSidewalks(gfx, ax0, ay0, ax1, ay1, ox, oy);
    639         this._bakeRoadMarkings(gfx, ax0, ay0, ax1, ay1, ox, oy);
    640         this._bakeBuildings(gfx, ax0, ay0, ax1, ay1, ox, oy);
    641         this._bakeParks(gfx, ax0, ay0, ax1, ay1, ox, oy);
    642         this._bakePalms(gfx, ax0, ay0, ax1, ay1, ox, oy);
    643 
    644         const key = `island-bake-${chunkX}_${chunkY}`;
    645         if (this.textures.exists(key)) this.textures.remove(key);
    646         gfx.generateTexture(key, CHUNK_PX, CHUNK_PX);
    647         gfx.destroy();
    648 
    649         const imageWorldX = (ox - 0.5) * CELL_SIZE;
    650         const imageWorldY = (oy - 0.5) * CELL_SIZE;
    651         const img = this.add.image(imageWorldX, imageWorldY, key)
    652           .setOrigin(0, 0)
    653           .setScale(SCALE)
    654           .setDepth(0);
    655         this.cityChunks.push(img);
    656       }
    657     }
    658   }
    659 
    660   // Fill a colored strip along each edge of a cell that faces a `neighborType`
    661   // neighbour (shallow water beside beach, wet sand beside water, …).
    662   _edgeBands(gfx, x, y, gx, gy, neighborType, color, thick) {
    663     const grid = this.island.grid;
    664     const nb = (dx, dy) => {
    665       const c = grid[y + dy] && grid[y + dy][x + dx];
    666       return c && c.type === neighborType;
    667     };
    668     gfx.fillStyle(color, 1);
    669     if (nb(0, -1)) gfx.fillRect(gx, gy, BAKE_CELL, thick);
    670     if (nb(0, 1)) gfx.fillRect(gx, gy + BAKE_CELL - thick, BAKE_CELL, thick);
    671     if (nb(-1, 0)) gfx.fillRect(gx, gy, thick, BAKE_CELL);
    672     if (nb(1, 0)) gfx.fillRect(gx + BAKE_CELL - thick, gy, thick, BAKE_CELL);
    673   }
    674 
    675   _bakeGround(gfx, ax0, ay0, ax1, ay1, ox, oy) {
    676     const grid = this.island.grid;
    677     const T = FT.CELL_TYPES;
    678     for (let y = ay0; y < ay1; y++) {
    679       for (let x = ax0; x < ax1; x++) {
    680         const cell = grid[y][x];
    681         const gx = (x - ox) * BAKE_CELL;
    682         const gy = (y - oy) * BAKE_CELL;
    683         if (cell.type === T.WATER) {
    684           gfx.fillStyle(WATER_COLOR, 1);
    685           gfx.fillRect(gx, gy, BAKE_CELL, BAKE_CELL);
    686           this._edgeBands(gfx, x, y, gx, gy, T.BEACH, WATER_SHALLOW, 22);
    687           if (FT.hashCell(x, y, 11) < 0.5) {
    688             gfx.fillStyle(WATER_FLECK, 0.7);
    689             const fx = gx + FT.hashCell(x, y, 12) * (BAKE_CELL - 24) + 6;
    690             const fy = gy + FT.hashCell(x, y, 13) * (BAKE_CELL - 16) + 6;
    691             gfx.fillRect(fx, fy, 14, 4);
    692           }
    693         } else if (cell.type === T.BEACH) {
    694           gfx.fillStyle(BEACH_COLOR, 1);
    695           gfx.fillRect(gx, gy, BAKE_CELL, BAKE_CELL);
    696           this._edgeBands(gfx, x, y, gx, gy, T.WATER, BEACH_WET, 20);
    697           if (FT.hashCell(x, y, 14) < 0.35) {
    698             gfx.fillStyle(0xffffff, 0.55);
    699             const sx = gx + FT.hashCell(x, y, 15) * (BAKE_CELL - 20) + 10;
    700             const sy = gy + FT.hashCell(x, y, 16) * (BAKE_CELL - 20) + 10;
    701             gfx.fillCircle(sx, sy, 3);
    702           }
    703         } else if (cell.type === T.ROAD) {
    704           gfx.fillStyle(ASPHALT_COLOR, 1);
    705           gfx.fillRect(gx, gy, BAKE_CELL, BAKE_CELL);
    706         } else {
    707           // building / park footprint → pavement base (body drawn on top later)
    708           gfx.fillStyle(SIDEWALK_COLOR, 1);
    709           gfx.fillRect(gx, gy, BAKE_CELL, BAKE_CELL);
    710         }
    711       }
    712     }
    713   }
    714 
    715   _bakeSidewalks(gfx, ax0, ay0, ax1, ay1, ox, oy) {
    716     const grid = this.island.grid;
    717     const maskSize = Math.round(BAKE_CELL * 0.22);
    718     const band = 15;
    719     const curb = darken(SIDEWALK_COLOR, 40);
    720     for (let y = ay0; y < ay1; y++) {
    721       for (let x = ax0; x < ax1; x++) {
    722         const cell = grid[y][x];
    723         if (cell.type !== FT.CELL_TYPES.ROAD) continue;
    724         const gx = (x - ox) * BAKE_CELL;
    725         const gy = (y - oy) * BAKE_CELL;
    726 
    727         // Round the road corners: paint the outer corner where two adjacent
    728         // exits are missing with the diagonal neighbour's ground colour.
    729         const fillCorner = (cx, cy, dgx, dgy) => {
    730           const dc = grid[y + dgy] && grid[y + dgy][x + dgx];
    731           gfx.fillStyle(groundColorForCell(dc), 1);
    732           gfx.fillRect(cx, cy, maskSize, maskSize);
    733         };
    734         if (!cell.exits.N && !cell.exits.W) fillCorner(gx, gy, -1, -1);
    735         if (!cell.exits.N && !cell.exits.E) fillCorner(gx + BAKE_CELL - maskSize, gy, 1, -1);
    736         if (!cell.exits.S && !cell.exits.E) fillCorner(gx + BAKE_CELL - maskSize, gy + BAKE_CELL - maskSize, 1, 1);
    737         if (!cell.exits.S && !cell.exits.W) fillCorner(gx, gy + BAKE_CELL - maskSize, -1, 1);
    738 
    739         // Sidewalk band on each edge that abuts a building/park.
    740         const edges = FT.sidewalkEdges(grid, x, y);
    741         gfx.fillStyle(SIDEWALK_COLOR, 1);
    742         if (edges.N) gfx.fillRect(gx, gy, BAKE_CELL, band);
    743         if (edges.S) gfx.fillRect(gx, gy + BAKE_CELL - band, BAKE_CELL, band);
    744         if (edges.W) gfx.fillRect(gx, gy, band, BAKE_CELL);
    745         if (edges.E) gfx.fillRect(gx + BAKE_CELL - band, gy, band, BAKE_CELL);
    746         gfx.fillStyle(curb, 1);
    747         if (edges.N) gfx.fillRect(gx, gy + band, BAKE_CELL, 2);
    748         if (edges.S) gfx.fillRect(gx, gy + BAKE_CELL - band - 2, BAKE_CELL, 2);
    749         if (edges.W) gfx.fillRect(gx + band, gy, 2, BAKE_CELL);
    750         if (edges.E) gfx.fillRect(gx + BAKE_CELL - band - 2, gy, 2, BAKE_CELL);
    751 
    752         // Bus-stop shelters dotted along straight sidewalk-lined streets.
    753         const meta = cell.meta || FT.classifyIntersection(cell.exits);
    754         if (meta.kind === 'straight' && this._busStopEdge(cell, edges, x, y)) {
    755           this._drawBusStop(gfx, gx, gy, this._busStopEdge(cell, edges, x, y));
    756         }
    757       }
    758     }
    759   }
    760 
    761   // Which sidewalk edge (if any) of this road cell hosts a bus stop.
    762   // Deterministic via hashCell so every bake places them identically.
    763   _busStopEdge(cell, edges, x, y) {
    764     if (FT.hashCell(x, y, 35) >= 0.10) return null;
    765     for (const card of ['N', 'S', 'E', 'W']) if (edges[card]) return card;
    766     return null;
    767   }
    768 
    769   _drawBusStop(gfx, gx, gy, edge) {
    770     const roof = 0x1d7cf2;
    771     // Shelter roof (36×10) hugging the sidewalk band, plus a small yellow sign.
    772     const draw = (rx, ry, w, h, sx, sy) => {
    773       gfx.fillStyle(0x000000, 0.15); gfx.fillRoundedRect(rx + 2, ry + 2, w, h, 3);
    774       gfx.fillStyle(roof, 1); gfx.fillRoundedRect(rx, ry, w, h, 3);
    775       gfx.fillStyle(0xffffff, 0.5); gfx.fillRoundedRect(rx + 3, ry + 2, w - 6, 3, 2);
    776       gfx.fillStyle(STRIPE_COLOR, 1); gfx.fillCircle(sx, sy, 4);
    777       gfx.fillStyle(0x22303a, 1); gfx.fillCircle(sx, sy, 2);
    778     };
    779     if (edge === 'N') draw(gx + 30, gy + 1, 36, 10, gx + 74, gy + 7);
    780     else if (edge === 'S') draw(gx + 30, gy + BAKE_CELL - 11, 36, 10, gx + 74, gy + BAKE_CELL - 7);
    781     else if (edge === 'W') draw(gx + 1, gy + 30, 10, 36, gx + 7, gy + 74);
    782     else draw(gx + BAKE_CELL - 11, gy + 30, 10, 36, gx + BAKE_CELL - 7, gy + 74);
    783   }
    784 
    785   _bakeRoadMarkings(gfx, ax0, ay0, ax1, ay1, ox, oy) {
    786     const grid = this.island.grid;
    787     for (let y = ay0; y < ay1; y++) {
    788       for (let x = ax0; x < ax1; x++) {
    789         const cell = grid[y][x];
    790         if (cell.type !== FT.CELL_TYPES.ROAD) continue;
    791         const gx = (x - ox) * BAKE_CELL;
    792         const gy = (y - oy) * BAKE_CELL;
    793         const meta = cell.meta || FT.classifyIntersection(cell.exits);
    794         if (meta.kind === 'straight') {
    795           // Painted protected bike lanes on both sides of every straight
    796           // segment (offsets match the bicyclists' riding line), with a
    797           // white separator dash on the traffic side.
    798           const laneIn = 16, laneOut = 30; // px from road centerline
    799           const mid = BAKE_CELL / 2;
    800           if (cell.exits.N && cell.exits.S) {
    801             gfx.fillStyle(BIKE_LANE_COLOR, 0.55);
    802             gfx.fillRect(gx + mid - laneOut, gy, laneOut - laneIn, BAKE_CELL);
    803             gfx.fillRect(gx + mid + laneIn, gy, laneOut - laneIn, BAKE_CELL);
    804             gfx.fillStyle(0xffffff, 0.7);
    805             for (let dy = 4; dy < BAKE_CELL - 4; dy += 20) {
    806               gfx.fillRect(gx + mid - laneIn - 2, gy + dy, 2, 10);
    807               gfx.fillRect(gx + mid + laneIn, gy + dy, 2, 10);
    808             }
    809           } else {
    810             gfx.fillStyle(BIKE_LANE_COLOR, 0.55);
    811             gfx.fillRect(gx, gy + mid - laneOut, BAKE_CELL, laneOut - laneIn);
    812             gfx.fillRect(gx, gy + mid + laneIn, BAKE_CELL, laneOut - laneIn);
    813             gfx.fillStyle(0xffffff, 0.7);
    814             for (let dx = 4; dx < BAKE_CELL - 4; dx += 20) {
    815               gfx.fillRect(gx + dx, gy + mid - laneIn - 2, 10, 2);
    816               gfx.fillRect(gx + dx, gy + mid + laneIn, 10, 2);
    817             }
    818           }
    819           gfx.fillStyle(STRIPE_COLOR, 0.9);
    820           if (cell.exits.N && cell.exits.S) {
    821             for (let dy = 8; dy < BAKE_CELL - 8; dy += 24) gfx.fillRect(gx + BAKE_CELL / 2 - 3, gy + dy, 6, 14);
    822           } else {
    823             for (let dx = 8; dx < BAKE_CELL - 8; dx += 24) gfx.fillRect(gx + dx, gy + BAKE_CELL / 2 - 3, 14, 6);
    824           }
    825         } else if (meta.kind === 't' || meta.kind === 'four') {
    826           // Zebra crosswalks near each approach edge.
    827           gfx.fillStyle(CROSSWALK_COLOR, 0.95);
    828           const cw = 10, gap = 8, inset = 6;
    829           if (cell.exits.N) for (let i = 0; i < 4; i++) gfx.fillRect(gx + inset + i * (cw + gap), gy + 4, cw, 16);
    830           if (cell.exits.S) for (let i = 0; i < 4; i++) gfx.fillRect(gx + inset + i * (cw + gap), gy + BAKE_CELL - 20, cw, 16);
    831           if (cell.exits.W) for (let i = 0; i < 4; i++) gfx.fillRect(gx + 4, gy + inset + i * (cw + gap), 16, cw);
    832           if (cell.exits.E) for (let i = 0; i < 4; i++) gfx.fillRect(gx + BAKE_CELL - 20, gy + inset + i * (cw + gap), 16, cw);
    833         }
    834       }
    835     }
    836   }
    837 
    838   _bakeBuildings(gfx, ax0, ay0, ax1, ay1, ox, oy) {
    839     const grid = this.island.grid;
    840     for (const block of this.blocks) {
    841       if (grid[block.y][block.x].park) continue; // parks handled separately
    842       if (block.x >= ax1 || block.x + block.w <= ax0 || block.y >= ay1 || block.y + block.h <= ay0) continue;
    843       this._drawBuilding(gfx, block, ox, oy);
    844     }
    845   }
    846 
    847   _drawBuilding(gfx, block, ox, oy) {
    848     const margin = 14; // pavement gap around the block
    849     const bx = (block.x - ox) * BAKE_CELL + margin;
    850     const by = (block.y - oy) * BAKE_CELL + margin;
    851     const bw = block.w * BAKE_CELL - margin * 2;
    852     const bh = block.h * BAKE_CELL - margin * 2;
    853     if (bw <= 10 || bh <= 10) return;
    854     const big = block.w >= 2 && block.h >= 2;
    855     const color = BUILDING_PALETTE[Math.floor(FT.hashCell(block.x, block.y, 0) * BUILDING_PALETTE.length) % BUILDING_PALETTE.length];
    856 
    857     // SE drop shadow, roof body, parapet outline.
    858     gfx.fillStyle(0x000000, 0.15);
    859     gfx.fillRoundedRect(bx + 7, by + 7, bw, bh, 10);
    860     gfx.fillStyle(color, 1);
    861     gfx.fillRoundedRect(bx, by, bw, bh, 10);
    862     gfx.lineStyle(3, darken(color, 45), 1);
    863     gfx.strokeRoundedRect(bx, by, bw, bh, 10);
    864 
    865     // Rooftop skylight / window grid.
    866     const winLit = 0xffe9a8;
    867     const winDark = darken(color, 25);
    868     for (let wy = by + 16; wy < by + bh - 14; wy += 26) {
    869       for (let wx = bx + 14; wx < bx + bw - 12; wx += 24) {
    870         const lit = FT.hashCell(Math.round(wx), Math.round(wy), 5) < 0.32;
    871         gfx.fillStyle(lit ? winLit : winDark, 0.95);
    872         gfx.fillRect(wx, wy, 12, 14);
    873       }
    874     }
    875 
    876     // One rooftop feature keyed by hash.
    877     const roofStyle = Math.floor(FT.hashCell(block.x, block.y, 3) * 5);
    878     if (roofStyle === 0) {
    879       gfx.fillStyle(0xb8bcc4, 1);
    880       gfx.fillRoundedRect(bx + bw * 0.20, by + bh * 0.20, 22, 18, 4);
    881       gfx.fillRoundedRect(bx + bw * 0.55, by + bh * 0.52, 20, 16, 4);
    882     } else if (roofStyle === 1) {
    883       const r = Math.min(bw, bh) * 0.16;
    884       gfx.fillStyle(0x9aa0a8, 1); gfx.fillCircle(bx + bw / 2, by + bh / 2, r);
    885       gfx.fillStyle(0x7d828a, 1); gfx.fillCircle(bx + bw / 2, by + bh / 2, r * 0.55);
    886     } else if (roofStyle === 2) {
    887       gfx.fillStyle(PARK_GRASS, 1); gfx.fillRoundedRect(bx + 10, by + 10, bw - 20, bh - 20, 8);
    888       gfx.fillStyle(darken(PARK_GRASS, 30), 1);
    889       gfx.fillCircle(bx + bw * 0.35, by + bh * 0.4, 9);
    890       gfx.fillCircle(bx + bw * 0.62, by + bh * 0.6, 9);
    891     } else if (roofStyle === 3 && big) {
    892       gfx.fillStyle(0x3fc9e0, 1); gfx.fillRoundedRect(bx + bw * 0.25, by + bh * 0.3, bw * 0.5, bh * 0.35, 8);
    893       gfx.fillStyle(0x8fe3f0, 0.6); gfx.fillRoundedRect(bx + bw * 0.28, by + bh * 0.33, bw * 0.44, bh * 0.12, 6);
    894     } else {
    895       gfx.fillStyle(darken(color, 35), 1);
    896       gfx.fillCircle(bx + bw * 0.3, by + bh * 0.3, 5);
    897       gfx.fillCircle(bx + bw * 0.7, by + bh * 0.7, 5);
    898     }
    899   }
    900 
    901   _bakeParks(gfx, ax0, ay0, ax1, ay1, ox, oy) {
    902     const grid = this.island.grid;
    903     for (const block of this.blocks) {
    904       if (!grid[block.y][block.x].park) continue;
    905       if (block.x >= ax1 || block.x + block.w <= ax0 || block.y >= ay1 || block.y + block.h <= ay0) continue;
    906       this._drawPark(gfx, block, ox, oy);
    907     }
    908   }
    909 
    910   _drawPark(gfx, block, ox, oy) {
    911     const margin = 8;
    912     const bx = (block.x - ox) * BAKE_CELL + margin;
    913     const by = (block.y - oy) * BAKE_CELL + margin;
    914     const bw = block.w * BAKE_CELL - margin * 2;
    915     const bh = block.h * BAKE_CELL - margin * 2;
    916     if (bw <= 10 || bh <= 10) return;
    917     gfx.fillStyle(0x000000, 0.12); gfx.fillRoundedRect(bx + 6, by + 6, bw, bh, 10);
    918     gfx.fillStyle(PARK_GRASS, 1); gfx.fillRoundedRect(bx, by, bw, bh, 10);
    919     gfx.lineStyle(2, darken(PARK_GRASS, 40), 1); gfx.strokeRoundedRect(bx, by, bw, bh, 10);
    920     // Path cross.
    921     gfx.fillStyle(0xe6dcc0, 0.9);
    922     gfx.fillRect(bx + bw / 2 - 8, by, 16, bh);
    923     gfx.fillRect(bx, by + bh / 2 - 8, bw, 16);
    924     // Fountain.
    925     gfx.fillStyle(0x9aa0a8, 1); gfx.fillCircle(bx + bw / 2, by + bh / 2, 16);
    926     gfx.fillStyle(WATER_SHALLOW, 1); gfx.fillCircle(bx + bw / 2, by + bh / 2, 11);
    927     // Round-canopy trees.
    928     const nTrees = 3 + Math.floor(FT.hashCell(block.x, block.y, 8) * 4);
    929     for (let i = 0; i < nTrees; i++) {
    930       const hx = FT.hashCell(block.x * 7 + i, block.y * 3 + 1, 20);
    931       const hy = FT.hashCell(block.x * 3 + 1, block.y * 7 + i, 21);
    932       const tx = bx + 18 + hx * (bw - 36);
    933       const ty = by + 18 + hy * (bh - 36);
    934       gfx.fillStyle(0x000000, 0.12); gfx.fillEllipse(tx + 3, ty + 4, 26, 16);
    935       gfx.fillStyle(darken(PARK_GRASS, 22), 1); gfx.fillCircle(tx, ty, 13);
    936       gfx.fillStyle(lighten(PARK_GRASS, 25), 0.85); gfx.fillCircle(tx - 3, ty - 3, 6);
    937     }
    938   }
    939 
    940   _bakePalms(gfx, ax0, ay0, ax1, ay1, ox, oy) {
    941     const grid = this.island.grid;
    942     const T = FT.CELL_TYPES;
    943     for (let y = ay0; y < ay1; y++) {
    944       for (let x = ax0; x < ax1; x++) {
    945         const cell = grid[y][x];
    946         const gx = (x - ox) * BAKE_CELL;
    947         const gy = (y - oy) * BAKE_CELL;
    948         if (cell.type === T.BEACH && FT.hashCell(x, y, 30) < 0.5) {
    949           const jx = gx + BAKE_CELL / 2 + (FT.hashCell(x, y, 32) - 0.5) * BAKE_CELL * 0.4;
    950           const jy = gy + BAKE_CELL / 2 + (FT.hashCell(x, y, 33) - 0.5) * BAKE_CELL * 0.4;
    951           this._drawPalm(gfx, jx, jy);
    952         } else if (cell.type === T.ROAD) {
    953           // Street trees stand on the sidewalk band, never in the roadway:
    954           // anchor to a sidewalk edge and jitter only along that edge.
    955           const e = FT.sidewalkEdges(grid, x, y);
    956           if (FT.hashCell(x, y, 31) >= 0.12) continue;
    957           if (this._busStopEdge(cell, e, x, y)) continue; // shelter lives here
    958           const edges = ['N', 'S', 'E', 'W'].filter((c) => e[c]);
    959           if (!edges.length) continue;
    960           const edge = edges[Math.floor(FT.hashCell(x, y, 34) * edges.length) % edges.length];
    961           const along = BAKE_CELL / 2 + (FT.hashCell(x, y, 32) - 0.5) * BAKE_CELL * 0.5;
    962           if (edge === 'N') this._drawPalm(gfx, gx + along, gy + 7);
    963           else if (edge === 'S') this._drawPalm(gfx, gx + along, gy + BAKE_CELL - 7);
    964           else if (edge === 'W') this._drawPalm(gfx, gx + 7, gy + along);
    965           else this._drawPalm(gfx, gx + BAKE_CELL - 7, gy + along);
    966         }
    967       }
    968     }
    969   }
    970 
    971   _drawPalm(gfx, cx, cy) {
    972     gfx.fillStyle(0x000000, 0.15); gfx.fillEllipse(cx + 6, cy + 12, 42, 16);
    973     gfx.fillStyle(PALM_TRUNK, 1); gfx.fillRect(cx - 4, cy - 6, 8, 26);
    974     const n = 7;
    975     const top = cy - 8;
    976     for (let i = 0; i < n; i++) {
    977       const a = (i / n) * Math.PI * 2;
    978       gfx.fillStyle(i % 2 ? PALM_FROND : lighten(PALM_FROND, 25), 1);
    979       gfx.fillTriangle(
    980         cx, top,
    981         cx + Math.cos(a - 0.2) * 30, top + Math.sin(a - 0.2) * 22,
    982         cx + Math.cos(a) * 34, top + Math.sin(a) * 24
    983       );
    984     }
    985     gfx.fillStyle(darken(PALM_TRUNK, 20), 1); gfx.fillCircle(cx, top, 5);
    986   }
    987 
    988   _addOcean() {
    989     const key = 'water-tile';
    990     if (!this.textures.exists(key)) {
    991       const g = this.make.graphics({ add: false });
    992       g.fillStyle(WATER_COLOR, 1); g.fillRect(0, 0, 256, 256);
    993       g.fillStyle(WATER_SHALLOW, 0.4);
    994       for (let i = 0; i < 6; i++) g.fillRect(0, (i * 43) % 256, 256, 6);
    995       g.fillStyle(WATER_FLECK, 0.4);
    996       for (let i = 0; i < 20; i++) g.fillRect((i * 61) % 256, (i * 97) % 256, 12, 3);
    997       g.generateTexture(key, 256, 256); g.destroy();
    998     }
    999     // Screen-fixed animated water backdrop (scrollFactor 0). A world-sized
   1000     // TileSprite would allocate a multi-GB fill canvas, so we keep it viewport
   1001     // sized; the opaque island chunks cover the centre and the sea shows around
   1002     // the edges, drifting each frame.
   1003     this.ocean = this.add.tileSprite(0, 0, this.scale.width, this.scale.height, key)
   1004       .setOrigin(0, 0)
   1005       .setScrollFactor(0)
   1006       .setDepth(-1);
   1007     this.uiCamera.ignore(this.ocean);
   1008 
   1009     // A couple of static buoys bobbing in the offshore ring.
   1010     if (!this.textures.exists('buoy')) {
   1011       const g = this.make.graphics({ add: false });
   1012       g.fillStyle(0x000000, 0.18); g.fillEllipse(20, 30, 26, 10);
   1013       g.fillStyle(0xe63946, 1); g.fillCircle(20, 18, 11);
   1014       g.fillStyle(0xffffff, 1); g.fillRect(9, 15, 22, 5);
   1015       g.fillStyle(0xffd23f, 1); g.fillCircle(20, 8, 4);
   1016       g.generateTexture('buoy', 40, 40); g.destroy();
   1017     }
   1018     this.buoys = [];
   1019     const b0 = this.island.bounds;
   1020     const spots = [
   1021       { x: (b0.minX - 3) * CELL_SIZE, y: (b0.minY + 12) * CELL_SIZE },
   1022       { x: (b0.maxX + 3) * CELL_SIZE, y: (b0.minY + 30) * CELL_SIZE },
   1023       { x: (b0.minX + 25) * CELL_SIZE, y: (b0.maxY + 3) * CELL_SIZE },
   1024     ];
   1025     for (const s of spots) {
   1026       const buoy = this.add.image(s.x, s.y, 'buoy').setScale(SCALE * 0.9).setDepth(6);
   1027       this.uiCamera.ignore(buoy);
   1028       this.tweens.add({ targets: buoy, y: s.y - 10 * SCALE, angle: 6, duration: 1400, ease: 'Sine.easeInOut', yoyo: true, repeat: -1 });
   1029       this.buoys.push(buoy);
   1030     }
   1031   }
   1032 
   1033   // ── Ambient life ──────────────────────────────────────────────────────────
   1034 
   1035   // Add a world object at a depth and hide it from the HUD camera.
   1036   _addWorld(obj, depth) {
   1037     obj.setDepth(depth);
   1038     this.uiCamera.ignore(obj);
   1039     return obj;
   1040   }
   1041 
   1042   _cellCenter(cell) {
   1043     return { x: cell.x * CELL_SIZE, y: cell.y * CELL_SIZE };
   1044   }
   1045 
   1046   // Unit vector pointing to the right of travel (for lane / curb offsets).
   1047   _rightVec(heading) {
   1048     if (heading === 'east') return { x: 0, y: 1 };
   1049     if (heading === 'south') return { x: -1, y: 0 };
   1050     if (heading === 'west') return { x: 0, y: -1 };
   1051     return { x: 1, y: 0 };
   1052   }
   1053 
   1054   _bakeAmbientTextures() {
   1055     const mk = () => this.make.graphics({ add: false });
   1056 
   1057     const carColors = [0xe63946, 0x1d7cf2, 0xffd23f, 0x2ec4b6, 0xff8c42];
   1058     carColors.forEach((color, i) => {
   1059       const key = 'car-' + i;
   1060       if (this.textures.exists(key)) return;
   1061       const g = mk();
   1062       g.fillStyle(0x000000, 0.2); g.fillEllipse(15, 15, 28, 8);
   1063       g.fillStyle(color, 1); g.fillRoundedRect(2, 2, 26, 12, 4);
   1064       g.fillStyle(0x22303a, 0.85); g.fillRect(19, 3, 6, 10); // windshield (front = +x)
   1065       g.fillStyle(0xffffff, 0.5); g.fillRect(6, 6, 11, 4);
   1066       g.generateTexture(key, 30, 18); g.destroy();
   1067     });
   1068 
   1069     // Top-down bicyclists (facing +x): two wheel lines, frame, rider on top.
   1070     const bikeShirts = [0xe63946, 0x1d7cf2, 0x2ec4b6, 0xff8c42];
   1071     bikeShirts.forEach((color, i) => {
   1072       const key = 'bike-' + i;
   1073       if (this.textures.exists(key)) return;
   1074       const g = mk();
   1075       g.fillStyle(0x000000, 0.2); g.fillEllipse(15, 10, 22, 5);
   1076       g.fillStyle(0x22303a, 1);
   1077       g.fillRoundedRect(2, 6, 9, 3, 1.5);   // rear wheel
   1078       g.fillRoundedRect(19, 6, 9, 3, 1.5);  // front wheel
   1079       g.fillStyle(0x8a929c, 1); g.fillRect(10, 6.5, 10, 2); // frame
   1080       g.fillStyle(0xd8dde3, 1); g.fillRect(20, 3, 2, 9);    // handlebars
   1081       g.fillStyle(color, 1); g.fillRoundedRect(9, 3, 10, 9, 3); // rider torso
   1082       g.fillStyle(0xf0c8a0, 1); g.fillCircle(14, 7.5, 3);   // head
   1083       g.generateTexture(key, 30, 15); g.destroy();
   1084     });
   1085 
   1086     // Top-down buses (facing +x): long body, window rows, white roof stripe.
   1087     const busColors = [0x2ec4b6, 0xffd23f];
   1088     busColors.forEach((color, i) => {
   1089       const key = 'bus-' + i;
   1090       if (this.textures.exists(key)) return;
   1091       const g = mk();
   1092       g.fillStyle(0x000000, 0.2); g.fillEllipse(24, 16, 44, 8);
   1093       g.fillStyle(color, 1); g.fillRoundedRect(2, 3, 44, 14, 4);
   1094       g.lineStyle(2, darken(color, 50), 1); g.strokeRoundedRect(2, 3, 44, 14, 4);
   1095       g.fillStyle(0x22303a, 0.85);
   1096       g.fillRect(41, 5, 4, 10); // windshield (front = +x)
   1097       for (let wx = 6; wx <= 34; wx += 7) { g.fillRect(wx, 4, 5, 3); g.fillRect(wx, 13, 5, 3); }
   1098       g.fillStyle(0xffffff, 0.6); g.fillRoundedRect(8, 8, 30, 4, 2); // roof stripe
   1099       g.generateTexture(key, 48, 20); g.destroy();
   1100     });
   1101 
   1102     const shirts = [0x1d7cf2, 0xe63946, 0x2ec4b6, 0xffd23f];
   1103     shirts.forEach((color, i) => {
   1104       const key = 'ped-' + i;
   1105       if (this.textures.exists(key)) return;
   1106       const g = mk();
   1107       g.fillStyle(0x000000, 0.2); g.fillEllipse(5, 14, 8, 3);
   1108       g.fillStyle(color, 1); g.fillRoundedRect(1, 6, 8, 7, 2);
   1109       g.fillStyle(0xf0c8a0, 1); g.fillCircle(5, 4, 3);
   1110       g.generateTexture(key, 11, 17); g.destroy();
   1111     });
   1112 
   1113     if (!this.textures.exists('boat')) {
   1114       const g = mk();
   1115       g.fillStyle(0xffffff, 0.5); g.fillEllipse(24, 40, 42, 10);
   1116       g.fillStyle(0x5a3a24, 1); g.fillRect(23, 6, 3, 26);
   1117       g.fillStyle(0xffffff, 1); g.fillTriangle(24, 6, 24, 32, 41, 30);
   1118       g.fillStyle(0xe63946, 1); g.fillTriangle(23, 6, 23, 19, 13, 17);
   1119       g.fillStyle(0x8a5a3b, 1);
   1120       g.fillPoints([{ x: 8, y: 32 }, { x: 40, y: 32 }, { x: 34, y: 43 }, { x: 14, y: 43 }], true);
   1121       g.generateTexture('boat', 48, 48); g.destroy();
   1122     }
   1123 
   1124     if (!this.textures.exists('cloud-shadow')) {
   1125       const g = mk();
   1126       g.fillStyle(0x14304a, 0.10);
   1127       g.fillEllipse(60, 40, 90, 46);
   1128       g.fillEllipse(40, 34, 54, 40);
   1129       g.fillEllipse(84, 44, 58, 36);
   1130       g.generateTexture('cloud-shadow', 120, 80); g.destroy();
   1131     }
   1132 
   1133     if (!this.textures.exists('bird')) {
   1134       const g = mk();
   1135       g.lineStyle(3, 0x3a3f4a, 1);
   1136       g.beginPath();
   1137       g.moveTo(2, 9); g.lineTo(12, 3); g.lineTo(22, 9);
   1138       g.strokePath();
   1139       g.generateTexture('bird', 24, 12); g.destroy();
   1140     }
   1141 
   1142     bakeFxTextures(this);
   1143 
   1144     if (!this.textures.exists('burning-overlay')) {
   1145       const g = mk();
   1146       g.fillStyle(0x2a1a12, 0.55); g.fillRoundedRect(6, 6, 84, 84, 10);
   1147       g.fillStyle(0xffb347, 0.9);
   1148       for (let wy = 16; wy < 80; wy += 22) {
   1149         for (let wx = 16; wx < 80; wx += 22) g.fillRect(wx, wy, 10, 12);
   1150       }
   1151       g.generateTexture('burning-overlay', 96, 96); g.destroy();
   1152     }
   1153   }
   1154 
   1155   _randomRoadStart() {
   1156     const grid = this.island.grid;
   1157     const roads = this.island.roads;
   1158     for (let tries = 0; tries < 40; tries++) {
   1159       const cell = FT.randPick(this.ambientRng, roads);
   1160       const headings = [];
   1161       for (const card of ['N', 'E', 'S', 'W']) {
   1162         if (cell.exits[card]) headings.push(FT.CARD_TO_HEADING[card]);
   1163       }
   1164       if (!headings.length) continue;
   1165       const heading = FT.randPick(this.ambientRng, headings);
   1166       const to = FT.cellAhead(grid, cell, heading);
   1167       if (to && to.type === FT.CELL_TYPES.ROAD) return { from: cell, to, heading };
   1168     }
   1169     return null;
   1170   }
   1171 
   1172   _initAmbient() {
   1173     this._bakeAmbientTextures();
   1174     this.ambientRng = FT.createSeededRng(0x51ce77);
   1175     this.trafficCars = [];
   1176     this.pedestrians = [];
   1177     this.bicyclists = [];
   1178     this.buses = [];
   1179     this.clouds = [];
   1180     this.boats = [];
   1181     this.birds = [];
   1182 
   1183     const spawnMovers = (count, list, texture, textureCount, scale, depth) => {
   1184       for (let i = 0; i < count; i++) {
   1185         const start = this._randomRoadStart();
   1186         if (!start) continue;
   1187         const key = texture + '-' + Math.floor(this.ambientRng.next() * textureCount);
   1188         const img = this._addWorld(this.add.image(0, 0, key).setScale(SCALE * scale), depth);
   1189         list.push({ img, from: start.from, to: start.to, heading: start.heading, t: this.ambientRng.next() });
   1190       }
   1191     };
   1192     spawnMovers(CAR_COUNT, this.trafficCars, 'car', 5, 0.85, 9);
   1193     spawnMovers(BUS_COUNT, this.buses, 'bus', 2, 0.9, 9);
   1194     spawnMovers(BIKE_COUNT, this.bicyclists, 'bike', 4, 0.85, 8.5);
   1195     spawnMovers(PED_COUNT, this.pedestrians, 'ped', 4, 0.85, 8);
   1196 
   1197     const b = this.island.bounds;
   1198     for (let i = 0; i < 3; i++) {
   1199       const scale = 4 + this.ambientRng.next() * 3;
   1200       const x = (b.minX + this.ambientRng.next() * b.width) * CELL_SIZE;
   1201       const y = (b.minY + this.ambientRng.next() * b.height) * CELL_SIZE;
   1202       const img = this._addWorld(this.add.image(x, y, 'cloud-shadow').setScale(scale).setAlpha(0.55), 34);
   1203       this.clouds.push({ img, vx: 55 + this.ambientRng.next() * 25, vy: 18 + this.ambientRng.next() * 14 });
   1204     }
   1205 
   1206     // Boats bob just offshore around the perimeter.
   1207     const boatSpots = [
   1208       { x: (b.minX - 1.5) * CELL_SIZE, y: (b.minY + 8) * CELL_SIZE },
   1209       { x: (b.maxX + 1.5) * CELL_SIZE, y: (b.minY + 20) * CELL_SIZE },
   1210       { x: (b.minX + 10) * CELL_SIZE, y: (b.minY - 1.5) * CELL_SIZE },
   1211       { x: (b.minX + 34) * CELL_SIZE, y: (b.maxY + 1.5) * CELL_SIZE },
   1212       { x: (b.maxX + 1.5) * CELL_SIZE, y: (b.maxY - 12) * CELL_SIZE },
   1213     ];
   1214     boatSpots.forEach((s, i) => {
   1215       const img = this._addWorld(this.add.image(s.x, s.y, 'boat').setScale(SCALE), 6);
   1216       this.boats.push(img);
   1217       this.tweens.add({ targets: img, y: s.y - 12 * SCALE, angle: 4, duration: 1600 + i * 220, ease: 'Sine.easeInOut', yoyo: true, repeat: -1 });
   1218     });
   1219 
   1220     this._scheduleBird();
   1221     this.ambientReady = true;
   1222   }
   1223 
   1224   _scheduleBird() {
   1225     const delay = 9000 + this.ambientRng.next() * 6000;
   1226     this._birdTimer = this.time.delayedCall(delay, () => {
   1227       this._spawnBirdFlock();
   1228       this._scheduleBird();
   1229     });
   1230   }
   1231 
   1232   _spawnBirdFlock() {
   1233     if (!this.birds || this.birds.length >= 4) return;
   1234     const view = this.cameras.main.worldView;
   1235     const fromLeft = this.ambientRng.next() < 0.5;
   1236     const count = 1 + Math.floor(this.ambientRng.next() * 3);
   1237     const y0 = view.y + view.height * (0.1 + this.ambientRng.next() * 0.3);
   1238     for (let i = 0; i < count && this.birds.length < 4; i++) {
   1239       const startX = fromLeft ? view.x - 60 * SCALE : view.right + 60 * SCALE;
   1240       const img = this._addWorld(this.add.image(startX, y0 + i * 26 * SCALE, 'bird').setScale(SCALE), 38);
   1241       const dir = fromLeft ? 1 : -1;
   1242       this.birds.push({ img, vx: dir * (90 + this.ambientRng.next() * 50), baseY: img.y, phase: this.ambientRng.next() * 6 });
   1243     }
   1244   }
   1245 
   1246   _moveMover(m, speed, offset, dt, rotate) {
   1247     const grid = this.island.grid;
   1248     const fromC = this._cellCenter(m.from);
   1249     const toC = this._cellCenter(m.to);
   1250     const r = this._rightVec(m.heading);
   1251     const px = fromC.x + (toC.x - fromC.x) * m.t + r.x * offset;
   1252     const py = fromC.y + (toC.y - fromC.y) * m.t + r.y * offset;
   1253     const nearTruck = Math.hypot(px - this.truck.x, py - this.truck.y) < CELL_SIZE * 0.8;
   1254     if (!nearTruck) {
   1255       m.t += (speed * dt) / CELL_SIZE;
   1256       let guard = 0;
   1257       while (m.t >= 1 && guard++ < 8) {
   1258         m.t -= 1;
   1259         const plan = FT.advanceCarPlan(grid, m.to, m.heading, this.ambientRng);
   1260         m.from = m.to;
   1261         m.to = plan.cell;
   1262         m.heading = plan.heading;
   1263       }
   1264     }
   1265     const fc = this._cellCenter(m.from);
   1266     const tc = this._cellCenter(m.to);
   1267     const rr = this._rightVec(m.heading);
   1268     m.img.setPosition(
   1269       fc.x + (tc.x - fc.x) * m.t + rr.x * offset,
   1270       fc.y + (tc.y - fc.y) * m.t + rr.y * offset
   1271     );
   1272     if (rotate) m.img.rotation = this.rotationForHeading(m.heading);
   1273   }
   1274 
   1275   _updateAmbient(dt) {
   1276     for (const car of this.trafficCars) this._moveMover(car, CAR_SPEED, CELL_SIZE * 0.16, dt, true);
   1277     for (const bus of this.buses) this._moveMover(bus, BUS_SPEED, CELL_SIZE * 0.14, dt, true);
   1278     for (const bike of this.bicyclists) this._moveMover(bike, BIKE_SPEED, CELL_SIZE * 0.24, dt, true);
   1279     for (const ped of this.pedestrians) this._moveMover(ped, PED_SPEED, CELL_SIZE * 0.34, dt, false);
   1280 
   1281     const b = this.island.bounds;
   1282     const margin = 6 * CELL_SIZE;
   1283     const minX = (b.minX) * CELL_SIZE - margin, maxX = (b.maxX) * CELL_SIZE + margin;
   1284     const minY = (b.minY) * CELL_SIZE - margin, maxY = (b.maxY) * CELL_SIZE + margin;
   1285     for (const c of this.clouds) {
   1286       c.img.x += c.vx * dt;
   1287       c.img.y += c.vy * dt;
   1288       if (c.img.x > maxX) c.img.x = minX;
   1289       if (c.img.y > maxY) c.img.y = minY;
   1290     }
   1291 
   1292     if (this.birds.length) {
   1293       const view = this.cameras.main.worldView;
   1294       const t = this.time.now * 0.006;
   1295       for (let i = this.birds.length - 1; i >= 0; i--) {
   1296         const bird = this.birds[i];
   1297         bird.img.x += bird.vx * dt;
   1298         bird.img.y = bird.baseY + Math.sin(t + bird.phase) * 10 * SCALE;
   1299         if (bird.img.x < view.x - 120 * SCALE || bird.img.x > view.right + 120 * SCALE) {
   1300           bird.img.destroy();
   1301           this.birds.splice(i, 1);
   1302         }
   1303       }
   1304     }
   1305   }
   1306 
   1307   createTruck() {
   1308     // Bake a top-down fire-truck texture once (nose points +x = east, matching
   1309     // rotationForHeading('east') === 0). The container keeps [image, truckLight]
   1310     // so toggleLights / _updateLightsEffect drive the light rectangle unchanged.
   1311     const key = 'truck-top';
   1312     if (!this.textures.exists(key)) {
   1313       const g = this.make.graphics({ add: false });
   1314       // Shadow
   1315       g.fillStyle(0x000000, 0.2); g.fillEllipse(26, 22, 46, 12);
   1316       // Red body
   1317       g.fillStyle(0xe63946, 1); g.fillRoundedRect(4, 5, 40, 18, 5);
   1318       g.lineStyle(2, 0x9b1c25, 1); g.strokeRoundedRect(4, 5, 40, 18, 5);
   1319       // White side stripe
   1320       g.fillStyle(0xffffff, 0.85); g.fillRect(6, 13, 38, 3);
   1321       // Yellow ladder along the spine with rungs
   1322       g.fillStyle(0xffd23f, 1); g.fillRect(9, 11, 22, 6);
   1323       g.fillStyle(0xcf9b1e, 1);
   1324       for (let rx = 11; rx < 30; rx += 4) g.fillRect(rx, 11, 1.6, 6);
   1325       // Turntable
   1326       g.fillStyle(0xf4b400, 1); g.fillCircle(13, 14, 4);
   1327       g.fillStyle(0xcf9b1e, 1); g.fillCircle(13, 14, 2);
   1328       // White cab at the front (+x)
   1329       g.fillStyle(0xf6f7fb, 1); g.fillRoundedRect(30, 6, 13, 16, 3);
   1330       g.lineStyle(2, 0xbcc6d4, 1); g.strokeRoundedRect(30, 6, 13, 16, 3);
   1331       // Windshield
   1332       g.fillStyle(0x88ccff, 1); g.fillRect(40, 8, 3, 12);
   1333       // Mirrors
   1334       g.fillStyle(0x30333c, 1); g.fillRect(31, 3, 4, 2); g.fillRect(31, 23, 4, 2);
   1335       g.generateTexture(key, 52, 28); g.destroy();
   1336     }
   1337     const truckImage = this.add.image(0, 0, key);
   1338     this.truckLight = this.add.rectangle(-14, 0, 8, 8, 0x1d7cf2, 1);
   1339     this.truck = this.add.container(0, 0, [truckImage, this.truckLight]);
   1340     this.truck.setDepth(35);
   1341     this.truck.setScale(SCALE * 0.82);
   1342   }
   1343 
   1344   onResize(gameSize) {
   1345     this.overlay.setSize(gameSize.width, gameSize.height);
   1346     this.successOverlay.setSize(gameSize.width, gameSize.height);
   1347     this.promptText.setPosition(gameSize.width / 2, gameSize.height - 74);
   1348     this.statusText.setPosition(gameSize.width / 2, gameSize.height - 34);
   1349     this.fireCountText.setPosition(gameSize.width / 2, 78);
   1350     if (this.uiCamera) this.uiCamera.setSize(gameSize.width, gameSize.height);
   1351     if (this.ocean) this.ocean.setSize(gameSize.width, gameSize.height);
   1352   }
   1353 
   1354   updateTargetSpeed() {
   1355     const m = window.GAME_SPEED_MULTIPLIER ?? 1.0;
   1356     if (this.controlMode === 'manual') return; // manual speed follows held keys
   1357     if (this.state === 'driving') {
   1358       this.targetSpeed = (this.promptResolved ? MAX_SPEED : BASE_SPEED) * m;
   1359     } else if (this.state === 'waiting' || this.state === 'stopped') {
   1360       this.targetSpeed = 0;
   1361     }
   1362   }
   1363 
   1364   onKeyDown(event) {
   1365     this.initAudio();
   1366     if (event.code === 'Space') return; // spraying lives in FireHoseScene now
   1367     if (event.key === 's' || event.key === 'S') {
   1368       this.toggleSiren();
   1369       return;
   1370     }
   1371     if (event.key === 'l' || event.key === 'L') {
   1372       this.toggleLights();
   1373       return;
   1374     }
   1375     const held = KEY_TO_HEADING[event.key];
   1376     if (held && !event.repeat) {
   1377       this.heldHeadings = this.heldHeadings.filter((h) => h !== held);
   1378       this.heldHeadings.push(held);
   1379     }
   1380     if (this.controlMode === 'manual') return; // stepManual reads heldHeadings
   1381     const move = event.key === 'ArrowLeft' ? 'left' : event.key === 'ArrowRight' ? 'right' : event.key === 'ArrowUp' ? 'straight' : null;
   1382     if (!move || !this.promptDir) return;
   1383     if (move === this.promptDir) {
   1384       this.promptResolved = true;
   1385       this.promptDir = null;
   1386       this.failVisible = false;
   1387       this.overlay.setFillStyle(0xe63946, 0);
   1388       if (this.state !== 'minigame') {
   1389         this.state = 'driving';
   1390         this.targetSpeed = MAX_SPEED * (window.GAME_SPEED_MULTIPLIER ?? 1.0);
   1391       }
   1392       this.statusText.setText('Great! Keep driving.');
   1393       this.playSuccessSound();
   1394       this.successOverlay.setAlpha(0.12);
   1395       this.successUntil = this.time.now + 200;
   1396       this.refreshDebug();
   1397       return;
   1398     }
   1399     if (this.state === 'driving' || this.state === 'waiting') this.enterStopped();
   1400   }
   1401 
   1402   onKeyUp(event) {
   1403     const held = KEY_TO_HEADING[event.key];
   1404     if (held) this.heldHeadings = this.heldHeadings.filter((h) => h !== held);
   1405   }
   1406 
   1407   initAudio() {
   1408     if (this._audioInited) return;
   1409     this._audioInited = true;
   1410     try {
   1411       this.audioCtx = new (window.AudioContext || window.webkitAudioContext)();
   1412     } catch (_) {}
   1413     Tone.start().then(() => {
   1414       this.initMusic();
   1415       if (this.sirenOn) this._startSiren();
   1416     }).catch(() => {});
   1417   }
   1418 
   1419   initMusic() {
   1420     if (this.bgGain) return;
   1421     this.bgGain = new Tone.Gain(this.musicMuted ? 0 : 0.8).toDestination();
   1422 
   1423     this.bassSynth = new Tone.Synth({
   1424       oscillator: { type: 'sawtooth' },
   1425       envelope: { attack: 0.005, decay: 0.12, sustain: 0.18, release: 0.08 },
   1426     }).connect(this.bgGain);
   1427     this.bassSynth.volume.value = -14;
   1428 
   1429     this.chordSynth = new Tone.PolySynth(Tone.Synth, {
   1430       oscillator: { type: 'square' },
   1431       envelope: { attack: 0.008, decay: 0.1, sustain: 0.12, release: 0.05 },
   1432     }).connect(this.bgGain);
   1433     this.chordSynth.volume.value = -22;
   1434 
   1435     this.melodySynth = new Tone.Synth({
   1436       oscillator: { type: 'square' },
   1437       envelope: { attack: 0.005, decay: 0.07, sustain: 0.18, release: 0.04 },
   1438     }).connect(this.bgGain);
   1439     this.melodySynth.volume.value = -18;
   1440 
   1441     let step = 0;
   1442     new Tone.Sequence((time, note) => {
   1443       const i = step % FT_MELODY.length;
   1444       step++;
   1445       this.melodySynth.triggerAttackRelease(note, '8n', time);
   1446       this.bassSynth.triggerAttackRelease(FT_BASS[i], '8n', time);
   1447       if (FT_CHORDS[i]) this.chordSynth.triggerAttackRelease(FT_CHORDS[i], '8n', time);
   1448     }, FT_MELODY, '8n').start(0);
   1449 
   1450     Tone.Transport.bpm.value = FT_BPM;
   1451     Tone.Transport.start();
   1452   }
   1453 
   1454   toggleSiren() {
   1455     this.sirenOn = !this.sirenOn;
   1456     if (this.sirenOn) {
   1457       this._startSiren();
   1458     } else {
   1459       this._stopSiren();
   1460     }
   1461   }
   1462 
   1463   _startSiren() {
   1464     if (this.sirenOsc || !this.audioCtx) return;
   1465     try {
   1466       const ctx = this.audioCtx;
   1467       const osc = ctx.createOscillator();
   1468       osc.type = 'triangle';
   1469       osc.frequency.value = 550;
   1470 
   1471       const lfo = ctx.createOscillator();
   1472       lfo.type = 'sine';
   1473       lfo.frequency.value = 0.7;
   1474 
   1475       const lfoGain = ctx.createGain();
   1476       lfoGain.gain.value = 110;
   1477       lfo.connect(lfoGain);
   1478       lfoGain.connect(osc.frequency);
   1479 
   1480       const gainNode = ctx.createGain();
   1481       gainNode.gain.value = this.sfxMuted ? 0 : 0.12;
   1482       osc.connect(gainNode);
   1483       gainNode.connect(ctx.destination);
   1484 
   1485       lfo.start();
   1486       osc.start();
   1487 
   1488       this.sirenOsc = osc;
   1489       this.sirenLfo = lfo;
   1490       this.sirenGainNode = gainNode;
   1491     } catch (_) {}
   1492   }
   1493 
   1494   _stopSiren() {
   1495     if (!this.sirenOsc) return;
   1496     try {
   1497       const ctx = this.audioCtx;
   1498       if (ctx) {
   1499         this.sirenGainNode.gain.setValueAtTime(this.sirenGainNode.gain.value, ctx.currentTime);
   1500         this.sirenGainNode.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.3);
   1501         this.sirenOsc.stop(ctx.currentTime + 0.31);
   1502         this.sirenLfo.stop(ctx.currentTime + 0.31);
   1503       }
   1504     } catch (_) {}
   1505     this.sirenOsc = null;
   1506     this.sirenLfo = null;
   1507     this.sirenGainNode = null;
   1508   }
   1509 
   1510   toggleLights() {
   1511     this.lightsOn = !this.lightsOn;
   1512     if (!this.lightsOn && this.truckLight) {
   1513       this.truckLight.setFillStyle(0x1d7cf2);
   1514     }
   1515   }
   1516 
   1517   _updateLightsEffect() {
   1518     if (!this.truckLight || !this.lightsOn) return;
   1519     const phase = Math.floor(this.time.now / 166) % 2;
   1520     this.truckLight.setFillStyle(phase === 0 ? 0xe63946 : 0x1d7cf2);
   1521   }
   1522 
   1523   setMusicMuted(muted) {
   1524     this.musicMuted = muted;
   1525     if (this.bgGain) this.bgGain.gain.value = muted ? 0 : 0.8;
   1526   }
   1527 
   1528   setSfxMuted(muted) {
   1529     this.sfxMuted = muted;
   1530     if (this.sirenGainNode && this.audioCtx) {
   1531       this.sirenGainNode.gain.setValueAtTime(muted ? 0.001 : 0.12, this.audioCtx.currentTime);
   1532     }
   1533   }
   1534 
   1535   playSuccessSound() {
   1536     this.initAudio();
   1537     if (!this.audioCtx || this.sfxMuted) return;
   1538     try {
   1539       const ctx = this.audioCtx;
   1540       const osc = ctx.createOscillator();
   1541       const gain = ctx.createGain();
   1542       osc.connect(gain);
   1543       gain.connect(ctx.destination);
   1544       osc.type = 'sine';
   1545       osc.frequency.setValueAtTime(880, ctx.currentTime);
   1546       osc.frequency.setValueAtTime(1100, ctx.currentTime + 0.08);
   1547       gain.gain.setValueAtTime(0.12, ctx.currentTime);
   1548       gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.18);
   1549       osc.start(ctx.currentTime);
   1550       osc.stop(ctx.currentTime + 0.2);
   1551     } catch (_) {}
   1552   }
   1553 
   1554   playFailSound() {
   1555     this.initAudio();
   1556     if (!this.audioCtx || this.sfxMuted) return;
   1557     try {
   1558       const ctx = this.audioCtx;
   1559       const osc = ctx.createOscillator();
   1560       const gain = ctx.createGain();
   1561       osc.connect(gain);
   1562       gain.connect(ctx.destination);
   1563       osc.type = 'square';
   1564       osc.frequency.setValueAtTime(220, ctx.currentTime);
   1565       osc.frequency.exponentialRampToValueAtTime(120, ctx.currentTime + 0.24);
   1566       gain.gain.setValueAtTime(0.18, ctx.currentTime);
   1567       gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.25);
   1568       osc.start(ctx.currentTime);
   1569       osc.stop(ctx.currentTime + 0.26);
   1570     } catch (_) {}
   1571   }
   1572 
   1573   enterStopped() {
   1574     this.state = 'stopped';
   1575     this.targetSpeed = 0;
   1576     this.failVisible = true;
   1577     this.warningUntil = this.time.now + 250;
   1578     this.overlay.setFillStyle(0xe63946, 0.18);
   1579     this.statusText.setText('Try the ' + arrowLabel(this.promptDir) + '.');
   1580     this.playFailSound();
   1581     this.refreshDebug();
   1582   }
   1583 
   1584   enterWaiting() {
   1585     if (this.state === 'stopped' || this.state === 'waiting') return;
   1586     this.state = 'waiting';
   1587     this.targetSpeed = 0;
   1588     this.statusText.setText('Stop! Press ' + arrowLabel(this.promptDir));
   1589     this.refreshDebug();
   1590   }
   1591 
   1592   update(_, deltaMs) {
   1593     const now = performance.now();
   1594     const elapsed = now - this.lastStepTime;
   1595     if (elapsed > 0) {
   1596       this.step(Math.min(elapsed / 1000, 0.5));
   1597       this.lastStepTime = now;
   1598     }
   1599     const dt = Math.min((deltaMs || 16) / 1000, 0.1);
   1600     if (this.ocean) {
   1601       this.ocean.tilePositionX += dt * 8;
   1602       this.ocean.tilePositionY += dt * 4;
   1603     }
   1604     if (this.ambientReady) this._updateAmbient(dt);
   1605   }
   1606 
   1607   step(dt) {
   1608     if (this.state === 'minigame') return; // FireHoseScene owns the action
   1609     if (this.controlMode === 'manual') { this.stepManual(dt); return; }
   1610     const m = window.GAME_SPEED_MULTIPLIER ?? 1.0;
   1611     if (this.speed < this.targetSpeed) this.speed = Math.min(this.targetSpeed, this.speed + ACCEL * m * dt);
   1612     if (this.speed > this.targetSpeed) this.speed = Math.max(this.targetSpeed, this.speed - BRAKE * m * dt);
   1613 
   1614     const dx = this.segmentEnd.x - this.truck.x;
   1615     const dy = this.segmentEnd.y - this.truck.y;
   1616     const dist = Math.hypot(dx, dy);
   1617 
   1618     const promptTriggerDist = Math.max(MIN_PROMPT_DIST, this.speed * REACTION_TIME_S);
   1619 
   1620     // F6: stall watchdog
   1621     if (this.state === 'driving' && this.targetSpeed > 0 && this.speed < 1 && !this.promptDir) {
   1622       if (!this._stallSince) this._stallSince = this.time.now;
   1623       if (this.time.now - this._stallSince > 2000) {
   1624         this.targetSpeed = BASE_SPEED * (window.GAME_SPEED_MULTIPLIER ?? 1.0);
   1625         this._stallSince = 0;
   1626       }
   1627     } else {
   1628       this._stallSince = 0;
   1629     }
   1630 
   1631     let promptJustSet = false;
   1632     if (this.phase === 'approach' && !this.promptDir && !this.promptResolved && this.currentStep && dist <= promptTriggerDist) {
   1633       this.promptDir = this.currentStep.move;
   1634       this.promptResolved = false;
   1635       this.promptShownAt = this.time.now;
   1636       promptJustSet = true;
   1637       this.statusText.setText('Press the ' + arrowLabel(this.promptDir) + ' before the crossing.');
   1638     }
   1639 
   1640     if (!promptJustSet && this.phase === 'approach' && this.promptDir && !this.promptResolved
   1641         && dist <= STOP_LINE_DIST + 1.0) {
   1642       this.enterWaiting();
   1643     }
   1644 
   1645     if (dist > 0.001 && this.speed > 0) {
   1646       let travel = Math.min(dist, this.speed * dt);
   1647       if (this.phase === 'approach' && this.promptDir && !this.promptResolved) {
   1648         travel = Math.min(travel, Math.max(0, dist - STOP_LINE_DIST));
   1649       }
   1650       this.truck.x += (dx / dist) * travel;
   1651       this.truck.y += (dy / dist) * travel;
   1652       this.debugDistance += travel;
   1653     }
   1654 
   1655     // Fire proximity → enter firefighting
   1656     if (this.fireMode && this.fireCell && this.state === 'driving') {
   1657       const fdx = this.fireCell.x * CELL_SIZE - this.truck.x;
   1658       const fdy = this.fireCell.y * CELL_SIZE - this.truck.y;
   1659       if (Math.hypot(fdx, fdy) < FIRE_TRIGGER_DIST) {
   1660         this.enterFirefighting();
   1661         return;
   1662       }
   1663     }
   1664     // Always animate fire while driving toward it
   1665     if (this.fireGraphics) this._updateFireAnimation(this.time.now);
   1666 
   1667     const remaining = Math.hypot(this.segmentEnd.x - this.truck.x, this.segmentEnd.y - this.truck.y);
   1668     if (remaining <= 0.8 * SCALE) {
   1669       this.truck.setPosition(this.segmentEnd.x, this.segmentEnd.y);
   1670       this.advancePhase();
   1671     }
   1672 
   1673     if (this.failVisible && this.state !== 'stopped') {
   1674       this.failVisible = false;
   1675       this.overlay.setFillStyle(0xe63946, 0);
   1676     }
   1677     if (this.state === 'stopped' && this.time.now > this.warningUntil) {
   1678       this.overlay.setFillStyle(0xe63946, 0.12);
   1679     }
   1680 
   1681     if (this.phase === 'exit') {
   1682       let diff = this.targetRotation - this.truck.rotation;
   1683       while (diff > Math.PI) diff -= 2 * Math.PI;
   1684       while (diff < -Math.PI) diff += 2 * Math.PI;
   1685       const rotateSpeed = 5;
   1686       if (Math.abs(diff) > 0.005) {
   1687         this.truck.rotation += Math.sign(diff) * Math.min(Math.abs(diff), rotateSpeed * dt);
   1688       } else {
   1689         this.truck.rotation = this.targetRotation;
   1690       }
   1691     } else {
   1692       this.truck.rotation = this.rotationForHeading(this.heading);
   1693     }
   1694 
   1695     if (this.time.now > this.successUntil && this.successOverlay.alpha > 0) {
   1696       const newAlpha = Math.max(0, this.successOverlay.alpha - 2 * dt);
   1697       this.successOverlay.setAlpha(newAlpha);
   1698     }
   1699 
   1700     this._updateLightsEffect();
   1701     this.refreshDebug();
   1702   }
   1703 
   1704   // ── Manual control mode ────────────────────────────────────────────────
   1705   // The truck drives only while an arrow key is held, in that key's compass
   1706   // direction, snapping cell-centre to cell-centre along the road graph.
   1707   // Reversing (opposite key) is allowed mid-segment; turns happen at cell
   1708   // centres where the road graph has an exit that way.
   1709 
   1710   setControlMode(mode) {
   1711     if (mode !== 'manual' && mode !== 'guided') return;
   1712     if (mode === this.controlMode) return;
   1713     this.controlMode = mode;
   1714     if (this.state === 'minigame') return; // applied when the minigame resumes us
   1715     if (mode === 'manual') this._enterManualMode();
   1716     else this._enterGuidedMode();
   1717   }
   1718 
   1719   _nearestRoadCell() {
   1720     const gx = Math.round(this.truck.x / CELL_SIZE);
   1721     const gy = Math.round(this.truck.y / CELL_SIZE);
   1722     const cell = this.cellAt(gx, gy);
   1723     if (cell && cell.type === FT.CELL_TYPES.ROAD) return cell;
   1724     for (const card of ['N', 'E', 'S', 'W']) {
   1725       const n = this.cellAt(gx + FT.DIRS[card].dx, gy + FT.DIRS[card].dy);
   1726       if (n && n.type === FT.CELL_TYPES.ROAD) return n;
   1727     }
   1728     return this.island.grid[this.island.routeStart.y][this.island.routeStart.x];
   1729   }
   1730 
   1731   _enterManualMode() {
   1732     this.promptDir = null;
   1733     this.promptResolved = false;
   1734     this.failVisible = false;
   1735     this.overlay.setFillStyle(0xe63946, 0);
   1736     this.manualCell = this._nearestRoadCell();
   1737     this.manualTarget = null;
   1738     const p = this.worldPoint(this.manualCell.x, this.manualCell.y);
   1739     this.truck.setPosition(p.x, p.y);
   1740     this.state = 'driving';
   1741     this.targetSpeed = 0;
   1742     this.speed = 0;
   1743     this._manualLastDist = this.fireDistances ? this.fireDistances.get(this.manualCell) : null;
   1744     this.statusText.setText('Hold an arrow key to drive to the fire!');
   1745     this._updateManualHint();
   1746     this.refreshDebug();
   1747   }
   1748 
   1749   _enterGuidedMode() {
   1750     this.heldHeadings = [];
   1751     this.manualTarget = null;
   1752     this.manualHintHeading = null;
   1753     const fromCell = this._nearestRoadCell();
   1754     const p = this.worldPoint(fromCell.x, fromCell.y);
   1755     this.truck.setPosition(p.x, p.y);
   1756     let routed = false;
   1757     if (this.fireMode && this.fireCell) {
   1758       const targetCell = this.island.grid[this.fireCell.y][this.fireCell.x];
   1759       routed = this._applyRouteTo(fromCell, targetCell);
   1760     }
   1761     if (!routed) this.segmentEnd = { x: this.truck.x, y: this.truck.y };
   1762     this.promptDir = null;
   1763     this.promptResolved = false;
   1764     this.state = 'driving';
   1765     this.targetSpeed = BASE_SPEED * (window.GAME_SPEED_MULTIPLIER ?? 1.0);
   1766     this.statusText.setText('Watch for the next arrow.');
   1767     this.refreshDebug();
   1768   }
   1769 
   1770   stepManual(dt) {
   1771     const m = window.GAME_SPEED_MULTIPLIER ?? 1.0;
   1772     if (!this.manualCell) this._enterManualMode();
   1773     const desired = this.heldHeadings.length ? this.heldHeadings[this.heldHeadings.length - 1] : null;
   1774 
   1775     if (desired) {
   1776       if (!this.manualTarget) {
   1777         this._manualDepart(desired);
   1778       } else if (desired === FT.OPPOSITE[this.heading]) {
   1779         // Reverse mid-segment: head back toward the cell we just left.
   1780         const behind = this.manualCell;
   1781         this.manualCell = this.manualTarget;
   1782         this.manualTarget = behind;
   1783         this.heading = desired;
   1784         this.targetRotation = this.rotationForHeading(desired);
   1785       }
   1786     }
   1787 
   1788     this.targetSpeed = (desired && this.manualTarget) ? MAX_SPEED * m : 0;
   1789     if (this.speed < this.targetSpeed) this.speed = Math.min(this.targetSpeed, this.speed + ACCEL * m * dt);
   1790     if (this.speed > this.targetSpeed) this.speed = Math.max(this.targetSpeed, this.speed - BRAKE * m * dt);
   1791 
   1792     if (this.manualTarget && this.speed > 0) {
   1793       const end = this.worldPoint(this.manualTarget.x, this.manualTarget.y);
   1794       const dx = end.x - this.truck.x;
   1795       const dy = end.y - this.truck.y;
   1796       const dist = Math.hypot(dx, dy);
   1797       const travel = Math.min(dist, this.speed * dt);
   1798       if (dist > 0.001) {
   1799         this.truck.x += (dx / dist) * travel;
   1800         this.truck.y += (dy / dist) * travel;
   1801         this.debugDistance += travel;
   1802       }
   1803       if (dist - travel <= 0.8 * SCALE) {
   1804         this.truck.setPosition(end.x, end.y);
   1805         this._manualArrive();
   1806       }
   1807     }
   1808 
   1809     // Fire proximity → enter firefighting
   1810     if (this.fireMode && this.fireCell && this.state === 'driving') {
   1811       const fdx = this.fireCell.x * CELL_SIZE - this.truck.x;
   1812       const fdy = this.fireCell.y * CELL_SIZE - this.truck.y;
   1813       if (Math.hypot(fdx, fdy) < FIRE_TRIGGER_DIST) {
   1814         this.enterFirefighting();
   1815         return;
   1816       }
   1817     }
   1818     if (this.fireGraphics) this._updateFireAnimation(this.time.now);
   1819 
   1820     let diff = this.targetRotation - this.truck.rotation;
   1821     while (diff > Math.PI) diff -= 2 * Math.PI;
   1822     while (diff < -Math.PI) diff += 2 * Math.PI;
   1823     if (Math.abs(diff) > 0.005) {
   1824       this.truck.rotation += Math.sign(diff) * Math.min(Math.abs(diff), 5 * dt);
   1825     } else {
   1826       this.truck.rotation = this.targetRotation;
   1827     }
   1828 
   1829     if (this.failVisible && this.time.now > this.warningUntil) {
   1830       this.failVisible = false;
   1831       this.overlay.setFillStyle(0xe63946, 0);
   1832     }
   1833     if (this.time.now > this.successUntil && this.successOverlay.alpha > 0) {
   1834       this.successOverlay.setAlpha(Math.max(0, this.successOverlay.alpha - 2 * dt));
   1835     }
   1836 
   1837     this._updateLightsEffect();
   1838     this.refreshDebug();
   1839   }
   1840 
   1841   _manualDepart(desired) {
   1842     const card = FT.HEADING_TO_CARD[desired];
   1843     const cell = this.manualCell;
   1844     if (cell.exits[card]) {
   1845       const next = this.cellAt(cell.x + FT.DIRS[card].dx, cell.y + FT.DIRS[card].dy);
   1846       if (next && next.type === FT.CELL_TYPES.ROAD) {
   1847         this.manualTarget = next;
   1848         this.heading = desired;
   1849         this.targetRotation = this.rotationForHeading(desired);
   1850         return;
   1851       }
   1852     }
   1853     this.statusText.setText('No road that way! Try another arrow.');
   1854   }
   1855 
   1856   _manualArrive() {
   1857     this.manualCell = this.manualTarget;
   1858     this.manualTarget = null;
   1859     if (this.fireMode && this.fireDistances) {
   1860       const d = this.fireDistances.get(this.manualCell);
   1861       if (d != null && this._manualLastDist != null) {
   1862         if (d > this._manualLastDist) this._flagWrongWay();
   1863         else if (d < this._manualLastDist) this.statusText.setText('Great! Keep going!');
   1864       }
   1865       if (d != null) this._manualLastDist = d;
   1866     }
   1867     this._updateManualHint();
   1868   }
   1869 
   1870   _flagWrongWay() {
   1871     this.failVisible = true;
   1872     this.warningUntil = this.time.now + 500;
   1873     this.overlay.setFillStyle(0xe63946, 0.15);
   1874     this.statusText.setText('Wrong way! Turn around — the fire is the other way!');
   1875     this.playFailSound();
   1876   }
   1877 
   1878   _updateManualHint() {
   1879     if (!this.fireMode || !this.fireDistances || !this.manualCell) {
   1880       this.manualHintHeading = null;
   1881       return;
   1882     }
   1883     const card = FT.bestRoadDirection(this.island.grid, this.manualCell, this.fireDistances);
   1884     this.manualHintHeading = card ? FT.CARD_TO_HEADING[card] : null;
   1885   }
   1886 
   1887   advancePhase() {
   1888     if (this.phase === 'approach') {
   1889       if (this.promptDir && !this.promptResolved) return;
   1890       if (!this.currentStep) {
   1891         // Straight-line final segment to fire cell — fire trigger handles the rest
   1892         this.phase = 'exit';
   1893         this.state = 'driving';
   1894         this.targetSpeed = BASE_SPEED * (window.GAME_SPEED_MULTIPLIER ?? 1.0);
   1895         return;
   1896       }
   1897       this.heading = this.currentStep.headingOut;
   1898       this.segmentEnd = this.worldPoint(this.currentStep.exitX, this.currentStep.exitY);
   1899       this.targetRotation = this.rotationForHeading(this.heading);
   1900       this.phase = 'exit';
   1901       this.state = 'driving';
   1902       this.targetSpeed = BASE_SPEED * (window.GAME_SPEED_MULTIPLIER ?? 1.0);
   1903       return;
   1904     }
   1905 
   1906     this.routeIndex += 1;
   1907     this.extendRouteIfNeeded();
   1908     this.currentStep = this.route[this.routeIndex];
   1909     if (!this.currentStep) {
   1910       if (this.fireMode && this.fireCell) {
   1911         this.segmentEnd = { x: this.fireCell.x * CELL_SIZE, y: this.fireCell.y * CELL_SIZE };
   1912         this.phase = 'approach';
   1913         this.promptDir = null;
   1914       }
   1915       return;
   1916     }
   1917     this.heading = this.currentStep.headingIn;
   1918     this.segmentEnd = this.worldPoint(this.currentStep.x, this.currentStep.y);
   1919     this.phase = 'approach';
   1920     this.promptDir = null;
   1921     this.promptResolved = false;
   1922     this.promptShownAt = 0;
   1923     this.state = 'driving';
   1924     this.targetSpeed = BASE_SPEED * (window.GAME_SPEED_MULTIPLIER ?? 1.0);
   1925     this.statusText.setText('Watch for the next arrow.');
   1926   }
   1927 
   1928   extendRouteIfNeeded() {
   1929     if (this.fireMode) return;
   1930     if (this.routeIndex < this.route.length - 12) return;
   1931     const extension = FT.extendRouteOnGraph(this.island, this.route, ROUTE_EXTENSION_COUNT);
   1932     this.route.push(...extension);
   1933   }
   1934 
   1935   rotationForHeading(heading) {
   1936     if (heading === 'east') return 0;
   1937     if (heading === 'south') return Math.PI / 2;
   1938     if (heading === 'west') return Math.PI;
   1939     return -Math.PI / 2;
   1940   }
   1941 
   1942   refreshDebug() {
   1943     this.truckX = this.truck ? this.truck.x : 0;
   1944     this.truckY = this.truck ? this.truck.y : 0;
   1945     this.debugDistancePx = this.debugDistance;
   1946     this.fps = this.game.loop.actualFps;
   1947     if (this.fpsText) this.fpsText.setText(`FPS: ${this.fps.toFixed(0)}`);
   1948     if (this.controlMode === 'manual') {
   1949       this.promptText.setText(this.manualHintHeading ? headingArrowLabel(this.manualHintHeading) : '');
   1950     } else {
   1951       this.promptText.setText(this.promptDir ? arrowLabel(this.promptDir) : '');
   1952     }
   1953   }
   1954 
   1955   cellAt(x, y) {
   1956     return this.island && this.island.grid[y] && this.island.grid[y][x] ? this.island.grid[y][x] : null;
   1957   }
   1958 
   1959   snapTruckToRouteIndex(index) {
   1960     if (!this.route[index]) throw new Error('Invalid route index: ' + index);
   1961 
   1962     this.routeIndex = index;
   1963     this.currentStep = this.route[index];
   1964     this.heading = this.currentStep.headingIn;
   1965     this.segmentEnd = this.worldPoint(this.currentStep.x, this.currentStep.y);
   1966     this.phase = 'approach';
   1967     this.promptDir = this.currentStep.move;
   1968     this.promptResolved = false;
   1969     this.state = 'driving';
   1970     this.targetSpeed = 0;
   1971     this.speed = 0;
   1972     this.targetRotation = this.rotationForHeading(this.heading);
   1973 
   1974     const pos = this.worldPoint(this.currentStep.approachX, this.currentStep.approachY);
   1975     this.truck.setPosition(pos.x, pos.y);
   1976     this.truck.rotation = this.targetRotation;
   1977     this.refreshDebug();
   1978   }
   1979 
   1980   _setupFireRoute(island, fromCell) {
   1981     const rng = FT.createSeededRng(Date.now());
   1982     const targetDistance = window.GAME_FIRE_DISTANCE ?? 20;
   1983     const variance = window.GAME_FIRE_VARIANCE ?? 5;
   1984     const dest = FT.pickFireDestination(island, rng, { fromCell, targetDistance, variance });
   1985     if (!dest) return false;
   1986     this.fireCell = dest.roadCell;
   1987     this.fireBuildingCell = dest.buildingCell;
   1988 
   1989     const targetCell = island.grid[this.fireCell.y][this.fireCell.x];
   1990     if (!this._applyRouteTo(fromCell, targetCell)) {
   1991       this.fireCell = null;
   1992       this.fireBuildingCell = null;
   1993       return false;
   1994     }
   1995     this.fireDistances = FT.bfsRoadDistances(island.grid, targetCell);
   1996     return true;
   1997   }
   1998 
   1999   _applyRouteTo(fromCell, targetCell) {
   2000     const island = this.island;
   2001     const cellPath = FT.bfsRoadPath(island.grid, fromCell, targetCell);
   2002     if (!cellPath) return false;
   2003 
   2004     const route = FT.pathToRouteSteps(island.grid, cellPath, 0);
   2005     this.route = route;
   2006     this.routeIndex = 0;
   2007     this.currentStep = route[0] ?? null;
   2008     this.phase = 'approach';
   2009 
   2010     if (route.length === 0) {
   2011       this.segmentEnd = { x: targetCell.x * CELL_SIZE, y: targetCell.y * CELL_SIZE };
   2012       if (cellPath.length >= 2) this.heading = FT.headingFromTo(cellPath[0], cellPath[1]);
   2013     } else {
   2014       this.heading = this.currentStep.headingIn;
   2015       this.segmentEnd = this.worldPoint(this.currentStep.x, this.currentStep.y);
   2016     }
   2017     this.targetRotation = this.rotationForHeading(this.heading);
   2018     return true;
   2019   }
   2020 
   2021   _updateFireCounter() {
   2022     this.fireCountText.setText(`🔥 ${this.firesExtinguished} / ${FIRES_TO_WIN}`);
   2023   }
   2024 
   2025   _initFireDestination() {
   2026     const island = this.island;
   2027     const fromCell = island.grid[island.routeStart.y][island.routeStart.x];
   2028     const ok = this._setupFireRoute(island, fromCell);
   2029     if (!ok) return;
   2030     this._createFireGraphics();
   2031     this.fireMode = true;
   2032   }
   2033 
   2034   _createFireGraphics() {
   2035     if (this.fireGraphics) { this.fireGraphics.destroy(); }
   2036     this.fireGraphics = this.add.graphics();
   2037     this.fireGraphics.setDepth(10);
   2038     this.uiCamera.ignore(this.fireGraphics);
   2039 
   2040     const cx = this.fireBuildingCell.x * CELL_SIZE;
   2041     const cy = this.fireBuildingCell.y * CELL_SIZE;
   2042 
   2043     if (this.burnOverlay) { this.burnOverlay.destroy(); }
   2044     this.burnOverlay = this.add.image(cx, cy, 'burning-overlay').setScale(SCALE).setDepth(9.5);
   2045     this.uiCamera.ignore(this.burnOverlay);
   2046 
   2047     if (this.fireGlow) { this.fireGlow.destroy(); }
   2048     this.fireGlow = this.add.image(cx, cy + CELL_SIZE * 0.1, 'glow')
   2049       .setScale(SCALE * 1.4).setDepth(9.7).setBlendMode(Phaser.BlendModes.ADD);
   2050     this.uiCamera.ignore(this.fireGlow);
   2051 
   2052     this._startSmoke();
   2053   }
   2054 
   2055   _startSmoke() {
   2056     if (this._smokeTimer) return;
   2057     this._smokeTimer = this.time.addEvent({ delay: 350, loop: true, callback: () => this._emitSmoke() });
   2058   }
   2059 
   2060   _stopSmoke() {
   2061     if (this._smokeTimer) { this._smokeTimer.remove(false); this._smokeTimer = null; }
   2062   }
   2063 
   2064   _emitSmoke() {
   2065     if (!this.fireBuildingCell) return;
   2066     const cx = this.fireBuildingCell.x * CELL_SIZE + (Math.random() - 0.5) * CELL_SIZE * 0.25;
   2067     const cy = this.fireBuildingCell.y * CELL_SIZE - CELL_SIZE * 0.1;
   2068     const puff = this.add.image(cx, cy, 'smoke-puff').setScale(SCALE * 0.6).setAlpha(0.7).setDepth(12);
   2069     this.uiCamera.ignore(puff);
   2070     this.tweens.add({
   2071       targets: puff,
   2072       x: cx - CELL_SIZE * 0.4, y: cy - CELL_SIZE * 0.95,
   2073       scale: SCALE * 1.4, alpha: 0,
   2074       duration: 1600, ease: 'Sine.easeOut',
   2075       onComplete: () => puff.destroy(),
   2076     });
   2077   }
   2078 
   2079   _updateFireAnimation(t) {
   2080     if (!this.fireGraphics || !this.fireBuildingCell) return;
   2081     const gfx = this.fireGraphics;
   2082     gfx.clear();
   2083     const cx = this.fireBuildingCell.x * CELL_SIZE;
   2084     const cy = this.fireBuildingCell.y * CELL_SIZE;
   2085     const baseY = cy + CELL_SIZE * 0.36;
   2086     const scale = 1; // full blaze until the hose minigame puts it out
   2087 
   2088     // Pulsing glow underneath.
   2089     if (this.fireGlow) {
   2090       const pulse = 1.3 + Math.sin(t * 0.006) * 0.18;
   2091       this.fireGlow.setScale(SCALE * 1.4 * pulse * (0.5 + scale * 0.5));
   2092       this.fireGlow.setAlpha(0.6 * (0.3 + scale * 0.7));
   2093     }
   2094 
   2095     // Three layered flames (outer orange, mid red, inner yellow) each flickering
   2096     // on its own phase, plus a few rising embers.
   2097     const layers = [
   2098       { color: 0xff7a18, half: 0.24, h: 0.78, phase: 0.0 },
   2099       { color: 0xff3b1f, half: 0.17, h: 0.62, phase: 1.7 },
   2100       { color: 0xffd23f, half: 0.10, h: 0.44, phase: 3.1 },
   2101     ];
   2102     for (const L of layers) {
   2103       const half = CELL_SIZE * L.half * scale;
   2104       for (let i = 0; i < 3; i++) {
   2105         const offset = Math.sin(t * 0.004 + i * 1.9 + L.phase) * CELL_SIZE * 0.10 * scale;
   2106         const h = (CELL_SIZE * L.h + Math.sin(t * 0.006 + i * 2.3 + L.phase) * CELL_SIZE * 0.16) * scale;
   2107         gfx.fillStyle(L.color, 0.78 + Math.sin(t * 0.005 + i + L.phase) * 0.2);
   2108         gfx.fillTriangle(
   2109           cx + offset - half, baseY,
   2110           cx + offset + half, baseY,
   2111           cx + offset, baseY - h
   2112         );
   2113       }
   2114     }
   2115     // Embers
   2116     gfx.fillStyle(0xffd76e, 0.9);
   2117     for (let i = 0; i < 5; i++) {
   2118       const ex = cx + Math.sin(t * 0.003 + i * 2.1) * CELL_SIZE * 0.28 * scale;
   2119       const ey = baseY - ((t * 0.12 + i * 90) % (CELL_SIZE * 0.9)) * scale;
   2120       gfx.fillCircle(ex, ey, 3 * scale + 1);
   2121     }
   2122   }
   2123 
   2124   // The truck has arrived at the fire: hand off to the street-level hose
   2125   // minigame. This scene pauses (its HUD and world stay rendered underneath,
   2126   // fully covered by the minigame backdrop) until _onMinigameComplete.
   2127   enterFirefighting() {
   2128     this.state = 'minigame';
   2129     this.targetSpeed = 0;
   2130     this.speed = 0;
   2131     this.promptDir = null;
   2132     // Keyups delivered while this scene is paused are lost — drop held keys.
   2133     this.heldHeadings = [];
   2134     this.manualTarget = null;
   2135     this.statusText.setText('You made it! Put out the fire!');
   2136     this.promptText.setText('');
   2137     this.scene.launch('FireHoseScene', { fireNumber: this.firesExtinguished });
   2138     this.scene.pause();
   2139   }
   2140 
   2141   // Called by FireHoseScene right after it resumes this scene.
   2142   _onMinigameComplete() {
   2143     if (!this.fireBuildingCell) return;
   2144     this.lastStepTime = performance.now();
   2145     this.firesExtinguished += 1;
   2146     this._updateFireCounter();
   2147     this.successOverlay.setAlpha(0.12);
   2148     this.successUntil = this.time.now + 200;
   2149     this.playSuccessSound();
   2150     this._stopSmoke();
   2151     if (this.fireGraphics) { this.fireGraphics.destroy(); this.fireGraphics = null; }
   2152     if (this.fireGlow) { this.fireGlow.destroy(); this.fireGlow = null; }
   2153     // Leave a brief scorch mark that fades out.
   2154     if (this.burnOverlay) {
   2155       const ov = this.burnOverlay;
   2156       this.burnOverlay = null;
   2157       this.tweens.add({ targets: ov, alpha: 0, duration: 800, onComplete: () => ov.destroy() });
   2158     }
   2159     this.fireCell = null;
   2160     this.fireBuildingCell = null;
   2161     this.statusText.setText('Fire out! Great job!');
   2162 
   2163     // Wall-clock timeouts (not scene delayedCalls): headless/throttled
   2164     // browsers can starve the scene clock, and these transitions must happen.
   2165     if (this.firesExtinguished >= FIRES_TO_WIN) {
   2166       setTimeout(() => {
   2167         if (this.scene && this.scene.isActive()) this.scene.start('FireTruckEndScene');
   2168       }, 900);
   2169       return;
   2170     }
   2171 
   2172     setTimeout(() => {
   2173       if (!this.scene || !this.scene.isActive() || this.state !== 'minigame') return;
   2174       const island = this.island;
   2175       const mult = window.GAME_SPEED_MULTIPLIER ?? 1.0;
   2176       const truckX = Math.round(this.truck.x / CELL_SIZE);
   2177       const truckY = Math.round(this.truck.y / CELL_SIZE);
   2178       const fromCell = (island.grid[truckY] && island.grid[truckY][truckX] &&
   2179         island.grid[truckY][truckX].type === FT.CELL_TYPES.ROAD)
   2180         ? island.grid[truckY][truckX]
   2181         : island.grid[island.routeStart.y][island.routeStart.x];
   2182 
   2183       const ok = this._setupFireRoute(island, fromCell);
   2184       if (!ok) {
   2185         this.fireMode = false;
   2186         this.fireDistances = null;
   2187         this.phase = 'approach';
   2188         this.promptDir = null;
   2189         this.promptResolved = false;
   2190         this.state = 'driving';
   2191         this.targetSpeed = BASE_SPEED * mult;
   2192         if (this.controlMode === 'manual') this._enterManualMode();
   2193         else this.statusText.setText('Watch for the next arrow.');
   2194         return;
   2195       }
   2196       this.phase = 'approach';
   2197       this.promptDir = null;
   2198       this.promptResolved = false;
   2199       this.state = 'driving';
   2200       this.targetSpeed = BASE_SPEED * mult;
   2201       if (this.controlMode === 'manual') this._enterManualMode();
   2202       else this.statusText.setText('Watch for the next arrow.');
   2203       this._createFireGraphics();
   2204     }, 600);
   2205   }
   2206 
   2207   shutdown() {
   2208     if (this.fallbackTimer) {
   2209       clearInterval(this.fallbackTimer);
   2210       this.fallbackTimer = null;
   2211     }
   2212     this._stopSiren();
   2213     this._stopSmoke();
   2214     if (this.bgGain) { try { Tone.Transport.stop(); } catch (_) {} }
   2215     if (this.fireGraphics) { this.fireGraphics.destroy(); this.fireGraphics = null; }
   2216     if (this.fireGlow) { this.fireGlow.destroy(); this.fireGlow = null; }
   2217     if (this.burnOverlay) { this.burnOverlay.destroy(); this.burnOverlay = null; }
   2218     this.ambientReady = false;
   2219     if (this._birdTimer) { this._birdTimer.remove(false); this._birdTimer = null; }
   2220   }
   2221 }
   2222 
   2223 function colorForCell(cell) {
   2224   if (!cell) return WATER_COLOR;
   2225   if (cell.type === FT.CELL_TYPES.WATER) return WATER_COLOR;
   2226   if (cell.type === FT.CELL_TYPES.BEACH) return BEACH_COLOR;
   2227   if (cell.type === FT.CELL_TYPES.ROAD) return ASPHALT_COLOR;
   2228   return BUILDING_PALETTE[Math.abs((cell.x * 13 + cell.y * 17) % BUILDING_PALETTE.length)];
   2229 }
   2230 
   2231 // Base ground colour under a cell (building/park footprints read as pavement).
   2232 function groundColorForCell(cell) {
   2233   if (!cell) return WATER_COLOR;
   2234   if (cell.type === FT.CELL_TYPES.WATER) return WATER_COLOR;
   2235   if (cell.type === FT.CELL_TYPES.BEACH) return BEACH_COLOR;
   2236   if (cell.type === FT.CELL_TYPES.ROAD) return ASPHALT_COLOR;
   2237   return SIDEWALK_COLOR;
   2238 }
   2239 
   2240 function lighten(color, amt) {
   2241   const c = Phaser.Display.Color.IntegerToColor(color);
   2242   return Phaser.Display.Color.GetColor(
   2243     Math.min(255, c.red + amt), Math.min(255, c.green + amt), Math.min(255, c.blue + amt));
   2244 }
   2245 
   2246 function darken(color, amt) {
   2247   const c = Phaser.Display.Color.IntegerToColor(color);
   2248   return Phaser.Display.Color.GetColor(
   2249     Math.max(0, c.red - amt), Math.max(0, c.green - amt), Math.max(0, c.blue - amt));
   2250 }
   2251 
   2252 // ── Street-level hose minigame ───────────────────────────────────────────────
   2253 // Launched (over the paused driving scene) when the truck reaches the fire.
   2254 // A building facade fills the view with several windows ablaze; arrow keys
   2255 // steer an aim reticle, holding SPACE sprays water from the truck's ladder
   2256 // nozzle. Dousing a window long enough quenches it — a resident (or cat!)
   2257 // appears waving thanks. When every window is out the crowd celebrates and
   2258 // control returns to the driving scene.
   2259 const HOSE_QUENCH_S = 1.4;
   2260 
   2261 class FireHoseScene extends Phaser.Scene {
   2262   constructor() {
   2263     super({ key: 'FireHoseScene' });
   2264   }
   2265 
   2266   init(data) {
   2267     this.fireNumber = (data && data.fireNumber) || 0;
   2268     this.done = false;
   2269     this.spraying = false;
   2270     this.hoverFire = null;
   2271     this.waterNoise = null;
   2272     this._steamAccum = 0;
   2273     this._catUsed = false;
   2274   }
   2275 
   2276   create() {
   2277     const W = this._W = this.scale.width;
   2278     const H = this._H = this.scale.height;
   2279     this.drive = this.scene.get('FireTruckScene');
   2280     this.rng = FT.createSeededRng(((Date.now() & 0xffff) + 1) * (this.fireNumber + 3));
   2281 
   2282     bakePeopleTextures(this);
   2283     bakeSideTruckTexture(this);
   2284     bakeLadderTexture(this);
   2285     bakeCritterTextures(this);
   2286     bakeFxTextures(this);
   2287     if (window.KGames && KGames.bakeFireworksAtlas) {
   2288       try { KGames.bakeFireworksAtlas(this); } catch (_) {}
   2289     }
   2290 
   2291     this.streetY = H * 0.76;
   2292     this._drawBackdrop(W, H);
   2293     this._buildFacade(W, H);
   2294     this._spawnCrowd(W, H);
   2295     this._placeTruck(W, H);
   2296 
   2297     this.fireGfx = this.add.graphics().setDepth(20);
   2298     this.waterGfx = this.add.graphics().setDepth(30);
   2299     this.ringGfx = this.add.graphics().setDepth(41);
   2300     this.lightGfx = this.add.graphics().setDepth(27);
   2301 
   2302     // Aim reticle: white outer ring + yellow inner ring + crosshair ticks.
   2303     // The green ring lights up when the aim is over a burning window.
   2304     this.aimX = (this.bldX0 + this.bldX1) / 2;
   2305     this.aimY = (this.bldTop + this.streetY) / 2;
   2306     const retGfx = this.add.graphics();
   2307     retGfx.lineStyle(4, 0xffffff, 0.9); retGfx.strokeCircle(0, 0, 26);
   2308     retGfx.lineStyle(3, 0xffd23f, 1); retGfx.strokeCircle(0, 0, 20);
   2309     retGfx.lineStyle(4, 0xffffff, 0.9);
   2310     retGfx.lineBetween(-34, 0, -12, 0); retGfx.lineBetween(12, 0, 34, 0);
   2311     retGfx.lineBetween(0, -34, 0, -12); retGfx.lineBetween(0, 12, 0, 34);
   2312     this.hoverRing = this.add.graphics();
   2313     this.hoverRing.lineStyle(5, 0x7dff9a, 0.95);
   2314     this.hoverRing.strokeCircle(0, 0, 30);
   2315     this.hoverRing.setVisible(false);
   2316     this.reticle = this.add.container(this.aimX, this.aimY, [retGfx, this.hoverRing]).setDepth(40);
   2317     this.tweens.add({
   2318       targets: this.reticle, scale: { from: 0.92, to: 1.08 },
   2319       duration: 500, yoyo: true, repeat: -1, ease: 'Sine.easeInOut',
   2320     });
   2321 
   2322     this.cursors = this.input.keyboard.createCursorKeys();
   2323 
   2324     this.hudText = this.add.text(W / 2, 66, '', {
   2325       fontFamily: 'Fredoka, sans-serif',
   2326       fontSize: '24px',
   2327       color: '#ffffff',
   2328       stroke: '#2f2f2f',
   2329       strokeThickness: 7,
   2330       align: 'center',
   2331     }).setOrigin(0.5, 0).setDepth(60);
   2332     this._updateHud();
   2333 
   2334     this._smokeTimer = this.time.addEvent({ delay: 320, loop: true, callback: () => this._emitFireSmoke() });
   2335 
   2336     // Headless/throttled browsers can starve RAF (same workaround as the
   2337     // driving scene): gameplay-critical logic lives in _step, driven from
   2338     // update() and from this wall-clock fallback. _finishAtWall is checked
   2339     // there too, so the return-to-driving handoff never relies on the scene
   2340     // clock.
   2341     this._finishAtWall = 0;
   2342     this._finished = false;
   2343     this.lastStepTime = performance.now();
   2344     this.fallbackTimer = setInterval(() => {
   2345       const now = performance.now();
   2346       if (now - this.lastStepTime > 250) {
   2347         this._step(Math.min((now - this.lastStepTime) / 1000, 0.5));
   2348         this.lastStepTime = now;
   2349       }
   2350     }, 200);
   2351 
   2352     window.__FT_HOSE_SCENE__ = this;
   2353     this.events.on('shutdown', this._shutdown, this);
   2354     this.events.on('destroy', this._shutdown, this);
   2355   }
   2356 
   2357   _drawBackdrop(W, H) {
   2358     const g = this.add.graphics().setDepth(0);
   2359     // Sky: cool blue up top warming toward the horizon.
   2360     g.fillGradientStyle(0x8fe0f2, 0x8fe0f2, 0xffe3a3, 0xffd08a, 1);
   2361     g.fillRect(0, 0, W, this.streetY);
   2362     g.fillStyle(0xfff2b0, 1); g.fillCircle(W * 0.07, H * 0.10, H * 0.045);
   2363     // Distant pastel skyline peeking out on both sides of the facade.
   2364     const silhouettes = [
   2365       [0.00, 0.13, 0.34], [0.08, 0.09, 0.26], [0.84, 0.10, 0.30], [0.92, 0.09, 0.38],
   2366     ];
   2367     silhouettes.forEach(([fx, fw, fh], i) => {
   2368       const color = lighten(BUILDING_PALETTE[(i * 2 + 1) % BUILDING_PALETTE.length], 40);
   2369       g.fillStyle(color, 1);
   2370       g.fillRect(W * fx, this.streetY - H * fh, W * fw, H * fh);
   2371       g.fillStyle(0xffffff, 0.35);
   2372       for (let wy = this.streetY - H * fh + 12; wy < this.streetY - 12; wy += 22) {
   2373         for (let wx = W * fx + 8; wx < W * (fx + fw) - 10; wx += 20) g.fillRect(wx, wy, 8, 10);
   2374       }
   2375     });
   2376     // Sidewalk band + curb.
   2377     g.fillStyle(SIDEWALK_COLOR, 1); g.fillRect(0, this.streetY, W, H * 0.07);
   2378     g.fillStyle(darken(SIDEWALK_COLOR, 40), 1); g.fillRect(0, this.streetY + H * 0.07 - 3, W, 3);
   2379     // Road with the painted bike lane nearest the curb.
   2380     g.fillStyle(ASPHALT_COLOR, 1); g.fillRect(0, this.streetY + H * 0.07, W, H * 0.23);
   2381     g.fillStyle(BIKE_LANE_COLOR, 0.6); g.fillRect(0, this.streetY + H * 0.075, W, H * 0.035);
   2382     g.fillStyle(0xffffff, 0.7);
   2383     for (let dx = 8; dx < W; dx += 46) g.fillRect(dx, this.streetY + H * 0.075 + H * 0.035, 22, 3);
   2384     g.fillStyle(STRIPE_COLOR, 0.9);
   2385     for (let dx = 0; dx < W; dx += 70) g.fillRect(dx, this.streetY + H * 0.16, 38, 5);
   2386     // Street palms framing the block.
   2387     drawSidePalm(g, W * 0.075, this.streetY + 6, H * 0.17);
   2388     drawSidePalm(g, W * 0.945, this.streetY + 6, H * 0.20);
   2389   }
   2390 
   2391   _buildFacade(W, H) {
   2392     const facade = FT.buildFacade(this.rng, { fireNumber: this.fireNumber });
   2393     const x0 = this.bldX0 = W * 0.18;
   2394     const x1 = this.bldX1 = W * 0.82;
   2395     const top = this.bldTop = H * 0.10;
   2396     const bw = x1 - x0;
   2397     const bh = this.streetY - top;
   2398     const color = BUILDING_PALETTE[Math.floor(this.rng.next() * BUILDING_PALETTE.length)];
   2399     const g = this.add.graphics().setDepth(5);
   2400 
   2401     // Body with drop shadow, outline, parapet.
   2402     g.fillStyle(0x000000, 0.12);
   2403     g.fillRoundedRect(x0 + 10, top + 10, bw, bh, { tl: 14, tr: 14, bl: 0, br: 0 });
   2404     g.fillStyle(color, 1);
   2405     g.fillRoundedRect(x0, top, bw, bh, { tl: 14, tr: 14, bl: 0, br: 0 });
   2406     g.lineStyle(4, darken(color, 50), 1);
   2407     g.strokeRoundedRect(x0, top, bw, bh, { tl: 14, tr: 14, bl: 0, br: 0 });
   2408     g.fillStyle(darken(color, 25), 1);
   2409     g.fillRoundedRect(x0 - 8, top - 6, bw + 16, 18, 8);
   2410 
   2411     // Entrance: awning-striped doorway at street level.
   2412     const doorW = Math.min(bw * 0.16, 96);
   2413     const doorH = H * 0.09;
   2414     const doorX = (x0 + x1) / 2 - doorW / 2;
   2415     g.fillStyle(darken(color, 60), 1);
   2416     g.fillRoundedRect(doorX, this.streetY - doorH, doorW, doorH, { tl: 10, tr: 10, bl: 0, br: 0 });
   2417     g.fillStyle(0xffe9a8, 0.9);
   2418     g.fillRoundedRect(doorX + doorW * 0.2, this.streetY - doorH * 0.85, doorW * 0.6, doorH * 0.55, 6);
   2419     const awnY = this.streetY - doorH - 14;
   2420     for (let i = 0; i < 6; i++) {
   2421       g.fillStyle(i % 2 ? 0xffffff : 0xe63946, 1);
   2422       g.fillRect(doorX - 8 + i * ((doorW + 16) / 6), awnY, (doorW + 16) / 6, 14);
   2423     }
   2424 
   2425     // Window grid (fire windows start dark + ablaze; the rest get lit/curtain
   2426     // interiors and the odd sill plant).
   2427     const cols = facade.cols, rows = facade.rows;
   2428     const areaX = x0 + bw * 0.07, areaW = bw * 0.86;
   2429     const areaY = top + 32;
   2430     const areaH = (this.streetY - doorH - 22) - areaY;
   2431     const gapX = areaW * 0.06, gapY = areaH * 0.10;
   2432     const winW = (areaW - gapX * (cols - 1)) / cols;
   2433     const winH = (areaH - gapY * (rows - 1)) / rows;
   2434     const fireSet = new Set(facade.fires.map((f) => f.index));
   2435     this.windows = [];
   2436     for (let r = 0; r < rows; r++) {
   2437       for (let c = 0; c < cols; c++) {
   2438         const index = r * cols + c;
   2439         const wx = areaX + c * (winW + gapX);
   2440         const wy = areaY + r * (winH + gapY);
   2441         g.fillStyle(darken(color, 45), 1);
   2442         g.fillRoundedRect(wx - 5, wy - 5, winW + 10, winH + 10, 6);
   2443         g.fillStyle(darken(color, 30), 1);
   2444         g.fillRect(wx - 9, wy + winH + 5, winW + 18, 6);
   2445         const burning = fireSet.has(index);
   2446         if (burning) {
   2447           g.fillStyle(0x2a1a12, 1);
   2448           g.fillRect(wx, wy, winW, winH);
   2449         } else {
   2450           g.fillStyle(this.rng.next() < 0.45 ? 0xffe9a8 : 0xbcd8e8, 1);
   2451           g.fillRect(wx, wy, winW, winH);
   2452           g.lineStyle(2, darken(color, 45), 0.8);
   2453           g.lineBetween(wx + winW / 2, wy, wx + winW / 2, wy + winH);
   2454           g.lineBetween(wx, wy + winH / 2, wx + winW, wy + winH / 2);
   2455           if (this.rng.next() < 0.3) {
   2456             g.fillStyle(0x46b063, 1);
   2457             g.fillCircle(wx + winW * 0.82, wy + winH - 7, 6);
   2458             g.fillStyle(0xd97b52, 1);
   2459             g.fillRect(wx + winW * 0.82 - 5, wy + winH - 4, 10, 5);
   2460           }
   2461         }
   2462         const win = {
   2463           index, x: wx, y: wy, w: winW, h: winH,
   2464           cx: wx + winW / 2, cy: wy + winH / 2,
   2465           burning, progress: 0, out: false, glow: null, flicker: this.rng.next() * 7,
   2466         };
   2467         if (burning) {
   2468           win.glow = this.add.image(win.cx, win.cy, 'glow')
   2469             .setDepth(19).setBlendMode(Phaser.BlendModes.ADD).setScale(winW / 34);
   2470         }
   2471         this.windows.push(win);
   2472       }
   2473     }
   2474     this.fires = this.windows.filter((w) => w.burning);
   2475   }
   2476 
   2477   _spawnCrowd(W, H) {
   2478     // Onlookers gathered on the sidewalk clear of the truck, with pets and a
   2479     // little tropical wildlife in the mix.
   2480     this.crowd = [];
   2481     const scale = (H * 0.115) / 96;
   2482     const baseY = this.streetY + H * 0.05;
   2483     const n = 6 + Math.floor(this.rng.next() * 3);
   2484     for (let i = 0; i < n; i++) {
   2485       const px = W * (0.42 + (i + this.rng.next() * 0.6) * (0.53 / n));
   2486       const p = this.add.image(px, baseY + (this.rng.next() - 0.5) * H * 0.012, 'kg-person-' + (i % KG_LOOKS.length))
   2487         .setOrigin(0.5, 1).setScale(scale).setDepth(12);
   2488       if (this.rng.next() < 0.5) p.setFlipX(true);
   2489       this.tweens.add({
   2490         targets: p, y: p.y - H * 0.008,
   2491         duration: 420 + i * 60, yoyo: true, repeat: -1, ease: 'Sine.easeInOut',
   2492       });
   2493       this.crowd.push(p);
   2494     }
   2495     const dog = this.add.image(W * 0.47, baseY, 'kg-dog').setOrigin(0.5, 1).setScale(scale * 1.1).setDepth(12);
   2496     this.tweens.add({ targets: dog, y: dog.y - H * 0.015, duration: 300, yoyo: true, repeat: -1, ease: 'Sine.easeInOut' });
   2497     this.crowd.push(dog);
   2498     const cat = this.add.image(W * 0.90, baseY - 2, 'kg-cat').setOrigin(0.5, 1).setScale(scale).setDepth(12);
   2499     this.crowd.push(cat);
   2500     const iguana = this.add.image(W * 0.955, baseY - 2, 'kg-iguana').setOrigin(0.5, 1).setScale(scale).setDepth(12);
   2501     if (this.rng.next() < 0.5) iguana.setFlipX(true);
   2502     this.crowd.push(iguana);
   2503     // A parrot cruises back and forth across the sky.
   2504     this.parrot = this.add.image(-60, H * 0.14, 'kg-parrot').setScale(scale * 1.3).setDepth(8);
   2505     this.tweens.add({
   2506       targets: this.parrot, x: W + 60,
   2507       duration: 11000, repeat: -1, yoyo: true,
   2508       onYoyo: () => this.parrot.setFlipX(true),
   2509       onRepeat: () => this.parrot.setFlipX(false),
   2510     });
   2511     this.tweens.add({ targets: this.parrot, y: H * 0.18, duration: 900, yoyo: true, repeat: -1, ease: 'Sine.easeInOut' });
   2512   }
   2513 
   2514   _placeTruck(W, H) {
   2515     const scale = Math.min((W * 0.30) / 260, (H * 0.24) / 112);
   2516     this.truckImg = this.add.image(W * 0.155, this.streetY + H * 0.145, 'kg-truck-side')
   2517       .setOrigin(0.5, 1).setScale(scale).setDepth(25);
   2518     // Ladder pivots on the turntable (local 70,31 in the 260×112 texture).
   2519     const px = this.truckImg.x + (70 - 130) * scale;
   2520     const py = this.truckImg.y + (31 - 112) * scale;
   2521     this.ladderPivot = { x: px, y: py };
   2522     const ladderScale = scale * 1.25;
   2523     this.ladder = this.add.image(px, py, 'kg-ladder')
   2524       .setOrigin(KG_LADDER_PIVOT_X / 210, 0.5).setScale(ladderScale).setDepth(26);
   2525     this.ladderLen = (KG_LADDER_TIP_X - KG_LADDER_PIVOT_X) * ladderScale;
   2526     // Light-bar flash position (local 212,17).
   2527     this._lightX = this.truckImg.x + (212 - 130) * scale;
   2528     this._lightY = this.truckImg.y + (17 - 112) * scale;
   2529   }
   2530 
   2531   _updateHud() {
   2532     const left = this.fires ? this.fires.filter((f) => !f.out).length : 0;
   2533     this.hudText.setText(left > 0
   2534       ? `🔥 ${left} window${left === 1 ? '' : 's'} on fire!   ◀ ▲ ▼ ▶ aim  ·  hold SPACE to spray`
   2535       : '✨ All the fires are out! ✨');
   2536   }
   2537 
   2538   // Gameplay-critical logic (aim, quench, finish handoff). Runs from update()
   2539   // and from the wall-clock fallback interval, so it keeps working even when
   2540   // the render loop is throttled.
   2541   _step(dt) {
   2542     const H = this._H;
   2543     if (!this.done) {
   2544       const mdt = Math.min(dt, 0.05); // don't teleport the aim on a long tick
   2545       const spd = H * 0.62;
   2546       if (this.cursors.left.isDown) this.aimX -= spd * mdt;
   2547       if (this.cursors.right.isDown) this.aimX += spd * mdt;
   2548       if (this.cursors.up.isDown) this.aimY -= spd * mdt;
   2549       if (this.cursors.down.isDown) this.aimY += spd * mdt;
   2550       this.aimX = Phaser.Math.Clamp(this.aimX, this.bldX0 + 16, this.bldX1 - 16);
   2551       this.aimY = Phaser.Math.Clamp(this.aimY, this.bldTop + 16, this.streetY - 20);
   2552       this.reticle.setPosition(this.aimX, this.aimY);
   2553       this.spraying = this.cursors.space.isDown;
   2554     } else {
   2555       this.spraying = false;
   2556     }
   2557 
   2558     // Which burning window (if any) is under the aim?
   2559     this.hoverFire = null;
   2560     for (const f of this.fires) {
   2561       if (f.out) continue;
   2562       const mx = f.w * 0.35, my = f.h * 0.35;
   2563       if (this.aimX >= f.x - mx && this.aimX <= f.x + f.w + mx &&
   2564           this.aimY >= f.y - my && this.aimY <= f.y + f.h + my) {
   2565         this.hoverFire = f;
   2566         break;
   2567       }
   2568     }
   2569     this.hoverRing.setVisible(!!this.hoverFire);
   2570 
   2571     // Quenching: douse a burning window to fill its progress ring.
   2572     if (this.spraying && this.hoverFire) {
   2573       const f = this.hoverFire;
   2574       f.progress += dt / HOSE_QUENCH_S;
   2575       this._steamAccum += dt;
   2576       if (this._steamAccum > 0.12) {
   2577         this._steamAccum = 0;
   2578         this._emitSteam(f);
   2579       }
   2580       if (f.progress >= 1) this._quenchWindow(f);
   2581     }
   2582 
   2583     if (this._finishAtWall && performance.now() >= this._finishAtWall) this._finish();
   2584   }
   2585 
   2586   update(time) {
   2587     const now = performance.now();
   2588     if (now > this.lastStepTime) {
   2589       this._step(Math.min((now - this.lastStepTime) / 1000, 0.5));
   2590       this.lastStepTime = now;
   2591     }
   2592 
   2593     // Ladder tracks the aim; water pours from its tip.
   2594     const ang = Math.atan2(this.aimY - this.ladderPivot.y, this.aimX - this.ladderPivot.x);
   2595     this.ladder.rotation = ang;
   2596     const tipX = this.ladderPivot.x + Math.cos(ang) * this.ladderLen;
   2597     const tipY = this.ladderPivot.y + Math.sin(ang) * this.ladderLen;
   2598 
   2599     this.waterGfx.clear();
   2600     if (this.spraying) {
   2601       if (!this.waterNoise && this.drive.audioCtx && !this.drive.sfxMuted) {
   2602         this.waterNoise = startWaterNoise(this.drive.audioCtx);
   2603       }
   2604       this._drawWater(time, tipX, tipY);
   2605     }
   2606     if ((!this.spraying || this.drive.sfxMuted) && this.waterNoise) {
   2607       stopWaterNoise(this.drive.audioCtx, this.waterNoise);
   2608       this.waterNoise = null;
   2609     }
   2610 
   2611     this.ringGfx.clear();
   2612     const hf = this.hoverFire;
   2613     if (hf && !hf.out && hf.progress > 0) {
   2614       this.ringGfx.lineStyle(7, 0x7ddcff, 0.95);
   2615       this.ringGfx.beginPath();
   2616       this.ringGfx.arc(this.aimX, this.aimY, 38, -Math.PI / 2, -Math.PI / 2 + hf.progress * Math.PI * 2);
   2617       this.ringGfx.strokePath();
   2618     }
   2619 
   2620     this._drawFires(time);
   2621 
   2622     // Emergency lights strobing on the cab.
   2623     this.lightGfx.clear();
   2624     const phase = Math.floor(time / 166) % 2;
   2625     this.lightGfx.fillStyle(phase ? 0xe63946 : 0x1d7cf2, 0.9);
   2626     this.lightGfx.fillCircle(this._lightX + (phase ? -8 : 8), this._lightY, 6);
   2627   }
   2628 
   2629   _drawWater(time, tx, ty) {
   2630     const gfx = this.waterGfx;
   2631     const ax = this.aimX, ay = this.aimY;
   2632     const dist = Math.hypot(ax - tx, ay - ty) || 1;
   2633     // Quadratic arc from the nozzle, sagging control point lifted above the chord.
   2634     const mx = (tx + ax) / 2, my = (ty + ay) / 2 - dist * 0.18;
   2635     const pt = (t) => ({
   2636       x: (1 - t) * (1 - t) * tx + 2 * (1 - t) * t * mx + t * t * ax,
   2637       y: (1 - t) * (1 - t) * ty + 2 * (1 - t) * t * my + t * t * ay,
   2638     });
   2639     const SEG = 14;
   2640     const strands = [
   2641       { color: 0x44aaff, alpha: 0.45, width: 14 },
   2642       { color: 0x9fdcff, alpha: 0.9, width: 7 },
   2643     ];
   2644     for (const s of strands) {
   2645       let prev = pt(0);
   2646       for (let i = 1; i <= SEG; i++) {
   2647         const t = i / SEG;
   2648         const p = pt(t);
   2649         const wig = Math.sin(t * 9 + time * 0.02) * 3;
   2650         gfx.lineStyle(s.width * (1 - t * 0.45), s.color, s.alpha);
   2651         gfx.lineBetween(prev.x, prev.y + wig, p.x, p.y + wig);
   2652         prev = p;
   2653       }
   2654     }
   2655     // Droplets and an expanding splash ring at the point of impact.
   2656     gfx.fillStyle(0xcdeeff, 0.9);
   2657     for (let i = 0; i < 5; i++) {
   2658       gfx.fillCircle(ax + (Math.random() - 0.5) * 36, ay + (Math.random() - 0.5) * 30, 2 + Math.random() * 3);
   2659     }
   2660     const ring = (Math.sin(time * 0.014) * 0.5 + 0.5) * 14 + 10;
   2661     gfx.lineStyle(3, 0x9fdcff, 0.6);
   2662     gfx.strokeCircle(ax, ay, ring);
   2663     // Water running down the wall when the spray isn't on a fire.
   2664     if (!this.hoverFire) {
   2665       gfx.fillStyle(0x9fdcff, 0.5);
   2666       for (let i = 0; i < 3; i++) {
   2667         gfx.fillRect(ax - 14 + i * 12, ay + 8 + ((time * 0.06 + i * 20) % 26), 3, 10);
   2668       }
   2669     }
   2670   }
   2671 
   2672   _drawFires(t) {
   2673     const gfx = this.fireGfx;
   2674     gfx.clear();
   2675     const layers = [
   2676       { color: 0xff7a18, half: 0.34, h: 1.15, phase: 0.0 },
   2677       { color: 0xff3b1f, half: 0.24, h: 0.90, phase: 1.7 },
   2678       { color: 0xffd23f, half: 0.14, h: 0.60, phase: 3.1 },
   2679     ];
   2680     for (const f of this.fires) {
   2681       if (f.out) continue;
   2682       const scale = 1 - f.progress * 0.75;
   2683       const baseY = f.y + f.h - 2;
   2684       if (f.glow) {
   2685         const pulse = 1 + Math.sin(t * 0.006 + f.flicker) * 0.15;
   2686         f.glow.setScale((f.w / 34) * pulse * (0.5 + scale * 0.5));
   2687         f.glow.setAlpha(0.35 + scale * 0.5);
   2688       }
   2689       for (const L of layers) {
   2690         const half = f.w * L.half * scale;
   2691         for (let i = 0; i < 2; i++) {
   2692           const off = Math.sin(t * 0.004 + i * 1.9 + L.phase + f.flicker) * f.w * 0.12 * scale;
   2693           const h = (f.h * L.h + Math.sin(t * 0.006 + i * 2.3 + L.phase + f.flicker) * f.h * 0.2) * scale;
   2694           gfx.fillStyle(L.color, 0.78 + Math.sin(t * 0.005 + i + L.phase) * 0.2);
   2695           gfx.fillTriangle(
   2696             f.cx + off - half, baseY,
   2697             f.cx + off + half, baseY,
   2698             f.cx + off, baseY - h
   2699           );
   2700         }
   2701       }
   2702     }
   2703   }
   2704 
   2705   _emitFireSmoke() {
   2706     if (!this.fires) return;
   2707     for (const f of this.fires) {
   2708       if (f.out || Math.random() > 0.6) continue;
   2709       const puff = this.add.image(f.cx + (Math.random() - 0.5) * f.w * 0.5, f.y, 'smoke-puff')
   2710         .setScale(f.w / 40).setAlpha(0.7).setDepth(21);
   2711       this.tweens.add({
   2712         targets: puff,
   2713         x: puff.x - 20 - Math.random() * 30, y: puff.y - 70 - Math.random() * 40,
   2714         scale: f.w / 18, alpha: 0,
   2715         duration: 1400, ease: 'Sine.easeOut',
   2716         onComplete: () => puff.destroy(),
   2717       });
   2718     }
   2719   }
   2720 
   2721   _emitSteam(f) {
   2722     const puff = this.add.image(this.aimX + (Math.random() - 0.5) * f.w * 0.4, f.cy, 'smoke-puff')
   2723       .setScale(f.w / 60).setAlpha(0.95).setTint(0xf2f8ff).setDepth(31);
   2724     this.tweens.add({
   2725       targets: puff,
   2726       y: puff.y - 40 - Math.random() * 25, scale: f.w / 26, alpha: 0,
   2727       duration: 700, ease: 'Sine.easeOut',
   2728       onComplete: () => puff.destroy(),
   2729     });
   2730   }
   2731 
   2732   _quenchWindow(f) {
   2733     if (f.out) return;
   2734     f.out = true;
   2735     f.progress = 1;
   2736     if (f.glow) { f.glow.destroy(); f.glow = null; }
   2737 
   2738     // Relight the window and pop in a grateful resident (one lucky window
   2739     // reveals a cat instead).
   2740     const g = this.add.graphics().setDepth(6);
   2741     g.fillStyle(0xffe9a8, 1);
   2742     g.fillRect(f.x, f.y, f.w, f.h);
   2743     const useCat = !this._catUsed && (this.rng.next() < 0.3 || this.fires.every((fi) => fi.out || fi === f));
   2744     if (useCat) {
   2745       this._catUsed = true;
   2746       const cat = this.add.image(f.cx, f.y + f.h, 'kg-cat').setOrigin(0.5, 1).setDepth(7);
   2747       cat.setScale(Math.min(f.w / 60, f.h / 48));
   2748       cat.setAlpha(0);
   2749       this.tweens.add({ targets: cat, alpha: 1, duration: 250 });
   2750     } else {
   2751       this._addWaver(f);
   2752     }
   2753 
   2754     // Feedback burst: steam, chime, popup, confetti, HUD.
   2755     for (let i = 0; i < 6; i++) this._emitSteam(f);
   2756     playJingle(this.drive.audioCtx, this.drive.sfxMuted, [[880, 0, 0.18], [1320, 0.09, 0.26]]);
   2757     this._popup(f.cx, f.y - 8, 'Fire out!');
   2758     if (window.KGames && KGames.burstConfetti) {
   2759       try { KGames.burstConfetti(this, f.cx, f.cy); } catch (_) {}
   2760     }
   2761     this._updateHud();
   2762 
   2763     if (this.fires.every((fi) => fi.out)) this._celebrate();
   2764   }
   2765 
   2766   // A cartoon head-and-shoulders resident waving from inside the window.
   2767   _addWaver(f) {
   2768     const look = KG_LOOKS[Math.floor(this.rng.next() * KG_LOOKS.length)];
   2769     const s = Math.min(f.w, f.h) / 60;
   2770     const c = this.add.container(f.cx, f.y + f.h).setDepth(7);
   2771     const body = this.add.graphics();
   2772     body.fillStyle(look.shirt, 1); body.fillRoundedRect(-16 * s, -22 * s, 32 * s, 22 * s, 8 * s);
   2773     body.fillStyle(look.hair, 1); body.fillCircle(0, -34 * s, 14 * s);
   2774     body.fillStyle(look.skin, 1); body.fillCircle(0, -31 * s, 12 * s);
   2775     body.fillStyle(look.hair, 1); body.fillEllipse(0, -40 * s, 22 * s, 9 * s);
   2776     body.fillStyle(0xffffff, 1); body.fillCircle(-4.5 * s, -31 * s, 3 * s); body.fillCircle(4.5 * s, -31 * s, 3 * s);
   2777     body.fillStyle(0x2b2b2b, 1); body.fillCircle(-4 * s, -30.4 * s, 1.5 * s); body.fillCircle(5 * s, -30.4 * s, 1.5 * s);
   2778     body.lineStyle(2 * s, 0x7a4a3a, 1);
   2779     body.beginPath(); body.arc(0, -27 * s, 5 * s, Math.PI * 0.15, Math.PI * 0.85); body.strokePath();
   2780     const hand = this.add.graphics();
   2781     hand.fillStyle(look.skin, 1); hand.fillCircle(0, 0, 4.5 * s);
   2782     hand.setPosition(19 * s, -34 * s);
   2783     c.add([body, hand]);
   2784     c.setAlpha(0);
   2785     this.tweens.add({ targets: c, alpha: 1, duration: 250 });
   2786     this.tweens.add({
   2787       targets: hand, y: -42 * s,
   2788       duration: 260, yoyo: true, repeat: -1, ease: 'Sine.easeInOut',
   2789     });
   2790   }
   2791 
   2792   _popup(x, y, text) {
   2793     const t = this.add.text(x, y, text, {
   2794       fontFamily: 'Fredoka, sans-serif',
   2795       fontSize: '26px',
   2796       color: '#ffffff',
   2797       stroke: '#2f2f2f',
   2798       strokeThickness: 7,
   2799     }).setOrigin(0.5, 1).setDepth(62);
   2800     this.tweens.add({
   2801       targets: t, y: y - 46, alpha: 0,
   2802       duration: 1100, ease: 'Sine.easeOut',
   2803       onComplete: () => t.destroy(),
   2804     });
   2805   }
   2806 
   2807   _celebrate() {
   2808     if (this.done) return;
   2809     this.done = true;
   2810     if (this.waterNoise) {
   2811       stopWaterNoise(this.drive.audioCtx, this.waterNoise);
   2812       this.waterNoise = null;
   2813     }
   2814     this.reticle.setVisible(false);
   2815     this._updateHud();
   2816 
   2817     const W = this._W, H = this._H;
   2818     const banner = this.add.text(W / 2, H * 0.34, 'You saved the building!', {
   2819       fontFamily: 'Fredoka, sans-serif',
   2820       fontSize: Math.round(W / 16) + 'px',
   2821       color: '#ffffff',
   2822       stroke: '#2f2f2f',
   2823       strokeThickness: 10,
   2824       align: 'center',
   2825     }).setOrigin(0.5).setScale(0.2).setDepth(63);
   2826     this.tweens.add({ targets: banner, scale: 1, duration: 420, ease: 'Back.easeOut' });
   2827 
   2828     for (let i = 0; i < this.crowd.length; i++) {
   2829       const p = this.crowd[i];
   2830       this.tweens.killTweensOf(p);
   2831       this.tweens.add({
   2832         targets: p, y: p.y - H * 0.045,
   2833         duration: 260 + (i % 3) * 50, yoyo: true, repeat: 5, ease: 'Sine.easeOut', delay: i * 60,
   2834       });
   2835     }
   2836     playJingle(this.drive.audioCtx, this.drive.sfxMuted,
   2837       [[523, 0, 0.15], [659, 0.12, 0.15], [784, 0.24, 0.15], [1047, 0.36, 0.45]]);
   2838     if (window.KGames && KGames.burstConfetti) {
   2839       try {
   2840         KGames.burstConfetti(this, W * 0.35, H * 0.3);
   2841         KGames.burstConfetti(this, W * 0.65, H * 0.35);
   2842       } catch (_) {}
   2843     }
   2844 
   2845     this._finishAtWall = performance.now() + 2600;
   2846   }
   2847 
   2848   _finish() {
   2849     if (this._finished) return;
   2850     this._finished = true;
   2851     const drive = this.drive;
   2852     this.scene.resume('FireTruckScene');
   2853     drive._onMinigameComplete();
   2854     this.scene.stop();
   2855   }
   2856 
   2857   _shutdown() {
   2858     if (this.fallbackTimer) { clearInterval(this.fallbackTimer); this.fallbackTimer = null; }
   2859     if (this._smokeTimer) { this._smokeTimer.remove(false); this._smokeTimer = null; }
   2860     if (this.waterNoise) {
   2861       stopWaterNoise(this.drive && this.drive.audioCtx, this.waterNoise);
   2862       this.waterNoise = null;
   2863     }
   2864     if (window.__FT_HOSE_SCENE__ === this) window.__FT_HOSE_SCENE__ = null;
   2865   }
   2866 }
   2867 
   2868 class FireTruckEndScene extends Phaser.Scene {
   2869   constructor() {
   2870     super({ key: 'FireTruckEndScene' });
   2871     this._hue = 0;
   2872     this._W = 0;
   2873     this._H = 0;
   2874   }
   2875 
   2876   preload() {
   2877     const CDN = 'https://cdn.jsdelivr.net/gh/jdecked/twemoji@15.1.0/assets/svg/';
   2878     this.load.svg('dolphin-emoji', CDN + '1f42c.svg', { width: 128, height: 128 });
   2879   }
   2880 
   2881   create() {
   2882     const W = this._W = this.scale.width;
   2883     const H = this._H = this.scale.height;
   2884 
   2885     bakePeopleTextures(this);
   2886     bakeSideTruckTexture(this);
   2887     bakeCritterTextures(this);
   2888 
   2889     // Warm tropical sky gradient
   2890     const skyGfx = this.add.graphics();
   2891     skyGfx.fillGradientStyle(0xffe3a3, 0xffd08a, 0x8fe0f2, 0x62d9e8, 1);
   2892     skyGfx.fillRect(0, 0, W, H * 0.52);
   2893 
   2894     // Pulsing sun (disc + halo rings) top-right.
   2895     this.sunGfx = this.add.graphics();
   2896     this._sunX = W * 0.82;
   2897     this._sunY = H * 0.16;
   2898 
   2899     // Drifting puffy clouds.
   2900     if (!this.textures.exists('end-cloud')) {
   2901       const g = this.make.graphics({ add: false });
   2902       g.fillStyle(0xffffff, 0.95);
   2903       g.fillEllipse(70, 40, 120, 46);
   2904       g.fillEllipse(44, 34, 60, 44);
   2905       g.fillEllipse(96, 36, 66, 40);
   2906       g.generateTexture('end-cloud', 140, 70); g.destroy();
   2907     }
   2908     this._clouds = [];
   2909     for (let i = 0; i < 4; i++) {
   2910       const cx = W * (0.1 + i * 0.24);
   2911       const cy = H * (0.08 + (i % 2) * 0.12);
   2912       const cloud = this.add.image(cx, cy, 'end-cloud').setScale(0.8 + (i % 3) * 0.4).setAlpha(0.9).setDepth(1);
   2913       this._clouds.push({ img: cloud, vx: 8 + i * 3 });
   2914     }
   2915 
   2916     // Dense tropical skyline (left 42%, varied heights + rooftop features).
   2917     const bldGfx = this.add.graphics();
   2918     const bldColors = BUILDING_PALETTE;
   2919     const numBlds = 10;
   2920     const bldZoneW = W * 0.42;
   2921     for (let i = 0; i < numBlds; i++) {
   2922       const bw = bldZoneW / numBlds;
   2923       const bh = H * (0.10 + ((i * 7 + 3) % 9) / 9 * 0.38);
   2924       const bx = i * bw;
   2925       const by = H * 0.52 - bh;
   2926       const color = bldColors[i % bldColors.length];
   2927       bldGfx.fillStyle(color, 1);
   2928       bldGfx.fillRect(bx, by, bw - 3, bh);
   2929       bldGfx.lineStyle(2, darken(color, 45), 1);
   2930       bldGfx.strokeRect(bx + 1, by + 1, bw - 5, bh - 2);
   2931       // Windows
   2932       bldGfx.fillStyle(0xffe8a0, 0.75);
   2933       for (let wy = by + 10; wy < H * 0.52 - 10; wy += 14) {
   2934         for (let wx = bx + 5; wx < bx + bw - 10; wx += 12) {
   2935           bldGfx.fillRect(wx, wy, 7, 8);
   2936         }
   2937       }
   2938       // Rooftop feature: alternating water tank / terracotta gable.
   2939       if (i % 3 === 0) {
   2940         bldGfx.fillStyle(0x9aa0a8, 1);
   2941         bldGfx.fillRect(bx + bw * 0.3, by - bh * 0.08, bw * 0.4, bh * 0.08);
   2942       } else if (i % 3 === 1) {
   2943         bldGfx.fillStyle(ROOF_TERRACOTTA, 1);
   2944         bldGfx.fillTriangle(bx, by, bx + bw - 3, by, bx + (bw - 3) / 2, by - bh * 0.14);
   2945       }
   2946     }
   2947 
   2948     // Side-view palms tucked between skyline and beach.
   2949     for (let i = 0; i < 3; i++) {
   2950       drawSidePalm(bldGfx, bldZoneW + i * W * 0.05 + W * 0.02, H * 0.52, H * 0.10);
   2951     }
   2952 
   2953     // Road
   2954     const roadGfx = this.add.graphics();
   2955     roadGfx.fillStyle(ASPHALT_COLOR, 1);
   2956     roadGfx.fillRect(0, H * 0.52, W, H * 0.10);
   2957     // Yellow dashes
   2958     roadGfx.fillStyle(STRIPE_COLOR, 1);
   2959     const dashW = W * 0.06;
   2960     const dashGap = W * 0.04;
   2961     const dashY = H * 0.52 + H * 0.048;
   2962     for (let dx = 0; dx < W; dx += dashW + dashGap) {
   2963       roadGfx.fillRect(dx, dashY, dashW, 5);
   2964     }
   2965 
   2966     // Beach
   2967     const beachGfx = this.add.graphics();
   2968     beachGfx.fillStyle(BEACH_COLOR, 1);
   2969     beachGfx.fillRect(0, H * 0.62, W, H * 0.06);
   2970 
   2971     // Ocean fill
   2972     const oceanGfx = this.add.graphics();
   2973     oceanGfx.fillGradientStyle(0x2bb5d8, 0x2bb5d8, 0x1c8fb0, 0x1c8fb0, 1);
   2974     oceanGfx.fillRect(0, H * 0.68, W, H * 0.32);
   2975 
   2976     // Beach parasol + towels (right side of the beach band).
   2977     const beachDetailGfx = this.add.graphics();
   2978     const towels = [[0x1d7cf2, W * 0.70], [0xe63946, W * 0.78], [0xffd23f, W * 0.86]];
   2979     for (const [tc, tx] of towels) {
   2980       beachDetailGfx.fillStyle(tc, 0.9);
   2981       beachDetailGfx.fillRoundedRect(tx, H * 0.635, W * 0.05, H * 0.03, 4);
   2982     }
   2983     // Parasol
   2984     const umbX = W * 0.62, umbY = H * 0.64;
   2985     beachDetailGfx.fillStyle(0x8a5a3b, 1);
   2986     beachDetailGfx.fillRect(umbX - 1, umbY, 3, H * 0.045);
   2987     beachDetailGfx.fillStyle(0xe63946, 1);
   2988     beachDetailGfx.fillTriangle(umbX - W * 0.045, umbY, umbX + W * 0.045, umbY, umbX, umbY - H * 0.05);
   2989     beachDetailGfx.fillStyle(0xffffff, 0.85);
   2990     beachDetailGfx.fillTriangle(umbX - W * 0.015, umbY, umbX + W * 0.015, umbY, umbX, umbY - H * 0.05);
   2991 
   2992     // Sailboat traversing the ocean with a bob tween.
   2993     if (!this.textures.exists('end-boat')) {
   2994       const g = this.make.graphics({ add: false });
   2995       g.fillStyle(0x5a3a24, 1); g.fillRect(28, 6, 3, 34);
   2996       g.fillStyle(0xffffff, 1); g.fillTriangle(30, 6, 30, 40, 54, 38);
   2997       g.fillStyle(0xe63946, 1); g.fillTriangle(28, 6, 28, 26, 12, 24);
   2998       g.fillStyle(0x9b1c25, 1);
   2999       g.fillPoints([{ x: 6, y: 40 }, { x: 52, y: 40 }, { x: 44, y: 54 }, { x: 14, y: 54 }], true);
   3000       g.generateTexture('end-boat', 60, 58); g.destroy();
   3001     }
   3002     this._endBoat = this.add.image(W * 0.1, H * 0.80, 'end-boat').setScale(H / 400).setDepth(2);
   3003     this.tweens.add({ targets: this._endBoat, y: H * 0.80 - 8, angle: 3, duration: 1800, ease: 'Sine.easeInOut', yoyo: true, repeat: -1 });
   3004     this.tweens.add({ targets: this._endBoat, x: W * 0.95, duration: 18000, ease: 'Linear', repeat: -1 });
   3005 
   3006     // Wave graphics (cleared/redrawn each frame)
   3007     this.waveGfx = this.add.graphics();
   3008 
   3009     // Fire truck (the shared cartoony side-view, parked on the road facing right)
   3010     const truckX = W * 0.30;
   3011     const truckY = H * 0.615;
   3012     const truckScale = (W * 0.15) / 260;
   3013     this.add.image(truckX, truckY, 'kg-truck-side')
   3014       .setOrigin(0.5, 1).setScale(truckScale).setDepth(2);
   3015 
   3016     // Water fan (cleared/redrawn each frame) sprays from the front of the truck
   3017     this.waterFanGfx = this.add.graphics();
   3018     this._truckFrontX = truckX + 130 * truckScale;
   3019     this._truckFrontY = truckY - 50 * truckScale;
   3020 
   3021     // Dolphins (3) — Twemoji SVG, jump above ocean surface
   3022     this._dolphins = [];
   3023     const dolphinSize = Math.round(H * 0.09);
   3024     for (let i = 0; i < 3; i++) {
   3025       const dx = W * (0.55 + i * 0.14);
   3026       const waterY = H * 0.70;
   3027       const peakY  = H * 0.50;
   3028       const d = this.add.image(dx, waterY, 'dolphin-emoji')
   3029         .setOrigin(0.5)
   3030         .setDisplaySize(dolphinSize, dolphinSize)
   3031         .setDepth(5);
   3032       d.setAlpha(0);
   3033       this._dolphins.push(d);
   3034       const launchDolphin = (dol) => {
   3035         dol.y = waterY;
   3036         dol.setAlpha(1).setAngle(90); // left-facing emoji: +90° = nose straight up
   3037         // Smooth 180° rotation over the full visible jump (no instant flip)
   3038         this.tweens.add({
   3039           targets: dol, angle: -90,
   3040           duration: 1200, ease: 'Sine.easeInOut',
   3041         });
   3042         // y arc: rise then fall
   3043         this.tweens.add({
   3044           targets: dol, y: peakY,
   3045           duration: 600, ease: 'Sine.easeOut',
   3046           onComplete: () => {
   3047             this.tweens.add({
   3048               targets: dol, y: waterY,
   3049               duration: 600, ease: 'Sine.easeIn',
   3050               onComplete: () => {
   3051                 dol.setAlpha(0);
   3052                 this.time.delayedCall(900 + i * 300, () => launchDolphin(dol));
   3053               }
   3054             });
   3055           }
   3056         });
   3057       };
   3058       this.time.delayedCall(i * 1100, () => launchDolphin(d));
   3059     }
   3060 
   3061     // Dancing crowd on the road — the same diverse cartoon townspeople as the
   3062     // hose minigame, bouncing and swaying, joined by a dog, cat, iguana, and a
   3063     // parrot doing laps across the sky.
   3064     const dancerScale = (H * 0.105) / 96;
   3065     for (let i = 0; i < 6; i++) {
   3066       const px = W * (0.46 + i * 0.075);
   3067       const py = H * 0.615;
   3068       const person = this.add.image(px, py, 'kg-person-' + (i % KG_LOOKS.length))
   3069         .setOrigin(0.5, 1).setScale(dancerScale).setDepth(3);
   3070       if (i % 2) person.setFlipX(true);
   3071       this.tweens.add({
   3072         targets: person, y: py - H * 0.028,
   3073         duration: 330 + i * 55, ease: 'Sine.easeInOut',
   3074         yoyo: true, repeat: -1,
   3075       });
   3076       this.tweens.add({
   3077         targets: person, angle: { from: -7, to: 7 },
   3078         duration: 400 + i * 50, ease: 'Sine.easeInOut',
   3079         yoyo: true, repeat: -1,
   3080       });
   3081     }
   3082     const endDog = this.add.image(W * 0.42, H * 0.615, 'kg-dog')
   3083       .setOrigin(0.5, 1).setScale(dancerScale * 1.2).setDepth(3);
   3084     this.tweens.add({
   3085       targets: endDog, y: H * 0.615 - H * 0.035,
   3086       duration: 300, ease: 'Sine.easeOut', yoyo: true, repeat: -1,
   3087     });
   3088     const endCat = this.add.image(W * 0.94, H * 0.61, 'kg-cat')
   3089       .setOrigin(0.5, 1).setScale(dancerScale).setDepth(3);
   3090     this.tweens.add({
   3091       targets: endCat, angle: { from: -5, to: 5 },
   3092       duration: 500, ease: 'Sine.easeInOut', yoyo: true, repeat: -1,
   3093     });
   3094     this.add.image(W * 0.48, H * 0.675, 'kg-iguana')
   3095       .setOrigin(0.5, 1).setScale(dancerScale).setDepth(3);
   3096     const endParrot = this.add.image(-50, H * 0.12, 'kg-parrot')
   3097       .setScale(dancerScale * 1.4).setDepth(3);
   3098     this.tweens.add({
   3099       targets: endParrot, x: W + 50,
   3100       duration: 12000, repeat: -1, yoyo: true,
   3101       onYoyo: () => endParrot.setFlipX(true),
   3102       onRepeat: () => endParrot.setFlipX(false),
   3103     });
   3104     this.tweens.add({
   3105       targets: endParrot, y: H * 0.16,
   3106       duration: 850, yoyo: true, repeat: -1, ease: 'Sine.easeInOut',
   3107     });
   3108 
   3109     // "You did it!" text
   3110     this.didItText = this.add.text(W / 2, H * 0.30, 'You did it!', {
   3111       fontFamily: 'Fredoka, sans-serif',
   3112       fontSize: Math.round(W / 8) + 'px',
   3113       color: '#ffffff',
   3114       stroke: '#2f2f2f',
   3115       strokeThickness: 10,
   3116       align: 'center',
   3117     }).setOrigin(0.5).setDepth(60);
   3118 
   3119     this.tweens.add({
   3120       targets: this.didItText,
   3121       scaleX: { from: 0.91, to: 1.09 },
   3122       scaleY: { from: 0.91, to: 1.09 },
   3123       angle: { from: -5, to: 5 },
   3124       duration: 650,
   3125       ease: 'Sine.easeInOut',
   3126       yoyo: true,
   3127       repeat: -1,
   3128     });
   3129 
   3130     // "Play Again" button
   3131     const btnY = H * 0.91;
   3132     const btnBg = this.add.rectangle(W / 2, btnY, 210, 56, 0xe63946)
   3133       .setStrokeStyle(4, 0x9b1c25)
   3134       .setInteractive({ useHandCursor: true })
   3135       .setDepth(61);
   3136     const btnText = this.add.text(W / 2, btnY, 'Play Again', {
   3137       fontFamily: 'Fredoka, sans-serif',
   3138       fontSize: '26px',
   3139       color: '#ffffff',
   3140     }).setOrigin(0.5).setDepth(62);
   3141 
   3142     btnBg.on('pointerover', () => btnBg.setFillStyle(0xff6b6b));
   3143     btnBg.on('pointerout', () => btnBg.setFillStyle(0xe63946));
   3144     btnBg.on('pointerdown', () => window.location.reload());
   3145 
   3146     // Celebration music — starts immediately since user has already been playing
   3147     try {
   3148       this._endGain = new Tone.Gain(0.7).toDestination();
   3149 
   3150       this._endMelodySynth = new Tone.Synth({
   3151         oscillator: { type: 'triangle' },
   3152         envelope: { attack: 0.006, decay: 0.10, sustain: 0.22, release: 0.08 },
   3153       }).connect(this._endGain);
   3154       this._endMelodySynth.volume.value = -14;
   3155 
   3156       this._endBassSynth = new Tone.Synth({
   3157         oscillator: { type: 'sawtooth' },
   3158         envelope: { attack: 0.005, decay: 0.08, sustain: 0.10, release: 0.05 },
   3159       }).connect(this._endGain);
   3160       this._endBassSynth.volume.value = -18;
   3161 
   3162       this._endChordSynth = new Tone.PolySynth(Tone.Synth, {
   3163         oscillator: { type: 'square' },
   3164         envelope: { attack: 0.005, decay: 0.06, sustain: 0.08, release: 0.03 },
   3165       }).connect(this._endGain);
   3166       this._endChordSynth.volume.value = -26;
   3167 
   3168       let step = 0;
   3169       new Tone.Sequence((time, note) => {
   3170         const i = step % FT_END_MELODY.length;
   3171         step++;
   3172         this._endMelodySynth.triggerAttackRelease(note, '8n', time);
   3173         this._endBassSynth.triggerAttackRelease(FT_END_BASS[i], '8n', time);
   3174         if (FT_END_CHORDS[i]) this._endChordSynth.triggerAttackRelease(FT_END_CHORDS[i], '8n', time);
   3175       }, FT_END_MELODY, '8n').start(0);
   3176 
   3177       Tone.Transport.bpm.value = FT_END_BPM;
   3178       Tone.Transport.start();
   3179     } catch (_) {}
   3180 
   3181     // Celebration fireworks + confetti (guarded so a shared-lib load failure
   3182     // can never throw into the console-error tests).
   3183     if (window.KGames && KGames.launchFireworks) {
   3184       try {
   3185         KGames.bakeFireworksAtlas(this);
   3186         KGames.launchFireworks(this);
   3187         KGames.burstConfetti(this, W / 2, H * 0.3);
   3188         this._fwTimer = this.time.addEvent({
   3189           delay: 4000, loop: true,
   3190           callback: () => {
   3191             if (!this.scene.isActive()) return;
   3192             KGames.launchFireworks(this);
   3193           },
   3194         });
   3195       } catch (_) {}
   3196     }
   3197 
   3198     window.__FT_END_SCENE__ = this;
   3199     this.events.on('shutdown', this._shutdown, this);
   3200     this.events.on('destroy', this._shutdown, this);
   3201   }
   3202 
   3203   _shutdown() {
   3204     if (this._fwTimer) { this._fwTimer.remove(false); this._fwTimer = null; }
   3205     try { Tone.Transport.stop(); } catch (_) {}
   3206     if (this._endGain) { try { this._endGain.dispose(); } catch (_) {} this._endGain = null; }
   3207     if (this._endMelodySynth) { try { this._endMelodySynth.dispose(); } catch (_) {} this._endMelodySynth = null; }
   3208     if (this._endBassSynth) { try { this._endBassSynth.dispose(); } catch (_) {} this._endBassSynth = null; }
   3209     if (this._endChordSynth) { try { this._endChordSynth.dispose(); } catch (_) {} this._endChordSynth = null; }
   3210     window.__FT_END_SCENE__ = null;
   3211   }
   3212 
   3213   update(time, delta) {
   3214     const dt = delta / 1000;
   3215     const W = this._W;
   3216     const H = this._H;
   3217 
   3218     // Color-cycle "You did it!" text
   3219     this._hue = (this._hue + dt * 0.18) % 1;
   3220     const c = Phaser.Display.Color.HSLToColor(this._hue, 0.85, 0.62);
   3221     this.didItText.setColor(Phaser.Display.Color.RGBToString(c.r, c.g, c.b));
   3222 
   3223     // Pulsing sun + halo rings.
   3224     if (this.sunGfx) {
   3225       const pulse = 1 + Math.sin(time * 0.002) * 0.06;
   3226       this.sunGfx.clear();
   3227       for (let r = 4; r >= 1; r--) {
   3228         this.sunGfx.fillStyle(0xffe08a, 0.08 * r);
   3229         this.sunGfx.fillCircle(this._sunX, this._sunY, W * 0.05 * r * 0.5 * pulse);
   3230       }
   3231       this.sunGfx.fillStyle(0xfff2b0, 1);
   3232       this.sunGfx.fillCircle(this._sunX, this._sunY, W * 0.045 * pulse);
   3233     }
   3234 
   3235     // Drifting clouds (wrap across the sky).
   3236     if (this._clouds) {
   3237       for (const cl of this._clouds) {
   3238         cl.img.x += cl.vx * dt;
   3239         if (cl.img.x - cl.img.displayWidth > W) cl.img.x = -cl.img.displayWidth;
   3240       }
   3241     }
   3242 
   3243     // Ocean waves
   3244     this.waveGfx.clear();
   3245     for (let w = 0; w < 3; w++) {
   3246       const waveY = H * 0.70 + w * H * 0.055;
   3247       this.waveGfx.lineStyle(3, 0x5bc8d0, 0.45 - w * 0.1);
   3248       this.waveGfx.beginPath();
   3249       let first = true;
   3250       for (let x = 0; x <= W; x += 4) {
   3251         const y = waveY + Math.sin((x / W) * Math.PI * 6 + time * 0.002 + w * 1.2) * H * 0.012;
   3252         if (first) { this.waveGfx.moveTo(x, y); first = false; } else { this.waveGfx.lineTo(x, y); }
   3253       }
   3254       this.waveGfx.strokePath();
   3255     }
   3256 
   3257     // Celebratory water fan arcing up from the truck front
   3258     this.waterFanGfx.clear();
   3259     this.waterFanGfx.lineStyle(3, 0x88ddff, 0.7);
   3260     const tfx = this._truckFrontX;
   3261     const tfy = this._truckFrontY;
   3262     for (let r = 0; r < 7; r++) {
   3263       const angle = -1.05 + r * 0.09 + Math.sin(time * 0.004 + r) * 0.04;
   3264       const len = W * 0.09 + Math.sin(time * 0.003 + r * 0.7) * W * 0.01;
   3265       this.waterFanGfx.lineBetween(tfx, tfy, tfx + Math.cos(angle) * len, tfy + Math.sin(angle) * len);
   3266     }
   3267   }
   3268 }
   3269 
   3270 // preserveDrawingBuffer is required so Playwright tests can readPixels the
   3271 // canvas, but it disables compositor fast paths on some drivers and tanks
   3272 // framerate. Keep it on only for tests.
   3273 const IS_TEST = !!window.__TEST_MODE__;
   3274 
   3275 window.__KG_GAME__ = new Phaser.Game({
   3276   type: Phaser.AUTO,
   3277   parent: 'game-container',
   3278   backgroundColor: '#2BB5D8',
   3279   scale: {
   3280     mode: Phaser.Scale.RESIZE,
   3281     width: window.innerWidth,
   3282     height: window.innerHeight,
   3283   },
   3284   render: {
   3285     preserveDrawingBuffer: IS_TEST,
   3286     antialias: false,
   3287     pixelArt: false,
   3288   },
   3289   scene: [FireTruckScene, FireHoseScene, FireTruckEndScene],
   3290 });