kgames

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

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:
M.gitignore | 2++
ACLAUDE.md | 85+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MREADME.md | 25+++++++++++++++++++++++--
Aassets/thumbnails/fire-truck.svg | 23+++++++++++++++++++++++
Agames/fire-truck/game.js | 856+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Agames/fire-truck/index.html | 105+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Agames/fire-truck/lib.js | 57+++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mgames/letter-find/game.js | 1+
Mjs/main.js | 2+-
Apackage-lock.json | 57+++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackage.json | 12++++++++++++
Atests/browser/runner.js | 146+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atests/serve.js | 47+++++++++++++++++++++++++++++++++++++++++++++++
Atests/unit/projection.test.js | 69+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atests/unit/route.test.js | 53+++++++++++++++++++++++++++++++++++++++++++++++++++++
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">&larr; Back to KGames</a></nav> + + <div id="audio-controls"> + <button id="btn-music">Music: ON</button> + <button id="btn-sfx">SFX: ON</button> + </div> + + <div id="truck-controls"> + <button id="btn-lights">&#128680; Lights</button> + <button id="btn-siren">&#128226; Siren</button> + <button id="btn-bell">&#128276; Bell</button> + </div> + + <div id="game-container"></div> + + <script src="https://cdn.jsdelivr.net/npm/phaser@3.80.1/dist/phaser.min.js"></script> + <script src="https://cdn.jsdelivr.net/npm/tone@14.7.77/build/Tone.js"></script> + <script src="lib.js"></script> + <script src="game.js"></script> +</body> +</html> diff --git a/games/fire-truck/lib.js b/games/fire-truck/lib.js @@ -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}`); + }); + }); +});