kgames

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

commit 1ca8544426df7055ce8be4e4fb9a20e29d3d2aca
parent 0cb47d542fdf48e0a75e3a6986f08caf6c0de34f
Author: Kyle Barlow <kb@kylebarlow.com>
Date:   Wed, 22 Apr 2026 12:11:30 -0700

Remove all code for fire truck game, leave placeholder icon

Diffstat:
Dgames/fire-truck/game.js | 1040-------------------------------------------------------------------------------
Dgames/fire-truck/index.html | 105-------------------------------------------------------------------------------
Dgames/fire-truck/lib.js | 143-------------------------------------------------------------------------------
Mjs/main.js | 2+-
Dtests/unit/fire-truck-segment.test.js | 46----------------------------------------------
5 files changed, 1 insertion(+), 1335 deletions(-)

diff --git a/games/fire-truck/game.js b/games/fire-truck/game.js @@ -1,1040 +0,0 @@ -// ── 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.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 = []; - - 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; - this.musicMuted = false; - this.sfxMuted = false; - - this.audioCtx = null; - this.musicReady = false; - this.bgGain = null; - this.jingleSynth = null; - this.seqStep = 0; - this.successSteps = 0; - - this.sirenOsc1 = null; - this.sirenOsc2 = null; - this.sirenGain = null; - this.sirenTimer = null; - this.sirenHi = true; - this.lightPhase = 0; - this.lightTimer = null; - } - - // ── Preload ──────────────────────────────────────────────────────────────── - - preload() { - this._genParticle(); - this._genTruck(); - this._genPedestrian(); - this._genTrafficLight(); - this._genArrows(); - this._genFire(); - } - - _genParticle() { - const g = this.make.graphics({ add: false }); - g.fillStyle(0xffffff); g.fillRect(0, 0, 8, 8); - g.generateTexture('sq', 8, 8); g.destroy(); - } - - _genTruck() { - const g = this.make.graphics({ add: false }); - 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); - }); - g.fillStyle(0x333333); g.fillRoundedRect(10, 8, 82, 14, 4); - g.generateTexture('truck', 140, 100); g.destroy(); - } - - _genPedestrian() { - const g = this.make.graphics({ add: false }); - g.fillStyle(0xF4C08A); g.fillCircle(8, 6, 6); - g.fillStyle(0x4444AA); g.fillRect(3, 12, 10, 14); - g.fillStyle(0x222266); g.fillRect(3, 26, 4, 10); g.fillRect(9, 26, 4, 10); - g.generateTexture('ped', 16, 36); g.destroy(); - } - - _genTrafficLight() { - const tlKeys = ['tlight_red', 'tlight_yellow', 'tlight_green']; - const litCols = [0xFF3333, 0xFFDD00, 0x33DD33]; - 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); - 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); - }); - g.generateTexture(key, 20, 100); g.destroy(); - }); - } - - _genArrows() { - [['arrow_left', -1], ['arrow_right', 1], ['arrow_up', 0]].forEach(([key, dir]) => { - const g = this.make.graphics({ add: false }); - g.fillStyle(0xFFFFFF); - if (dir === 0) { - g.fillTriangle(32, 0, 64, 40, 0, 40); - g.fillRect(20, 38, 24, 30); - } else { - const d = dir; - g.fillRect(24, 16, 16, 44); - g.fillTriangle(32 + d * 24, 0, 32 - d * 20, 30, 32 + d * 4, 30); - } - g.generateTexture(key, 64, 68); g.destroy(); - }); - } - - _genFire() { - const g = this.make.graphics({ add: false }); - g.fillStyle(0xFF4400); g.fillEllipse(24, 48, 48, 60); - g.fillStyle(0xFF8800); g.fillEllipse(24, 40, 32, 44); - g.fillStyle(0xFFDD00); g.fillEllipse(24, 34, 18, 28); - g.generateTexture('fire', 48, 60); g.destroy(); - } - - // ── Create ───────────────────────────────────────────────────────────────── - - create() { - const W = this.scale.width; - const H = this.scale.height; - - this.groundRect = this.add.rectangle(W / 2, H * HORIZON_FRAC, W, H, 0x555544).setOrigin(0.5, 0); - this.roadGfx = this.add.graphics(); - - this._spawnPools(); - - // 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); - - this.truckSprite = this.add.image(W / 2, H * 0.85, 'truck').setDepth(10); - 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, 160, 130, 0x000000, 0.6) - .setDepth(20).setVisible(false).setStrokeStyle(3, 0xffffff, 0.8).setOrigin(0.5); - // 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.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', - }).setOrigin(0.5).setDepth(30); - - this.roundText = this.add.text(12, 8, 'Round 1', { - fontFamily: 'Fredoka, sans-serif', fontSize: '18px', color: '#888888', - }).setDepth(30); - - // Input - this.input.keyboard.on('keydown', this.onKey, this); - document.getElementById('btn-music').addEventListener('click', () => this.toggleMusic()); - document.getElementById('btn-sfx').addEventListener('click', () => this.toggleSfx()); - document.getElementById('btn-lights').addEventListener('click', () => this.toggleLights()); - document.getElementById('btn-siren').addEventListener('click', () => this.toggleSiren()); - document.getElementById('btn-bell').addEventListener('click', () => this.ringBell()); - - this.scale.on('resize', gs => this.onResize(gs.width, gs.height)); - - this.newRound(); - window.__FT_SCENE__ = this; - } - - _spawnPools() { - 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(6).setVisible(false); - this.peds.push({ sp, side, z }); - } - const tlKeys = ['tlight_red', 'tlight_yellow', 'tlight_green']; - for (let i = 0; i < TLIGHT_COUNT; i++) { - 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(6).setVisible(false); - this.tlights.push({ sp, side, z, phase, timer: 3000 + phase * 1800 }); - } - } - - _sizeToScreen(W, H) { - 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; - const lbY = ty - sc * 47; - this.lightBarL.setDisplaySize(lbW, lbH).setPosition(W / 2 - tw * 0.18, lbY); - this.lightBarR.setDisplaySize(lbW, lbH).setPosition(W / 2 + tw * 0.18, lbY); - } - - // ── Update ───────────────────────────────────────────────────────────────── - - update(time, dt) { - if (this.state === 'arrived') { - // Still redraw road/buildings while stopped - const W = this.scale.width; - const H = this.scale.height; - this._drawSegments(W, H, H * HORIZON_FRAC); - return; - } - - 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._drawSegments(W, H, horizY); - this._updateWorld(W, H, horizY, dt, time); - this._checkIntersection(W, H); - } - -// ── Segment-based renderer ──────────────────────────────────────────────────── - // Renders road segments from far to near using a painter's algorithm. - - _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; - - 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); - } - -// Draw building facades above horizon - if (!seg.intersection) { - this._drawBuildingFacade(gfx, W, H, horizY, cosP, sinP, seg, sidePx, scale); - } - } - - _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); - } - } - - _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); - } - } - - _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); - } - } - } - - _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( - this.scale.width, this.scale.height, - this.camZ, CAM_Y_WORLD, CAM_DEPTH, this.scale.height * HORIZON_FRAC, - wx, wy, wz, 0 - ); - } - - _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; - - // Pedestrians - this.peds.forEach(p => { - const dz = p.z - this.camZ; - if (dz < SEG_LEN) { - p.z = this.camZ + Phaser.Math.Between(4000, 18000); - p.side = Phaser.Math.Between(0, 1) ? -1 : 1; - } - if (dz > maxDz) { p.sp.setVisible(false); return; } - const wobble = Math.sin(time / 350 + p.z * 0.003) * 0.03; - 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(6 + 1 / (dz + 1)); - }); - - // Traffic lights - const tlKeys = ['tlight_red', 'tlight_yellow', 'tlight_green']; - this.tlights.forEach(tl => { - tl.timer -= dt; - if (tl.timer <= 0) { - tl.phase = (tl.phase + 1) % 3; - tl.sp.setTexture(tlKeys[tl.phase]); - tl.timer = tl.phase === 1 ? 2000 : 5500; - } - const dz = tl.z - this.camZ; - if (dz < SEG_LEN || dz > maxDz) { tl.sp.setVisible(false); return; } - 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(6 + 1 / (dz + 1)); - }); - - // 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 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(7); - } else { - this.fireGlow.setVisible(false); - this.fireSprite.setVisible(false); - } - } - } - - // ── 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 (isLast) { - this._onArrived(); - } else { - this._hideArrow(); - this.routeIdx++; - this.state = 'driving'; - } - } else if (dist < PROMPT_DIST) { - this._showArrow(next, W, H); - } else { - if (this.state === 'awaiting') this._hideArrow(); - } - } - - _showArrow(entry, W, H) { - if (this.state === 'awaiting') return; - this.state = 'awaiting'; - - 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.arrowMain.setVisible(false); - this.arrowLeft.setVisible(false); - this.arrowRight.setVisible(false); - this.arrowText.setVisible(false); - } - - // ── Input ────────────────────────────────────────────────────────────────── - - onKey(event) { - this.initMusic(); - const k = event.key; - - if (k === 'l' || k === 'L') { this.toggleLights(); return; } - 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]; - if (!next) return; - - let pressed = null; - if (k === 'ArrowLeft') pressed = 'left'; - if (k === 'ArrowRight') pressed = 'right'; - 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 { - this._onWrongTurn(); - } - } - - _onCorrectTurn(dir) { - this._hideArrow(); - this.state = 'turning'; - this.playTurnSound(); - - // 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, - turnAngle: targetAngle, - duration: TURN_DURATION_MS * 0.6, - ease: 'Quad.In', - onComplete: () => { - // 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'; - }, - }); - }, - }); - } - - _onWrongTurn() { - this.playBong(); - this.instrText.setText('Wrong way! Try again.'); - this.cameras.main.shake(130, 0.006); - this.time.delayedCall(1000, () => { - if (this.instrText.active) this.instrText.setText(''); - }); - } - - _onArrived() { - this.state = 'arrived'; - this._hideArrow(); - 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(2500, () => { - this.round++; - this.newRound(); - }); - } - - _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, - }); - em.explode(55); - this.time.delayedCall(1200, () => { if (em.active) em.destroy(); }); - } - - _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: 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(1800, () => { - em.stop(); - this.fireSprite.setVisible(false); - this.fireGlow.setVisible(false); - this.time.delayedCall(400, () => { if (em.active) em.destroy(); }); - }); - } - - // ── Round management ─────────────────────────────────────────────────────── - - newRound() { - this.camZ = 0; - this.routeIdx = 0; - 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]; - 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); - this.speak('Fire! Follow the arrows!'); - - this.time.delayedCall(2200, () => { - if (this.instrText.active) this.instrText.setText(''); - }); - } - - // ── Layout / resize ──────────────────────────────────────────────────────── - - onResize(W, H) { - this.groundRect.setPosition(W / 2, H * HORIZON_FRAC).setSize(W, H); - this._sizeToScreen(W, H); - - this.arrowBg.setPosition(W / 2, H * 0.3); - 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); - } - - // ── Lights ───────────────────────────────────────────────────────────────── - - 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); - this.lightTimer = this.time.addEvent({ - delay: 280, - callback: () => { - this.lightPhase = 1 - this.lightPhase; - this.lightBarL.setFillStyle(this.lightPhase ? 0xE63946 : 0x220000); - this.lightBarR.setFillStyle(this.lightPhase ? 0x220022 : 0x1D7CF2); - }, - loop: true - }); - } else { - if (this.lightTimer) { - this.lightTimer.remove(); - this.lightTimer = null; - } - this.lightBarL.setVisible(false); - this.lightBarR.setVisible(false); - } - } - - // ── Siren ────────────────────────────────────────────────────────────────── - - toggleSiren() { - this.initAudio(); - this.sirenOn = !this.sirenOn; - document.getElementById('btn-siren').classList.toggle('active', this.sirenOn); - this.sirenOn ? this._startSiren() : this._stopSiren(); - } - - _startSiren() { - if (this.sfxMuted) return; - try { - 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(); - this.sirenHi = true; - this.sirenTimer = this.time.addEvent({ - delay: 450, - callback: () => { - if (!this.sirenOsc1) return; - const t = this.getAudioCtx().currentTime; - this.sirenOsc1.frequency.linearRampToValueAtTime(this.sirenHi ? 960 : 770, t + 0.45); - this.sirenOsc2.frequency.linearRampToValueAtTime(this.sirenHi ? 700 : 570, t + 0.45); - this.sirenHi = !this.sirenHi; - }, - loop: true - }); - } catch (_) {} - } - - _stopSiren() { - if (this.sirenTimer) { - this.sirenTimer.remove(); - this.sirenTimer = null; - } - try { - if (this.sirenOsc1) { this.sirenOsc1.stop(); this.sirenOsc1 = null; } - if (this.sirenOsc2) { this.sirenOsc2.stop(); this.sirenOsc2 = null; } - if (this.sirenGain) { this.sirenGain.disconnect(); this.sirenGain = null; } - } catch (_) {} - } - - // ── Bell ─────────────────────────────────────────────────────────────────── - - ringBell() { this.initAudio(); this.playBell(); } - - playBell() { - if (this.sfxMuted) return; - try { - 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'; - const t = ctx.currentTime + i * 0.06; - osc.frequency.setValueAtTime(freq, t); - gain.gain.setValueAtTime(0, t); - gain.gain.linearRampToValueAtTime(0.28, t + 0.01); - gain.gain.exponentialRampToValueAtTime(0.001, t + 0.5); - osc.start(t); osc.stop(t + 0.52); - }); - } catch (_) {} - } - - // ── Audio helpers ────────────────────────────────────────────────────────── - - initAudio() { this.getAudioCtx(); } - - getAudioCtx() { - if (!this.audioCtx) - this.audioCtx = new (window.AudioContext || window.webkitAudioContext)(); - return this.audioCtx; - } - - playBong() { - if (this.sfxMuted) return; - try { - const ctx = this.getAudioCtx(); - const osc = ctx.createOscillator(); const gain = ctx.createGain(); - osc.connect(gain); gain.connect(ctx.destination); osc.type = 'sine'; - osc.frequency.setValueAtTime(160, ctx.currentTime); - osc.frequency.exponentialRampToValueAtTime(90, ctx.currentTime + 0.3); - gain.gain.setValueAtTime(0.35, ctx.currentTime); - gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.4); - osc.start(ctx.currentTime); osc.stop(ctx.currentTime + 0.41); - } catch (_) {} - } - - playTurnSound() { - if (this.sfxMuted) return; - try { - const ctx = this.getAudioCtx(); - const osc = ctx.createOscillator(); const gain = ctx.createGain(); - osc.connect(gain); gain.connect(ctx.destination); osc.type = 'triangle'; - osc.frequency.setValueAtTime(440, ctx.currentTime); - osc.frequency.linearRampToValueAtTime(660, ctx.currentTime + 0.15); - gain.gain.setValueAtTime(0.2, ctx.currentTime); - gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.2); - osc.start(ctx.currentTime); osc.stop(ctx.currentTime + 0.22); - } catch (_) {} - } - - playSuccess() { - if (this.sfxMuted) return; - try { - const ctx = this.getAudioCtx(); - [523, 659, 784, 1046].forEach((freq, i) => { - const osc = ctx.createOscillator(); const gain = ctx.createGain(); - osc.connect(gain); gain.connect(ctx.destination); osc.type = 'sine'; - const t = ctx.currentTime + i * 0.1; - osc.frequency.setValueAtTime(freq, t); - gain.gain.setValueAtTime(0, t); - gain.gain.linearRampToValueAtTime(0.28, t + 0.02); - gain.gain.exponentialRampToValueAtTime(0.001, t + 0.35); - osc.start(t); osc.stop(t + 0.37); - }); - } catch (_) {} - } - - // ── Music ────────────────────────────────────────────────────────────────── - - initMusic() { - this.initAudio(); - if (this.musicReady) return; - this.musicReady = true; - - this.bgGain = new Tone.Gain(this.musicMuted ? 0 : 0.7).toDestination(); - - const melody = new Tone.PolySynth(Tone.Synth, { - oscillator: { type: 'square' }, - envelope: { attack: 0.01, decay: 0.05, sustain: 0.25, release: 0.08 }, - }).connect(this.bgGain); - melody.volume.value = -20; - - this.jingleSynth = new Tone.Synth({ - oscillator: { type: 'triangle' }, - envelope: { attack: 0.01, decay: 0.08, sustain: 0.3, release: 0.15 }, - }).connect(this.bgGain); - this.jingleSynth.volume.value = -18; - - const bass = new Tone.Synth({ - oscillator: { type: 'triangle' }, - envelope: { attack: 0.02, decay: 0.12, sustain: 0.4, release: 0.2 }, - }).connect(this.bgGain); - bass.volume.value = -24; - - const mel = ['C5','E5','G5','A5','G5','E5','C5','E5','F5','A5','C6','A5','G5','B4','C5','G4']; - const harm = ['E5','G5','B5','C6','B5','G5','E5','G5','A5','C6','E6','C6','B5','D5','E5','B4']; - const bss = ['C3',null,null,null,null,null,null,null,'F2',null,null,null,'G2',null,null,null]; - - new Tone.Sequence((time, note) => { - const step = this.seqStep++ % mel.length; - melody.triggerAttackRelease(note, '16n', time); - if (this.successSteps > 0) { - this.jingleSynth.triggerAttackRelease(harm[step], '16n', time); - this.successSteps--; - } - }, mel, '8n').start(0); - - new Tone.Sequence((time, note) => { - if (note) bass.triggerAttackRelease(note, '4n', time); - }, bss, '8n').start(0); - - Tone.Transport.bpm.value = 120; - Tone.start().then(() => Tone.Transport.start()); - } - - playSuccessJingle() { - if (!this.musicReady) return; - this.successSteps = 8; - } - - toggleMusic() { - this.musicMuted = !this.musicMuted; - if (this.bgGain) this.bgGain.gain.value = this.musicMuted ? 0 : 0.7; - const btn = document.getElementById('btn-music'); - btn.textContent = 'Music: ' + (this.musicMuted ? 'OFF' : 'ON'); - btn.classList.toggle('muted', this.musicMuted); - } - - toggleSfx() { - this.sfxMuted = !this.sfxMuted; - if (this.sfxMuted && this.sirenOn) this._stopSiren(); - const btn = document.getElementById('btn-sfx'); - btn.textContent = 'SFX: ' + (this.sfxMuted ? 'OFF' : 'ON'); - btn.classList.toggle('muted', this.sfxMuted); - } - - speak(text) { - if (!window.speechSynthesis) return; - window.speechSynthesis.cancel(); - window.speechSynthesis.speak(new SpeechSynthesisUtterance(text)); - } -} - -// ── Boot ────────────────────────────────────────────────────────────────────── - -new Phaser.Game({ - type: Phaser.AUTO, - parent: 'game-container', - backgroundColor: '#1A1A2E', - scene: FireTruckScene, - scale: { - mode: Phaser.Scale.RESIZE, - width: '100%', - height: '100%', - }, - render: { preserveDrawingBuffer: true }, -}); diff --git a/games/fire-truck/index.html b/games/fire-truck/index.html @@ -1,105 +0,0 @@ -<!DOCTYPE html> -<html lang="en"> -<head> - <meta charset="UTF-8"> - <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <title>Fire Truck — KGames</title> - <link rel="preconnect" href="https://fonts.googleapis.com"> - <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> - <link href="https://fonts.googleapis.com/css2?family=Fredoka:wght@400;600&display=swap" rel="stylesheet"> - <style> - * { margin: 0; padding: 0; box-sizing: border-box; } - html, body { height: 100%; overflow: hidden; } - body { - background: #1A1A2E; - font-family: 'Fredoka', sans-serif; - display: flex; - flex-direction: column; - } - nav { width: 100%; padding: 0.6rem 1.25rem; flex-shrink: 0; } - nav a { color: #aaa; text-decoration: none; font-size: 1rem; } - nav a:hover { color: #fff; } - #game-container { flex: 1; width: 100%; } - - #audio-controls { - position: fixed; - top: 0.45rem; - right: 1rem; - display: flex; - gap: 0.4rem; - z-index: 10; - } - #audio-controls button { - background: rgba(255,255,255,0.08); - border: 1px solid rgba(255,255,255,0.18); - color: #bbb; - font-family: 'Fredoka', sans-serif; - font-size: 0.85rem; - padding: 0.25rem 0.65rem; - border-radius: 6px; - cursor: pointer; - transition: background 0.15s, color 0.15s; - user-select: none; - } - #audio-controls button:hover { background: rgba(255,255,255,0.16); color: #fff; } - #audio-controls button.muted { color: #555; border-color: rgba(255,255,255,0.08); } - #audio-controls button.active { - background: rgba(255,255,255,0.22); - color: #fff; - border-color: rgba(255,255,255,0.4); - } - - #truck-controls { - position: fixed; - bottom: 1rem; - left: 50%; - transform: translateX(-50%); - display: flex; - gap: 0.6rem; - z-index: 10; - } - #truck-controls button { - background: rgba(255,255,255,0.1); - border: 2px solid rgba(255,255,255,0.25); - color: #fff; - font-family: 'Fredoka', sans-serif; - font-size: 1.1rem; - padding: 0.5rem 1rem; - border-radius: 10px; - cursor: pointer; - transition: background 0.15s; - user-select: none; - min-width: 80px; - } - #truck-controls button:hover { background: rgba(255,255,255,0.2); } - #truck-controls button.active { - background: rgba(255,200,0,0.25); - border-color: rgba(255,200,0,0.6); - } - #btn-lights.active { background: rgba(230,57,70,0.35); border-color: #E63946; } - #btn-siren.active { background: rgba(29,124,242,0.35); border-color: #1D7CF2; } - #btn-bell.active { background: rgba(255,210,63,0.35); border-color: #FFD23F; } - </style> -</head> -<body> - <nav><a href="../../index.html">&larr; Back to KGames</a></nav> - - <div id="audio-controls"> - <button id="btn-music">Music: ON</button> - <button id="btn-sfx">SFX: ON</button> - </div> - - <div id="truck-controls"> - <button id="btn-lights">&#128680; Lights</button> - <button id="btn-siren">&#128226; Siren</button> - <button id="btn-bell">&#128276; Bell</button> - </div> - - <div id="game-container"></div> - - <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="game.js"></script> -</body> -</html> diff --git a/games/fire-truck/lib.js b/games/fire-truck/lib.js @@ -1,143 +0,0 @@ -// 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) { - const dz = wz - camZ; - if (dz <= 0) { - return { x: W / 2 + (turnShift || 0), y: horizonY, scale: 0, visible: false }; - } - const scale = camDepth / dz; - const sx = W / 2 + scale * wx * (W / 2) + (turnShift || 0); - const sy = horizonY + scale * (camYWorld - wy) * (H / 2); - return { x: sx, y: sy, scale, visible: true }; -} - -// ── Route generator ─────────────────────────────────────────────────────────── -// -// 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 lrDirs = ['left', 'right']; - const allDirs = ['left', 'right', 'straight']; - const route = []; - for (let i = 0; i < routeLen; i++) { - 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; -} - -// ── 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, RoadSegment, generateRoadSegments }; -} diff --git a/js/main.js b/js/main.js @@ -1,6 +1,6 @@ const GAMES = [ { slug: 'letter-find', title: 'Letter Find', thumb: 'assets/thumbnails/letter-find.svg', status: 'live' }, - { slug: 'fire-truck', title: 'Fire Truck', thumb: 'assets/thumbnails/fire-truck.svg', status: 'live' }, + { slug: 'fire-truck', title: 'Fire Truck', thumb: 'assets/thumbnails/fire-truck.svg', status: 'coming-soon' }, { slug: null, title: 'Coming Soon', thumb: 'assets/thumbnails/placeholder.svg', status: 'placeholder' }, { slug: null, title: 'Coming Soon', thumb: 'assets/thumbnails/placeholder.svg', status: 'placeholder' }, { slug: null, title: 'Coming Soon', thumb: 'assets/thumbnails/placeholder.svg', status: 'placeholder' }, diff --git a/tests/unit/fire-truck-segment.test.js b/tests/unit/fire-truck-segment.test.js @@ -1,45 +0,0 @@ -// 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