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