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:
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`);
+ }
+});