kgames

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

commit c4578d57ea48bb50f909851757590f8bf6e9eb0f
parent 089cebc27835aa4ae13536e03a2ee9bcf9d77798
Author: Kyle Barlow <kb@kylebarlow.com>
Date:   Wed, 22 Apr 2026 10:58:37 -0700

Try and fix fire truck

Diffstat:
Mgames/fire-truck/game.js | 627++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
Mgames/fire-truck/lib.js | 40+++++++++++++---------------------------
Mtests/unit/route.test.js | 5+++--
3 files changed, 426 insertions(+), 246 deletions(-)

diff --git a/games/fire-truck/game.js b/games/fire-truck/game.js @@ -4,68 +4,85 @@ const PALETTE = ['#E63946', '#1D7CF2', '#FFD23F', '#2EC4B6']; const TINTS = [0xE63946, 0x1D7CF2, 0xFFD23F, 0x2EC4B6]; // Camera / projection -const CAM_DEPTH = 0.84; // 1/tan(fov/2), fov≈100° -const HORIZON_FRAC = 0.45; // horizon is at 45% screen height -const CAM_Y_WORLD = 1500; // camera height above ground in world units +const CAM_DEPTH = 0.84; +const HORIZON_FRAC = 0.45; +const CAM_Y_WORLD = 1500; -// Road geometry (world units) +// Road geometry const ROAD_HALF_W = 1000; const KERB_W = 120; const SIDEW_HALF_W = 1700; -// World objects (world units) -const BLDG_X_W = 2200; // building X offset from center -const BLDG_H_W = 600; // building world height (for sprite scaling) +// 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 = 180; +const FIRE_H_W = 220; // Road scanline -const SEG_LEN = 200; // world units per segment (stripe alternation) -const DRAW_SEGS = 120; // max visible segments +const SEG_LEN = 200; +const DRAW_SEGS = 120; // Gameplay -const DRIVE_SPEED = 550; // world units / second +const DRIVE_SPEED = 550; const ROUTE_LEN = 5; const INTERSECTION_GAP = 5000; -const PROMPT_DIST = 1800; +const PROMPT_DIST = 2000; const ARRIVE_DIST = 700; -// Object pool sizes -const BLDG_COUNT = 16; +// 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.turnShift = 0; + 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 - this.bldgs = []; this.peds = []; this.tlights = []; - this.truckSprite = null; - this.lightBarL = null; - this.lightBarR = null; - this.arrowBg = null; - this.arrowSprite = null; - this.arrowText = null; - this.instrText = null; - this.roundText = null; - this.roadGfx = null; - this.groundRect = null; - this.fireSprite = null; + this.truckSprite = null; + this.lightBarL = null; + this.lightBarR = null; + this.arrowBg = null; + this.arrowLeft = null; + this.arrowRight = null; + this.arrowMain = null; + this.arrowText = null; + this.instrText = null; + this.roundText = null; + this.roadGfx = null; + this.groundRect = null; + this.fireSprite = null; + this.fireGlow = null; this.lightsOn = false; this.sirenOn = false; @@ -87,12 +104,11 @@ class FireTruckScene extends Phaser.Scene { this.lightInterval = null; } - // ── Preload: bake all textures ───────────────────────────────────────────── + // ── Preload ──────────────────────────────────────────────────────────────── preload() { this._genParticle(); this._genTruck(); - this._genBuildings(); this._genPedestrian(); this._genTrafficLight(); this._genArrows(); @@ -107,40 +123,18 @@ class FireTruckScene extends Phaser.Scene { _genTruck() { const g = this.make.graphics({ add: false }); - g.fillStyle(0xE63946); g.fillRoundedRect(0, 30, 140, 70, 8); // body - g.fillStyle(0xCC2233); g.fillRoundedRect(100, 10, 40, 90, 8); // cab - g.fillStyle(0xB8E0FF); g.fillRoundedRect(106, 18, 28, 32, 4); // windshield - g.fillStyle(0xB8E0FF); g.fillRoundedRect(106, 56, 28, 18, 4); // side window - g.fillStyle(0xAA1122); g.fillRect(8, 44, 90, 7); // ladder rail + g.fillStyle(0xE63946); g.fillRoundedRect(0, 30, 140, 70, 8); + g.fillStyle(0xCC2233); g.fillRoundedRect(100, 10, 40, 90, 8); + g.fillStyle(0xB8E0FF); g.fillRoundedRect(106, 18, 28, 32, 4); + g.fillStyle(0xB8E0FF); g.fillRoundedRect(106, 56, 28, 18, 4); + g.fillStyle(0xAA1122); g.fillRect(8, 44, 90, 7); [18, 36, 54, 72, 88].forEach(x => { - g.fillStyle(0xAA1122); g.fillRect(x, 40, 4, 15); // rungs + g.fillStyle(0xAA1122); g.fillRect(x, 40, 4, 15); }); - g.fillStyle(0x333333); g.fillRoundedRect(10, 8, 82, 14, 4); // light bar base + g.fillStyle(0x333333); g.fillRoundedRect(10, 8, 82, 14, 4); g.generateTexture('truck', 140, 100); g.destroy(); } - _genBuildings() { - const cfgs = [ - { w: 90, h: 120, fill: 0x3A3A5C, win: 0x7090CC }, - { w: 70, h: 160, fill: 0x4A3A2C, win: 0xFFD8A0 }, - { w: 110, h: 100, fill: 0x2C4A3A, win: 0x90DDA0 }, - { w: 80, h: 140, fill: 0x3C2C4C, win: 0xCCA0FF }, - ]; - cfgs.forEach(({ w, h, fill, win }, i) => { - const g = this.make.graphics({ add: false }); - g.fillStyle(fill); g.fillRect(0, 0, w, h); - for (let row = 10; row < h - 20; row += 24) { - for (let col = 8; col < w - 12; col += 18) { - g.fillStyle(win, Math.random() > 0.3 ? 0.85 : 0.15); - g.fillRect(col, row, 10, 14); - } - } - // cornice – lighter tone of fill color - g.fillStyle(Math.min(fill + 0x1a1a1a, 0xffffff)); g.fillRect(0, 0, w, 8); - g.generateTexture('bldg' + i, w, h); g.destroy(); - }); - } - _genPedestrian() { const g = this.make.graphics({ add: false }); g.fillStyle(0xF4C08A); g.fillCircle(8, 6, 6); @@ -155,8 +149,8 @@ class FireTruckScene extends Phaser.Scene { const dimCols = [0x551111, 0x554400, 0x115511]; tlKeys.forEach((key, bi) => { const g = this.make.graphics({ add: false }); - g.fillStyle(0x555555); g.fillRect(7, 60, 6, 40); // pole - g.fillStyle(0x222222); g.fillRoundedRect(0, 0, 20, 60, 3); // box + g.fillStyle(0x555555); g.fillRect(7, 60, 6, 40); + g.fillStyle(0x222222); g.fillRoundedRect(0, 0, 20, 60, 3); litCols.forEach((c, j) => { g.fillStyle(bi === j ? c : dimCols[j]); g.fillCircle(10, 12 + j * 18, 7); @@ -195,49 +189,43 @@ class FireTruckScene extends Phaser.Scene { const W = this.scale.width; const H = this.scale.height; - // Ground fills the lower half of screen (road draws on top) this.groundRect = this.add.rectangle(W / 2, H * HORIZON_FRAC, W, H, 0x555544).setOrigin(0.5, 0); + this.roadGfx = this.add.graphics(); - // Road (redrawn every frame in update) - this.roadGfx = this.add.graphics(); - - // World pools this._spawnPools(); - // Fire sprite (hidden until destination) - this.fireSprite = this.add.image(W / 2, H * 0.4, 'fire').setScale(0).setDepth(5); + // Fire glow + sprite (on building wall) + this.fireGlow = this.add.rectangle(0, 0, 80, 80, 0xFF6600, 0.5).setDepth(4).setVisible(false); + this.fireSprite = this.add.image(0, 0, 'fire').setScale(0).setDepth(5).setVisible(false); - // Truck at bottom-center this.truckSprite = this.add.image(W / 2, H * 0.85, 'truck').setDepth(10); - - // Light bars (Phaser Rectangle objects) - this.lightBarL = this.add.rectangle(0, 0, 30, 8, 0xE63946).setDepth(11).setVisible(false); - this.lightBarR = this.add.rectangle(0, 0, 30, 8, 0x1D7CF2).setDepth(11).setVisible(false); + this.lightBarL = this.add.rectangle(0, 0, 30, 8, 0xE63946).setDepth(11).setVisible(false); + this.lightBarR = this.add.rectangle(0, 0, 30, 8, 0x1D7CF2).setDepth(11).setVisible(false); this._sizeToScreen(W, H); // Arrow prompt UI - this.arrowBg = this.add.rectangle(W / 2, H * 0.3, 120, 120, 0x000000, 0.6) + this.arrowBg = this.add.rectangle(W / 2, H * 0.3, 160, 130, 0x000000, 0.6) .setDepth(20).setVisible(false).setStrokeStyle(3, 0xffffff, 0.8).setOrigin(0.5); - this.arrowSprite = this.add.image(W / 2, H * 0.28, 'arrow_up') + // T-intersection: show both arrows side-by-side + this.arrowLeft = this.add.image(W / 2 - 44, H * 0.27, 'arrow_left') + .setDepth(21).setVisible(false).setScale(1.0).setAlpha(0.4); + this.arrowRight = this.add.image(W / 2 + 44, H * 0.27, 'arrow_right') + .setDepth(21).setVisible(false).setScale(1.0).setAlpha(0.4); + // 4-way: single arrow + this.arrowMain = this.add.image(W / 2, H * 0.27, 'arrow_up') .setDepth(21).setVisible(false).setScale(1.3); - this.arrowText = this.add.text(W / 2, H * 0.4, '', { - fontFamily: 'Fredoka, sans-serif', - fontSize: '22px', - color: '#ffffff', + this.arrowText = this.add.text(W / 2, H * 0.41, '', { + fontFamily: 'Fredoka, sans-serif', fontSize: '22px', color: '#ffffff', }).setOrigin(0.5).setDepth(22).setVisible(false); // HUD this.instrText = this.add.text(W / 2, H * 0.07, '', { - fontFamily: 'Fredoka, sans-serif', - fontSize: '20px', - color: '#eeeeee', + fontFamily: 'Fredoka, sans-serif', fontSize: '20px', color: '#eeeeee', }).setOrigin(0.5).setDepth(30); this.roundText = this.add.text(12, 8, 'Round 1', { - fontFamily: 'Fredoka, sans-serif', - fontSize: '18px', - color: '#888888', + fontFamily: 'Fredoka, sans-serif', fontSize: '18px', color: '#888888', }).setDepth(30); // Input @@ -251,24 +239,14 @@ class FireTruckScene extends Phaser.Scene { this.scale.on('resize', gs => this.onResize(gs.width, gs.height)); this.newRound(); - - // Expose scene for browser tests window.__FT_SCENE__ = this; } _spawnPools() { - const bldgKeys = ['bldg0', 'bldg1', 'bldg2', 'bldg3']; - for (let i = 0; i < BLDG_COUNT; i++) { - const key = Phaser.Math.RND.pick(bldgKeys); - const side = i % 2 === 0 ? -1 : 1; - const z = 1500 + i * 2000; - const sp = this.add.image(0, 0, key).setDepth(3).setVisible(false); - this.bldgs.push({ sp, side, z, key }); - } for (let i = 0; i < PED_COUNT; i++) { const side = i % 2 === 0 ? -1 : 1; const z = 2000 + i * 2800; - const sp = this.add.image(0, 0, 'ped').setDepth(4).setVisible(false); + const sp = this.add.image(0, 0, 'ped').setDepth(6).setVisible(false); this.peds.push({ sp, side, z }); } const tlKeys = ['tlight_red', 'tlight_yellow', 'tlight_green']; @@ -276,7 +254,7 @@ class FireTruckScene extends Phaser.Scene { const side = i % 2 === 0 ? -1 : 1; const z = (i + 1) * INTERSECTION_GAP - 500; const phase = Phaser.Math.Between(0, 2); - const sp = this.add.image(0, 0, tlKeys[phase]).setDepth(4).setVisible(false); + const sp = this.add.image(0, 0, tlKeys[phase]).setDepth(6).setVisible(false); this.tlights.push({ sp, side, z, phase, timer: 3000 + phase * 1800 }); } } @@ -285,7 +263,6 @@ class FireTruckScene extends Phaser.Scene { const sc = Math.min(W, H) / 340; const ty = H * 0.82; this.truckSprite.setScale(sc).setPosition(W / 2, ty); - const tw = sc * 140; const lbW = tw * 0.24; const lbH = sc * 10; @@ -294,105 +271,254 @@ class FireTruckScene extends Phaser.Scene { this.lightBarR.setDisplaySize(lbW, lbH).setPosition(W / 2 + tw * 0.18, lbY); } - // ── Update (called every frame) ──────────────────────────────────────────── + // ── Update ───────────────────────────────────────────────────────────────── update(time, dt) { - if (this.state === 'arrived') return; + 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); + return; + } - const W = this.scale.width; - const H = this.scale.height; - const horizY = H * HORIZON_FRAC; + const W = this.scale.width; + const H = this.scale.height; + const horizY = H * HORIZON_FRAC; if (this.state === 'driving' || this.state === 'awaiting') { this.camZ += DRIVE_SPEED * (dt / 1000); } - this._drawRoad(W, H, horizY); + this._drawCity(W, H, horizY); this._updateWorld(W, H, horizY, dt, time); this._checkIntersection(W, H); } - // ── Road rendering (Y-based scanline) ───────────────────────────────────── - // Iterates from screen bottom upward, computing the Z (and thus depth scale) - // for each row. This guarantees full coverage from bottom to horizon. + // ── City scanline renderer ───────────────────────────────────────────────── + // Draws road + kerb + sidewalk + building walls in one Y-scanline pass, + // then paints above-horizon building facades in a second pass. - _drawRoad(W, H, horizY) { + _drawCity(W, H, horizY) { const gfx = this.roadGfx; gfx.clear(); - const roadColA = 0x3A3A3A; - const roadColB = 0x484848; - const kerbColA = 0xDDDDDD; - const kerbColB = 0x888888; - const sidewCol = 0x666655; - const dashCol = 0xCCCC44; - const cx = W / 2 + this.turnShift; + 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; - // scale = offset / (CAM_Y_WORLD * H/2); and scale = CAM_DEPTH / dz const scale = offset / (CAM_Y_WORLD * (H / 2)); const dz = CAM_DEPTH / scale; const worldZ = this.camZ + dz; - const segNum = Math.floor(worldZ / SEG_LEN); - const alt = segNum % 2; - - const roadPx = scale * ROAD_HALF_W * (W / 2); - const kerbPx = scale * (ROAD_HALF_W + KERB_W) * (W / 2); - const sidePx = scale * SIDEW_HALF_W * (W / 2); + // 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); } + } + } - // sidewalk - gfx.fillStyle(sidewCol); - gfx.fillRect(cx - sidePx, sy, sidePx - kerbPx, 2); - gfx.fillRect(cx + kerbPx, sy, sidePx - kerbPx, 2); + // ── Above-horizon building facades ─────────────────────────────────────── + // Render several upcoming building segments as tall quads on each side. + this._drawBuildingFacades(gfx, W, H, horizY, iZones, sinP, cosP); + } - // kerb (striped alternating white/grey) - gfx.fillStyle(alt ? kerbColA : kerbColB); - gfx.fillRect(cx - kerbPx, sy, kerbPx - roadPx, 2); - gfx.fillRect(cx + roadPx, sy, kerbPx - roadPx, 2); + _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); + + // Draw facade as stacked horizontal slices for correct fill + // Use polygon approach: 4 corners of the facade face + const topY = Math.min(yNearTop, yFarTop, 0); + const bottomY = Math.max(yNearBottom, yFarBottom, horizY); + + if (bottomY <= topY) continue; + + // Interpolate x-left/right at each screen row between horizY and bottomY + // Near edge: at y approaching horizY, x → horizon vanishing point; at yNearBottom x = xNearBottom + // Far edge: similar but for far Z + // Simpler: just draw a filled trapezoid using fillPoints + if (side < 0) { + // Left side: facade from sidewalk edge to screen left + // Near wall x = xNearBottom (at bottom) to xNearTop (at top) + // Fill screen left (x=0) to wall face + const pts = [ + { x: 0, y: topY }, + { x: xFarTop, y: topY }, + { x: xFarBottom, y: horizY }, + { x: xNearBottom, y: yNearBottom }, + { x: 0, y: yNearBottom }, + ]; + gfx.fillPoints(pts.map(p => new Phaser.Geom.Point(p.x, p.y)), true); + } else { + // Right side + const pts = [ + { x: xFarTop, y: topY }, + { x: W, y: topY }, + { x: W, y: yNearBottom }, + { x: xNearBottom, y: yNearBottom }, + { x: xFarBottom, y: horizY }, + ]; + gfx.fillPoints(pts.map(p => new Phaser.Geom.Point(p.x, p.y)), true); + } - // road - gfx.fillStyle(alt ? roadColA : roadColB); - gfx.fillRect(cx - roadPx, sy, 2 * roadPx, 2); + // 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); + } + } + } + } - // center dashes - if (alt === 0) { - gfx.fillStyle(dashCol); - gfx.fillRect(cx - 2, sy, 4, 2); + _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); + } } } } - // ── World-object update & projection ────────────────────────────────────── + _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 }; + } + + // ── World-object update ──────────────────────────────────────────────────── _proj(wx, wy, wz) { return project( this.scale.width, this.scale.height, this.camZ, CAM_Y_WORLD, CAM_DEPTH, this.scale.height * HORIZON_FRAC, - wx, wy, wz, this.turnShift + wx, wy, wz, 0 ); } _updateWorld(W, H, horizY, dt, time) { - const bldgKeys = ['bldg0', 'bldg1', 'bldg2', 'bldg3']; - const maxDz = DRAW_SEGS * SEG_LEN; - - // Buildings - this.bldgs.forEach(b => { - const dz = b.z - this.camZ; - if (dz < SEG_LEN) { - b.z = this.camZ + Phaser.Math.Between(8000, 20000); - b.key = Phaser.Math.RND.pick(bldgKeys); - b.sp.setTexture(b.key); - } - if (dz > maxDz) { b.sp.setVisible(false); return; } - const { x, y, scale, visible } = this._proj(b.side * BLDG_X_W, 0, b.z); - if (!visible) { b.sp.setVisible(false); return; } - const texH = this._texH(b.key); - const sc = scale * BLDG_H_W * (H / 2) / texH; - b.sp.setVisible(sc > 0.05).setPosition(x, y - texH * sc * 0.5).setScale(sc).setDepth(3 + 1 / (dz + 1)); - }); + const maxDz = DRAW_SEGS * SEG_LEN; // Pedestrians this.peds.forEach(p => { @@ -406,7 +532,7 @@ class FireTruckScene extends Phaser.Scene { const { x, y, scale, visible } = this._proj(p.side * PED_X_W + wobble * 200, 0, p.z); if (!visible) { p.sp.setVisible(false); return; } const sc = scale * PED_H_W * (H / 2) / 36; - p.sp.setVisible(sc > 0.04).setPosition(x, y - 18 * sc).setScale(sc).setDepth(4 + 1 / (dz + 1)); + p.sp.setVisible(sc > 0.04).setPosition(x, y - 18 * sc).setScale(sc).setDepth(6 + 1 / (dz + 1)); }); // Traffic lights @@ -423,40 +549,43 @@ class FireTruckScene extends Phaser.Scene { const { x, y, scale, visible } = this._proj(tl.side * TLIGHT_X_W, 0, tl.z); if (!visible) { tl.sp.setVisible(false); return; } const sc = scale * TLIGHT_H_W * (H / 2) / 100; - tl.sp.setVisible(sc > 0.04).setPosition(x, y - 50 * sc).setScale(sc).setDepth(4 + 1 / (dz + 1)); + tl.sp.setVisible(sc > 0.04).setPosition(x, y - 50 * sc).setScale(sc).setDepth(6 + 1 / (dz + 1)); }); - // Fire (at final destination) + // Fire (on building wall at final destination) if (this.route.length > 0) { const dest = this.route[this.route.length - 1]; const dz = dest.z - this.camZ; if (dz > 0 && dz < maxDz) { - const { x, y, scale } = this._proj(0, 0, dest.z); - const sc = scale * FIRE_H_W * (H / 2) / 60; - const flicker = 1 + 0.06 * Math.sin(Date.now() / 150); - this.fireSprite.setVisible(sc > 0.05) - .setPosition(x, y - 30 * sc) + const fireWx = this.fireSide * (SIDEW_HALF_W - 300); + const { x, y, scale } = this._proj(fireWx, FIRE_H_W * 1.5, dest.z); + const sc = scale * FIRE_H_W * (H / 2) / 60; + const flicker = 1 + 0.07 * Math.sin(Date.now() / 140); + const glowSc = sc * flicker * 2.2; + this.fireGlow.setVisible(sc > 0.04) + .setPosition(x, y) + .setDisplaySize(60 * glowSc, 60 * glowSc); + this.fireSprite.setVisible(sc > 0.04) + .setPosition(x, y - 20 * sc) .setScale(sc * flicker) - .setDepth(5); + .setDepth(7); } else { + this.fireGlow.setVisible(false); this.fireSprite.setVisible(false); } } } - _texH(key) { - return { bldg0: 120, bldg1: 160, bldg2: 100, bldg3: 140 }[key] || 120; - } - // ── Intersection logic ───────────────────────────────────────────────────── _checkIntersection(W, H) { if (this.routeIdx >= this.route.length) return; const next = this.route[this.routeIdx]; const dist = next.z - this.camZ; + const isLast = this.routeIdx === this.route.length - 1; if (dist <= ARRIVE_DIST) { - if (this.routeIdx === this.route.length - 1) { + if (isLast) { this._onArrived(); } else { this._hideArrow(); @@ -464,26 +593,49 @@ class FireTruckScene extends Phaser.Scene { this.state = 'driving'; } } else if (dist < PROMPT_DIST) { - this._showArrow(next.dir, W, H); + this._showArrow(next, W, H); } else { if (this.state === 'awaiting') this._hideArrow(); } } - _showArrow(dir, W, H) { - if (this.state === 'awaiting') return; // already showing + _showArrow(entry, W, H) { + if (this.state === 'awaiting') return; this.state = 'awaiting'; - const key = dir === 'left' ? 'arrow_left' : dir === 'right' ? 'arrow_right' : 'arrow_up'; - const label = dir === 'left' ? '← Turn Left' : dir === 'right' ? 'Turn Right →' : '↑ Straight'; - this.arrowBg.setVisible(true).setPosition(W / 2, H * 0.3); - this.arrowSprite.setTexture(key).setVisible(true).setPosition(W / 2, H * 0.27); - this.arrowText.setText(label).setVisible(true).setPosition(W / 2, H * 0.41); + + const isT = entry.kind === 't'; + + if (isT) { + // Show both arrows; highlight the correct one + this.arrowBg.setVisible(true).setPosition(W / 2, H * 0.3).setSize(160, 130); + this.arrowMain.setVisible(false); + this.arrowLeft.setVisible(true).setPosition(W / 2 - 44, H * 0.27) + .setAlpha(entry.dir === 'left' ? 1.0 : 0.3); + this.arrowRight.setVisible(true).setPosition(W / 2 + 44, H * 0.27) + .setAlpha(entry.dir === 'right' ? 1.0 : 0.3); + this.arrowText.setText('T-intersection!').setVisible(true).setPosition(W / 2, H * 0.41); + if (this.instrText.active) { + this.instrText.setText('Left or right — no straight!'); + this.time.delayedCall(2000, () => { if (this.instrText.active) this.instrText.setText(''); }); + } + } else { + // 4-way: single arrow + const key = entry.dir === 'left' ? 'arrow_left' : entry.dir === 'right' ? 'arrow_right' : 'arrow_up'; + const label = entry.dir === 'left' ? '← Turn Left' : entry.dir === 'right' ? 'Turn Right →' : '↑ Straight'; + this.arrowBg.setVisible(true).setPosition(W / 2, H * 0.3).setSize(120, 120); + this.arrowLeft.setVisible(false); + this.arrowRight.setVisible(false); + this.arrowMain.setTexture(key).setVisible(true).setPosition(W / 2, H * 0.27); + this.arrowText.setText(label).setVisible(true).setPosition(W / 2, H * 0.41); + } } _hideArrow() { this.state = 'driving'; this.arrowBg.setVisible(false); - this.arrowSprite.setVisible(false); + this.arrowMain.setVisible(false); + this.arrowLeft.setVisible(false); + this.arrowRight.setVisible(false); this.arrowText.setVisible(false); } @@ -497,6 +649,12 @@ class FireTruckScene extends Phaser.Scene { if (k === 's' || k === 'S') { this.toggleSiren(); return; } if (k === 'b' || k === 'B') { this.ringBell(); return; } + // Spacebar sprays water at the fire + if (this.state === 'arrived' && (k === ' ' || k === 'Spacebar')) { + this._sprayAndAdvance(); + return; + } + if (this.state !== 'awaiting') return; const next = this.route[this.routeIdx]; @@ -508,6 +666,12 @@ class FireTruckScene extends Phaser.Scene { if (k === 'ArrowUp') pressed = 'straight'; if (!pressed) return; + // T-intersection: straight is always wrong + if (next.kind === 't' && pressed === 'straight') { + this._onWrongTurn(); + return; + } + if (pressed === next.dir) { this._onCorrectTurn(next.dir); } else { @@ -520,16 +684,39 @@ class FireTruckScene extends Phaser.Scene { this.state = 'turning'; this.playTurnSound(); - const shift = dir === 'left' ? 140 : dir === 'right' ? -140 : 0; + // Pivot angle: negative = swing left, positive = swing right + const targetAngle = dir === 'left' ? -Math.PI / 2 : dir === 'right' ? Math.PI / 2 : 0; + + if (targetAngle === 0) { + // Straight: brief forward surge, no pivot needed + this.routeIdx++; + this.state = 'driving'; + return; + } + + // Phase 1: swing into the turn this.tweens.add({ targets: this, - turnShift: shift, - duration: 280, - ease: 'Quad.InOut', + turnAngle: targetAngle, + duration: TURN_DURATION_MS * 0.6, + ease: 'Quad.In', onComplete: () => { - this.turnShift = 0; - this.routeIdx++; - this.state = 'driving'; + // Snap: advance past intersection, reset pivot + const next = this.route[this.routeIdx]; + if (next) this.camZ = next.z + ARRIVE_DIST + 100; + this.turnAngle = -targetAngle * 0.45; // come from opposite side + // Phase 2: settle back to straight + this.tweens.add({ + targets: this, + turnAngle: 0, + duration: TURN_DURATION_MS * 0.4, + ease: 'Quad.Out', + onComplete: () => { + this.turnAngle = 0; + this.routeIdx++; + this.state = 'driving'; + }, + }); }, }); } @@ -546,13 +733,20 @@ class FireTruckScene extends Phaser.Scene { _onArrived() { this.state = 'arrived'; this._hideArrow(); - this.instrText.setText('You made it! Putting out the fire!'); + this.instrText.setText('🔥 Press SPACE to spray water!'); + this.speak('Fire! Press space to spray water!'); + } + + _sprayAndAdvance() { + // Prevent double-firing + this.state = 'spraying'; + this.instrText.setText('Putting out the fire!'); this.playSuccess(); this.playSuccessJingle(); this._burstConfetti(); this._sprayWater(); - this.time.delayedCall(3000, () => { + this.time.delayedCall(2500, () => { this.round++; this.newRound(); }); @@ -561,13 +755,9 @@ class FireTruckScene extends Phaser.Scene { _burstConfetti() { const { width: W, height: H } = this.scale; const em = this.add.particles(W / 2, H * 0.4, 'sq', { - speed: { min: 120, max: 380 }, - angle: { min: 0, max: 360 }, - scale: { start: 1.1, end: 0 }, - gravityY: 450, - lifespan: 950, - tint: TINTS, - emitting: false, + speed: { min: 120, max: 380 }, angle: { min: 0, max: 360 }, + scale: { start: 1.1, end: 0 }, gravityY: 450, + lifespan: 950, tint: TINTS, emitting: false, }); em.explode(55); this.time.delayedCall(1200, () => { if (em.active) em.destroy(); }); @@ -575,19 +765,20 @@ class FireTruckScene extends Phaser.Scene { _sprayWater() { const { width: W, height: H } = this.scale; + // Spray toward the fire side + const fireScreenX = this.fireSprite.visible ? this.fireSprite.x : W / 2; + const angleCenter = fireScreenX < W / 2 ? 220 : 320; const em = this.add.particles(W / 2, H * 0.35, 'sq', { - speed: { min: 60, max: 200 }, - angle: { min: 250, max: 290 }, - scale: { start: 0.9, end: 0 }, - gravityY: 130, - lifespan: 700, - tint: [0x1D7CF2, 0x55AAFF, 0xAADDFF], - frequency: 35, - quantity: 3, + speed: { min: 80, max: 240 }, + angle: { min: angleCenter - 25, max: angleCenter + 25 }, + scale: { start: 0.9, end: 0 }, gravityY: 130, + lifespan: 700, tint: [0x1D7CF2, 0x55AAFF, 0xAADDFF], + frequency: 30, quantity: 4, }); - this.time.delayedCall(2000, () => { + this.time.delayedCall(1800, () => { em.stop(); this.fireSprite.setVisible(false); + this.fireGlow.setVisible(false); this.time.delayedCall(400, () => { if (em.active) em.destroy(); }); }); } @@ -597,11 +788,16 @@ class FireTruckScene extends Phaser.Scene { newRound() { this.camZ = 0; this.routeIdx = 0; - this.turnShift = 0; + this.turnAngle = 0; this.state = 'driving'; this.route = buildRoute(ROUTE_LEN, INTERSECTION_GAP, Phaser.Math.RND); + // Pick fire side from the last route entry direction + const last = this.route[this.route.length - 1]; + this.fireSide = last.dir === 'left' ? -1 : 1; + this.fireSprite.setVisible(false).setScale(0); + this.fireGlow.setVisible(false); this._hideArrow(); this.instrText.setText('Follow the arrows to the fire!'); this.roundText.setText('Round ' + this.round); @@ -618,9 +814,10 @@ class FireTruckScene extends Phaser.Scene { this.groundRect.setPosition(W / 2, H * HORIZON_FRAC).setSize(W, H); this._sizeToScreen(W, H); - const horizY = H * HORIZON_FRAC; this.arrowBg.setPosition(W / 2, H * 0.3); - this.arrowSprite.setPosition(W / 2, H * 0.27); + this.arrowMain.setPosition(W / 2, H * 0.27); + this.arrowLeft.setPosition(W / 2 - 44, H * 0.27); + this.arrowRight.setPosition(W / 2 + 44, H * 0.27); this.arrowText.setPosition(W / 2, H * 0.41); this.instrText.setPosition(W / 2, H * 0.07); } @@ -630,7 +827,6 @@ class FireTruckScene extends Phaser.Scene { toggleLights() { this.lightsOn = !this.lightsOn; document.getElementById('btn-lights').classList.toggle('active', this.lightsOn); - if (this.lightsOn) { this.lightBarL.setVisible(true); this.lightBarR.setVisible(true); @@ -661,13 +857,10 @@ class FireTruckScene extends Phaser.Scene { const ctx = this.getAudioCtx(); this.sirenGain = ctx.createGain(); this.sirenGain.gain.value = 0.3; this.sirenGain.connect(ctx.destination); - this.sirenOsc1 = ctx.createOscillator(); this.sirenOsc1.type = 'sawtooth'; this.sirenOsc1.frequency.value = 770; this.sirenOsc1.connect(this.sirenGain); this.sirenOsc1.start(); - this.sirenOsc2 = ctx.createOscillator(); this.sirenOsc2.type = 'sawtooth'; this.sirenOsc2.frequency.value = 570; this.sirenOsc2.connect(this.sirenGain); this.sirenOsc2.start(); - let hi = true; this.sirenInterval = setInterval(() => { if (!this.sirenOsc1) return; @@ -695,7 +888,7 @@ class FireTruckScene extends Phaser.Scene { playBell() { if (this.sfxMuted) return; try { - const ctx = this.getAudioCtx(); + const ctx = this.getAudioCtx(); [1046, 1318, 1568].forEach((freq, i) => { const osc = ctx.createOscillator(); const gain = ctx.createGain(); osc.connect(gain); gain.connect(ctx.destination); osc.type = 'sine'; diff --git a/games/fire-truck/lib.js b/games/fire-truck/lib.js @@ -2,21 +2,6 @@ // Browser: exposed as globals. Node: CommonJS exports. // ── Projection ──────────────────────────────────────────────────────────────── -// -// Standard Outrun-style perspective projection. -// -// W, H – canvas dimensions (px) -// camZ – camera Z position in world units -// camYWorld – camera height above ground in world units (e.g. 1500) -// camDepth – 1/tan(fov/2) where fov≈100° ⟹ ~0.84 -// horizonY – screen Y of the vanishing line (px); typically H * HORIZON_FRAC -// wx, wy, wz – world-space position of the point to project -// turnShift – extra horizontal pixel offset (used during turn animation) -// -// Returns { x, y, scale, visible } -// x, y – screen pixel position -// scale – CAM_DEPTH / dz, used to size sprites -// visible – false when dz ≤ 0 (behind camera) function project(W, H, camZ, camYWorld, camDepth, horizonY, wx, wy, wz, turnShift) { const dz = wz - camZ; @@ -25,27 +10,28 @@ function project(W, H, camZ, camYWorld, camDepth, horizonY, wx, wy, wz, turnShif } const scale = camDepth / dz; const sx = W / 2 + scale * wx * (W / 2) + (turnShift || 0); - // Ground-level points (wy=0) project BELOW horizonY; offset shrinks as dz→∞ const sy = horizonY + scale * (camYWorld - wy) * (H / 2); return { x: sx, y: sy, scale, visible: true }; } // ── Route generator ─────────────────────────────────────────────────────────── // -// routeLen – number of intersections (last one = fire destination) -// gap – world-unit spacing between intersections -// rng – optional object with .pick(array) method; falls back to Math.random -// -// Returns an array of { z, dir } objects with strictly-increasing Z values. +// Returns array of { z, dir, kind } where: +// kind – 'four' (L/R/straight allowed) or 't' (only L/R allowed) +// dir – correct direction at this intersection function buildRoute(routeLen, gap, rng) { - const dirs = ['left', 'right', 'straight']; - const route = []; + const lrDirs = ['left', 'right']; + const allDirs = ['left', 'right', 'straight']; + const route = []; for (let i = 0; i < routeLen; i++) { - const dir = rng - ? rng.pick(dirs) - : dirs[Math.floor(Math.random() * dirs.length)]; - route.push({ z: (i + 1) * gap, dir }); + const isLast = i === routeLen - 1; + // Last intersection is always T (fire is on a corner building) + const kind = isLast || (rng ? rng.pick([true, false, false]) : Math.random() < 0.4) + ? 't' : 'four'; + const dirs = kind === 't' ? lrDirs : allDirs; + const dir = rng ? rng.pick(dirs) : dirs[Math.floor(Math.random() * dirs.length)]; + route.push({ z: (i + 1) * gap, dir, kind }); } return route; } diff --git a/tests/unit/route.test.js b/tests/unit/route.test.js @@ -43,11 +43,12 @@ describe('buildRoute()', () => { assert.deepEqual(a, b); }); - it('each entry has exactly z and dir properties', () => { + it('each entry has z, dir, and kind properties', () => { const r = buildRoute(3, 4000, fixedRng); r.forEach((seg, i) => { const keys = Object.keys(seg).sort(); - assert.deepEqual(keys, ['dir', 'z'], `r[${i}] keys: ${keys}`); + assert.deepEqual(keys, ['dir', 'kind', 'z'], `r[${i}] keys: ${keys}`); + assert.ok(['four', 't'].includes(seg.kind), `r[${i}] kind invalid: ${seg.kind}`); }); }); });