kgames

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

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:
Mgames/fire-truck/game.js | 467++++++++++++++++++++++++++++++++++++++++---------------------------------------
Mgames/fire-truck/lib.js | 102++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Aopencode.json | 11+++++++++++
Mtests/browser/runner.js | 6+++---
Atests/unit/fire-truck-segment.test.js | 46++++++++++++++++++++++++++++++++++++++++++++++
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