kgames

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

commit 3ff6072bb4c3a95f57597d9c4cb800d110c24c6e
parent 0c3443e82a212334ed421ae55c6fb03db24d2bbb
Author: Kyle Barlow <kb@kylebarlow.com>
Date:   Mon, 13 Jul 2026 19:00:56 -0700

Graphics improvements for fire truck

Diffstat:
MCLAUDE.md | 65+++++++++++++++++++++++++++++++++++++++++++++--------------------
Mgames/fire-truck/game.js | 1064+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Mgames/fire-truck/index.html | 1+
Mgames/fire-truck/lib.js | 125+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mtests/browser/runner.js | 16++++++++++++++++
Atests/unit/fire-truck-decor.test.js | 117+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
6 files changed, 1239 insertions(+), 149 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md @@ -36,26 +36,51 @@ rendering that syntax checks miss (see the projection-bug incident). | `tests/browser/runner.js` | Playwright smoke tests — no JS errors + canvas renders | | `tests/serve.js` | Tiny Node HTTP server used by browser tests (and for local dev) | -## Pseudo-3D rendering (fire-truck) - -The road uses a Y-based scanline loop (`_drawRoad` in game.js), not a -Z-based segment loop. For each screen row `sy` from bottom to horizon: - -``` -scale = (sy - horizonY) / (CAM_Y_WORLD × H/2) -dz = CAM_DEPTH / scale // world units to this row -worldZ = camZ + dz -``` - -Pure projection lives in `lib.js` (`project()`): -``` -sx = W/2 + scale × worldX × W/2 + turnShift -sy = horizonY + scale × (CAM_Y_WORLD − worldY) × H/2 -``` - -**Ground-level objects (`worldY = 0`) project BELOW `horizonY`** — this is -intentional and correct. The `sy = horizonY − worldY × scale` formula from -the first implementation was wrong (collapsed everything to the horizon). +## Top-down rendering (fire-truck) + +Fire-truck is a **top-down** driving game: the camera zooms 1/`SCALE` (SCALE=5) +and follows the truck over a 50×50-cell island. `lib.js` `buildIsland()` lays +out water/beach ring roads + BSP-subdivided interior blocks; the road graph +drives the route and fire pathing. + +**Chunk bake pipeline** (`renderIsland()` in game.js). The static island is +baked once into 16×16-cell chunk textures at `BAKE_CELL = 96` px/cell, drawn +×SCALE (zoom 1/SCALE) → **~1:1 on screen**, so ~96 px of detail per cell costs +nothing per frame and Phaser culls offscreen chunks for free. Each chunk runs +named passes into one `Graphics`, in order: `_bakeGround` → `_bakeSidewalks` +(incl. road corner masks) → `_bakeRoadMarkings` → `_bakeBuildings` → `_bakeParks` +→ `_bakePalms`. + +- **1-cell apron rule** (highest visual-bug risk): every pass iterates a 1-cell + margin around the chunk (clamped) at true local coords so overhanging art — + SE drop shadows, palm fronds, block-spanning buildings — that crosses a chunk + seam is re-drawn by the neighbour; `generateTexture` clips the overflow. +- Buildings are drawn **per block, not per cell**: `FT.findBuildingBlocks(grid)` + covers all building cells with disjoint rectangles (BSP guarantees rectangular + road-bounded blocks) so each block reads as one building with one roof. + `FT.assignParks()` tags ~15% of blocks `cell.park = true` (**type stays + `building`**, so grid/route tests are untouched; parks are excluded as fire + destinations). + +**Ocean underlay**: a screen-fixed (`scrollFactor 0`) `TileSprite` at depth −1 +drifts each frame. A world-sized TileSprite would allocate a multi-GB fill +canvas — keep it viewport-sized. + +**Dual cameras** (game.js): `cameras.main` is the world; `uiCamera` is the HUD. +Every new **world** object must be hidden from the HUD camera — +`this._addWorld(obj, depth)` sets depth + `uiCamera.ignore`. HUD objects are +added to `cameras.main.ignore(...)`. + +**Depth map**: −1 ocean · 0 chunks · 6 boats/buoys · 8 peds · 9 cars · 9.5 +burning overlay · 9.7 fire glow · 10 fire · 11 spray · 12 smoke · 34 cloud +shadows · 35 truck · 38 birds · 40+ HUD. + +**Ambient life** (`_initAmbient` / `_updateAmbient`, driven from `update()` — +never `step()`, which tests call directly): baked-texture Images for traffic +(`FT.advanceCarPlan` graph walk), pedestrians, cloud shadows, boats, birds. +Guard with `this.ambientReady`; clean up in `shutdown()`. All decor variety is +deterministic via `FT.hashCell(x, y, salt)` so the island bakes identically +every run. ## Mobile / touch controls diff --git a/games/fire-truck/game.js b/games/fire-truck/game.js @@ -2,6 +2,7 @@ const FT = window.FireTruckLib; const SCALE = 5; const CELL_SIZE = 96 * SCALE; +const BAKE_CELL = 96; // px per cell in the baked chunk textures (CELL_SIZE / SCALE) const BASE_SPEED = 425; const MAX_SPEED = 550; const ACCEL = 650; @@ -11,14 +12,27 @@ const MIN_PROMPT_DIST = CELL_SIZE * 0.72; const STOP_LINE_DIST = Math.round(CELL_SIZE * 0.45); const ROUTE_EXTENSION_COUNT = 36; const FIRE_TRIGGER_DIST = CELL_SIZE * 0.6; +const CAR_SPEED = 300; // world u/s for ambient traffic +const PED_SPEED = 90; // world u/s for pedestrians const FIRE_EXTINGUISH_S = 3.0; const FIRES_TO_WIN = 4; -const WATER_COLOR = 0x6bc4e8; -const BEACH_COLOR = 0xf2e2b6; -const ASPHALT_COLOR = 0x2a2d34; +// Tropical-island palette. Water stays saturated; asphalt stays dark for +// stripe/crosswalk contrast. Building bodies are bright candy colors. +const WATER_COLOR = 0x2bb5d8; +const WATER_SHALLOW = 0x62d9e8; +const WATER_FLECK = 0xc9f2f7; +const BEACH_COLOR = 0xf9e3a9; +const BEACH_WET = 0xeccd8a; +const ASPHALT_COLOR = 0x30333c; +const SIDEWALK_COLOR = 0xded5c3; +const CROSSWALK_COLOR = 0xf4f2ec; const STRIPE_COLOR = 0xffd23f; -const BUILDING_PALETTE = [0xf8d4a5, 0xb8dfe1, 0xf7b4b4, 0xffefb0, 0xcad6f9, 0xdcc7eb]; +const BUILDING_PALETTE = [0xff8a70, 0xffb347, 0x53d3c4, 0xffd76e, 0xf68fb8, 0xa8d96c]; +const ROOF_TERRACOTTA = 0xd97b52; +const PARK_GRASS = 0x7ecb62; +const PALM_TRUNK = 0x9a6b43; +const PALM_FROND = 0x46b063; const FT_BPM = 158; const FT_MELODY = [ @@ -115,7 +129,12 @@ class FireTruckScene extends Phaser.Scene { this.route = this.island.route; this.cellSize = CELL_SIZE; - this.cameras.main.setBackgroundColor('#6BC4E8'); + // Decoration precompute: building blocks (one rect per road-bounded block) + // and park tagging. Must run before renderIsland + fire destination pick. + this.blocks = FT.findBuildingBlocks(this.island.grid); + FT.assignParks(this.island, { fraction: 0.15 }); + + this.cameras.main.setBackgroundColor('#2BB5D8'); this.cameras.main.setZoom(1 / SCALE); this.cameras.main.setBounds( this.island.bounds.minX * CELL_SIZE, @@ -172,6 +191,7 @@ class FireTruckScene extends Phaser.Scene { this.uiCamera = this.cameras.add(0, 0, this.scale.width, this.scale.height); + this._addOcean(); this.renderIsland(); this.createTruck(); @@ -194,6 +214,8 @@ class FireTruckScene extends Phaser.Scene { this.cameras.main.startFollow(this.truck, false, 1, 1); + this._initAmbient(); + this.lastStepTime = performance.now(); this.fallbackTimer = setInterval(() => { const now = performance.now(); @@ -218,10 +240,14 @@ class FireTruckScene extends Phaser.Scene { } renderIsland() { - // Bake the static island into tiled textures so per-frame cost drops to a - // handful of textured quads — and Phaser's camera culling skips offscreen - // chunks for free. - const BAKE_CELL = CELL_SIZE / SCALE; + // Bake the static island into 16-cell chunk textures at BAKE_CELL px/cell, + // drawn ×SCALE (camera zoom 1/SCALE) → ~1:1 on screen. Per-frame cost drops + // to a handful of textured quads and Phaser culls offscreen chunks for free. + // + // Chunk-seam rule: every pass iterates a 1-cell apron around the chunk + // (clamped to the grid) so overhanging art — SE shadows, palm fronds, + // block-spanning buildings — that crosses a chunk border is re-drawn by the + // neighbouring chunk. generateTexture clips whatever falls outside the tile. const CHUNK_CELLS = 16; const CHUNK_PX = CHUNK_CELLS * BAKE_CELL; const chunkCountX = Math.ceil(this.island.width / CHUNK_CELLS); @@ -231,96 +257,30 @@ class FireTruckScene extends Phaser.Scene { for (let chunkY = 0; chunkY < chunkCountY; chunkY++) { for (let chunkX = 0; chunkX < chunkCountX; chunkX++) { - const cellStartX = chunkX * CHUNK_CELLS; - const cellStartY = chunkY * CHUNK_CELLS; - const cellEndX = Math.min(this.island.width, cellStartX + CHUNK_CELLS); - const cellEndY = Math.min(this.island.height, cellStartY + CHUNK_CELLS); + const ox = chunkX * CHUNK_CELLS; + const oy = chunkY * CHUNK_CELLS; + const cellEndX = Math.min(this.island.width, ox + CHUNK_CELLS); + const cellEndY = Math.min(this.island.height, oy + CHUNK_CELLS); + const ax0 = Math.max(0, ox - 1); + const ay0 = Math.max(0, oy - 1); + const ax1 = Math.min(this.island.width, cellEndX + 1); + const ay1 = Math.min(this.island.height, cellEndY + 1); const gfx = this.make.graphics({ add: false }); - - for (let y = cellStartY; y < cellEndY; y++) { - for (let x = cellStartX; x < cellEndX; x++) { - const cell = this.island.grid[y][x]; - const localX = (x - cellStartX) * BAKE_CELL; - const localY = (y - cellStartY) * BAKE_CELL; - - // 1. Non-road cells (water, beach, building) over full footprint - if (cell.type !== FT.CELL_TYPES.ROAD) { - gfx.fillStyle(colorForCell(cell), 1); - gfx.fillRect(localX, localY, BAKE_CELL, BAKE_CELL); - if (cell.type === FT.CELL_TYPES.BUILDING) { - gfx.lineStyle(2, 0x000000, 0.08); - gfx.strokeRect(localX + 1, localY + 1, BAKE_CELL - 2, BAKE_CELL - 2); - } - continue; - } - - // 2. Road base - gfx.fillStyle(ASPHALT_COLOR, 1); - gfx.fillRect(localX, localY, BAKE_CELL, BAKE_CELL); - } - } - - // 3. Road corner masks - const maskSize = Math.round(BAKE_CELL * 0.22); - for (let y = cellStartY; y < cellEndY; y++) { - for (let x = cellStartX; x < cellEndX; x++) { - const cell = this.island.grid[y][x]; - if (cell.type !== FT.CELL_TYPES.ROAD) continue; - const localX = (x - cellStartX) * BAKE_CELL; - const localY = (y - cellStartY) * BAKE_CELL; - - if (!cell.exits.N && !cell.exits.W) { - const dc = this.island.grid[y - 1] && this.island.grid[y - 1][x - 1]; - gfx.fillStyle(colorForCell(dc), 1); - gfx.fillRect(localX, localY, maskSize, maskSize); - } - if (!cell.exits.N && !cell.exits.E) { - const dc = this.island.grid[y - 1] && this.island.grid[y - 1][x + 1]; - gfx.fillStyle(colorForCell(dc), 1); - gfx.fillRect(localX + BAKE_CELL - maskSize, localY, maskSize, maskSize); - } - if (!cell.exits.S && !cell.exits.E) { - const dc = this.island.grid[y + 1] && this.island.grid[y + 1][x + 1]; - gfx.fillStyle(colorForCell(dc), 1); - gfx.fillRect(localX + BAKE_CELL - maskSize, localY + BAKE_CELL - maskSize, maskSize, maskSize); - } - if (!cell.exits.S && !cell.exits.W) { - const dc = this.island.grid[y + 1] && this.island.grid[y + 1][x - 1]; - gfx.fillStyle(colorForCell(dc), 1); - gfx.fillRect(localX, localY + BAKE_CELL - maskSize, maskSize, maskSize); - } - } - } - - // 4. Lane stripes on straight roads only - for (let y = cellStartY; y < cellEndY; y++) { - for (let x = cellStartX; x < cellEndX; x++) { - const cell = this.island.grid[y][x]; - if (cell.type !== FT.CELL_TYPES.ROAD) continue; - const localX = (x - cellStartX) * BAKE_CELL; - const localY = (y - cellStartY) * BAKE_CELL; - - const meta = cell.meta || FT.classifyIntersection(cell.exits); - if (meta.kind === 'straight') { - gfx.fillStyle(STRIPE_COLOR, 0.92); - if (cell.exits.N && cell.exits.S) { - gfx.fillRect(localX + BAKE_CELL / 2 - 3, localY + BAKE_CELL * 0.1, 6, BAKE_CELL * 0.8); - } - if (cell.exits.E && cell.exits.W) { - gfx.fillRect(localX + BAKE_CELL * 0.1, localY + BAKE_CELL / 2 - 3, BAKE_CELL * 0.8, 6); - } - } - } - } + this._bakeGround(gfx, ax0, ay0, ax1, ay1, ox, oy); + this._bakeSidewalks(gfx, ax0, ay0, ax1, ay1, ox, oy); + this._bakeRoadMarkings(gfx, ax0, ay0, ax1, ay1, ox, oy); + this._bakeBuildings(gfx, ax0, ay0, ax1, ay1, ox, oy); + this._bakeParks(gfx, ax0, ay0, ax1, ay1, ox, oy); + this._bakePalms(gfx, ax0, ay0, ax1, ay1, ox, oy); const key = `island-bake-${chunkX}_${chunkY}`; if (this.textures.exists(key)) this.textures.remove(key); gfx.generateTexture(key, CHUNK_PX, CHUNK_PX); gfx.destroy(); - const imageWorldX = (cellStartX - 0.5) * CELL_SIZE; - const imageWorldY = (cellStartY - 0.5) * CELL_SIZE; + const imageWorldX = (ox - 0.5) * CELL_SIZE; + const imageWorldY = (oy - 0.5) * CELL_SIZE; const img = this.add.image(imageWorldX, imageWorldY, key) .setOrigin(0, 0) .setScale(SCALE) @@ -330,14 +290,605 @@ class FireTruckScene extends Phaser.Scene { } } + // Fill a colored strip along each edge of a cell that faces a `neighborType` + // neighbour (shallow water beside beach, wet sand beside water, …). + _edgeBands(gfx, x, y, gx, gy, neighborType, color, thick) { + const grid = this.island.grid; + const nb = (dx, dy) => { + const c = grid[y + dy] && grid[y + dy][x + dx]; + return c && c.type === neighborType; + }; + gfx.fillStyle(color, 1); + if (nb(0, -1)) gfx.fillRect(gx, gy, BAKE_CELL, thick); + if (nb(0, 1)) gfx.fillRect(gx, gy + BAKE_CELL - thick, BAKE_CELL, thick); + if (nb(-1, 0)) gfx.fillRect(gx, gy, thick, BAKE_CELL); + if (nb(1, 0)) gfx.fillRect(gx + BAKE_CELL - thick, gy, thick, BAKE_CELL); + } + + _bakeGround(gfx, ax0, ay0, ax1, ay1, ox, oy) { + const grid = this.island.grid; + const T = FT.CELL_TYPES; + for (let y = ay0; y < ay1; y++) { + for (let x = ax0; x < ax1; x++) { + const cell = grid[y][x]; + const gx = (x - ox) * BAKE_CELL; + const gy = (y - oy) * BAKE_CELL; + if (cell.type === T.WATER) { + gfx.fillStyle(WATER_COLOR, 1); + gfx.fillRect(gx, gy, BAKE_CELL, BAKE_CELL); + this._edgeBands(gfx, x, y, gx, gy, T.BEACH, WATER_SHALLOW, 22); + if (FT.hashCell(x, y, 11) < 0.5) { + gfx.fillStyle(WATER_FLECK, 0.7); + const fx = gx + FT.hashCell(x, y, 12) * (BAKE_CELL - 24) + 6; + const fy = gy + FT.hashCell(x, y, 13) * (BAKE_CELL - 16) + 6; + gfx.fillRect(fx, fy, 14, 4); + } + } else if (cell.type === T.BEACH) { + gfx.fillStyle(BEACH_COLOR, 1); + gfx.fillRect(gx, gy, BAKE_CELL, BAKE_CELL); + this._edgeBands(gfx, x, y, gx, gy, T.WATER, BEACH_WET, 20); + if (FT.hashCell(x, y, 14) < 0.35) { + gfx.fillStyle(0xffffff, 0.55); + const sx = gx + FT.hashCell(x, y, 15) * (BAKE_CELL - 20) + 10; + const sy = gy + FT.hashCell(x, y, 16) * (BAKE_CELL - 20) + 10; + gfx.fillCircle(sx, sy, 3); + } + } else if (cell.type === T.ROAD) { + gfx.fillStyle(ASPHALT_COLOR, 1); + gfx.fillRect(gx, gy, BAKE_CELL, BAKE_CELL); + } else { + // building / park footprint → pavement base (body drawn on top later) + gfx.fillStyle(SIDEWALK_COLOR, 1); + gfx.fillRect(gx, gy, BAKE_CELL, BAKE_CELL); + } + } + } + } + + _bakeSidewalks(gfx, ax0, ay0, ax1, ay1, ox, oy) { + const grid = this.island.grid; + const maskSize = Math.round(BAKE_CELL * 0.22); + const band = 15; + const curb = darken(SIDEWALK_COLOR, 40); + for (let y = ay0; y < ay1; y++) { + for (let x = ax0; x < ax1; x++) { + const cell = grid[y][x]; + if (cell.type !== FT.CELL_TYPES.ROAD) continue; + const gx = (x - ox) * BAKE_CELL; + const gy = (y - oy) * BAKE_CELL; + + // Round the road corners: paint the outer corner where two adjacent + // exits are missing with the diagonal neighbour's ground colour. + const fillCorner = (cx, cy, dgx, dgy) => { + const dc = grid[y + dgy] && grid[y + dgy][x + dgx]; + gfx.fillStyle(groundColorForCell(dc), 1); + gfx.fillRect(cx, cy, maskSize, maskSize); + }; + if (!cell.exits.N && !cell.exits.W) fillCorner(gx, gy, -1, -1); + if (!cell.exits.N && !cell.exits.E) fillCorner(gx + BAKE_CELL - maskSize, gy, 1, -1); + if (!cell.exits.S && !cell.exits.E) fillCorner(gx + BAKE_CELL - maskSize, gy + BAKE_CELL - maskSize, 1, 1); + if (!cell.exits.S && !cell.exits.W) fillCorner(gx, gy + BAKE_CELL - maskSize, -1, 1); + + // Sidewalk band on each edge that abuts a building/park. + const edges = FT.sidewalkEdges(grid, x, y); + gfx.fillStyle(SIDEWALK_COLOR, 1); + if (edges.N) gfx.fillRect(gx, gy, BAKE_CELL, band); + if (edges.S) gfx.fillRect(gx, gy + BAKE_CELL - band, BAKE_CELL, band); + if (edges.W) gfx.fillRect(gx, gy, band, BAKE_CELL); + if (edges.E) gfx.fillRect(gx + BAKE_CELL - band, gy, band, BAKE_CELL); + gfx.fillStyle(curb, 1); + if (edges.N) gfx.fillRect(gx, gy + band, BAKE_CELL, 2); + if (edges.S) gfx.fillRect(gx, gy + BAKE_CELL - band - 2, BAKE_CELL, 2); + if (edges.W) gfx.fillRect(gx + band, gy, 2, BAKE_CELL); + if (edges.E) gfx.fillRect(gx + BAKE_CELL - band - 2, gy, 2, BAKE_CELL); + } + } + } + + _bakeRoadMarkings(gfx, ax0, ay0, ax1, ay1, ox, oy) { + const grid = this.island.grid; + for (let y = ay0; y < ay1; y++) { + for (let x = ax0; x < ax1; x++) { + const cell = grid[y][x]; + if (cell.type !== FT.CELL_TYPES.ROAD) continue; + const gx = (x - ox) * BAKE_CELL; + const gy = (y - oy) * BAKE_CELL; + const meta = cell.meta || FT.classifyIntersection(cell.exits); + if (meta.kind === 'straight') { + gfx.fillStyle(STRIPE_COLOR, 0.9); + if (cell.exits.N && cell.exits.S) { + for (let dy = 8; dy < BAKE_CELL - 8; dy += 24) gfx.fillRect(gx + BAKE_CELL / 2 - 3, gy + dy, 6, 14); + } else { + for (let dx = 8; dx < BAKE_CELL - 8; dx += 24) gfx.fillRect(gx + dx, gy + BAKE_CELL / 2 - 3, 14, 6); + } + } else if (meta.kind === 't' || meta.kind === 'four') { + // Zebra crosswalks near each approach edge. + gfx.fillStyle(CROSSWALK_COLOR, 0.95); + const cw = 10, gap = 8, inset = 6; + if (cell.exits.N) for (let i = 0; i < 4; i++) gfx.fillRect(gx + inset + i * (cw + gap), gy + 4, cw, 16); + if (cell.exits.S) for (let i = 0; i < 4; i++) gfx.fillRect(gx + inset + i * (cw + gap), gy + BAKE_CELL - 20, cw, 16); + if (cell.exits.W) for (let i = 0; i < 4; i++) gfx.fillRect(gx + 4, gy + inset + i * (cw + gap), 16, cw); + if (cell.exits.E) for (let i = 0; i < 4; i++) gfx.fillRect(gx + BAKE_CELL - 20, gy + inset + i * (cw + gap), 16, cw); + } + } + } + } + + _bakeBuildings(gfx, ax0, ay0, ax1, ay1, ox, oy) { + const grid = this.island.grid; + for (const block of this.blocks) { + if (grid[block.y][block.x].park) continue; // parks handled separately + if (block.x >= ax1 || block.x + block.w <= ax0 || block.y >= ay1 || block.y + block.h <= ay0) continue; + this._drawBuilding(gfx, block, ox, oy); + } + } + + _drawBuilding(gfx, block, ox, oy) { + const margin = 14; // pavement gap around the block + const bx = (block.x - ox) * BAKE_CELL + margin; + const by = (block.y - oy) * BAKE_CELL + margin; + const bw = block.w * BAKE_CELL - margin * 2; + const bh = block.h * BAKE_CELL - margin * 2; + if (bw <= 10 || bh <= 10) return; + const big = block.w >= 2 && block.h >= 2; + const color = BUILDING_PALETTE[Math.floor(FT.hashCell(block.x, block.y, 0) * BUILDING_PALETTE.length) % BUILDING_PALETTE.length]; + + // SE drop shadow, roof body, parapet outline. + gfx.fillStyle(0x000000, 0.15); + gfx.fillRoundedRect(bx + 7, by + 7, bw, bh, 10); + gfx.fillStyle(color, 1); + gfx.fillRoundedRect(bx, by, bw, bh, 10); + gfx.lineStyle(3, darken(color, 45), 1); + gfx.strokeRoundedRect(bx, by, bw, bh, 10); + + // Rooftop skylight / window grid. + const winLit = 0xffe9a8; + const winDark = darken(color, 25); + for (let wy = by + 16; wy < by + bh - 14; wy += 26) { + for (let wx = bx + 14; wx < bx + bw - 12; wx += 24) { + const lit = FT.hashCell(Math.round(wx), Math.round(wy), 5) < 0.32; + gfx.fillStyle(lit ? winLit : winDark, 0.95); + gfx.fillRect(wx, wy, 12, 14); + } + } + + // One rooftop feature keyed by hash. + const roofStyle = Math.floor(FT.hashCell(block.x, block.y, 3) * 5); + if (roofStyle === 0) { + gfx.fillStyle(0xb8bcc4, 1); + gfx.fillRoundedRect(bx + bw * 0.20, by + bh * 0.20, 22, 18, 4); + gfx.fillRoundedRect(bx + bw * 0.55, by + bh * 0.52, 20, 16, 4); + } else if (roofStyle === 1) { + const r = Math.min(bw, bh) * 0.16; + gfx.fillStyle(0x9aa0a8, 1); gfx.fillCircle(bx + bw / 2, by + bh / 2, r); + gfx.fillStyle(0x7d828a, 1); gfx.fillCircle(bx + bw / 2, by + bh / 2, r * 0.55); + } else if (roofStyle === 2) { + gfx.fillStyle(PARK_GRASS, 1); gfx.fillRoundedRect(bx + 10, by + 10, bw - 20, bh - 20, 8); + gfx.fillStyle(darken(PARK_GRASS, 30), 1); + gfx.fillCircle(bx + bw * 0.35, by + bh * 0.4, 9); + gfx.fillCircle(bx + bw * 0.62, by + bh * 0.6, 9); + } else if (roofStyle === 3 && big) { + gfx.fillStyle(0x3fc9e0, 1); gfx.fillRoundedRect(bx + bw * 0.25, by + bh * 0.3, bw * 0.5, bh * 0.35, 8); + gfx.fillStyle(0x8fe3f0, 0.6); gfx.fillRoundedRect(bx + bw * 0.28, by + bh * 0.33, bw * 0.44, bh * 0.12, 6); + } else { + gfx.fillStyle(darken(color, 35), 1); + gfx.fillCircle(bx + bw * 0.3, by + bh * 0.3, 5); + gfx.fillCircle(bx + bw * 0.7, by + bh * 0.7, 5); + } + } + + _bakeParks(gfx, ax0, ay0, ax1, ay1, ox, oy) { + const grid = this.island.grid; + for (const block of this.blocks) { + if (!grid[block.y][block.x].park) continue; + if (block.x >= ax1 || block.x + block.w <= ax0 || block.y >= ay1 || block.y + block.h <= ay0) continue; + this._drawPark(gfx, block, ox, oy); + } + } + + _drawPark(gfx, block, ox, oy) { + const margin = 8; + const bx = (block.x - ox) * BAKE_CELL + margin; + const by = (block.y - oy) * BAKE_CELL + margin; + const bw = block.w * BAKE_CELL - margin * 2; + const bh = block.h * BAKE_CELL - margin * 2; + if (bw <= 10 || bh <= 10) return; + gfx.fillStyle(0x000000, 0.12); gfx.fillRoundedRect(bx + 6, by + 6, bw, bh, 10); + gfx.fillStyle(PARK_GRASS, 1); gfx.fillRoundedRect(bx, by, bw, bh, 10); + gfx.lineStyle(2, darken(PARK_GRASS, 40), 1); gfx.strokeRoundedRect(bx, by, bw, bh, 10); + // Path cross. + gfx.fillStyle(0xe6dcc0, 0.9); + gfx.fillRect(bx + bw / 2 - 8, by, 16, bh); + gfx.fillRect(bx, by + bh / 2 - 8, bw, 16); + // Fountain. + gfx.fillStyle(0x9aa0a8, 1); gfx.fillCircle(bx + bw / 2, by + bh / 2, 16); + gfx.fillStyle(WATER_SHALLOW, 1); gfx.fillCircle(bx + bw / 2, by + bh / 2, 11); + // Round-canopy trees. + const nTrees = 3 + Math.floor(FT.hashCell(block.x, block.y, 8) * 4); + for (let i = 0; i < nTrees; i++) { + const hx = FT.hashCell(block.x * 7 + i, block.y * 3 + 1, 20); + const hy = FT.hashCell(block.x * 3 + 1, block.y * 7 + i, 21); + const tx = bx + 18 + hx * (bw - 36); + const ty = by + 18 + hy * (bh - 36); + gfx.fillStyle(0x000000, 0.12); gfx.fillEllipse(tx + 3, ty + 4, 26, 16); + gfx.fillStyle(darken(PARK_GRASS, 22), 1); gfx.fillCircle(tx, ty, 13); + gfx.fillStyle(lighten(PARK_GRASS, 25), 0.85); gfx.fillCircle(tx - 3, ty - 3, 6); + } + } + + _bakePalms(gfx, ax0, ay0, ax1, ay1, ox, oy) { + const grid = this.island.grid; + const T = FT.CELL_TYPES; + for (let y = ay0; y < ay1; y++) { + for (let x = ax0; x < ax1; x++) { + const cell = grid[y][x]; + let place = false; + if (cell.type === T.BEACH && FT.hashCell(x, y, 30) < 0.5) { + place = true; + } else if (cell.type === T.ROAD) { + const e = FT.sidewalkEdges(grid, x, y); + if ((e.N || e.S || e.E || e.W) && FT.hashCell(x, y, 31) < 0.12) place = true; + } + if (!place) continue; + const gx = (x - ox) * BAKE_CELL; + const gy = (y - oy) * BAKE_CELL; + const jx = gx + BAKE_CELL / 2 + (FT.hashCell(x, y, 32) - 0.5) * BAKE_CELL * 0.4; + const jy = gy + BAKE_CELL / 2 + (FT.hashCell(x, y, 33) - 0.5) * BAKE_CELL * 0.4; + this._drawPalm(gfx, jx, jy); + } + } + } + + _drawPalm(gfx, cx, cy) { + gfx.fillStyle(0x000000, 0.15); gfx.fillEllipse(cx + 6, cy + 12, 42, 16); + gfx.fillStyle(PALM_TRUNK, 1); gfx.fillRect(cx - 4, cy - 6, 8, 26); + const n = 7; + const top = cy - 8; + for (let i = 0; i < n; i++) { + const a = (i / n) * Math.PI * 2; + gfx.fillStyle(i % 2 ? PALM_FROND : lighten(PALM_FROND, 25), 1); + gfx.fillTriangle( + cx, top, + cx + Math.cos(a - 0.2) * 30, top + Math.sin(a - 0.2) * 22, + cx + Math.cos(a) * 34, top + Math.sin(a) * 24 + ); + } + gfx.fillStyle(darken(PALM_TRUNK, 20), 1); gfx.fillCircle(cx, top, 5); + } + + _addOcean() { + const key = 'water-tile'; + if (!this.textures.exists(key)) { + const g = this.make.graphics({ add: false }); + g.fillStyle(WATER_COLOR, 1); g.fillRect(0, 0, 256, 256); + g.fillStyle(WATER_SHALLOW, 0.4); + for (let i = 0; i < 6; i++) g.fillRect(0, (i * 43) % 256, 256, 6); + g.fillStyle(WATER_FLECK, 0.4); + for (let i = 0; i < 20; i++) g.fillRect((i * 61) % 256, (i * 97) % 256, 12, 3); + g.generateTexture(key, 256, 256); g.destroy(); + } + // Screen-fixed animated water backdrop (scrollFactor 0). A world-sized + // TileSprite would allocate a multi-GB fill canvas, so we keep it viewport + // sized; the opaque island chunks cover the centre and the sea shows around + // the edges, drifting each frame. + this.ocean = this.add.tileSprite(0, 0, this.scale.width, this.scale.height, key) + .setOrigin(0, 0) + .setScrollFactor(0) + .setDepth(-1); + this.uiCamera.ignore(this.ocean); + + // A couple of static buoys bobbing in the offshore ring. + if (!this.textures.exists('buoy')) { + const g = this.make.graphics({ add: false }); + g.fillStyle(0x000000, 0.18); g.fillEllipse(20, 30, 26, 10); + g.fillStyle(0xe63946, 1); g.fillCircle(20, 18, 11); + g.fillStyle(0xffffff, 1); g.fillRect(9, 15, 22, 5); + g.fillStyle(0xffd23f, 1); g.fillCircle(20, 8, 4); + g.generateTexture('buoy', 40, 40); g.destroy(); + } + this.buoys = []; + const b0 = this.island.bounds; + const spots = [ + { x: (b0.minX - 3) * CELL_SIZE, y: (b0.minY + 12) * CELL_SIZE }, + { x: (b0.maxX + 3) * CELL_SIZE, y: (b0.minY + 30) * CELL_SIZE }, + { x: (b0.minX + 25) * CELL_SIZE, y: (b0.maxY + 3) * CELL_SIZE }, + ]; + for (const s of spots) { + const buoy = this.add.image(s.x, s.y, 'buoy').setScale(SCALE * 0.9).setDepth(6); + this.uiCamera.ignore(buoy); + this.tweens.add({ targets: buoy, y: s.y - 10 * SCALE, angle: 6, duration: 1400, ease: 'Sine.easeInOut', yoyo: true, repeat: -1 }); + this.buoys.push(buoy); + } + } + + // ── Ambient life ────────────────────────────────────────────────────────── + + // Add a world object at a depth and hide it from the HUD camera. + _addWorld(obj, depth) { + obj.setDepth(depth); + this.uiCamera.ignore(obj); + return obj; + } + + _cellCenter(cell) { + return { x: cell.x * CELL_SIZE, y: cell.y * CELL_SIZE }; + } + + // Unit vector pointing to the right of travel (for lane / curb offsets). + _rightVec(heading) { + if (heading === 'east') return { x: 0, y: 1 }; + if (heading === 'south') return { x: -1, y: 0 }; + if (heading === 'west') return { x: 0, y: -1 }; + return { x: 1, y: 0 }; + } + + _bakeAmbientTextures() { + const mk = () => this.make.graphics({ add: false }); + + const carColors = [0xe63946, 0x1d7cf2, 0xffd23f, 0x2ec4b6, 0xff8c42]; + carColors.forEach((color, i) => { + const key = 'car-' + i; + if (this.textures.exists(key)) return; + const g = mk(); + g.fillStyle(0x000000, 0.2); g.fillEllipse(15, 15, 28, 8); + g.fillStyle(color, 1); g.fillRoundedRect(2, 2, 26, 12, 4); + g.fillStyle(0x22303a, 0.85); g.fillRect(19, 3, 6, 10); // windshield (front = +x) + g.fillStyle(0xffffff, 0.5); g.fillRect(6, 6, 11, 4); + g.generateTexture(key, 30, 18); g.destroy(); + }); + + const shirts = [0x1d7cf2, 0xe63946, 0x2ec4b6, 0xffd23f]; + shirts.forEach((color, i) => { + const key = 'ped-' + i; + if (this.textures.exists(key)) return; + const g = mk(); + g.fillStyle(0x000000, 0.2); g.fillEllipse(5, 14, 8, 3); + g.fillStyle(color, 1); g.fillRoundedRect(1, 6, 8, 7, 2); + g.fillStyle(0xf0c8a0, 1); g.fillCircle(5, 4, 3); + g.generateTexture(key, 11, 17); g.destroy(); + }); + + if (!this.textures.exists('boat')) { + const g = mk(); + g.fillStyle(0xffffff, 0.5); g.fillEllipse(24, 40, 42, 10); + g.fillStyle(0x5a3a24, 1); g.fillRect(23, 6, 3, 26); + g.fillStyle(0xffffff, 1); g.fillTriangle(24, 6, 24, 32, 41, 30); + g.fillStyle(0xe63946, 1); g.fillTriangle(23, 6, 23, 19, 13, 17); + g.fillStyle(0x8a5a3b, 1); + g.fillPoints([{ x: 8, y: 32 }, { x: 40, y: 32 }, { x: 34, y: 43 }, { x: 14, y: 43 }], true); + g.generateTexture('boat', 48, 48); g.destroy(); + } + + if (!this.textures.exists('cloud-shadow')) { + const g = mk(); + g.fillStyle(0x14304a, 0.10); + g.fillEllipse(60, 40, 90, 46); + g.fillEllipse(40, 34, 54, 40); + g.fillEllipse(84, 44, 58, 36); + g.generateTexture('cloud-shadow', 120, 80); g.destroy(); + } + + if (!this.textures.exists('bird')) { + const g = mk(); + g.lineStyle(3, 0x3a3f4a, 1); + g.beginPath(); + g.moveTo(2, 9); g.lineTo(12, 3); g.lineTo(22, 9); + g.strokePath(); + g.generateTexture('bird', 24, 12); g.destroy(); + } + + if (!this.textures.exists('smoke-puff')) { + const g = mk(); + g.fillStyle(0xb9bcc2, 0.5); g.fillCircle(14, 14, 12); + g.fillStyle(0xe4e6ea, 0.5); g.fillCircle(11, 11, 7); + g.generateTexture('smoke-puff', 28, 28); g.destroy(); + } + + if (!this.textures.exists('glow')) { + const g = mk(); + for (let r = 32; r > 0; r -= 4) { + g.fillStyle(0xffa733, 0.06); + g.fillCircle(32, 32, r); + } + g.generateTexture('glow', 64, 64); g.destroy(); + } + + if (!this.textures.exists('burning-overlay')) { + const g = mk(); + g.fillStyle(0x2a1a12, 0.55); g.fillRoundedRect(6, 6, 84, 84, 10); + g.fillStyle(0xffb347, 0.9); + for (let wy = 16; wy < 80; wy += 22) { + for (let wx = 16; wx < 80; wx += 22) g.fillRect(wx, wy, 10, 12); + } + g.generateTexture('burning-overlay', 96, 96); g.destroy(); + } + } + + _randomRoadStart() { + const grid = this.island.grid; + const roads = this.island.roads; + for (let tries = 0; tries < 40; tries++) { + const cell = FT.randPick(this.ambientRng, roads); + const headings = []; + for (const card of ['N', 'E', 'S', 'W']) { + if (cell.exits[card]) headings.push(FT.CARD_TO_HEADING[card]); + } + if (!headings.length) continue; + const heading = FT.randPick(this.ambientRng, headings); + const to = FT.cellAhead(grid, cell, heading); + if (to && to.type === FT.CELL_TYPES.ROAD) return { from: cell, to, heading }; + } + return null; + } + + _initAmbient() { + this._bakeAmbientTextures(); + this.ambientRng = FT.createSeededRng(0x51ce77); + this.trafficCars = []; + this.pedestrians = []; + this.clouds = []; + this.boats = []; + this.birds = []; + + for (let i = 0; i < 10; i++) { + const start = this._randomRoadStart(); + if (!start) continue; + const key = 'car-' + Math.floor(this.ambientRng.next() * 5); + const img = this._addWorld(this.add.image(0, 0, key).setScale(SCALE * 0.85), 9); + this.trafficCars.push({ img, from: start.from, to: start.to, heading: start.heading, t: this.ambientRng.next() }); + } + + for (let i = 0; i < 18; i++) { + const start = this._randomRoadStart(); + if (!start) continue; + const key = 'ped-' + Math.floor(this.ambientRng.next() * 4); + const img = this._addWorld(this.add.image(0, 0, key).setScale(SCALE * 0.7), 8); + this.pedestrians.push({ img, from: start.from, to: start.to, heading: start.heading, t: this.ambientRng.next() }); + } + + const b = this.island.bounds; + for (let i = 0; i < 3; i++) { + const scale = 4 + this.ambientRng.next() * 3; + const x = (b.minX + this.ambientRng.next() * b.width) * CELL_SIZE; + const y = (b.minY + this.ambientRng.next() * b.height) * CELL_SIZE; + const img = this._addWorld(this.add.image(x, y, 'cloud-shadow').setScale(scale).setAlpha(0.55), 34); + this.clouds.push({ img, vx: 55 + this.ambientRng.next() * 25, vy: 18 + this.ambientRng.next() * 14 }); + } + + // Boats bob just offshore around the perimeter. + const boatSpots = [ + { x: (b.minX - 1.5) * CELL_SIZE, y: (b.minY + 8) * CELL_SIZE }, + { x: (b.maxX + 1.5) * CELL_SIZE, y: (b.minY + 20) * CELL_SIZE }, + { x: (b.minX + 10) * CELL_SIZE, y: (b.minY - 1.5) * CELL_SIZE }, + { x: (b.minX + 34) * CELL_SIZE, y: (b.maxY + 1.5) * CELL_SIZE }, + { x: (b.maxX + 1.5) * CELL_SIZE, y: (b.maxY - 12) * CELL_SIZE }, + ]; + boatSpots.forEach((s, i) => { + const img = this._addWorld(this.add.image(s.x, s.y, 'boat').setScale(SCALE), 6); + this.boats.push(img); + this.tweens.add({ targets: img, y: s.y - 12 * SCALE, angle: 4, duration: 1600 + i * 220, ease: 'Sine.easeInOut', yoyo: true, repeat: -1 }); + }); + + this._scheduleBird(); + this.ambientReady = true; + } + + _scheduleBird() { + const delay = 9000 + this.ambientRng.next() * 6000; + this._birdTimer = this.time.delayedCall(delay, () => { + this._spawnBirdFlock(); + this._scheduleBird(); + }); + } + + _spawnBirdFlock() { + if (!this.birds || this.birds.length >= 4) return; + const view = this.cameras.main.worldView; + const fromLeft = this.ambientRng.next() < 0.5; + const count = 1 + Math.floor(this.ambientRng.next() * 3); + const y0 = view.y + view.height * (0.1 + this.ambientRng.next() * 0.3); + for (let i = 0; i < count && this.birds.length < 4; i++) { + const startX = fromLeft ? view.x - 60 * SCALE : view.right + 60 * SCALE; + const img = this._addWorld(this.add.image(startX, y0 + i * 26 * SCALE, 'bird').setScale(SCALE), 38); + const dir = fromLeft ? 1 : -1; + this.birds.push({ img, vx: dir * (90 + this.ambientRng.next() * 50), baseY: img.y, phase: this.ambientRng.next() * 6 }); + } + } + + _moveMover(m, speed, offset, dt, rotate) { + const grid = this.island.grid; + const fromC = this._cellCenter(m.from); + const toC = this._cellCenter(m.to); + const r = this._rightVec(m.heading); + const px = fromC.x + (toC.x - fromC.x) * m.t + r.x * offset; + const py = fromC.y + (toC.y - fromC.y) * m.t + r.y * offset; + const nearTruck = Math.hypot(px - this.truck.x, py - this.truck.y) < CELL_SIZE * 0.8; + if (!nearTruck) { + m.t += (speed * dt) / CELL_SIZE; + let guard = 0; + while (m.t >= 1 && guard++ < 8) { + m.t -= 1; + const plan = FT.advanceCarPlan(grid, m.to, m.heading, this.ambientRng); + m.from = m.to; + m.to = plan.cell; + m.heading = plan.heading; + } + } + const fc = this._cellCenter(m.from); + const tc = this._cellCenter(m.to); + const rr = this._rightVec(m.heading); + m.img.setPosition( + fc.x + (tc.x - fc.x) * m.t + rr.x * offset, + fc.y + (tc.y - fc.y) * m.t + rr.y * offset + ); + if (rotate) m.img.rotation = this.rotationForHeading(m.heading); + } + + _updateAmbient(dt) { + for (const car of this.trafficCars) this._moveMover(car, CAR_SPEED, CELL_SIZE * 0.16, dt, true); + for (const ped of this.pedestrians) this._moveMover(ped, PED_SPEED, CELL_SIZE * 0.34, dt, false); + + const b = this.island.bounds; + const margin = 6 * CELL_SIZE; + const minX = (b.minX) * CELL_SIZE - margin, maxX = (b.maxX) * CELL_SIZE + margin; + const minY = (b.minY) * CELL_SIZE - margin, maxY = (b.maxY) * CELL_SIZE + margin; + for (const c of this.clouds) { + c.img.x += c.vx * dt; + c.img.y += c.vy * dt; + if (c.img.x > maxX) c.img.x = minX; + if (c.img.y > maxY) c.img.y = minY; + } + + if (this.birds.length) { + const view = this.cameras.main.worldView; + const t = this.time.now * 0.006; + for (let i = this.birds.length - 1; i >= 0; i--) { + const bird = this.birds[i]; + bird.img.x += bird.vx * dt; + bird.img.y = bird.baseY + Math.sin(t + bird.phase) * 10 * SCALE; + if (bird.img.x < view.x - 120 * SCALE || bird.img.x > view.right + 120 * SCALE) { + bird.img.destroy(); + this.birds.splice(i, 1); + } + } + } + } + createTruck() { - const body = this.add.rectangle(0, 0, 40, 22, 0xe63946, 1).setStrokeStyle(3, 0x9b1c25, 1); - const cab = this.add.rectangle(11, 0, 14, 16, 0xf6f7fb, 1).setStrokeStyle(2, 0xbcc6d4, 1); - const ladder = this.add.rectangle(-4, 0, 14, 4, 0xffd23f, 1); + // Bake a top-down fire-truck texture once (nose points +x = east, matching + // rotationForHeading('east') === 0). The container keeps [image, truckLight] + // so toggleLights / _updateLightsEffect drive the light rectangle unchanged. + const key = 'truck-top'; + if (!this.textures.exists(key)) { + const g = this.make.graphics({ add: false }); + // Shadow + g.fillStyle(0x000000, 0.2); g.fillEllipse(26, 22, 46, 12); + // Red body + g.fillStyle(0xe63946, 1); g.fillRoundedRect(4, 5, 40, 18, 5); + g.lineStyle(2, 0x9b1c25, 1); g.strokeRoundedRect(4, 5, 40, 18, 5); + // White side stripe + g.fillStyle(0xffffff, 0.85); g.fillRect(6, 13, 38, 3); + // Yellow ladder along the spine with rungs + g.fillStyle(0xffd23f, 1); g.fillRect(9, 11, 22, 6); + g.fillStyle(0xcf9b1e, 1); + for (let rx = 11; rx < 30; rx += 4) g.fillRect(rx, 11, 1.6, 6); + // Turntable + g.fillStyle(0xf4b400, 1); g.fillCircle(13, 14, 4); + g.fillStyle(0xcf9b1e, 1); g.fillCircle(13, 14, 2); + // White cab at the front (+x) + g.fillStyle(0xf6f7fb, 1); g.fillRoundedRect(30, 6, 13, 16, 3); + g.lineStyle(2, 0xbcc6d4, 1); g.strokeRoundedRect(30, 6, 13, 16, 3); + // Windshield + g.fillStyle(0x88ccff, 1); g.fillRect(40, 8, 3, 12); + // Mirrors + g.fillStyle(0x30333c, 1); g.fillRect(31, 3, 4, 2); g.fillRect(31, 23, 4, 2); + g.generateTexture(key, 52, 28); g.destroy(); + } + const truckImage = this.add.image(0, 0, key); this.truckLight = this.add.rectangle(-14, 0, 8, 8, 0x1d7cf2, 1); - this.truck = this.add.container(0, 0, [body, cab, ladder, this.truckLight]); + this.truck = this.add.container(0, 0, [truckImage, this.truckLight]); this.truck.setDepth(35); - this.truck.setScale(SCALE); + this.truck.setScale(SCALE * 0.82); } onResize(gameSize) { @@ -347,6 +898,7 @@ class FireTruckScene extends Phaser.Scene { this.statusText.setPosition(gameSize.width / 2, gameSize.height - 34); this.fireCountText.setPosition(gameSize.width / 2, 78); if (this.uiCamera) this.uiCamera.setSize(gameSize.width, gameSize.height); + if (this.ocean) this.ocean.setSize(gameSize.width, gameSize.height); } updateTargetSpeed() { @@ -590,6 +1142,12 @@ class FireTruckScene extends Phaser.Scene { this.step(Math.min(elapsed / 1000, 0.5)); this.lastStepTime = now; } + const dt = Math.min((deltaMs || 16) / 1000, 0.1); + if (this.ocean) { + this.ocean.tilePositionX += dt * 8; + this.ocean.tilePositionY += dt * 4; + } + if (this.ambientReady) this._updateAmbient(dt); } step(dt) { @@ -839,6 +1397,44 @@ class FireTruckScene extends Phaser.Scene { this.fireGraphics = this.add.graphics(); this.fireGraphics.setDepth(10); this.uiCamera.ignore(this.fireGraphics); + + const cx = this.fireBuildingCell.x * CELL_SIZE; + const cy = this.fireBuildingCell.y * CELL_SIZE; + + if (this.burnOverlay) { this.burnOverlay.destroy(); } + this.burnOverlay = this.add.image(cx, cy, 'burning-overlay').setScale(SCALE).setDepth(9.5); + this.uiCamera.ignore(this.burnOverlay); + + if (this.fireGlow) { this.fireGlow.destroy(); } + this.fireGlow = this.add.image(cx, cy + CELL_SIZE * 0.1, 'glow') + .setScale(SCALE * 1.4).setDepth(9.7).setBlendMode(Phaser.BlendModes.ADD); + this.uiCamera.ignore(this.fireGlow); + + this._startSmoke(); + } + + _startSmoke() { + if (this._smokeTimer) return; + this._smokeTimer = this.time.addEvent({ delay: 350, loop: true, callback: () => this._emitSmoke() }); + } + + _stopSmoke() { + if (this._smokeTimer) { this._smokeTimer.remove(false); this._smokeTimer = null; } + } + + _emitSmoke() { + if (!this.fireBuildingCell) return; + const cx = this.fireBuildingCell.x * CELL_SIZE + (Math.random() - 0.5) * CELL_SIZE * 0.25; + const cy = this.fireBuildingCell.y * CELL_SIZE - CELL_SIZE * 0.1; + const puff = this.add.image(cx, cy, 'smoke-puff').setScale(SCALE * 0.6).setAlpha(0.7).setDepth(12); + this.uiCamera.ignore(puff); + this.tweens.add({ + targets: puff, + x: cx - CELL_SIZE * 0.4, y: cy - CELL_SIZE * 0.95, + scale: SCALE * 1.4, alpha: 0, + duration: 1600, ease: 'Sine.easeOut', + onComplete: () => puff.destroy(), + }); } _updateFireAnimation(t) { @@ -847,18 +1443,45 @@ class FireTruckScene extends Phaser.Scene { gfx.clear(); const cx = this.fireBuildingCell.x * CELL_SIZE; const cy = this.fireBuildingCell.y * CELL_SIZE; - const baseY = cy + CELL_SIZE * 0.40; - const half = CELL_SIZE * 0.22; - const colors = [0xff6600, 0xff2200, 0xffcc00, 0xff8800]; - for (let i = 0; i < 7; i++) { - const offset = Math.sin(t * 0.003 + i * 1.3) * CELL_SIZE * 0.16; - const h = CELL_SIZE * 0.70 + Math.sin(t * 0.005 + i * 2.1) * CELL_SIZE * 0.22; - gfx.fillStyle(colors[i % colors.length], 0.7 + Math.sin(t * 0.004 + i) * 0.3); - gfx.fillTriangle( - cx + offset - half, baseY, - cx + offset + half, baseY, - cx + offset, baseY - h - ); + const baseY = cy + CELL_SIZE * 0.36; + + // Shrink as the fire is put out. + const progress = Math.min(1, this.fireExtinguishAccum / FIRE_EXTINGUISH_S); + const scale = 1 - progress * 0.75; + + // Pulsing glow underneath. + if (this.fireGlow) { + const pulse = 1.3 + Math.sin(t * 0.006) * 0.18; + this.fireGlow.setScale(SCALE * 1.4 * pulse * (0.5 + scale * 0.5)); + this.fireGlow.setAlpha(0.6 * (0.3 + scale * 0.7)); + } + + // Three layered flames (outer orange, mid red, inner yellow) each flickering + // on its own phase, plus a few rising embers. + const layers = [ + { color: 0xff7a18, half: 0.24, h: 0.78, phase: 0.0 }, + { color: 0xff3b1f, half: 0.17, h: 0.62, phase: 1.7 }, + { color: 0xffd23f, half: 0.10, h: 0.44, phase: 3.1 }, + ]; + for (const L of layers) { + const half = CELL_SIZE * L.half * scale; + for (let i = 0; i < 3; i++) { + const offset = Math.sin(t * 0.004 + i * 1.9 + L.phase) * CELL_SIZE * 0.10 * scale; + const h = (CELL_SIZE * L.h + Math.sin(t * 0.006 + i * 2.3 + L.phase) * CELL_SIZE * 0.16) * scale; + gfx.fillStyle(L.color, 0.78 + Math.sin(t * 0.005 + i + L.phase) * 0.2); + gfx.fillTriangle( + cx + offset - half, baseY, + cx + offset + half, baseY, + cx + offset, baseY - h + ); + } + } + // Embers + gfx.fillStyle(0xffd76e, 0.9); + for (let i = 0; i < 5; i++) { + const ex = cx + Math.sin(t * 0.003 + i * 2.1) * CELL_SIZE * 0.28 * scale; + const ey = baseY - ((t * 0.12 + i * 90) % (CELL_SIZE * 0.9)) * scale; + gfx.fillCircle(ex, ey, 3 * scale + 1); } } @@ -880,16 +1503,42 @@ class FireTruckScene extends Phaser.Scene { const ty = this.fireBuildingCell.y * CELL_SIZE; const sx = this.truck.x; const sy = this.truck.y; - gfx.lineStyle(8 * SCALE, 0x44aaff, 0.75); - for (let i = 0; i < 8; i++) { - const t0 = i / 8; - const t1 = (i + 1) / 8; - const x0 = sx + (tx - sx) * t0 + Math.sin(t0 * Math.PI * 2) * 10; - const y0 = sy + (ty - sy) * t0; - const x1 = sx + (tx - sx) * t1 + Math.sin(t1 * Math.PI * 2) * 10; - const y1 = sy + (ty - sy) * t1; - gfx.lineBetween(x0, y0, x1, y1); + const t = this.time.now; + const dx = tx - sx, dy = ty - sy; + const len = Math.hypot(dx, dy) || 1; + // Perpendicular unit vector for wiggle + strand spread. + const px = -dy / len, py = dx / len; + + // Two tapered strands (bright core over a wider translucent stream). + const strands = [ + { color: 0x44aaff, alpha: 0.5, width: 10, wig: 22 }, + { color: 0x9fdcff, alpha: 0.85, width: 5, wig: 16 }, + ]; + const SEG = 10; + for (const s of strands) { + for (let i = 0; i < SEG; i++) { + const t0 = i / SEG, t1 = (i + 1) / SEG; + const w0 = s.wig * Math.sin(t0 * Math.PI * 3 + t * 0.02); + const w1 = s.wig * Math.sin(t1 * Math.PI * 3 + t * 0.02); + gfx.lineStyle((s.width * (1 - t0 * 0.6)) * SCALE * 0.5, s.color, s.alpha); + gfx.lineBetween( + sx + dx * t0 + px * w0, sy + dy * t0 + py * w0, + sx + dx * t1 + px * w1, sy + dy * t1 + py * w1 + ); + } } + // Jittered droplets near the far end. + gfx.fillStyle(0xcdeeff, 0.85); + for (let i = 0; i < 6; i++) { + const tt = 0.7 + Math.random() * 0.3; + const jx = sx + dx * tt + px * (Math.random() - 0.5) * 40 * SCALE * 0.4; + const jy = sy + dy * tt + py * (Math.random() - 0.5) * 40 * SCALE * 0.4; + gfx.fillCircle(jx, jy, (2 + Math.random() * 3) * SCALE * 0.5); + } + // Expanding splash ring at the building. + const ring = (Math.sin(t * 0.012) * 0.5 + 0.5) * CELL_SIZE * 0.3 + CELL_SIZE * 0.1; + gfx.lineStyle(3 * SCALE * 0.5, 0x9fdcff, 0.5); + gfx.strokeCircle(tx, ty, ring); } _startWaterSound() { @@ -951,8 +1600,16 @@ class FireTruckScene extends Phaser.Scene { this.successUntil = this.time.now + 200; this.playSuccessSound(); this._stopWaterSound(); + this._stopSmoke(); if (this.fireGraphics) { this.fireGraphics.destroy(); this.fireGraphics = null; } if (this.waterGraphics) { this.waterGraphics.destroy(); this.waterGraphics = null; } + if (this.fireGlow) { this.fireGlow.destroy(); this.fireGlow = null; } + // Leave a brief scorch mark that fades out. + if (this.burnOverlay) { + const ov = this.burnOverlay; + this.burnOverlay = null; + this.tweens.add({ targets: ov, alpha: 0, duration: 800, onComplete: () => ov.destroy() }); + } this.fireCell = null; this.fireBuildingCell = null; this.statusText.setText('Fire out! Great job!'); @@ -1000,9 +1657,14 @@ class FireTruckScene extends Phaser.Scene { } this._stopWaterSound(); this._stopSiren(); + this._stopSmoke(); if (this.bgGain) { try { Tone.Transport.stop(); } catch (_) {} } if (this.fireGraphics) { this.fireGraphics.destroy(); this.fireGraphics = null; } if (this.waterGraphics) { this.waterGraphics.destroy(); this.waterGraphics = null; } + if (this.fireGlow) { this.fireGlow.destroy(); this.fireGlow = null; } + if (this.burnOverlay) { this.burnOverlay.destroy(); this.burnOverlay = null; } + this.ambientReady = false; + if (this._birdTimer) { this._birdTimer.remove(false); this._birdTimer = null; } } } @@ -1014,6 +1676,27 @@ function colorForCell(cell) { return BUILDING_PALETTE[Math.abs((cell.x * 13 + cell.y * 17) % BUILDING_PALETTE.length)]; } +// Base ground colour under a cell (building/park footprints read as pavement). +function groundColorForCell(cell) { + if (!cell) return WATER_COLOR; + if (cell.type === FT.CELL_TYPES.WATER) return WATER_COLOR; + if (cell.type === FT.CELL_TYPES.BEACH) return BEACH_COLOR; + if (cell.type === FT.CELL_TYPES.ROAD) return ASPHALT_COLOR; + return SIDEWALK_COLOR; +} + +function lighten(color, amt) { + const c = Phaser.Display.Color.IntegerToColor(color); + return Phaser.Display.Color.GetColor( + Math.min(255, c.red + amt), Math.min(255, c.green + amt), Math.min(255, c.blue + amt)); +} + +function darken(color, amt) { + const c = Phaser.Display.Color.IntegerToColor(color); + return Phaser.Display.Color.GetColor( + Math.max(0, c.red - amt), Math.max(0, c.green - amt), Math.max(0, c.blue - amt)); +} + class FireTruckEndScene extends Phaser.Scene { constructor() { super({ key: 'FireTruckEndScene' }); @@ -1031,32 +1714,68 @@ class FireTruckEndScene extends Phaser.Scene { const W = this._W = this.scale.width; const H = this._H = this.scale.height; - // Sky gradient + // Warm tropical sky gradient const skyGfx = this.add.graphics(); - skyGfx.fillGradientStyle(0x87ceeb, 0x87ceeb, 0x5bacd8, 0x5bacd8, 1); + skyGfx.fillGradientStyle(0xffe3a3, 0xffd08a, 0x8fe0f2, 0x62d9e8, 1); skyGfx.fillRect(0, 0, W, H * 0.52); - // Dense urban buildings (left 40%, varying heights) + // Pulsing sun (disc + halo rings) top-right. + this.sunGfx = this.add.graphics(); + this._sunX = W * 0.82; + this._sunY = H * 0.16; + + // Drifting puffy clouds. + if (!this.textures.exists('end-cloud')) { + const g = this.make.graphics({ add: false }); + g.fillStyle(0xffffff, 0.95); + g.fillEllipse(70, 40, 120, 46); + g.fillEllipse(44, 34, 60, 44); + g.fillEllipse(96, 36, 66, 40); + g.generateTexture('end-cloud', 140, 70); g.destroy(); + } + this._clouds = []; + for (let i = 0; i < 4; i++) { + const cx = W * (0.1 + i * 0.24); + const cy = H * (0.08 + (i % 2) * 0.12); + const cloud = this.add.image(cx, cy, 'end-cloud').setScale(0.8 + (i % 3) * 0.4).setAlpha(0.9).setDepth(1); + this._clouds.push({ img: cloud, vx: 8 + i * 3 }); + } + + // Dense tropical skyline (left 42%, varied heights + rooftop features). const bldGfx = this.add.graphics(); const bldColors = BUILDING_PALETTE; const numBlds = 10; - const bldZoneW = W * 0.40; + const bldZoneW = W * 0.42; for (let i = 0; i < numBlds; i++) { const bw = bldZoneW / numBlds; const bh = H * (0.10 + ((i * 7 + 3) % 9) / 9 * 0.38); const bx = i * bw; const by = H * 0.52 - bh; - bldGfx.fillStyle(bldColors[i % bldColors.length], 1); + const color = bldColors[i % bldColors.length]; + bldGfx.fillStyle(color, 1); bldGfx.fillRect(bx, by, bw - 3, bh); - bldGfx.lineStyle(2, 0x000000, 0.12); + bldGfx.lineStyle(2, darken(color, 45), 1); bldGfx.strokeRect(bx + 1, by + 1, bw - 5, bh - 2); // Windows - bldGfx.fillStyle(0xffe8a0, 0.7); - for (let wy = by + 8; wy < H * 0.52 - 10; wy += 14) { + bldGfx.fillStyle(0xffe8a0, 0.75); + for (let wy = by + 10; wy < H * 0.52 - 10; wy += 14) { for (let wx = bx + 5; wx < bx + bw - 10; wx += 12) { bldGfx.fillRect(wx, wy, 7, 8); } } + // Rooftop feature: alternating water tank / terracotta gable. + if (i % 3 === 0) { + bldGfx.fillStyle(0x9aa0a8, 1); + bldGfx.fillRect(bx + bw * 0.3, by - bh * 0.08, bw * 0.4, bh * 0.08); + } else if (i % 3 === 1) { + bldGfx.fillStyle(ROOF_TERRACOTTA, 1); + bldGfx.fillTriangle(bx, by, bx + bw - 3, by, bx + (bw - 3) / 2, by - bh * 0.14); + } + } + + // Side-view palms tucked between skyline and beach. + for (let i = 0; i < 3; i++) { + this._drawSidePalm(bldGfx, bldZoneW + i * W * 0.05 + W * 0.02, H * 0.52, H * 0.10); } // Road @@ -1079,9 +1798,39 @@ class FireTruckEndScene extends Phaser.Scene { // Ocean fill const oceanGfx = this.add.graphics(); - oceanGfx.fillStyle(0x2a9d8f, 1); + oceanGfx.fillGradientStyle(0x2bb5d8, 0x2bb5d8, 0x1c8fb0, 0x1c8fb0, 1); oceanGfx.fillRect(0, H * 0.68, W, H * 0.32); + // Beach parasol + towels (right side of the beach band). + const beachDetailGfx = this.add.graphics(); + const towels = [[0x1d7cf2, W * 0.70], [0xe63946, W * 0.78], [0xffd23f, W * 0.86]]; + for (const [tc, tx] of towels) { + beachDetailGfx.fillStyle(tc, 0.9); + beachDetailGfx.fillRoundedRect(tx, H * 0.635, W * 0.05, H * 0.03, 4); + } + // Parasol + const umbX = W * 0.62, umbY = H * 0.64; + beachDetailGfx.fillStyle(0x8a5a3b, 1); + beachDetailGfx.fillRect(umbX - 1, umbY, 3, H * 0.045); + beachDetailGfx.fillStyle(0xe63946, 1); + beachDetailGfx.fillTriangle(umbX - W * 0.045, umbY, umbX + W * 0.045, umbY, umbX, umbY - H * 0.05); + beachDetailGfx.fillStyle(0xffffff, 0.85); + beachDetailGfx.fillTriangle(umbX - W * 0.015, umbY, umbX + W * 0.015, umbY, umbX, umbY - H * 0.05); + + // Sailboat traversing the ocean with a bob tween. + if (!this.textures.exists('end-boat')) { + const g = this.make.graphics({ add: false }); + g.fillStyle(0x5a3a24, 1); g.fillRect(28, 6, 3, 34); + g.fillStyle(0xffffff, 1); g.fillTriangle(30, 6, 30, 40, 54, 38); + g.fillStyle(0xe63946, 1); g.fillTriangle(28, 6, 28, 26, 12, 24); + g.fillStyle(0x9b1c25, 1); + g.fillPoints([{ x: 6, y: 40 }, { x: 52, y: 40 }, { x: 44, y: 54 }, { x: 14, y: 54 }], true); + g.generateTexture('end-boat', 60, 58); g.destroy(); + } + this._endBoat = this.add.image(W * 0.1, H * 0.80, 'end-boat').setScale(H / 400).setDepth(2); + this.tweens.add({ targets: this._endBoat, y: H * 0.80 - 8, angle: 3, duration: 1800, ease: 'Sine.easeInOut', yoyo: true, repeat: -1 }); + this.tweens.add({ targets: this._endBoat, x: W * 0.95, duration: 18000, ease: 'Linear', repeat: -1 }); + // Wave graphics (cleared/redrawn each frame) this.waveGfx = this.add.graphics(); @@ -1262,12 +2011,53 @@ class FireTruckEndScene extends Phaser.Scene { Tone.Transport.start(); } catch (_) {} + // Celebration fireworks + confetti (guarded so a shared-lib load failure + // can never throw into the console-error tests). + if (window.KGames && KGames.launchFireworks) { + try { + KGames.bakeFireworksAtlas(this); + KGames.launchFireworks(this); + KGames.burstConfetti(this, W / 2, H * 0.3); + this._fwTimer = this.time.addEvent({ + delay: 4000, loop: true, + callback: () => { + if (!this.scene.isActive()) return; + KGames.launchFireworks(this); + }, + }); + } catch (_) {} + } + window.__FT_END_SCENE__ = this; this.events.on('shutdown', this._shutdown, this); this.events.on('destroy', this._shutdown, this); } + _drawSidePalm(gfx, x, groundY, height) { + // Curved trunk + drooping fronds, seen from the side. + gfx.lineStyle(Math.max(4, height * 0.09), PALM_TRUNK, 1); + gfx.beginPath(); + gfx.moveTo(x, groundY); + gfx.lineTo(x - height * 0.08, groundY - height * 0.5); + gfx.lineTo(x + height * 0.06, groundY - height); + gfx.strokePath(); + const topX = x + height * 0.06; + const topY = groundY - height; + const frondL = height * 0.55; + const angles = [-2.5, -1.9, -1.3, -0.7, -0.1, 0.5]; + for (const a of angles) { + gfx.lineStyle(Math.max(3, height * 0.05), PALM_FROND, 1); + gfx.beginPath(); + gfx.moveTo(topX, topY); + gfx.lineTo(topX + Math.cos(a) * frondL, topY + Math.sin(a) * frondL * 0.6 + frondL * 0.2); + gfx.strokePath(); + } + gfx.fillStyle(darken(PALM_TRUNK, 20), 1); + gfx.fillCircle(topX, topY, height * 0.05); + } + _shutdown() { + if (this._fwTimer) { this._fwTimer.remove(false); this._fwTimer = null; } try { Tone.Transport.stop(); } catch (_) {} if (this._endGain) { try { this._endGain.dispose(); } catch (_) {} this._endGain = null; } if (this._endMelodySynth) { try { this._endMelodySynth.dispose(); } catch (_) {} this._endMelodySynth = null; } @@ -1286,6 +2076,26 @@ class FireTruckEndScene extends Phaser.Scene { const c = Phaser.Display.Color.HSLToColor(this._hue, 0.85, 0.62); this.didItText.setColor(Phaser.Display.Color.RGBToString(c.r, c.g, c.b)); + // Pulsing sun + halo rings. + if (this.sunGfx) { + const pulse = 1 + Math.sin(time * 0.002) * 0.06; + this.sunGfx.clear(); + for (let r = 4; r >= 1; r--) { + this.sunGfx.fillStyle(0xffe08a, 0.08 * r); + this.sunGfx.fillCircle(this._sunX, this._sunY, W * 0.05 * r * 0.5 * pulse); + } + this.sunGfx.fillStyle(0xfff2b0, 1); + this.sunGfx.fillCircle(this._sunX, this._sunY, W * 0.045 * pulse); + } + + // Drifting clouds (wrap across the sky). + if (this._clouds) { + for (const cl of this._clouds) { + cl.img.x += cl.vx * dt; + if (cl.img.x - cl.img.displayWidth > W) cl.img.x = -cl.img.displayWidth; + } + } + // Ocean waves this.waveGfx.clear(); for (let w = 0; w < 3; w++) { @@ -1321,7 +2131,7 @@ const IS_TEST = !!window.__TEST_MODE__; window.__KG_GAME__ = new Phaser.Game({ type: Phaser.AUTO, parent: 'game-container', - backgroundColor: '#6BC4E8', + backgroundColor: '#2BB5D8', scale: { mode: Phaser.Scale.RESIZE, width: window.innerWidth, diff --git a/games/fire-truck/index.html b/games/fire-truck/index.html @@ -162,6 +162,7 @@ <script src="https://cdn.jsdelivr.net/npm/phaser@3.80.1/dist/phaser.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/tone@14.7.77/build/Tone.js"></script> <script src="lib.js"></script> + <script src="../../js/shared/game-shared.js"></script> <script src="game.js"></script> <script src="../../js/shared/touch-controls.js"></script> <script>KGames.initTouchControls({ layout: 'drive' });</script> diff --git a/games/fire-truck/lib.js b/games/fire-truck/lib.js @@ -389,7 +389,7 @@ const nx = cell.x + DIRS[card].dx; const ny = cell.y + DIRS[card].dy; const neighbor = island.grid[ny] && island.grid[ny][nx]; - if (neighbor && neighbor.type === CELL_TYPES.BUILDING) return true; + if (neighbor && neighbor.type === CELL_TYPES.BUILDING && !neighbor.park) return true; } return false; }); @@ -425,13 +425,129 @@ const nx = chosen.x + DIRS[card].dx; const ny = chosen.y + DIRS[card].dy; const neighbor = island.grid[ny] && island.grid[ny][nx]; - if (neighbor && neighbor.type === CELL_TYPES.BUILDING) { + if (neighbor && neighbor.type === CELL_TYPES.BUILDING && !neighbor.park) { return { roadCell: chosen, buildingCell: neighbor }; } } return null; } + // ------------------------------------------------------------------ + // Decoration helpers (pure, deterministic) — drive all baked/ambient + // variety so the island renders identically every run. + // ------------------------------------------------------------------ + + // Deterministic hash → float in [0,1). All decor variety derives from this. + function hashCell(x, y, salt) { + let h = Math.imul(((x | 0) + 0x9e3779b9) >>> 0, 0x85ebca6b); + h = (h ^ Math.imul(((y | 0) + 0x165667b1) >>> 0, 0xc2b2ae35)) >>> 0; + h = (h ^ Math.imul(((salt | 0) + 0x27d4eb2f) >>> 0, 0x2545f491)) >>> 0; + h ^= h >>> 15; + h = Math.imul(h, 0x2c1b3c6d) >>> 0; + h ^= h >>> 13; + return (h >>> 0) / 0x100000000; + } + + // Greedily cover all BUILDING cells with disjoint rectangles. BSP road + // subdivision guarantees building cells form road-bounded rectangular blocks, + // so this yields one rectangle per block — render each as ONE building. + function findBuildingBlocks(grid) { + const h = grid.length; + const w = grid[0].length; + const covered = []; + for (let y = 0; y < h; y++) covered.push(new Array(w).fill(false)); + const isB = (x, y) => !!(grid[y] && grid[y][x] && grid[y][x].type === CELL_TYPES.BUILDING); + const blocks = []; + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + if (!isB(x, y) || covered[y][x]) continue; + let bw = 1; + while (isB(x + bw, y) && !covered[y][x + bw]) bw++; + let bh = 1; + let canGrow = true; + while (canGrow) { + const ny = y + bh; + for (let xx = x; xx < x + bw; xx++) { + if (!isB(xx, ny) || covered[ny][xx]) { canGrow = false; break; } + } + if (canGrow) bh++; + } + for (let yy = y; yy < y + bh; yy++) { + for (let xx = x; xx < x + bw; xx++) covered[yy][xx] = true; + } + blocks.push({ x, y, w: bw, h: bh }); + } + } + return blocks; + } + + // Tag ~fraction of building blocks as parks (cell.park = true), preferring + // larger (≥2×2) blocks. Type stays 'building' so grid/route tests are + // untouched. Deterministic ordering via hashCell. Returns the block list. + function assignParks(island, opts) { + const fraction = (opts && opts.fraction != null) ? opts.fraction : 0.15; + const grid = island.grid; + const blocks = findBuildingBlocks(grid); + const scored = blocks.map(b => ({ + b, + big: (b.w >= 2 && b.h >= 2) ? 1 : 0, + r: hashCell(b.x, b.y, 7), + })); + scored.sort((a, c) => (c.big - a.big) || (a.r - c.r) || (a.b.y - c.b.y) || (a.b.x - c.b.x)); + const target = Math.round(blocks.length * fraction); + let count = 0; + for (const s of scored) { + if (count >= target) break; + for (let yy = s.b.y; yy < s.b.y + s.b.h; yy++) { + for (let xx = s.b.x; xx < s.b.x + s.b.w; xx++) { + if (grid[yy] && grid[yy][xx]) grid[yy][xx].park = true; + } + } + count++; + } + return blocks; + } + + // Next {cell, heading} for an ambient car: reuse road exits, prefer going + // straight, never reverse unless it's the only option (dead-end). + function advanceCarPlan(grid, cell, heading, rand) { + const back = OPPOSITE[heading]; + const options = []; + for (const card of ['N', 'E', 'S', 'W']) { + if (cell.exits && cell.exits[card]) { + const hd = CARD_TO_HEADING[card]; + if (hd !== back) options.push(hd); + } + } + let chosen; + if (!options.length) { + chosen = back; + } else if (options.indexOf(heading) !== -1 && rand.next() < 0.7) { + chosen = heading; + } else { + chosen = randPick(rand, options); + } + const next = cellAhead(grid, cell, chosen); + if (!next || next.type !== CELL_TYPES.ROAD) { + const rev = cellAhead(grid, cell, back); + return { cell: (rev && rev.type === CELL_TYPES.ROAD) ? rev : cell, heading: back }; + } + return { cell: next, heading: chosen }; + } + + // Which edges of a road cell abut a building/park (drives sidewalk baking + // and pedestrian paths). Parks are type 'building' so they're included. + function sidewalkEdges(grid, x, y) { + const out = { N: false, E: false, S: false, W: false }; + for (const card of ['N', 'E', 'S', 'W']) { + const nx = x + DIRS[card].dx; + const ny = y + DIRS[card].dy; + const nb = grid[ny] && grid[ny][nx]; + if (nb && nb.type === CELL_TYPES.BUILDING) out[card] = true; + } + return out; + } + function buildIsland(opts, rng) { const width = (opts && opts.width) ?? ISLAND_WIDTH; const height = (opts && opts.height) ?? ISLAND_HEIGHT; @@ -648,6 +764,11 @@ bfsRoadDistances, pathToRouteSteps, pickFireDestination, + hashCell, + findBuildingBlocks, + assignParks, + advanceCarPlan, + sidewalkEdges, }; if (typeof module !== 'undefined' && module.exports) module.exports = api; diff --git a/tests/browser/runner.js b/tests/browser/runner.js @@ -522,6 +522,22 @@ async function main() { if (result.counter !== 0) throw new Error(`firesExtinguished should be 0 after restart, got ${result.counter}`); }); + await test('fire-truck: ambient traffic spawns and stays on roads', async (page, url) => { + await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' }); + await page.waitForTimeout(1800); + const info = await page.evaluate(() => { + const s = window.__FT_SCENE__; + const cars = s.trafficCars || []; + const allRoad = cars.every(c => + s.cellAt(c.from.x, c.from.y).type === 'road' && + s.cellAt(c.to.x, c.to.y).type === 'road'); + return { count: cars.length, allRoad, ambientReady: s.ambientReady }; + }); + if (!info.ambientReady) throw new Error('ambient systems not ready'); + if (info.count === 0) throw new Error('no traffic cars spawned'); + if (!info.allRoad) throw new Error('a traffic car left the road graph'); + }); + // ── On-screen touch controls ──────────────────────────────────────────────── // Helper: returns an ElementHandle for the touch key whose label === text. diff --git a/tests/unit/fire-truck-decor.test.js b/tests/unit/fire-truck-decor.test.js @@ -0,0 +1,117 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert'); +const FT = require('../../games/fire-truck/lib.js'); + +function makeIsland(seed) { + return FT.buildIsland({ width: 50, height: 50, routeCount: 50, seed: seed ?? 1 }); +} + +test('hashCell is deterministic and in [0,1)', () => { + for (let i = 0; i < 200; i++) { + const x = (i * 7) % 50; + const y = (i * 13) % 50; + const a = FT.hashCell(x, y, 3); + const b = FT.hashCell(x, y, 3); + assert.strictEqual(a, b, 'same inputs → same output'); + assert.ok(a >= 0 && a < 1, `in range: ${a}`); + } + // Salt changes the value (very likely) + assert.notStrictEqual(FT.hashCell(4, 9, 1), FT.hashCell(4, 9, 2)); +}); + +test('findBuildingBlocks returns disjoint rectangles covering all building cells', () => { + const island = makeIsland(2); + const blocks = FT.findBuildingBlocks(island.grid); + const covered = new Set(); + for (const b of blocks) { + assert.ok(b.w >= 1 && b.h >= 1, 'positive size'); + for (let yy = b.y; yy < b.y + b.h; yy++) { + for (let xx = b.x; xx < b.x + b.w; xx++) { + const key = xx + ',' + yy; + assert.ok(!covered.has(key), `no overlap at ${key}`); + covered.add(key); + assert.strictEqual(island.grid[yy][xx].type, FT.CELL_TYPES.BUILDING, + `block cell ${key} is a building`); + } + } + } + // Every building cell is covered by exactly one block + for (let y = 0; y < island.height; y++) { + for (let x = 0; x < island.width; x++) { + if (island.grid[y][x].type === FT.CELL_TYPES.BUILDING) { + assert.ok(covered.has(x + ',' + y), `building ${x},${y} covered`); + } + } + } +}); + +test('assignParks is deterministic and keeps type building', () => { + const a = makeIsland(3); + const b = makeIsland(3); + FT.assignParks(a, { fraction: 0.15 }); + FT.assignParks(b, { fraction: 0.15 }); + let parkCount = 0; + for (let y = 0; y < a.height; y++) { + for (let x = 0; x < a.width; x++) { + const ca = a.grid[y][x]; + const cb = b.grid[y][x]; + assert.strictEqual(!!ca.park, !!cb.park, `deterministic park tag at ${x},${y}`); + if (ca.park) { + parkCount++; + assert.strictEqual(ca.type, FT.CELL_TYPES.BUILDING, 'park stays type building'); + } + } + } + assert.ok(parkCount > 0, 'at least one park tagged'); +}); + +test('advanceCarPlan stays on roads and avoids immediate reversal', () => { + const island = makeIsland(4); + const rand = FT.createSeededRng(99); + let cell = island.roads[0]; + let heading = 'east'; + for (const card of ['N', 'E', 'S', 'W']) { + if (cell.exits[card]) { heading = FT.CARD_TO_HEADING[card]; break; } + } + for (let i = 0; i < 500; i++) { + const prev = cell; + const next = FT.advanceCarPlan(island.grid, cell, heading, rand); + assert.strictEqual(next.cell.type, FT.CELL_TYPES.ROAD, `step ${i} on road`); + // No immediate reversal unless the current cell is a straight/dead corridor + // where reversing is the only option (avoidable = >1 exit besides back). + const exits = ['N', 'E', 'S', 'W'].filter(c => prev.exits[c]); + const back = FT.OPPOSITE[heading]; + const forwardOptions = exits + .map(c => FT.CARD_TO_HEADING[c]) + .filter(h => h !== back); + if (forwardOptions.length > 0) { + assert.notStrictEqual(next.heading, back, `step ${i} did not reverse when avoidable`); + } + cell = next.cell; + heading = next.heading; + } +}); + +test('sidewalkEdges flags building-adjacent edges of a road cell', () => { + const island = makeIsland(5); + // The ring road (index 2) has beach outside and buildings inside. + // Find a straight vertical stretch of the left ring road. + const cell = island.grid[25][2]; + assert.strictEqual(cell.type, FT.CELL_TYPES.ROAD, 'ring cell is road'); + const edges = FT.sidewalkEdges(island.grid, 2, 25); + assert.strictEqual(edges.E, true, 'east edge abuts building interior'); + assert.strictEqual(edges.W, false, 'west edge abuts beach, not building'); +}); + +test('pickFireDestination never targets a park building', () => { + const island = makeIsland(6); + FT.assignParks(island, { fraction: 0.3 }); + const rand = FT.createSeededRng(1234); + for (let i = 0; i < 50; i++) { + const dest = FT.pickFireDestination(island, rand, {}); + if (!dest) continue; + assert.ok(!dest.buildingCell.park, `dest ${dest.buildingCell.x},${dest.buildingCell.y} is not a park`); + } +});