kgames

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

game.js (139071B)


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