- tram.js: 1 small bus, seeded out-and-back loop on the main-street spine, door-dwell at each busShelterStops stop, ping-pong at ends. Deterministic, exactly 2 draws, night headlights via lighting.isNight(). Flag-off identical. Shell wiring (2 lines) documented for F in LANE_B_NOTES. - dbg.js: 8 v2 tour bookmarks (16 total) incl. rain_verandah hero shot; tram_stop now frames the nearest-origin (open) shelter stop. - weather.js: cap rain gl_PointSize (clamp 2-22px) - near drops are thin streaks now, not blobs. Rain smoke still GREEN. - qa.sh --strict GREEN, golden hash frozen, flags-off byte-identical. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
138 lines
7.1 KiB
JavaScript
138 lines
7.1 KiB
JavaScript
// 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;
|
|
}
|