commit 089cebc27835aa4ae13536e03a2ee9bcf9d77798
parent 5034c9fec10ff9310f26480efcf37a406ef7cb54
Author: Kyle Barlow <kb@kylebarlow.com>
Date: Sun, 19 Apr 2026 19:52:24 -0700
Add Fire Truck game with pseudo-3D renderer and test suite
- Fire Truck driving game: Outrun-style scanline road, projected
buildings/pedestrians/traffic lights, arrow-prompt intersection
mechanic, fire destination with confetti + water spray on arrival
- Lights (L), siren (S), bell (B) via keyboard and on-screen buttons
- Fixed critical projection bug from initial implementation: Y formula
now correctly places ground-level objects below the horizon
- Extract pure projection + route logic into lib.js for unit testing
- Add Playwright browser smoke tests + Node built-in unit tests
- Add CLAUDE.md with conventions and test-before-commit rule
- Update README with testing section and deploy exclusions
- Add .claude/ and *.log to .gitignore
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat:
15 files changed, 1537 insertions(+), 3 deletions(-)
diff --git a/.gitignore b/.gitignore
@@ -5,3 +5,5 @@ Thumbs.db
.idea/
.vscode/
node_modules/
+.claude/
+*.log
diff --git a/CLAUDE.md b/CLAUDE.md
@@ -0,0 +1,85 @@
+# KGames — Claude Guide
+
+## What this project is
+
+Free educational games for young kids. No ads, no signup, no backend.
+Static HTML + CSS + JavaScript served from any web host. Phaser 3 and
+Tone.js loaded from CDN. GPL-3.0.
+
+No build tools — double-clicking `index.html` is a valid development
+workflow. Keep it that way.
+
+## Testing — run before every commit
+
+```bash
+npm install # one-time; downloads playwright (~112 MB Chromium)
+npm test # unit tests + browser smoke tests; must all pass
+npm run test:unit # fast, no browser (~0.2 s)
+npm run test:browser # headless Chromium (~10 s)
+```
+
+**Never commit if `npm test` fails.** The browser tests catch broken
+rendering that syntax checks miss (see the projection-bug incident).
+
+## Architecture
+
+| Path | Purpose |
+|---|---|
+| `index.html` + `css/style.css` + `js/main.js` | Portal — game tile grid |
+| `games/<slug>/index.html` | HTML shell: CDN scripts, HUD buttons, game container |
+| `games/<slug>/lib.js` | Pure logic (projection math, route gen) — dual-export for unit tests |
+| `games/<slug>/game.js` | Phaser scene — all graphics via `generateTexture`, audio via Web Audio / Tone.js |
+| `assets/thumbnails/<slug>.svg` | 160×130 viewBox, rx=18, stroke from palette, `#FFF5F5` fill |
+| `tests/unit/` | `node --test` unit tests — no browser, no Phaser |
+| `tests/browser/runner.js` | Playwright smoke tests — no JS errors + canvas renders |
+| `tests/serve.js` | Tiny Node HTTP server used by browser tests (and for local dev) |
+
+## Pseudo-3D rendering (fire-truck)
+
+The road uses a Y-based scanline loop (`_drawRoad` in game.js), not a
+Z-based segment loop. For each screen row `sy` from bottom to horizon:
+
+```
+scale = (sy - horizonY) / (CAM_Y_WORLD × H/2)
+dz = CAM_DEPTH / scale // world units to this row
+worldZ = camZ + dz
+```
+
+Pure projection lives in `lib.js` (`project()`):
+```
+sx = W/2 + scale × worldX × W/2 + turnShift
+sy = horizonY + scale × (CAM_Y_WORLD − worldY) × H/2
+```
+
+**Ground-level objects (`worldY = 0`) project BELOW `horizonY`** — this is
+intentional and correct. The `sy = horizonY − worldY × scale` formula from
+the first implementation was wrong (collapsed everything to the horizon).
+
+## Adding a new game
+
+1. Create `games/<slug>/index.html` (clone from `games/fire-truck/index.html`)
+2. Create `games/<slug>/lib.js` for pure (unit-testable) logic
+3. Create `games/<slug>/game.js` for the Phaser scene
+4. Add `render: { preserveDrawingBuffer: true }` to the `Phaser.Game` config
+ (required for the Playwright pixel-reading tests to work)
+5. Create `assets/thumbnails/<slug>.svg` (160×130, rx=18 rounded rect)
+6. Replace a `Coming Soon` placeholder in `js/main.js`
+7. Add unit tests in `tests/unit/<slug>-*.test.js`
+8. Add browser test cases to `tests/browser/runner.js`
+9. Run `npm test` — all must pass before committing
+
+## Color palette
+
+`#E63946` red · `#1D7CF2` blue · `#FFD23F` yellow · `#2EC4B6` teal
+
+Tint equivalents: `0xE63946 · 0x1D7CF2 · 0xFFD23F · 0x2EC4B6`
+
+## Key conventions
+
+- **No external image assets** — all textures via `make.graphics({ add: false }).generateTexture()`
+- **Audio**: Tone.js for looping music; Web Audio API oscillators for SFX
+ (lazy-init on first user interaction to satisfy browser autoplay policy)
+- **Font**: Fredoka, 400 + 600 weight (Google Fonts)
+- **Scale**: `Phaser.Scale.RESIZE` + `applyLayout(W, H)` / `onResize` pattern
+- **No `console.log` in production code** — browser tests assert zero console errors
+- **`window.__FT_SCENE__ = this`** exposed in `create()` for browser test inspection
diff --git a/README.md b/README.md
@@ -18,24 +18,45 @@ xdg-open index.html # Linux
open index.html # macOS
```
+Or use the included dev server (also used by the test suite):
+
+```
+node tests/serve.js
+# then open http://localhost:8765
+```
+
An internet connection is required on first load so the browser can fetch Phaser and Google Fonts from their CDNs. After that, the games themselves work offline.
+## Testing
+
+Run tests before committing:
+
+```
+npm install # one-time setup (downloads headless Chromium ~112 MB)
+npm test # unit tests + browser smoke tests
+```
+
+- `npm run test:unit` — fast pure-logic tests, no browser required
+- `npm run test:browser` — Playwright tests that load each game in a real browser and verify it actually renders
+
## Deploy
```
-rsync -av --delete . user@server:/path/to/docroot/
+rsync -av --delete --exclude=node_modules --exclude='.git' . user@server:/path/to/docroot/
```
Standard static file hosting. No `.htaccess` tricks needed.
## Add a game
-1. Create `games/<slug>/index.html` and `games/<slug>/game.js`
+1. Create `games/<slug>/index.html`, `games/<slug>/lib.js`, and `games/<slug>/game.js`
2. Add an entry to the `GAMES` array in `js/main.js`:
```js
{ slug: '<slug>', title: 'My Game', thumb: 'assets/thumbnails/<slug>.svg', status: 'live' }
```
3. Add a thumbnail SVG at `assets/thumbnails/<slug>.svg`
+4. Add unit tests in `tests/unit/` and browser test cases in `tests/browser/runner.js`
+5. Run `npm test` — must pass before committing
## License
diff --git a/assets/thumbnails/fire-truck.svg b/assets/thumbnails/fire-truck.svg
@@ -0,0 +1,23 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 130" width="160" height="130">
+ <rect x="8" y="8" width="144" height="114" rx="18" fill="#FFF5F5" stroke="#E63946" stroke-width="4"/>
+ <!-- truck body -->
+ <rect x="24" y="62" width="90" height="38" rx="6" fill="#E63946"/>
+ <!-- cab -->
+ <rect x="94" y="50" width="38" height="50" rx="6" fill="#E63946"/>
+ <!-- cab window -->
+ <rect x="100" y="55" width="26" height="20" rx="3" fill="#B8E0FF"/>
+ <!-- light bar -->
+ <rect x="30" y="55" width="18" height="8" rx="2" fill="#E63946"/>
+ <rect x="52" y="55" width="18" height="8" rx="2" fill="#1D7CF2"/>
+ <!-- ladder on side -->
+ <rect x="28" y="68" width="82" height="5" rx="1" fill="#cc0000"/>
+ <rect x="38" y="65" width="3" height="11" fill="#cc0000"/>
+ <rect x="58" y="65" width="3" height="11" fill="#cc0000"/>
+ <rect x="78" y="65" width="3" height="11" fill="#cc0000"/>
+ <rect x="98" y="65" width="3" height="11" fill="#cc0000"/>
+ <!-- wheels -->
+ <circle cx="50" cy="100" r="12" fill="#333"/>
+ <circle cx="50" cy="100" r="6" fill="#888"/>
+ <circle cx="110" cy="100" r="12" fill="#333"/>
+ <circle cx="110" cy="100" r="6" fill="#888"/>
+</svg>
diff --git a/games/fire-truck/game.js b/games/fire-truck/game.js
@@ -0,0 +1,856 @@
+// ── World constants ───────────────────────────────────────────────────────────
+
+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
+
+// Road geometry (world units)
+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)
+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;
+
+// Road scanline
+const SEG_LEN = 200; // world units per segment (stripe alternation)
+const DRAW_SEGS = 120; // max visible segments
+
+// Gameplay
+const DRIVE_SPEED = 550; // world units / second
+const ROUTE_LEN = 5;
+const INTERSECTION_GAP = 5000;
+const PROMPT_DIST = 1800;
+const ARRIVE_DIST = 700;
+
+// Object pool sizes
+const BLDG_COUNT = 16;
+const PED_COUNT = 10;
+const TLIGHT_COUNT = 6;
+
+// ── 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.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.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.sirenInterval = null;
+ this.lightPhase = 0;
+ this.lightInterval = null;
+ }
+
+ // ── Preload: bake all textures ─────────────────────────────────────────────
+
+ preload() {
+ this._genParticle();
+ this._genTruck();
+ this._genBuildings();
+ 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); // 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
+ [18, 36, 54, 72, 88].forEach(x => {
+ g.fillStyle(0xAA1122); g.fillRect(x, 40, 4, 15); // rungs
+ });
+ g.fillStyle(0x333333); g.fillRoundedRect(10, 8, 82, 14, 4); // light bar base
+ 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);
+ 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); // pole
+ g.fillStyle(0x222222); g.fillRoundedRect(0, 0, 20, 60, 3); // box
+ 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;
+
+ // 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);
+
+ // 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);
+
+ // 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._sizeToScreen(W, H);
+
+ // Arrow prompt UI
+ this.arrowBg = this.add.rectangle(W / 2, H * 0.3, 120, 120, 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')
+ .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',
+ }).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();
+
+ // 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);
+ 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(4).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 (called every frame) ────────────────────────────────────────────
+
+ update(time, dt) {
+ if (this.state === 'arrived') 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._drawRoad(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.
+
+ _drawRoad(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;
+
+ 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);
+
+ // sidewalk
+ gfx.fillStyle(sidewCol);
+ gfx.fillRect(cx - sidePx, sy, sidePx - kerbPx, 2);
+ gfx.fillRect(cx + kerbPx, sy, sidePx - kerbPx, 2);
+
+ // 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);
+
+ // road
+ gfx.fillStyle(alt ? roadColA : roadColB);
+ gfx.fillRect(cx - roadPx, sy, 2 * roadPx, 2);
+
+ // center dashes
+ if (alt === 0) {
+ gfx.fillStyle(dashCol);
+ gfx.fillRect(cx - 2, sy, 4, 2);
+ }
+ }
+ }
+
+ // ── World-object update & projection ──────────────────────────────────────
+
+ _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
+ );
+ }
+
+ _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));
+ });
+
+ // 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(4 + 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(4 + 1 / (dz + 1));
+ });
+
+ // Fire (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)
+ .setScale(sc * flicker)
+ .setDepth(5);
+ } else {
+ 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;
+
+ if (dist <= ARRIVE_DIST) {
+ if (this.routeIdx === this.route.length - 1) {
+ this._onArrived();
+ } else {
+ this._hideArrow();
+ this.routeIdx++;
+ this.state = 'driving';
+ }
+ } else if (dist < PROMPT_DIST) {
+ this._showArrow(next.dir, W, H);
+ } else {
+ if (this.state === 'awaiting') this._hideArrow();
+ }
+ }
+
+ _showArrow(dir, W, H) {
+ if (this.state === 'awaiting') return; // already showing
+ 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);
+ }
+
+ _hideArrow() {
+ this.state = 'driving';
+ this.arrowBg.setVisible(false);
+ this.arrowSprite.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; }
+
+ 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;
+
+ if (pressed === next.dir) {
+ this._onCorrectTurn(next.dir);
+ } else {
+ this._onWrongTurn();
+ }
+ }
+
+ _onCorrectTurn(dir) {
+ this._hideArrow();
+ this.state = 'turning';
+ this.playTurnSound();
+
+ const shift = dir === 'left' ? 140 : dir === 'right' ? -140 : 0;
+ this.tweens.add({
+ targets: this,
+ turnShift: shift,
+ duration: 280,
+ ease: 'Quad.InOut',
+ onComplete: () => {
+ this.turnShift = 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('You made it! Putting out the fire!');
+ this.playSuccess();
+ this.playSuccessJingle();
+ this._burstConfetti();
+ this._sprayWater();
+
+ this.time.delayedCall(3000, () => {
+ 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;
+ 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,
+ });
+ this.time.delayedCall(2000, () => {
+ em.stop();
+ this.fireSprite.setVisible(false);
+ this.time.delayedCall(400, () => { if (em.active) em.destroy(); });
+ });
+ }
+
+ // ── Round management ───────────────────────────────────────────────────────
+
+ newRound() {
+ this.camZ = 0;
+ this.routeIdx = 0;
+ this.turnShift = 0;
+ this.state = 'driving';
+ this.route = buildRoute(ROUTE_LEN, INTERSECTION_GAP, Phaser.Math.RND);
+
+ this.fireSprite.setVisible(false).setScale(0);
+ 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);
+
+ const horizY = H * HORIZON_FRAC;
+ this.arrowBg.setPosition(W / 2, H * 0.3);
+ this.arrowSprite.setPosition(W / 2, 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.lightInterval = setInterval(() => {
+ this.lightPhase = 1 - this.lightPhase;
+ this.lightBarL.setFillStyle(this.lightPhase ? 0xE63946 : 0x220000);
+ this.lightBarR.setFillStyle(this.lightPhase ? 0x220022 : 0x1D7CF2);
+ }, 280);
+ } else {
+ clearInterval(this.lightInterval);
+ 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();
+
+ let hi = true;
+ this.sirenInterval = setInterval(() => {
+ if (!this.sirenOsc1) return;
+ const t = this.getAudioCtx().currentTime;
+ this.sirenOsc1.frequency.linearRampToValueAtTime(hi ? 960 : 770, t + 0.45);
+ this.sirenOsc2.frequency.linearRampToValueAtTime(hi ? 700 : 570, t + 0.45);
+ hi = !hi;
+ }, 450);
+ } catch (_) {}
+ }
+
+ _stopSiren() {
+ clearInterval(this.sirenInterval);
+ 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
@@ -0,0 +1,105 @@
+<!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
@@ -0,0 +1,57 @@
+// Pure projection + route functions shared between game.js and unit tests.
+// 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;
+ 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);
+ // 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.
+
+function buildRoute(routeLen, gap, rng) {
+ const dirs = ['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 });
+ }
+ return route;
+}
+
+// ── Export ────────────────────────────────────────────────────────────────────
+
+if (typeof module !== 'undefined' && module.exports) {
+ module.exports = { project, buildRoute };
+}
diff --git a/games/letter-find/game.js b/games/letter-find/game.js
@@ -374,4 +374,5 @@ new Phaser.Game({
width: '100%',
height: '100%',
},
+ render: { preserveDrawingBuffer: true },
});
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: null, title: 'Coming Soon', thumb: 'assets/thumbnails/placeholder.svg', status: 'placeholder' },
+ { slug: 'fire-truck', title: 'Fire Truck', thumb: 'assets/thumbnails/fire-truck.svg', status: 'live' },
{ 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/package-lock.json b/package-lock.json
@@ -0,0 +1,57 @@
+{
+ "name": "kgames",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "kgames",
+ "devDependencies": {
+ "playwright": "^1.40.0"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/playwright": {
+ "version": "1.59.1",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
+ "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
+ "dev": true,
+ "dependencies": {
+ "playwright-core": "1.59.1"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.2"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.59.1",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
+ "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
+ "dev": true,
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
@@ -0,0 +1,12 @@
+{
+ "name": "kgames",
+ "private": true,
+ "scripts": {
+ "test": "npm run test:unit && npm run test:browser",
+ "test:unit": "node --test tests/unit/",
+ "test:browser": "node tests/browser/runner.js"
+ },
+ "devDependencies": {
+ "playwright": "^1.40.0"
+ }
+}
diff --git a/tests/browser/runner.js b/tests/browser/runner.js
@@ -0,0 +1,146 @@
+// Playwright browser smoke tests for KGames.
+// Run with: node tests/browser/runner.js
+// Requires: npm install (playwright)
+
+const { chromium } = require('playwright');
+const serve = require('../serve.js');
+
+async function main() {
+ const { server, url } = await serve(8766);
+ const browser = await chromium.launch();
+ let pass = 0;
+ let fail = 0;
+
+ async function test(name, fn) {
+ const page = await browser.newPage();
+ const errors = [];
+ page.on('console', m => { if (m.type() === 'error') errors.push('[console] ' + m.text()); });
+ page.on('pageerror', e => { errors.push('[pageerror] ' + e.message); });
+ try {
+ await fn(page, url, errors);
+ console.log(` ✓ ${name}`);
+ pass++;
+ } catch (e) {
+ console.error(` ✗ ${name}`);
+ console.error(` ${e.message}`);
+ fail++;
+ } finally {
+ await page.close();
+ }
+ }
+
+ console.log('\nBrowser tests:');
+
+ // ── Portal ────────────────────────────────────────────────────────────────
+
+ await test('portal loads and contains both game tiles', async (page, url) => {
+ await page.goto(url, { waitUntil: 'domcontentloaded' });
+ const letterFind = await page.$('a[href*="letter-find"]');
+ const fireTruck = await page.$('a[href*="fire-truck"]');
+ if (!letterFind) throw new Error('Letter Find tile not found');
+ if (!fireTruck) throw new Error('Fire Truck tile not found');
+ });
+
+ // ── Letter Find ───────────────────────────────────────────────────────────
+
+ await test('letter-find: loads, no JS errors, canvas exists', async (page, url, errors) => {
+ await page.goto(`${url}/games/letter-find/`, { waitUntil: 'domcontentloaded' });
+ await page.waitForTimeout(1800);
+ if (errors.length) throw new Error(errors[0]);
+ const canvas = await page.$('canvas');
+ if (!canvas) throw new Error('No canvas element');
+ });
+
+ await test('letter-find: canvas has multiple distinct colors (actually renders)', async (page, url) => {
+ await page.goto(`${url}/games/letter-find/`, { waitUntil: 'domcontentloaded' });
+ await page.waitForTimeout(1800);
+ const colorCount = await page.evaluate(() => {
+ const cv = document.querySelector('canvas');
+ if (!cv) return 0;
+ const seen = new Set();
+ // Phaser uses WebGL by default
+ const gl = cv.getContext('webgl2') || cv.getContext('webgl');
+ if (gl) {
+ const pixels = new Uint8Array(cv.width * cv.height * 4);
+ gl.readPixels(0, 0, cv.width, cv.height, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
+ for (let i = 0; i < pixels.length; i += 32) seen.add(`${pixels[i]},${pixels[i+1]},${pixels[i+2]}`);
+ } else {
+ const ctx = cv.getContext('2d');
+ const data = ctx.getImageData(0, 0, cv.width, cv.height).data;
+ for (let i = 0; i < data.length; i += 32) seen.add(`${data[i]},${data[i+1]},${data[i+2]}`);
+ }
+ return seen.size;
+ });
+ if (colorCount < 4) throw new Error(`Only ${colorCount} colors found — canvas may be blank`);
+ });
+
+ // ── Fire Truck ────────────────────────────────────────────────────────────
+
+ await test('fire-truck: loads, no JS errors, canvas exists', async (page, url, errors) => {
+ await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' });
+ await page.waitForTimeout(2200);
+ if (errors.length) throw new Error(errors[0]);
+ const canvas = await page.$('canvas');
+ if (!canvas) throw new Error('No canvas element');
+ });
+
+ await test('fire-truck: canvas has road (many distinct colors, not just sky+ground)', async (page, url) => {
+ await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' });
+ await page.waitForTimeout(2200);
+ const colorCount = await page.evaluate(() => {
+ const cv = document.querySelector('canvas');
+ if (!cv) return 0;
+ const seen = new Set();
+ const gl = cv.getContext('webgl2') || cv.getContext('webgl');
+ if (gl) {
+ const pixels = new Uint8Array(cv.width * cv.height * 4);
+ gl.readPixels(0, 0, cv.width, cv.height, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
+ for (let i = 0; i < pixels.length; i += 16) seen.add(`${pixels[i]},${pixels[i+1]},${pixels[i+2]}`);
+ } else {
+ const ctx = cv.getContext('2d');
+ const data = ctx.getImageData(0, 0, cv.width, cv.height).data;
+ for (let i = 0; i < data.length; i += 16) seen.add(`${data[i]},${data[i+1]},${data[i+2]}`);
+ }
+ return seen.size;
+ });
+ // sky + ground + road grey tones + kerb stripes + lane dash = many more than 3
+ if (colorCount < 8) throw new Error(`Only ${colorCount} colors found — road may not be rendering`);
+ });
+
+ await test('fire-truck: initial state is "driving"', async (page, url) => {
+ await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' });
+ await page.waitForTimeout(2200);
+ const state = await page.evaluate(() => window.__FT_SCENE__ && window.__FT_SCENE__.state);
+ if (state !== 'driving') throw new Error(`Expected state "driving", got "${state}"`);
+ });
+
+ await test('fire-truck: lights button toggles .active class', async (page, url) => {
+ await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' });
+ await page.waitForTimeout(1200);
+ await page.click('#btn-lights');
+ const on = await page.$eval('#btn-lights', el => el.classList.contains('active'));
+ if (!on) throw new Error('Button did not get .active after first click');
+ await page.click('#btn-lights');
+ const off = await page.$eval('#btn-lights', el => el.classList.contains('active'));
+ if (off) throw new Error('Button should not have .active after second click');
+ });
+
+ await test('fire-truck: lightsOn scene state tracks button', async (page, url) => {
+ await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' });
+ await page.waitForTimeout(1200);
+ await page.click('#btn-lights');
+ const on = await page.evaluate(() => window.__FT_SCENE__ && window.__FT_SCENE__.lightsOn);
+ if (!on) throw new Error('lightsOn should be true after click');
+ });
+
+ // ── Summary ───────────────────────────────────────────────────────────────
+
+ await browser.close();
+ server.close();
+
+ const total = pass + fail;
+ console.log(`\n${total} tests: ${pass} passed, ${fail} failed.\n`);
+ if (fail > 0) process.exit(1);
+}
+
+main().catch(e => { console.error(e); process.exit(1); });
diff --git a/tests/serve.js b/tests/serve.js
@@ -0,0 +1,47 @@
+const http = require('http');
+const fs = require('fs');
+const path = require('path');
+
+const ROOT = path.join(__dirname, '..');
+const MIME = {
+ '.html': 'text/html',
+ '.js': 'application/javascript',
+ '.css': 'text/css',
+ '.svg': 'image/svg+xml',
+ '.png': 'image/png',
+};
+
+function serve(port) {
+ port = port || parseInt(process.env.PORT || '8765');
+ return new Promise((resolve, reject) => {
+ const server = http.createServer((req, res) => {
+ let urlPath = req.url.split('?')[0];
+ if (urlPath === '/') urlPath = '/index.html';
+ // also serve directory index
+ if (!path.extname(urlPath)) urlPath += '/index.html';
+ const file = path.join(ROOT, urlPath);
+ const ext = path.extname(file);
+ try {
+ const data = fs.readFileSync(file);
+ res.writeHead(200, { 'Content-Type': MIME[ext] || 'application/octet-stream' });
+ res.end(data);
+ } catch (_) {
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
+ res.end('not found: ' + urlPath);
+ }
+ });
+ server.listen(port, '127.0.0.1', () => {
+ resolve({ server, url: `http://127.0.0.1:${port}` });
+ });
+ server.on('error', reject);
+ });
+}
+
+if (require.main === module) {
+ serve().then(({ url }) => {
+ console.log(`KGames dev server: ${url}`);
+ process.on('SIGINT', () => process.exit(0));
+ }).catch(e => { console.error(e); process.exit(1); });
+}
+
+module.exports = serve;
diff --git a/tests/unit/projection.test.js b/tests/unit/projection.test.js
@@ -0,0 +1,69 @@
+const { describe, it } = require('node:test');
+const assert = require('node:assert/strict');
+const { project } = require('../../games/fire-truck/lib.js');
+
+const W = 800;
+const H = 600;
+const camZ = 0;
+const camY = 1500; // world units
+const camDepth = 0.84;
+const horizonY = H * 0.45; // 270px
+
+describe('project()', () => {
+ it('returns visible=false when point is behind camera (dz ≤ 0)', () => {
+ const r = project(W, H, camZ, camY, camDepth, horizonY, 0, 0, camZ - 1, 0);
+ assert.equal(r.visible, false);
+ assert.equal(r.scale, 0);
+ });
+
+ it('returns visible=false when point is exactly at camera (dz = 0)', () => {
+ const r = project(W, H, camZ, camY, camDepth, horizonY, 0, 0, camZ, 0);
+ assert.equal(r.visible, false);
+ });
+
+ it('ground-level point near camera projects below horizon', () => {
+ const r = project(W, H, camZ, camY, camDepth, horizonY, 0, 0, camZ + 2000, 0);
+ assert.equal(r.visible, true);
+ assert(r.y > horizonY, `y (${r.y.toFixed(1)}) should be below horizon (${horizonY})`);
+ assert(r.y < H, `y (${r.y.toFixed(1)}) should be on-screen (H=${H})`);
+ });
+
+ it('ground-level point far away projects just below horizon (not at horizon)', () => {
+ const r = project(W, H, camZ, camY, camDepth, horizonY, 0, 0, camZ + 80000, 0);
+ assert.equal(r.visible, true);
+ assert(r.y > horizonY, `y (${r.y.toFixed(2)}) should be below horizon`);
+ assert(r.y < horizonY + 15, `y (${r.y.toFixed(2)}) should be very close to horizon`);
+ });
+
+ it('center-X world point projects to W/2 screenX', () => {
+ const r = project(W, H, camZ, camY, camDepth, horizonY, 0, 0, camZ + 5000, 0);
+ assert.equal(r.x, W / 2);
+ });
+
+ it('±worldX projects symmetrically around W/2', () => {
+ const L = project(W, H, camZ, camY, camDepth, horizonY, -1000, 0, camZ + 5000, 0);
+ const R = project(W, H, camZ, camY, camDepth, horizonY, 1000, 0, camZ + 5000, 0);
+ const dL = W / 2 - L.x;
+ const dR = R.x - W / 2;
+ assert(Math.abs(dL - dR) < 0.001, `L offset (${dL.toFixed(3)}) ≠ R offset (${dR.toFixed(3)})`);
+ });
+
+ it('turnShift offsets screenX linearly', () => {
+ const r0 = project(W, H, camZ, camY, camDepth, horizonY, 0, 0, camZ + 5000, 0);
+ const r1 = project(W, H, camZ, camY, camDepth, horizonY, 0, 0, camZ + 5000, 50);
+ assert(Math.abs(r1.x - r0.x - 50) < 0.001, `x shift should be 50, got ${(r1.x - r0.x).toFixed(3)}`);
+ });
+
+ it('closer point has larger scale than farther point', () => {
+ const near = project(W, H, camZ, camY, camDepth, horizonY, 0, 0, camZ + 2000, 0);
+ const far = project(W, H, camZ, camY, camDepth, horizonY, 0, 0, camZ + 10000, 0);
+ assert(near.scale > far.scale, `near scale (${near.scale}) should exceed far scale (${far.scale})`);
+ });
+
+ it('scale = CAM_DEPTH / dz', () => {
+ const dz = 3000;
+ const expected = camDepth / dz;
+ const r = project(W, H, camZ, camY, camDepth, horizonY, 0, 0, camZ + dz, 0);
+ assert(Math.abs(r.scale - expected) < 1e-9, `scale ${r.scale} ≠ expected ${expected}`);
+ });
+});
diff --git a/tests/unit/route.test.js b/tests/unit/route.test.js
@@ -0,0 +1,53 @@
+const { describe, it } = require('node:test');
+const assert = require('node:assert/strict');
+const { buildRoute } = require('../../games/fire-truck/lib.js');
+
+const VALID_DIRS = new Set(['left', 'right', 'straight']);
+const fixedRng = { pick: arr => arr[0] }; // always picks first element
+
+describe('buildRoute()', () => {
+ it('returns the requested number of intersections', () => {
+ assert.equal(buildRoute(5, 4000, fixedRng).length, 5);
+ assert.equal(buildRoute(1, 4000, fixedRng).length, 1);
+ assert.equal(buildRoute(0, 4000, fixedRng).length, 0);
+ });
+
+ it('Z values are strictly increasing', () => {
+ const r = buildRoute(6, 4000, fixedRng);
+ for (let i = 1; i < r.length; i++) {
+ assert(r[i].z > r[i - 1].z,
+ `r[${i}].z (${r[i].z}) should be > r[${i-1}].z (${r[i-1].z})`);
+ }
+ });
+
+ it('Z values equal (index + 1) * gap', () => {
+ const gap = 5000;
+ const r = buildRoute(4, gap, fixedRng);
+ r.forEach((seg, i) => {
+ assert.equal(seg.z, (i + 1) * gap);
+ });
+ });
+
+ it('all dir values are valid', () => {
+ // run with real random to get variety
+ const r = buildRoute(20, 1000);
+ r.forEach((seg, i) => {
+ assert(VALID_DIRS.has(seg.dir),
+ `r[${i}].dir "${seg.dir}" is not a valid direction`);
+ });
+ });
+
+ it('deterministic rng produces repeatable routes', () => {
+ const a = buildRoute(5, 4000, fixedRng);
+ const b = buildRoute(5, 4000, fixedRng);
+ assert.deepEqual(a, b);
+ });
+
+ it('each entry has exactly z and dir 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}`);
+ });
+ });
+});