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