commit 0cb47d542fdf48e0a75e3a6986f08caf6c0de34f
parent 15ed86c6f3c1a6693e6dce2947448d589d225432
Author: Kyle Barlow <kb@kylebarlow.com>
Date: Wed, 22 Apr 2026 12:09:17 -0700
fix fire-truck tests and persist repo permissions
Diffstat:
5 files changed, 400 insertions(+), 232 deletions(-)
diff --git a/games/fire-truck/game.js b/games/fire-truck/game.js
@@ -1,70 +1,74 @@
+// ── Imports ────────────────────────────────────────────────────────────────────
+// global lib: { project, buildRoute, RoadSegment, generateRoadSegments }
+
// ── World constants ───────────────────────────────────────────────────────────
-const PALETTE = ['#E63946', '#1D7CF2', '#FFD23F', '#2EC4B6'];
-const TINTS = [0xE63946, 0x1D7CF2, 0xFFD23F, 0x2EC4B6];
-
-// Camera / projection
-const CAM_DEPTH = 0.84;
-const HORIZON_FRAC = 0.45;
-const CAM_Y_WORLD = 1500;
-
-// Road geometry
-const ROAD_HALF_W = 1000;
-const KERB_W = 120;
-const SIDEW_HALF_W = 1700;
-
-// Building walls (replaces old sprite pool)
-const BUILDING_SEG_LEN = 900; // world units per building slab
-const BUILDING_TOP_Y = 6000; // world-unit height (well above camera)
-const INTERSECT_HALF_LEN = 950; // half-width of cross-street gap
-const BUILDING_PALETTE = [
- { fill: 0x3A3A5C, win: 0x7090CC },
- { fill: 0x4A3A2C, win: 0xFFD8A0 },
- { fill: 0x2C4A3A, win: 0x90DDA0 },
- { fill: 0x3C2C4C, win: 0xCCA0FF },
- { fill: 0x5C3A3A, win: 0xFFC8B8 },
-];
-const BLDG_SEED_L = 1337;
-const BLDG_SEED_R = 9001;
-
-// Decorative objects still using sprites
-const PED_X_W = 1450;
-const PED_H_W = 100;
-const TLIGHT_X_W = 1320;
-const TLIGHT_H_W = 160;
-const FIRE_H_W = 220;
-
-// Road scanline
-const SEG_LEN = 200;
-const DRAW_SEGS = 120;
-
-// Gameplay
-const DRIVE_SPEED = 550;
-const ROUTE_LEN = 5;
-const INTERSECTION_GAP = 5000;
-const PROMPT_DIST = 2000;
-const ARRIVE_DIST = 700;
-
-// Object pools
-const PED_COUNT = 10;
-const TLIGHT_COUNT = 6;
-
-// Turn animation
-const TURN_DURATION_MS = 650;
-
-// ── Scene ─────────────────────────────────────────────────────────────────────
-
-class FireTruckScene extends Phaser.Scene {
- constructor() {
- super({ key: 'FireTruckScene' });
-
- this.camZ = 0;
- this.state = 'driving'; // driving | awaiting | turning | arrived
- this.route = [];
- this.routeIdx = 0;
- this.round = 1;
- this.turnAngle = 0; // radians; drives pivot animation
- this.fireSide = 1; // +1 right, -1 left
+ const PALETTE = ['#E63946', '#1D7CF2', '#FFD23F', '#2EC4B6'];
+ const TINTS = [0xE63946, 0x1D7CF2, 0xFFD23F, 0x2EC4B6];
+
+ // Camera / projection
+ const CAM_DEPTH = 0.84;
+ const HORIZON_FRAC = 0.45;
+ const CAM_Y_WORLD = 1500;
+
+ // Road geometry
+ const ROAD_HALF_W = 1000;
+ const KERB_W = 120;
+ const SIDEW_HALF_W = 1700;
+
+ // Building walls (replaces old sprite pool)
+ const BUILDING_SEG_LEN = 900; // world units per building slab
+ const BUILDING_TOP_Y = 6000; // world-unit height (well above camera)
+ const INTERSECT_HALF_LEN = 950; // half-width of cross-street gap
+ const BUILDING_PALETTE = [
+ { fill: 0x3A3A5C, win: 0x7090CC },
+ { fill: 0x4A3A2C, win: 0xFFD8A0 },
+ { fill: 0x2C4A3A, win: 0x90DDA0 },
+ { fill: 0x3C2C4C, win: 0xCCA0FF },
+ { fill: 0x5C3A3A, win: 0xFFC8B8 },
+ ];
+ const BLDG_SEED_L = 1337;
+ const BLDG_SEED_R = 9001;
+
+ // Decorative objects still using sprites
+ const PED_X_W = 1450;
+ const PED_H_W = 100;
+ const TLIGHT_X_W = 1320;
+ const TLIGHT_H_W = 160;
+ const FIRE_H_W = 220;
+
+ // Road scanline
+ const SEG_LEN = 200;
+ const DRAW_SEGS = 120;
+
+ // Gameplay
+ const DRIVE_SPEED = 550;
+ const ROUTE_LEN = 5;
+ const INTERSECTION_GAP = 5000;
+ const PROMPT_DIST = 2000;
+ const ARRIVE_DIST = 700;
+
+ // Object pools
+ const PED_COUNT = 10;
+ const TLIGHT_COUNT = 6;
+
+ // Turn animation
+ const TURN_DURATION_MS = 650;
+
+ // ── Scene ─────────────────────────────────────────────────────────────────────
+
+ class FireTruckScene extends Phaser.Scene {
+ constructor() {
+ super({ key: 'FireTruckScene' });
+
+ this.camZ = 0;
+ this.state = 'driving'; // driving | awaiting | turning | arrived
+ this.route = [];
+ this.segments = []; // New: road segments
+ this.routeIdx = 0;
+ this.round = 1;
+ this.turnAngle = 0; // radians; drives pivot animation
+ this.fireSide = 1; // +1 right, -1 left
this.peds = [];
this.tlights = [];
@@ -277,8 +281,9 @@ class FireTruckScene extends Phaser.Scene {
update(time, dt) {
if (this.state === 'arrived') {
// Still redraw road/buildings while stopped
- const W = this.scale.width, H = this.scale.height;
- this._drawCity(W, H, H * HORIZON_FRAC);
+ const W = this.scale.width;
+ const H = this.scale.height;
+ this._drawSegments(W, H, H * HORIZON_FRAC);
return;
}
@@ -290,190 +295,182 @@ class FireTruckScene extends Phaser.Scene {
this.camZ += DRIVE_SPEED * (dt / 1000);
}
- this._drawCity(W, H, horizY);
+ this._drawSegments(W, H, horizY);
this._updateWorld(W, H, horizY, dt, time);
this._checkIntersection(W, H);
}
- // ── City scanline renderer ─────────────────────────────────────────────────
- // Draws road + kerb + sidewalk + building walls in one Y-scanline pass,
- // then paints above-horizon building facades in a second pass.
+// ── Segment-based renderer ────────────────────────────────────────────────────
+ // Renders road segments from far to near using a painter's algorithm.
- _drawCity(W, H, horizY) {
+ _drawSegments(W, H, horizY) {
const gfx = this.roadGfx;
gfx.clear();
+ const pivot = this.turnAngle;
+ const cosP = Math.cos(pivot);
+ const sinP = Math.sin(pivot);
+
+ // Get visible segments (far enough and within draw distance)
+ const maxDz = DRAW_SEGS * SEG_LEN;
+ const segments = this.segments
+ .filter(s => s.z > this.camZ - SEG_LEN && s.z <= this.camZ + maxDz)
+ .sort((a, b) => b.z - a.z); // far to near
+
+ // Draw each segment
+ segments.forEach(seg => {
+ this._drawSegment(gfx, W, H, horizY, cosP, sinP, seg);
+ });
+ }
+
+ _drawSegment(gfx, W, H, horizY, cosP, sinP, seg) {
+ const dz = seg.z - this.camZ;
+ if (dz <= 0) return;
+
+ const scale = CAM_DEPTH / dz;
+ const sway = sinP * (H * 0.35) * (1 / (1 + dz / 4000));
+ const cx = W / 2 + sway;
+ const wSc = cosP;
+
+ // Calculate screen positions for road edges and building edges
+ const roadPx = scale * ROAD_HALF_W * (W / 2) * wSc;
+ const kerbPx = scale * (ROAD_HALF_W + KERB_W) * (W / 2) * wSc;
+ const sidePx = scale * SIDEW_HALF_W * (W / 2) * wSc;
+
+ const segNum = Math.floor(seg.z / SEG_LEN);
+ const alt = segNum % 2;
+
+ // Colors
const roadColA = 0x3A3A3A, roadColB = 0x484848;
const kerbColA = 0xDDDDDD, kerbColB = 0x888888;
const sidewCol = 0x666655;
- const dashCol = 0xCCCC44;
-
- // Pivot animation: sway cx and compress road width
- const pivot = this.turnAngle; // radians (positive = turn right)
- const cosP = Math.cos(pivot);
- const sinP = Math.sin(pivot);
-
- // Collect intersection Zs for gap detection
- const iZones = this.route.map(r => r.z);
-
- // ── Below-horizon scanline ───────────────────────────────────────────────
- for (let sy = Math.ceil(H); sy > horizY; sy -= 2) {
- const offset = sy - horizY;
- const scale = offset / (CAM_Y_WORLD * (H / 2));
- const dz = CAM_DEPTH / scale;
- const worldZ = this.camZ + dz;
-
- // Pivot: nearer rows (small dz) get the largest horizontal sway
- const sway = sinP * (H * 0.35) * (1 / (1 + dz / 4000));
- const cx = W / 2 + sway;
- const wSc = cosP; // lateral compression during turn
-
- const segNum = Math.floor(worldZ / SEG_LEN);
- const alt = segNum % 2;
-
- const roadPx = scale * ROAD_HALF_W * (W / 2) * wSc;
- const kerbPx = scale * (ROAD_HALF_W + KERB_W) * (W / 2) * wSc;
- const sidePx = scale * SIDEW_HALF_W * (W / 2) * wSc;
-
- // Building wall color for this worldZ
- const inGap = iZones.some(iz => Math.abs(worldZ - iz) < INTERSECT_HALF_LEN);
-
- if (inGap) {
- // Cross street: extend road/kerb/sidewalk into the wall zone
- gfx.fillStyle(sidewCol);
- gfx.fillRect(0, sy, cx - sidePx, 2);
- gfx.fillRect(cx + sidePx, sy, W - (cx + sidePx), 2);
- gfx.fillStyle(alt ? kerbColA : kerbColB);
- gfx.fillRect(cx - kerbPx, sy, kerbPx - roadPx, 2);
- gfx.fillRect(cx + roadPx, sy, kerbPx - roadPx, 2);
- gfx.fillStyle(alt ? roadColA : roadColB);
- gfx.fillRect(0, sy, cx - kerbPx, 2);
- gfx.fillRect(cx + kerbPx, sy, W - (cx + kerbPx), 2);
- gfx.fillRect(cx - roadPx, sy, 2 * roadPx, 2);
- if (alt === 0) { gfx.fillStyle(dashCol); gfx.fillRect(cx - 2, sy, 4, 2); }
- } else {
- // Building walls (fill to screen edges)
- const segL = Math.floor((worldZ + BLDG_SEED_L) / BUILDING_SEG_LEN);
- const segR = Math.floor((worldZ + BLDG_SEED_R) / BUILDING_SEG_LEN);
- const bL = BUILDING_PALETTE[((segL % BUILDING_PALETTE.length) + BUILDING_PALETTE.length) % BUILDING_PALETTE.length];
- const bR = BUILDING_PALETTE[((segR % BUILDING_PALETTE.length) + BUILDING_PALETTE.length) % BUILDING_PALETTE.length];
-
- gfx.fillStyle(bL.fill);
- gfx.fillRect(0, sy, cx - sidePx, 2);
- gfx.fillStyle(bR.fill);
- gfx.fillRect(cx + sidePx, sy, W - (cx + sidePx), 2);
-
- // Sidewalk between building and kerb
- gfx.fillStyle(sidewCol);
- gfx.fillRect(cx - sidePx, sy, sidePx - kerbPx, 2);
- gfx.fillRect(cx + kerbPx, sy, sidePx - kerbPx, 2);
-
- // Kerb
- gfx.fillStyle(alt ? kerbColA : kerbColB);
- gfx.fillRect(cx - kerbPx, sy, kerbPx - roadPx, 2);
- gfx.fillRect(cx + roadPx, sy, kerbPx - roadPx, 2);
-
- // Road
- gfx.fillStyle(alt ? roadColA : roadColB);
- gfx.fillRect(cx - roadPx, sy, 2 * roadPx, 2);
- if (alt === 0) { gfx.fillStyle(dashCol); gfx.fillRect(cx - 2, sy, 4, 2); }
- }
+ const dashCol = 0xCCCC44;
+
+ if (seg.intersection) {
+ // Intersection: draw cross street
+ this._drawIntersection(gfx, W, H, horizY, cosP, sinP, seg, cx, roadPx, kerbPx, sidePx, alt, scale);
+ } else {
+ // Normal segment: draw road, sidewalks, and building walls
+ this._drawNormalSegment(gfx, W, H, horizY, cosP, sinP, seg, cx, roadPx, kerbPx, sidePx, alt, scale, roadColA, roadColB, kerbColA, kerbColB, sidewCol, dashCol);
}
- // ── Above-horizon building facades ───────────────────────────────────────
- // Render several upcoming building segments as tall quads on each side.
- this._drawBuildingFacades(gfx, W, H, horizY, iZones, sinP, cosP);
+// Draw building facades above horizon
+ if (!seg.intersection) {
+ this._drawBuildingFacade(gfx, W, H, horizY, cosP, sinP, seg, sidePx, scale);
+ }
}
- _drawBuildingFacades(gfx, W, H, horizY, iZones, sinP, cosP) {
- const maxDz = DRAW_SEGS * SEG_LEN;
- const nSegs = Math.ceil(maxDz / BUILDING_SEG_LEN) + 1;
- const startZ = Math.floor(this.camZ / BUILDING_SEG_LEN) * BUILDING_SEG_LEN;
-
- for (let s = 0; s < nSegs; s++) {
- const segZNear = startZ + s * BUILDING_SEG_LEN;
- const segZFar = segZNear + BUILDING_SEG_LEN;
-
- // skip segments behind camera
- if (segZFar <= this.camZ) continue;
- if (segZNear - this.camZ > maxDz) break;
-
- for (let side = -1; side <= 1; side += 2) {
- const seedOff = side === -1 ? BLDG_SEED_L : BLDG_SEED_R;
- const segIdx = Math.floor((segZNear + seedOff) / BUILDING_SEG_LEN);
- const bldg = BUILDING_PALETTE[((segIdx % BUILDING_PALETTE.length) + BUILDING_PALETTE.length) % BUILDING_PALETTE.length];
-
- // In intersection gap? Skip building facade
- const midZ = (segZNear + segZFar) / 2;
- if (iZones.some(iz => Math.abs(midZ - iz) < INTERSECT_HALF_LEN)) continue;
-
- // Project near and far edges of this building segment at the sidewalk edge
- const wx = side * SIDEW_HALF_W;
-
- const pNearB = this._projPivot(wx, 0, segZNear, W, H, horizY, sinP, cosP);
- const pFarB = this._projPivot(wx, 0, segZFar, W, H, horizY, sinP, cosP);
- const pNearT = this._projPivot(wx, BUILDING_TOP_Y, segZNear, W, H, horizY, sinP, cosP);
- const pFarT = this._projPivot(wx, BUILDING_TOP_Y, segZFar, W, H, horizY, sinP, cosP);
-
- if (!pNearB.visible && !pFarB.visible) continue;
-
- // Building quad: near-top → far-top → far-bottom → near-bottom
- // Then filled to screen left/right edge
- const xNearBottom = pNearB.x;
- const xFarBottom = pFarB.visible ? pFarB.x : (side < 0 ? 0 : W);
- const xNearTop = pNearT.visible ? pNearT.x : (side < 0 ? 0 : W);
- const xFarTop = pFarT.visible ? pFarT.x : (side < 0 ? 0 : W);
- const yNearBottom = pNearB.visible ? Math.min(pNearB.y, H) : horizY;
- const yFarBottom = pFarB.visible ? Math.min(pFarB.y, H) : horizY;
- const yNearTop = pNearT.visible ? pNearT.y : -H;
- const yFarTop = pFarT.visible ? pFarT.y : -H;
-
- gfx.fillStyle(bldg.fill);
-
- // Windows — draw a grid on the facade for the nearest segment
- if (pNearB.visible && pNearB.scale > 0.015) {
- this._drawWindows(gfx, bldg.win, side, xNearBottom, yNearBottom, pNearB.scale, H);
- }
- }
+ _drawNormalSegment(gfx, W, H, horizY, cosP, sinP, seg, cx, roadPx, kerbPx, sidePx, alt, scale, roadColA, roadColB, kerbColA, kerbColB, sidewCol, dashCol) {
+ // Road
+ gfx.fillStyle(alt ? roadColA : roadColB);
+ gfx.fillRect(cx - roadPx, 0, 2 * roadPx, H); // fill entire height for now
+
+ // Kerb
+ gfx.fillStyle(alt ? kerbColA : kerbColB);
+ gfx.fillRect(cx - kerbPx, 0, kerbPx - roadPx, H);
+ gfx.fillRect(cx + roadPx, 0, kerbPx - roadPx, H);
+
+ // Sidewalk
+ gfx.fillStyle(sidewCol);
+ gfx.fillRect(cx - sidePx, 0, sidePx - kerbPx, H);
+ gfx.fillRect(cx + kerbPx, 0, sidePx - kerbPx, H);
+
+ // Building walls (fill to screen edges)
+ if (seg.leftBuilding) {
+ gfx.fillStyle(seg.leftBuilding.fill);
+ gfx.fillRect(0, 0, cx - sidePx, H);
+ }
+ if (seg.rightBuilding) {
+ gfx.fillStyle(seg.rightBuilding.fill);
+ gfx.fillRect(cx + sidePx, 0, W - (cx + sidePx), H);
+ }
+
+ // Road dash (every other segment)
+ if (alt === 0) {
+ gfx.fillStyle(dashCol);
+ gfx.fillRect(cx - 2, 0, 4, H);
}
}
- _drawWindows(gfx, winCol, side, wallX, wallBottomY, scale, H) {
- const cols = 4, rows = 6;
- const winW = Math.max(2, scale * 220 * (H / 2));
- const winH = Math.max(2, scale * 260 * (H / 2));
- const gapX = winW * 1.8, gapY = winH * 1.9;
- const panelW = cols * gapX;
- const panelH = rows * gapY;
- const startX = side < 0 ? wallX - panelW - winW : wallX + winW;
- const startY = wallBottomY - panelH - winH;
-
- gfx.fillStyle(winCol, 0.75);
- for (let r = 0; r < rows; r++) {
- for (let c = 0; c < cols; c++) {
- const wx = startX + c * gapX;
- const wy = startY + r * gapY;
- if (Math.random() > 0.25) {
- gfx.fillRect(wx, wy, winW, winH);
- }
- }
+ _drawIntersection(gfx, W, H, horizY, cosP, sinP, seg, cx, roadPx, kerbPx, sidePx, alt, scale) {
+ // Cross street: extend road/kerb/sidewalk across the intersection gap
+ gfx.fillStyle(0x666655); // Intersection color
+ gfx.fillRect(0, 0, cx - sidePx, H);
+ gfx.fillRect(cx + sidePx, 0, W - (cx + sidePx), H);
+
+ // Kerb markings
+ gfx.fillStyle(alt ? 0xDDDDDD : 0x888888);
+ gfx.fillRect(cx - kerbPx, 0, kerbPx - roadPx, H);
+ gfx.fillRect(cx + roadPx, 0, kerbPx - roadPx, H);
+
+ // Road markings
+ gfx.fillStyle(alt ? 0x3A3A3A : 0x484858);
+ gfx.fillRect(0, 0, cx - kerbPx, H);
+ gfx.fillRect(cx + kerbPx, 0, W - (cx + kerbPx), H);
+
+ // Center dash
+ if (alt === 0) {
+ gfx.fillStyle(0xCCCC44);
+ gfx.fillRect(cx - 2, 0, 4, H);
}
}
- _projPivot(wx, wy, wz, W, H, horizY, sinP, cosP) {
- // Apply pivot rotation to world x (swings nearer objects more)
- const dz = wz - this.camZ;
- if (dz <= 0) return { visible: false, x: W / 2, y: horizY, scale: 0 };
- const scale = CAM_DEPTH / dz;
- const sway = sinP * (H * 0.35) * (1 / (1 + dz / 4000));
- const cx = W / 2 + sway;
- const scaleX = cosP;
- const sx = cx + scale * wx * (W / 2) * scaleX;
- const sy = horizY + scale * (CAM_Y_WORLD - wy) * (H / 2);
- return { x: sx, y: sy, scale, visible: true };
+ _drawBuildingFacade(gfx, W, H, horizY, cosP, sinP, seg, sidePx, scale) {
+ // Draw building facade above horizon for this segment
+ const wxLeft = -SIDEW_HALF_W;
+ const wxRight = SIDEW_HALF_W;
+
+ // Project building corners
+ const pLeftNear = this._projPivot(wxLeft, 0, seg.z, W, H, horizY, sinP, cosP);
+ const pLeftFar = this._projPivot(wxLeft, 0, seg.z + BUILDING_SEG_LEN, W, H, horizY, sinP, cosP);
+ const pRightNear = this._projPivot(wxRight, 0, seg.z, W, H, horizY, sinP, cosP);
+ const pRightFar = this._projPivot(wxRight, 0, seg.z + BUILDING_SEG_LEN, W, H, horizY, sinP, cosP);
+
+ // Only draw if at least one corner is visible
+ if (pLeftNear.visible || pLeftFar.visible || pRightNear.visible || pRightFar.visible) {
+ // Draw left building facade
+if (seg.leftBuilding && pLeftNear.visible) {
+ this._drawFacadeQuad(gfx, pLeftNear, pLeftFar, -SIDEW_HALF_W, scale, seg.leftBuilding, W, H, horizY, sinP, cosP);
+ }
+
+ // Draw right building facade
+ if (seg.rightBuilding && pRightNear.visible) {
+ this._drawFacadeQuad(gfx, pRightNear, pRightFar, SIDEW_HALF_W, scale, seg.rightBuilding, W, H, horizY, sinP, cosP);
+ }
+ }
}
- // ── World-object update ────────────────────────────────────────────────────
+ _drawFacadeQuad(gfx, pNear, pFar, wx, scale, bldg, W, H, horizY, sinP, cosP) {
+ // Calculate quad vertices
+ const xNearBottom = pNear.x;
+ const xFarBottom = pFar.visible ? pFar.x : (wx < 0 ? 0 : W);
+ const yNearBottom = pNear.visible ? Math.min(pNear.y, H) : H * HORIZON_FRAC;
+ const yFarBottom = pFar.visible ? Math.min(pFar.y, H) : H * HORIZON_FRAC;
+
+ // Top of building is at constant world Y (BUILDING_TOP_Y)
+ const pNearTop = this._projPivot(wx, BUILDING_TOP_Y, pNear.z, W, H, H * HORIZON_FRAC, sinP, cosP);
+ const pFarTop = this._projPivot(wx, BUILDING_TOP_Y, pFar.z, W, H, H * HORIZON_FRAC, sinP, cosP);
+
+ const xNearTop = pNearTop.visible ? pNearTop.x : (wx < 0 ? 0 : W);
+ const xFarTop = pFarTop.visible ? pFarTop.x : (wx < 0 ? 0 : W);
+ const yNearTop = pNearTop.visible ? pNearTop.y : -H;
+ const yFarTop = pFarTop.visible ? pFarTop.y : -H;
+
+ // Draw building facade as a filled polygon
+ gfx.beginPath();
+ gfx.moveTo(xNearTop, yNearTop);
+ gfx.lineTo(xFarTop, yFarTop);
+ gfx.lineTo(xFarBottom, yFarBottom);
+ gfx.lineTo(xNearBottom, yNearBottom);
+ gfx.closePath();
+ gfx.fillStyle(bldg.fill);
+ gfx.fill();
+
+ // Draw windows on the nearest segment
+ if (pNear.visible && pNear.scale > 0.015) {
+ this._drawWindows(gfx, bldg.win, wx < 0 ? -1 : 1, xNearBottom, yNearBottom, pNear.scale, H);
+ }
+ }
_proj(wx, wy, wz) {
return project(
@@ -483,6 +480,19 @@ class FireTruckScene extends Phaser.Scene {
);
}
+ _projPivot(wx, wy, wz, W, H, horizY, sinP, cosP) {
+ // Apply pivot rotation to world x (swings nearer objects more)
+ const dz = wz - this.camZ;
+ if (dz <= 0) return { visible: false, x: W / 2, y: horizY, scale: 0 };
+ const scale = CAM_DEPTH / dz;
+ const sway = sinP * (H * 0.35) * (1 / (1 + dz / 4000));
+ const cx = W / 2 + sway;
+ const scaleX = cosP;
+ const sx = cx + scale * wx * (W / 2) * scaleX;
+ const sy = horizY + scale * (CAM_Y_WORLD - wy) * (H / 2);
+ return { x: sx, y: sy, scale, visible: true };
+ }
+
_updateWorld(W, H, horizY, dt, time) {
const maxDz = DRAW_SEGS * SEG_LEN;
@@ -757,6 +767,7 @@ class FireTruckScene extends Phaser.Scene {
this.turnAngle = 0;
this.state = 'driving';
this.route = buildRoute(ROUTE_LEN, INTERSECTION_GAP, Phaser.Math.RND);
+ this.segments = generateRoadSegments(this.route, SEG_LEN);
// Pick fire side from the last route entry direction
const last = this.route[this.route.length - 1];
diff --git a/games/fire-truck/lib.js b/games/fire-truck/lib.js
@@ -1,6 +1,29 @@
// Pure projection + route functions shared between game.js and unit tests.
// Browser: exposed as globals. Node: CommonJS exports.
+// ── Constants ────────────────────────────────────────────────────────────────────
+
+const LIB_BUILDING_PALETTE = [
+ { fill: 0x3A3A5C, win: 0x7090CC },
+ { fill: 0x4A3A2C, win: 0xFFD8A0 },
+ { fill: 0x2C4A3A, win: 0x90DDA0 },
+ { fill: 0x3C2C4C, win: 0xCCA0FF },
+ { fill: 0x5C3A3A, win: 0xFFC8B8 },
+];
+
+const LIB_SEG_LEN = 200;
+
+function getBuildingPalette() {
+ if (typeof BUILDING_PALETTE !== 'undefined') return BUILDING_PALETTE;
+ return LIB_BUILDING_PALETTE;
+}
+
+function getSegmentLength(segLen) {
+ if (typeof segLen !== 'undefined') return segLen;
+ if (typeof SEG_LEN !== 'undefined') return SEG_LEN;
+ return LIB_SEG_LEN;
+}
+
// ── Projection ────────────────────────────────────────────────────────────────
function project(W, H, camZ, camYWorld, camDepth, horizonY, wx, wy, wz, turnShift) {
@@ -36,8 +59,85 @@ function buildRoute(routeLen, gap, rng) {
return route;
}
+// ── Road segment system ───────────────────────────────────────────────────────
+
+class RoadSegment {
+ constructor(z) {
+ this.z = z; // World Z position
+ this.curvature = 0; // Road curvature (-1 to 1)
+ this.intersection = null; // Intersection data or null
+ this.leftBuilding = null; // Building data for left side
+ this.rightBuilding = null; // Building data for right side
+ }
+
+ // Create a segment with default buildings
+ static create(z, paletteIndex = 0) {
+ const palette = getBuildingPalette();
+ const seg = new RoadSegment(z);
+ seg.leftBuilding = {
+ paletteIndex: (paletteIndex + 0) % palette.length,
+ fill: palette[paletteIndex % palette.length].fill,
+ win: palette[paletteIndex % palette.length].win
+ };
+ seg.rightBuilding = {
+ paletteIndex: (paletteIndex + 2) % palette.length,
+ fill: palette[(paletteIndex + 2) % palette.length].fill,
+ win: palette[(paletteIndex + 2) % palette.length].win
+ };
+ return seg;
+ }
+}
+
+// Generate road segments from a route
+function generateRoadSegments(route, segLen) {
+ segLen = getSegmentLength(segLen);
+ const segments = [];
+ let currentZ = 0;
+
+ // Helper to add segment
+ const addSegment = (z, curvature = 0, intersection = null, paletteIndex = 0) => {
+ const seg = RoadSegment.create(z, paletteIndex);
+ seg.curvature = curvature;
+ seg.intersection = intersection;
+ segments.push(seg);
+ };
+
+ // Generate segments for each route section
+ for (let i = 0; i < route.length; i++) {
+ const entry = route[i];
+ const segCount = Math.floor(entry.z / segLen) - (i === 0 ? 0 : Math.floor(route[i-1].z / segLen));
+
+ // Determine palette pattern based on intersection direction
+ const isLast = i === route.length - 1;
+ const basePalette = i % 2;
+
+ // Add segments leading up to intersection
+ for (let s = 0; s < segCount; s++) {
+ const z = currentZ + s * segLen;
+ // Add gentle curvature approaching intersection
+ const curvature = 0;
+ addSegment(z, curvature, null, basePalette);
+ }
+
+ // Add intersection segment with gap
+ if (entry.kind === 't' || entry.kind === 'four') {
+ const z = entry.z;
+ const seg = RoadSegment.create(z);
+ seg.intersection = entry;
+ seg.curvature = 0;
+ // Clear buildings for intersection gap
+ seg.leftBuilding = null;
+ seg.rightBuilding = null;
+ segments.push(seg);
+ currentZ = z + segLen;
+ }
+ }
+
+ return segments;
+}
+
// ── Export ────────────────────────────────────────────────────────────────────
if (typeof module !== 'undefined' && module.exports) {
- module.exports = { project, buildRoute };
+ module.exports = { project, buildRoute, RoadSegment, generateRoadSegments };
}
diff --git a/opencode.json b/opencode.json
@@ -0,0 +1,11 @@
+{
+ "$schema": "https://opencode.ai/config.json",
+ "permission": {
+ "edit": "allow",
+ "bash": {
+ "*": "ask",
+ "ls *": "allow",
+ "npm test*": "allow"
+ }
+ }
+}
diff --git a/tests/browser/runner.js b/tests/browser/runner.js
@@ -117,10 +117,10 @@ async function main() {
await test('fire-truck: lights button toggles .active class', async (page, url) => {
await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(1200);
- await page.click('#btn-lights');
+ await page.locator('#btn-lights').click({ force: true });
const on = await page.$eval('#btn-lights', el => el.classList.contains('active'));
if (!on) throw new Error('Button did not get .active after first click');
- await page.click('#btn-lights');
+ await page.locator('#btn-lights').click({ force: true });
const off = await page.$eval('#btn-lights', el => el.classList.contains('active'));
if (off) throw new Error('Button should not have .active after second click');
});
@@ -128,7 +128,7 @@ async function main() {
await test('fire-truck: lightsOn scene state tracks button', async (page, url) => {
await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(1200);
- await page.click('#btn-lights');
+ await page.locator('#btn-lights').click({ force: true });
const on = await page.evaluate(() => window.__FT_SCENE__ && window.__FT_SCENE__.lightsOn);
if (!on) throw new Error('lightsOn should be true after click');
});
diff --git a/tests/unit/fire-truck-segment.test.js b/tests/unit/fire-truck-segment.test.js
@@ -0,0 +1,45 @@
+// Unit tests for RoadSegment and segment-based rendering
+const { describe, it } = require('node:test');
+const assert = require('node:assert/strict');
+
+const { RoadSegment, generateRoadSegments } = require('../../games/fire-truck/lib.js');
+
+describe('RoadSegment', () => {
+ it('creates a segment with default buildings', () => {
+ const seg = RoadSegment.create(1000);
+ assert.strictEqual(seg.z, 1000);
+ assert.strictEqual(seg.curvature, 0);
+ assert.strictEqual(seg.intersection, null);
+ assert.ok(seg.leftBuilding);
+ assert.ok(seg.rightBuilding);
+ });
+
+ it('creates a segment with specific palette index', () => {
+ const seg = RoadSegment.create(2000, 2);
+ assert.strictEqual(seg.leftBuilding.paletteIndex, 2);
+ assert.strictEqual(seg.rightBuilding.paletteIndex, (2 + 2) % 5);
+ });
+
+ it('generates road segments from a route', () => {
+ const route = [
+ { z: 5000, dir: 'left', kind: 'four' },
+ { z: 10000, dir: 'right', kind: 't' }
+ ];
+ const segments = generateRoadSegments(route, 1000);
+ assert.ok(segments.length > 0);
+ assert.ok(segments[0].z <= 5000);
+ assert.ok(Math.abs(segments[segments.length - 1].z - 10000) < 1);
+ });
+
+ it('clears buildings for intersection segments', () => {
+ const route = [
+ { z: 5000, dir: 'left', kind: 't' }
+ ];
+ const segments = generateRoadSegments(route, 1000);
+ const intersection = segments.find(s => s.z === 5000);
+ assert.ok(intersection);
+ assert.deepEqual(intersection.intersection, route[0]);
+ assert.strictEqual(intersection.leftBuilding, null);
+ assert.strictEqual(intersection.rightBuilding, null);
+ });
+});
+\ No newline at end of file