diff --git a/B-progress.md b/B-progress.md index 7b989e1..748f244 100644 --- a/B-progress.md +++ b/B-progress.md @@ -4,6 +4,25 @@ plus the game shell. It runs on my hand-written fixture **and** auto-integrates with Lane A's `generatePlan` (which landed mid-session). Everything below was verified live in a browser. +## Round 9 (Fable's ROUND9 → Lane B) — v2 tour bookmarks + tram (`?tram=1`) · qa GREEN + +Ship-v2.0 round. Two deliverables, both v2-law clean, `qa.sh --strict` GREEN, flags-off byte-identical. + +- **8 v2 tour bookmarks** in `js/world/dbg.js` (16 total): `rain_verandah` (hero — open VIDEO shop w/ + winmap parallax rooms, CLOSED dark neighbours, night rain), `patronage_door`, `dig_real`, + `katoomba_main`, `tram_stop`, `rain_street`, `night_crowd`, `shopfront_detail`. All un-letterboxed, + 113–188 draws, 0 errors. `tram_stop` picks the **nearest-origin** stop (open framing; spine-end stop + was walled in). F shoots these — best hero combo `?winmap=1&weather=rain&dbg=1` seg 5. +- **Tram** (`js/world/tram.js`, `?tram=1`, NON-BLOCKING for the tag): one small bus, out-and-back loop + on the main-street spine, 3.5 s door-dwell at each `busShelterStops` stop, ping-pong at the ends. + **Deterministic** (verified byte-identical across fresh instances). **Exactly 2 draws** (verified via + `renderer.info` delta). Composes w/ weather+night (headlights 0.15→1.6 via `lighting.isNight()`). + "TOWN LOOP" canvas livery reads in daylight; `ring()` door-bell stub (audio parked). Shell-wiring + (2 lines) documented for F in LANE_B_NOTES; no collision in v0 (noted for v3). +- **Rain polish** (weather.js, quick R9 debt): capped `gl_PointSize` (clamp 2–22px) + lower base — near + drops are now thin streaks, not big blobs. Re-verified: rain smoke still GREEN, money-shot re-shot. +- Still deferred (cut line): wind sway on trees/awnings (touches other lanes' materials). + ## Round 8 (Fable's ROUND8 → Lane B) — weather (`?weather=1`), carried from R7 · qa GREEN New `js/world/weather.js` + `lighting.setSky()`. Seeded per citySeed → clear/overcast/rain diff --git a/docs/LANES/LANE_B_NOTES.md b/docs/LANES/LANE_B_NOTES.md index c92c89d..eee9376 100644 --- a/docs/LANES/LANE_B_NOTES.md +++ b/docs/LANES/LANE_B_NOTES.md @@ -1,5 +1,39 @@ # LANE B — NOTES (measured) +## Round 9 (Fable's ROUND9 → Lane B) — v2 tour bookmarks + the tram (`?tram=1`) + +Two deliverables, both v2-law clean (default boot untouched, golden hash frozen, `qa.sh --strict` GREEN): + +### 1. v2 tour bookmark poses (`dbg.js`) — F shoots these +Added **8 bookmarks** to `js/world/dbg.js` (16 total now), all measured un-letterboxed at 113–188 draws: +`rain_verandah` (the money shot — open VIDEO shop w/ winmap parallax, CLOSED dark neighbours, night rain), +`patronage_door`, `dig_real`, `katoomba_main`, `tram_stop`, `rain_street`, `night_crowd`, `shopfront_detail`. +Best combo to shoot the hero: `?winmap=1&weather=rain&dbg=1` → `DBG.shot('rain_verandah')` at night seg 5. +`tram_stop` now picks the **nearest-origin** shelter stop (most open framing — the spine-end stop was +boxed in by big buildings); frames the `bus_shelter` cleanly at midday. + +### 2. Tram (`?tram=1`) — new file `js/world/tram.js`, NON-BLOCKING for the tag +One small bus/tram, out-and-back loop along the **main-street spine**, door-dwell (3.5 s) at each +`busShelterStops` stop, ping-pong reverse at the ends. **Deterministic** (position is a pure function of +elapsed time — verified byte-identical across fresh instances). **Exactly 2 draws** (textured body + +merged headlight pair — verified via `renderer.info` delta). Composes with weather/night: headlights +`emissiveIntensity` jumps 0.15→1.6 when `lighting.isNight()`. "TOWN LOOP" canvas livery. `ring()` is a +door-bell stub (audio still parked). Flag-off is byte-identical (shell never constructs it). + +#### → Fable (F, shell wiring — I don't touch `index.html`) +```js +import { createTram } from './js/world/tram.js'; +const tram = params.get('tram') && params.get('tram') !== '0' + ? createTram({ scene, plan, camera, lighting }) + : null; // default-off; needs lighting.isNight() for night headlights +// in the street branch of the main loop: if (tram) tram.update(dt); +``` +Flags-table row: `?tram=1` — owner B — landed, default-off, self-contained module (F wires the 2 lines +above). Smoke: boot `?tram=1`, assert `scene.getObjectByName('tram')` present + tram-group render delta +≤2 draws. No collision in v0 (player walks through — noted for v3). `lighting.isNight()` is assumed on +the lighting object; if F's lighting exposes night differently, pass a predicate — tram degrades to the +day headlight value if `isNight` is absent (guarded). + ## Round 8 (Fable's ROUND8 → Lane B) — weather (`?weather=1`), carried from R7 New file `js/world/weather.js` + a one-line `setSky()` on `lighting.js`. Seeded, deterministic, cheap. diff --git a/docs/shots/laneB/r9_rain_verandah.png b/docs/shots/laneB/r9_rain_verandah.png new file mode 100644 index 0000000..77ea261 Binary files /dev/null and b/docs/shots/laneB/r9_rain_verandah.png differ diff --git a/docs/shots/laneB/r9_tram_dayloop.png b/docs/shots/laneB/r9_tram_dayloop.png new file mode 100644 index 0000000..1c1e19b Binary files /dev/null and b/docs/shots/laneB/r9_tram_dayloop.png differ diff --git a/docs/shots/laneB/r9_tram_night.png b/docs/shots/laneB/r9_tram_night.png new file mode 100644 index 0000000..ea99cca Binary files /dev/null and b/docs/shots/laneB/r9_tram_night.png differ diff --git a/docs/shots/laneB/r9_tram_stop.png b/docs/shots/laneB/r9_tram_stop.png new file mode 100644 index 0000000..3ec5c12 Binary files /dev/null and b/docs/shots/laneB/r9_tram_stop.png differ diff --git a/web/js/world/dbg.js b/web/js/world/dbg.js index 452dc18..3b58f2a 100644 --- a/web/js/world/dbg.js +++ b/web/js/world/dbg.js @@ -8,6 +8,8 @@ // Lives in its own module (not the shell) so it can grow without touching index.html — the shell // just calls installDBG(deps) with the systems it owns when ?dbg=1 is present. +import { busShelterStops } from './furniture.js'; + export function installDBG(deps) { const { plan, camera, renderer, composer, chunks, lighting, player, hud, setMode, getMode, enterShop } = deps; @@ -117,6 +119,17 @@ export function installDBG(deps) { return { pos: [node.x - dx * 15, 1.7, node.z - dz * 15], look: [node.x + dx * 25, 2.2, node.z + dz * 25] }; }; + // A 3/4 view of the first bus-shelter stop (Lane B's tram stop), from the road side. + const stopPose = () => { + const stops = busShelterStops(plan); + if (!stops.length) return streetViewPose(['main']); + const s = [...stops].sort((a, b) => Math.hypot(a.x, a.z) - Math.hypot(b.x, b.z))[0]; // nearest origin = most open + const px = Math.sin(s.yaw), pz = Math.cos(s.yaw), ex = Math.cos(s.yaw), ez = -Math.sin(s.yaw); + return { pos: [s.x - px * 6 + ex * 4, 2.0, s.z - pz * 6 + ez * 4], look: [s.x, 1.3, s.z] }; + }; + // nearest openLate (video) shop, for the night/rain money shots + const videoShop = () => (plan.shops || []).find((s) => s.hours && s.hours[1] >= 22); + // Named bookmarks (LANE_F_NOTES §4). seg = day segment index (0 DAWN … 5 NIGHT). // Predicates cover both Lane A's taxonomy (dept/stall/record/…) and the fixture's; each falls back // to the nearest shop, so every bookmark always yields a valid street pose. @@ -131,6 +144,15 @@ export function installDBG(deps) { crossroads_busy: { seg: 2, resolve: () => crossroadsPose() }, residential_collar: { seg: 3, resolve: () => pickLot((l) => l.use === 'house' || blockKindOfLot(l) === 'residential' || blockKindOfLot(l) === 'backstreets') }, warehouse_fringe: { seg: 4, resolve: () => streetViewPose(['side', 'main'], { far: true }) }, // down the outer-ring street + // ── v2 tour (round 9): the town's new life. F shoots these with the matching flags (noted per pose). ── + rain_verandah: { seg: 5, resolve: () => { const v = videoShop(); return v ? poseForShop(v, 6) : streetViewPose(['main']); } }, // ?winmap=1&weather=rain — rain outside the lit video-shop window + patronage_door: { seg: 2, resolve: () => pickShop((s) => s.hours && s.hours[0] <= 12 && s.hours[1] > 13 && s.type !== 'video') }, // peds at an OPEN shop's door (default roster/patronage) + dig_real: { seg: 2, resolve: () => pickShop((s) => s.type === 'record') }, // ?stock=real&dig=1 — F enters + riffles a real-cover bin + katoomba_main: { seg: 2, resolve: () => streetViewPose(['main']) }, // ?plansrc=osm&town=katoomba — plan-agnostic main-strip view + tram_stop: { seg: 2, resolve: () => stopPose() }, // ?tram=1 — a bus shelter (tram pauses here) + rain_street: { seg: 2, resolve: () => streetViewPose(['main']) }, // ?weather=rain — wet strip, rain down the street + night_crowd: { seg: 5, resolve: () => { const v = videoShop(); return v ? poseForShop(v, 16) : streetViewPose(['main']); } }, // night video-shop draw (wider, shows the crowd) + shopfront_detail: { seg: 3, resolve: () => pickShop((s) => s.type === 'opshop' || s.type === 'record' || s.type === 'milkbar') }, // verandah + sign + window up close }; const DBG = { diff --git a/web/js/world/tram.js b/web/js/world/tram.js new file mode 100644 index 0000000..5e64cdb --- /dev/null +++ b/web/js/world/tram.js @@ -0,0 +1,137 @@ +// PROCITY Lane B — tram.js +// v2 tram (?tram=1), default-off, NON-BLOCKING. One small bus/tram runs a seeded out-and-back loop +// along the main-street spine, pausing (door dwell) at each bus_shelter stop (busShelterStops), then +// reversing at the ends. Deterministic: the route + speed + dwell are fixed, so position is a pure +// function of elapsed time — same each run. ≤2 draws (a textured body + a merged headlight pair). +// Composes with weather/night (headlights glow at night). Flag-off identical (shell never builds it). +// Bell-ready: ring() is a stub for the future door-bell audio. No traffic sim, no collision (v0). + +import * as THREE from 'three'; +import { busShelterStops } from './furniture.js'; + +const SPEED = 9; // m/s cruising +const DWELL = 3.5; // s door pause at each stop +const LANE = 3.2; // offset from the road centreline (drive on the left, AU) + +// Ordered spine polyline: walk the chain of MAIN edges from a spine end. Returns [[x,z],…] or null. +function spinePolyline(plan) { + const nodes = new Map(plan.streets.nodes.map((n) => [n.id, n])); + const mains = plan.streets.edges.filter((e) => e.kind === 'main'); + if (!mains.length) return null; + const byNode = new Map(); + for (const e of mains) for (const nid of [e.a, e.b]) { if (!byNode.has(nid)) byNode.set(nid, []); byNode.get(nid).push(e); } + let start = null; + for (const [nid, es] of byNode) if (es.length === 1) { start = nid; break; } + if (start == null) start = mains[0].a; + const seq = [start]; const used = new Set(); let cur = start; + for (;;) { + const next = (byNode.get(cur) || []).find((e) => !used.has(e.id)); + if (!next) break; + used.add(next.id); cur = next.a === cur ? next.b : next.a; seq.push(cur); + } + return seq.map((id) => { const n = nodes.get(id); return [n.x, n.z]; }); +} + +// bus livery texture (canvas — no fetch): cream body, window band, a red stripe + a route blind. +function busTexture() { + const c = document.createElement('canvas'); c.width = 256; c.height = 128; + const x = c.getContext('2d'); + x.fillStyle = '#e8e0cf'; x.fillRect(0, 0, 256, 128); // cream + x.fillStyle = '#233043'; x.fillRect(0, 30, 256, 42); // window band + for (let i = 12; i < 256; i += 34) { x.fillStyle = '#9fb2c8'; x.fillRect(i, 34, 24, 34); } // panes + x.fillStyle = '#9a2f2a'; x.fillRect(0, 78, 256, 10); // red stripe + x.fillStyle = '#1a1a1a'; x.fillRect(96, 6, 64, 20); // route blind + x.fillStyle = '#ffd75e'; x.font = 'bold 15px Arial'; x.textAlign = 'center'; x.textBaseline = 'middle'; + x.fillText('TOWN LOOP', 128, 16); + const t = new THREE.CanvasTexture(c); t.colorSpace = THREE.SRGBColorSpace; t.anisotropy = 4; + return t; +} + +export function createTram({ scene, plan, camera, lighting }) { + const route = spinePolyline(plan); + const group = new THREE.Group(); group.name = 'tram'; + if (!route || route.length < 2) { scene.add(group); return { group, update() {}, ring() {}, dispose() { scene.remove(group); } }; } + + // ── cumulative arc length + stop positions projected to arc length ── + const seg = []; // { x0,z0, dx,dz, len, s0 } + let total = 0; + for (let i = 0; i < route.length - 1; i++) { + const [x0, z0] = route[i], [x1, z1] = route[i + 1]; + const dx = x1 - x0, dz = z1 - z0, len = Math.hypot(dx, dz) || 1; + seg.push({ x0, z0, dx: dx / len, dz: dz / len, len, s0: total }); total += len; + } + const posAt = (s) => { + s = Math.max(0, Math.min(total, s)); + let g = seg[0]; for (const q of seg) if (s >= q.s0) g = q; + const d = s - g.s0; + return { x: g.x0 + g.dx * d, z: g.z0 + g.dz * d, dx: g.dx, dz: g.dz }; + }; + // project each shelter stop onto the route → sorted arc-length stop marks + const stopS = busShelterStops(plan).map((st) => { + let best = 0, bd = 1e18; + for (let s = 0; s <= total; s += 3) { const p = posAt(s); const dd = (p.x - st.x) ** 2 + (p.z - st.z) ** 2; if (dd < bd) { bd = dd; best = s; } } + return best; + }).sort((a, b) => a - b); + + // ── tram mesh: textured body (1 draw) + merged emissive headlights (1 draw) ── + const bodyMat = new THREE.MeshStandardMaterial({ map: busTexture(), roughness: 0.7, metalness: 0.1 }); + const body = new THREE.Mesh(new THREE.BoxGeometry(2.4, 2.7, 9.0), bodyMat); + body.position.y = 1.55; body.castShadow = true; + group.add(body); + const headMat = new THREE.MeshStandardMaterial({ color: 0xfff2cc, emissive: new THREE.Color(0xffe6a0), emissiveIntensity: 0.2, roughness: 0.4 }); + const hl = (x) => { const q = new THREE.PlaneGeometry(0.4, 0.3); q.rotateY(0).translate(x, 0.9, 4.55); return q; }; + const heads = new THREE.Mesh(mergeQuads([hl(-0.8), hl(0.8)]), headMat); + group.add(heads); + scene.add(group); + + // ── deterministic schedule: position s(t) with dwell at stops, ping-pong at the ends ── + let s = 0, dir = 1, dwell = 0, nextStop = 0; + const EPS = 2.5; + + function update(dt) { + if (dwell > 0) { dwell = Math.max(0, dwell - dt); } + else { + s += dir * SPEED * dt; + // stop dwell when passing a mark in the travel direction + if (dir > 0 && nextStop < stopS.length && s >= stopS[nextStop] - EPS) { s = stopS[nextStop]; dwell = DWELL; nextStop++; } + else if (dir < 0 && nextStop >= 0 && stopS[nextStop] != null && s <= stopS[nextStop] + EPS) { s = stopS[nextStop]; dwell = DWELL; nextStop--; } + if (s >= total) { s = total; dir = -1; nextStop = stopS.length - 1; } + else if (s <= 0) { s = 0; dir = 1; nextStop = 0; } + } + const p = posAt(s); + // sit in the left lane relative to travel direction, face along travel + const perpx = -p.dz * dir, perpz = p.dx * dir; + group.position.set(p.x + perpx * LANE, 0, p.z + perpz * LANE); + group.rotation.y = Math.atan2(p.dx * dir, p.dz * dir); + headMat.emissiveIntensity = (lighting && lighting.isNight && lighting.isNight()) ? 1.6 : 0.15; + } + update(0); + + function ring() { /* door-bell hook — audio parked (V2_IDEAS greenfield) */ } + function dispose() { + body.geometry.dispose(); bodyMat.map.dispose(); bodyMat.dispose(); + heads.geometry.dispose(); headMat.dispose(); + scene.remove(group); + } + return { group, update, ring, dispose, get stops() { return stopS.length; } }; +} + +function mergeQuads(geos) { + // tiny local merge (headlight quads share a material) — avoids importing BufferGeometryUtils for 2 quads + let n = 0; for (const g of geos) n += g.attributes.position.count; + const pos = new Float32Array(n * 3), uv = new Float32Array(n * 2), nor = new Float32Array(n * 3); + const idx = []; let vo = 0; + for (const g of geos) { + const gp = g.attributes.position.array, gu = g.attributes.uv.array, gn = g.attributes.normal.array; + pos.set(gp, vo * 3); uv.set(gu, vo * 2); nor.set(gn, vo * 3); + const gi = g.index ? g.index.array : [...Array(g.attributes.position.count).keys()]; + for (const i of gi) idx.push(i + vo); + vo += g.attributes.position.count; g.dispose(); + } + const m = new THREE.BufferGeometry(); + m.setAttribute('position', new THREE.BufferAttribute(pos, 3)); + m.setAttribute('uv', new THREE.BufferAttribute(uv, 2)); + m.setAttribute('normal', new THREE.BufferAttribute(nor, 3)); + m.setIndex(idx); + return m; +} diff --git a/web/js/world/weather.js b/web/js/world/weather.js index 3b246d8..5177c36 100644 --- a/web/js/world/weather.js +++ b/web/js/world/weather.js @@ -88,7 +88,7 @@ export function createWeather({ scene, plan, skins, lighting, camera, force = nu transparent: true, depthWrite: false, uniforms: { uTime: { value: 0 }, uTex: { value: streakTexture() }, uBoxH: { value: BOXH }, - uSpeed: { value: 32 }, uSize: { value: 9.0 }, uWind: { value: 0.18 }, uOpacity: { value: 0.32 + state.intensity * 0.3 }, + uSpeed: { value: 32 }, uSize: { value: 5.5 }, uWind: { value: 0.18 }, uOpacity: { value: 0.34 + state.intensity * 0.28 }, }, vertexShader: ` uniform float uTime, uBoxH, uSpeed, uSize, uWind; attribute float aPhase; @@ -99,7 +99,7 @@ export function createWeather({ scene, plan, skins, lighting, camera, force = nu p.x += (uBoxH * 0.5 - p.y) * uWind; // wind slant vec4 mv = modelViewMatrix * vec4(p, 1.0); gl_Position = projectionMatrix * mv; - gl_PointSize = uSize * 34.0 / max(-mv.z, 1.0); + gl_PointSize = clamp(uSize * 30.0 / max(-mv.z, 1.0), 2.0, 22.0); // capped so near drops stay streaks, not blobs }`, fragmentShader: ` uniform sampler2D uTex; uniform float uOpacity;