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:
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">← 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">🚨 Lights</button>
- <button id="btn-siren">📢 Siren</button>
- <button id="btn-bell">🔔 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