kgames

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

commit 231201da5967788ebf93121d698acf9182f034cb
parent 3e2661dffc75de2d933851692e9ef37d3d6bc71d
Author: Kyle Barlow <kb@kylebarlow.com>
Date:   Mon, 13 Jul 2026 19:50:02 -0700

fire-truck: people-first city — bike lanes, bicyclists, buses, denser crowds

- Fix palms baking into the middle of roads: street palms now anchor to a
  sidewalk edge and jitter only along it
- Bake painted green bike lanes (with white separator dashes) on straight
  segments, matched to the bicyclists' riding line
- New ambient movers on the road graph: 48 bicyclists, 6 buses; pedestrians
  up 18 -> 160 and drawn larger; private cars cut 10 -> 4
- Bus-stop shelters baked along straight sidewalk-lined streets (palms skip
  those cells)
- Browser ambient test now covers bikes/buses/ped density; CLAUDE.md depth
  map replaced with ordering (values live in game.js)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Diffstat:
MCLAUDE.md | 6+++---
Mgames/fire-truck/game.js | 154++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
Mtests/browser/runner.js | 27++++++++++++++++++++-------
3 files changed, 153 insertions(+), 34 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md @@ -71,9 +71,9 @@ Every new **world** object must be hidden from the HUD camera — `this._addWorld(obj, depth)` sets depth + `uiCamera.ignore`. HUD objects are added to `cameras.main.ignore(...)`. -**Depth map**: −1 ocean · 0 chunks · 6 boats/buoys · 8 peds · 9 cars · 9.5 -burning overlay · 9.7 fire glow · 10 fire · 11 spray · 12 smoke · 34 cloud -shadows · 35 truck · 38 birds · 40+ HUD. +**Depth ordering** (exact values live in game.js): ocean < chunks < +boats/buoys < peds < bikes < cars/buses < burning overlay < fire glow < fire < +spray < smoke < cloud shadows < truck < birds < HUD. **Ambient life** (`_initAmbient` / `_updateAmbient`, driven from `update()` — never `step()`, which tests call directly): baked-texture Images for traffic diff --git a/games/fire-truck/game.js b/games/fire-truck/game.js @@ -14,6 +14,14 @@ const ROUTE_EXTENSION_COUNT = 36; const FIRE_TRIGGER_DIST = CELL_SIZE * 0.6; const CAR_SPEED = 300; // world u/s for ambient traffic const PED_SPEED = 90; // world u/s for pedestrians +const BIKE_SPEED = 170; // world u/s for bicyclists +const BUS_SPEED = 230; // world u/s for buses +// A people-first city: streets are full of walkers, bikes, and buses; +// private cars are a rare sight. +const PED_COUNT = 160; +const BIKE_COUNT = 48; +const BUS_COUNT = 6; +const CAR_COUNT = 4; const FIRE_EXTINGUISH_S = 3.0; const FIRES_TO_WIN = 4; @@ -33,6 +41,7 @@ const ROOF_TERRACOTTA = 0xd97b52; const PARK_GRASS = 0x7ecb62; const PALM_TRUNK = 0x9a6b43; const PALM_FROND = 0x46b063; +const BIKE_LANE_COLOR = 0x3e8e5a; // painted protected bike lanes const FT_BPM = 158; const FT_MELODY = [ @@ -385,10 +394,40 @@ class FireTruckScene extends Phaser.Scene { if (edges.S) gfx.fillRect(gx, gy + BAKE_CELL - band - 2, BAKE_CELL, 2); if (edges.W) gfx.fillRect(gx + band, gy, 2, BAKE_CELL); if (edges.E) gfx.fillRect(gx + BAKE_CELL - band - 2, gy, 2, BAKE_CELL); + + // Bus-stop shelters dotted along straight sidewalk-lined streets. + const meta = cell.meta || FT.classifyIntersection(cell.exits); + if (meta.kind === 'straight' && this._busStopEdge(cell, edges, x, y)) { + this._drawBusStop(gfx, gx, gy, this._busStopEdge(cell, edges, x, y)); + } } } } + // Which sidewalk edge (if any) of this road cell hosts a bus stop. + // Deterministic via hashCell so every bake places them identically. + _busStopEdge(cell, edges, x, y) { + if (FT.hashCell(x, y, 35) >= 0.10) return null; + for (const card of ['N', 'S', 'E', 'W']) if (edges[card]) return card; + return null; + } + + _drawBusStop(gfx, gx, gy, edge) { + const roof = 0x1d7cf2; + // Shelter roof (36×10) hugging the sidewalk band, plus a small yellow sign. + const draw = (rx, ry, w, h, sx, sy) => { + gfx.fillStyle(0x000000, 0.15); gfx.fillRoundedRect(rx + 2, ry + 2, w, h, 3); + gfx.fillStyle(roof, 1); gfx.fillRoundedRect(rx, ry, w, h, 3); + gfx.fillStyle(0xffffff, 0.5); gfx.fillRoundedRect(rx + 3, ry + 2, w - 6, 3, 2); + gfx.fillStyle(STRIPE_COLOR, 1); gfx.fillCircle(sx, sy, 4); + gfx.fillStyle(0x22303a, 1); gfx.fillCircle(sx, sy, 2); + }; + if (edge === 'N') draw(gx + 30, gy + 1, 36, 10, gx + 74, gy + 7); + else if (edge === 'S') draw(gx + 30, gy + BAKE_CELL - 11, 36, 10, gx + 74, gy + BAKE_CELL - 7); + else if (edge === 'W') draw(gx + 1, gy + 30, 10, 36, gx + 7, gy + 74); + else draw(gx + BAKE_CELL - 11, gy + 30, 10, 36, gx + BAKE_CELL - 7, gy + 74); + } + _bakeRoadMarkings(gfx, ax0, ay0, ax1, ay1, ox, oy) { const grid = this.island.grid; for (let y = ay0; y < ay1; y++) { @@ -399,6 +438,30 @@ class FireTruckScene extends Phaser.Scene { const gy = (y - oy) * BAKE_CELL; const meta = cell.meta || FT.classifyIntersection(cell.exits); if (meta.kind === 'straight') { + // Painted protected bike lanes on both sides of every straight + // segment (offsets match the bicyclists' riding line), with a + // white separator dash on the traffic side. + const laneIn = 16, laneOut = 30; // px from road centerline + const mid = BAKE_CELL / 2; + if (cell.exits.N && cell.exits.S) { + gfx.fillStyle(BIKE_LANE_COLOR, 0.55); + gfx.fillRect(gx + mid - laneOut, gy, laneOut - laneIn, BAKE_CELL); + gfx.fillRect(gx + mid + laneIn, gy, laneOut - laneIn, BAKE_CELL); + gfx.fillStyle(0xffffff, 0.7); + for (let dy = 4; dy < BAKE_CELL - 4; dy += 20) { + gfx.fillRect(gx + mid - laneIn - 2, gy + dy, 2, 10); + gfx.fillRect(gx + mid + laneIn, gy + dy, 2, 10); + } + } else { + gfx.fillStyle(BIKE_LANE_COLOR, 0.55); + gfx.fillRect(gx, gy + mid - laneOut, BAKE_CELL, laneOut - laneIn); + gfx.fillRect(gx, gy + mid + laneIn, BAKE_CELL, laneOut - laneIn); + gfx.fillStyle(0xffffff, 0.7); + for (let dx = 4; dx < BAKE_CELL - 4; dx += 20) { + gfx.fillRect(gx + dx, gy + mid - laneIn - 2, 10, 2); + gfx.fillRect(gx + dx, gy + mid + laneIn, 10, 2); + } + } gfx.fillStyle(STRIPE_COLOR, 0.9); if (cell.exits.N && cell.exits.S) { for (let dy = 8; dy < BAKE_CELL - 8; dy += 24) gfx.fillRect(gx + BAKE_CELL / 2 - 3, gy + dy, 6, 14); @@ -526,19 +589,27 @@ class FireTruckScene extends Phaser.Scene { for (let y = ay0; y < ay1; y++) { for (let x = ax0; x < ax1; x++) { const cell = grid[y][x]; - let place = false; + const gx = (x - ox) * BAKE_CELL; + const gy = (y - oy) * BAKE_CELL; if (cell.type === T.BEACH && FT.hashCell(x, y, 30) < 0.5) { - place = true; + const jx = gx + BAKE_CELL / 2 + (FT.hashCell(x, y, 32) - 0.5) * BAKE_CELL * 0.4; + const jy = gy + BAKE_CELL / 2 + (FT.hashCell(x, y, 33) - 0.5) * BAKE_CELL * 0.4; + this._drawPalm(gfx, jx, jy); } else if (cell.type === T.ROAD) { + // Street trees stand on the sidewalk band, never in the roadway: + // anchor to a sidewalk edge and jitter only along that edge. const e = FT.sidewalkEdges(grid, x, y); - if ((e.N || e.S || e.E || e.W) && FT.hashCell(x, y, 31) < 0.12) place = true; + if (FT.hashCell(x, y, 31) >= 0.12) continue; + if (this._busStopEdge(cell, e, x, y)) continue; // shelter lives here + const edges = ['N', 'S', 'E', 'W'].filter((c) => e[c]); + if (!edges.length) continue; + const edge = edges[Math.floor(FT.hashCell(x, y, 34) * edges.length) % edges.length]; + const along = BAKE_CELL / 2 + (FT.hashCell(x, y, 32) - 0.5) * BAKE_CELL * 0.5; + if (edge === 'N') this._drawPalm(gfx, gx + along, gy + 7); + else if (edge === 'S') this._drawPalm(gfx, gx + along, gy + BAKE_CELL - 7); + else if (edge === 'W') this._drawPalm(gfx, gx + 7, gy + along); + else this._drawPalm(gfx, gx + BAKE_CELL - 7, gy + along); } - if (!place) continue; - const gx = (x - ox) * BAKE_CELL; - const gy = (y - oy) * BAKE_CELL; - const jx = gx + BAKE_CELL / 2 + (FT.hashCell(x, y, 32) - 0.5) * BAKE_CELL * 0.4; - const jy = gy + BAKE_CELL / 2 + (FT.hashCell(x, y, 33) - 0.5) * BAKE_CELL * 0.4; - this._drawPalm(gfx, jx, jy); } } } @@ -641,6 +712,39 @@ class FireTruckScene extends Phaser.Scene { g.generateTexture(key, 30, 18); g.destroy(); }); + // Top-down bicyclists (facing +x): two wheel lines, frame, rider on top. + const bikeShirts = [0xe63946, 0x1d7cf2, 0x2ec4b6, 0xff8c42]; + bikeShirts.forEach((color, i) => { + const key = 'bike-' + i; + if (this.textures.exists(key)) return; + const g = mk(); + g.fillStyle(0x000000, 0.2); g.fillEllipse(15, 10, 22, 5); + g.fillStyle(0x22303a, 1); + g.fillRoundedRect(2, 6, 9, 3, 1.5); // rear wheel + g.fillRoundedRect(19, 6, 9, 3, 1.5); // front wheel + g.fillStyle(0x8a929c, 1); g.fillRect(10, 6.5, 10, 2); // frame + g.fillStyle(0xd8dde3, 1); g.fillRect(20, 3, 2, 9); // handlebars + g.fillStyle(color, 1); g.fillRoundedRect(9, 3, 10, 9, 3); // rider torso + g.fillStyle(0xf0c8a0, 1); g.fillCircle(14, 7.5, 3); // head + g.generateTexture(key, 30, 15); g.destroy(); + }); + + // Top-down buses (facing +x): long body, window rows, white roof stripe. + const busColors = [0x2ec4b6, 0xffd23f]; + busColors.forEach((color, i) => { + const key = 'bus-' + i; + if (this.textures.exists(key)) return; + const g = mk(); + g.fillStyle(0x000000, 0.2); g.fillEllipse(24, 16, 44, 8); + g.fillStyle(color, 1); g.fillRoundedRect(2, 3, 44, 14, 4); + g.lineStyle(2, darken(color, 50), 1); g.strokeRoundedRect(2, 3, 44, 14, 4); + g.fillStyle(0x22303a, 0.85); + g.fillRect(41, 5, 4, 10); // windshield (front = +x) + for (let wx = 6; wx <= 34; wx += 7) { g.fillRect(wx, 4, 5, 3); g.fillRect(wx, 13, 5, 3); } + g.fillStyle(0xffffff, 0.6); g.fillRoundedRect(8, 8, 30, 4, 2); // roof stripe + g.generateTexture(key, 48, 20); g.destroy(); + }); + const shirts = [0x1d7cf2, 0xe63946, 0x2ec4b6, 0xffd23f]; shirts.forEach((color, i) => { const key = 'ped-' + i; @@ -730,25 +834,25 @@ class FireTruckScene extends Phaser.Scene { this.ambientRng = FT.createSeededRng(0x51ce77); this.trafficCars = []; this.pedestrians = []; + this.bicyclists = []; + this.buses = []; this.clouds = []; this.boats = []; this.birds = []; - for (let i = 0; i < 10; i++) { - const start = this._randomRoadStart(); - if (!start) continue; - const key = 'car-' + Math.floor(this.ambientRng.next() * 5); - const img = this._addWorld(this.add.image(0, 0, key).setScale(SCALE * 0.85), 9); - this.trafficCars.push({ img, from: start.from, to: start.to, heading: start.heading, t: this.ambientRng.next() }); - } - - for (let i = 0; i < 18; i++) { - const start = this._randomRoadStart(); - if (!start) continue; - const key = 'ped-' + Math.floor(this.ambientRng.next() * 4); - const img = this._addWorld(this.add.image(0, 0, key).setScale(SCALE * 0.7), 8); - this.pedestrians.push({ img, from: start.from, to: start.to, heading: start.heading, t: this.ambientRng.next() }); - } + const spawnMovers = (count, list, texture, textureCount, scale, depth) => { + for (let i = 0; i < count; i++) { + const start = this._randomRoadStart(); + if (!start) continue; + const key = texture + '-' + Math.floor(this.ambientRng.next() * textureCount); + const img = this._addWorld(this.add.image(0, 0, key).setScale(SCALE * scale), depth); + list.push({ img, from: start.from, to: start.to, heading: start.heading, t: this.ambientRng.next() }); + } + }; + spawnMovers(CAR_COUNT, this.trafficCars, 'car', 5, 0.85, 9); + spawnMovers(BUS_COUNT, this.buses, 'bus', 2, 0.9, 9); + spawnMovers(BIKE_COUNT, this.bicyclists, 'bike', 4, 0.85, 8.5); + spawnMovers(PED_COUNT, this.pedestrians, 'ped', 4, 0.85, 8); const b = this.island.bounds; for (let i = 0; i < 3; i++) { @@ -830,6 +934,8 @@ class FireTruckScene extends Phaser.Scene { _updateAmbient(dt) { for (const car of this.trafficCars) this._moveMover(car, CAR_SPEED, CELL_SIZE * 0.16, dt, true); + for (const bus of this.buses) this._moveMover(bus, BUS_SPEED, CELL_SIZE * 0.14, dt, true); + for (const bike of this.bicyclists) this._moveMover(bike, BIKE_SPEED, CELL_SIZE * 0.24, dt, true); for (const ped of this.pedestrians) this._moveMover(ped, PED_SPEED, CELL_SIZE * 0.34, dt, false); const b = this.island.bounds; diff --git a/tests/browser/runner.js b/tests/browser/runner.js @@ -536,15 +536,28 @@ async function main() { await page.waitForTimeout(1800); const info = await page.evaluate(() => { const s = window.__FT_SCENE__; - const cars = s.trafficCars || []; - const allRoad = cars.every(c => - s.cellAt(c.from.x, c.from.y).type === 'road' && - s.cellAt(c.to.x, c.to.y).type === 'road'); - return { count: cars.length, allRoad, ambientReady: s.ambientReady }; + const movers = [ + ...(s.trafficCars || []), ...(s.buses || []), + ...(s.bicyclists || []), ...(s.pedestrians || []), + ]; + const allRoad = movers.every(m => + s.cellAt(m.from.x, m.from.y).type === 'road' && + s.cellAt(m.to.x, m.to.y).type === 'road'); + return { + cars: (s.trafficCars || []).length, + buses: (s.buses || []).length, + bikes: (s.bicyclists || []).length, + peds: (s.pedestrians || []).length, + allRoad, + ambientReady: s.ambientReady, + }; }); if (!info.ambientReady) throw new Error('ambient systems not ready'); - if (info.count === 0) throw new Error('no traffic cars spawned'); - if (!info.allRoad) throw new Error('a traffic car left the road graph'); + if (info.cars === 0) throw new Error('no traffic cars spawned'); + if (info.buses === 0) throw new Error('no buses spawned'); + if (info.bikes === 0) throw new Error('no bicyclists spawned'); + if (info.peds < 20) throw new Error(`expected a bustling crowd, got ${info.peds} pedestrians`); + if (!info.allRoad) throw new Error('an ambient mover left the road graph'); }); // ── On-screen touch controls ────────────────────────────────────────────────