- 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>
207 lines
13 KiB
JavaScript
207 lines
13 KiB
JavaScript
// PROCITY Lane B — dbg.js
|
|
// The QA debug harness hook (window.DBG), installed by the shell only with ?dbg=1. It drives Lane
|
|
// F's tools/shots.py + tools/soak.py (LANE_F_NOTES §4) and mirrors 90sDJsim's window.DBG.shot()
|
|
// contract: named camera bookmarks, teleport, time-of-day, scripted shop entry, and a perf/heap
|
|
// readout. Every pose is derived from the LIVE plan so bookmarks work on any seed; the day/night
|
|
// cycle is paused on the first scripted segment change so captures are deterministic.
|
|
//
|
|
// 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;
|
|
const CS = 64;
|
|
|
|
// Force the renderer + composer + camera to the live viewport before every harness render, then
|
|
// reset perf counters and render one full composited frame (bloom + output). Headless captures
|
|
// otherwise letterbox: EffectComposer's render targets don't rebuild on the initial resize and the
|
|
// Playwright viewport settles across the first frames (LANE_F_NOTES §9.2). We re-sync every shot
|
|
// (only ~10 of them) so a captured frame always fills the canvas regardless of settle order.
|
|
// Pin all three (renderer drawing buffer, composer render targets, camera aspect) to the live
|
|
// viewport UNCONDITIONALLY each harness render — they settle independently in headless and any
|
|
// change-guard races the settle. It's ~10 shots, so the extra setSize calls are free.
|
|
const syncSize = () => {
|
|
const w = Math.max(1, innerWidth), h = Math.max(1, innerHeight);
|
|
renderer.setSize(w, h, false); // false: don't rewrite canvas CSS (it's 100vw/100vh)
|
|
composer.setSize(w, h);
|
|
if (camera.isPerspectiveCamera) { camera.aspect = w / h; camera.updateProjectionMatrix(); }
|
|
};
|
|
const render = () => { syncSize(); renderer.info.reset(); composer.render(); };
|
|
|
|
const info = () => {
|
|
const r = renderer.info;
|
|
return {
|
|
drawCalls: r.render.calls, tris: r.render.triangles,
|
|
fps: hud && hud.getFps ? hud.getFps() : null,
|
|
heapMB: (performance.memory ? +(performance.memory.usedJSHeapSize / 1048576).toFixed(1) : null),
|
|
geometries: r.memory.geometries, textures: r.memory.textures,
|
|
chunk: `${Math.round(player.position.x / CS)},${Math.round(player.position.z / CS)}`,
|
|
chunks: chunks.count, mode: getMode(),
|
|
};
|
|
};
|
|
|
|
// ── camera pose facing a shop's front ──
|
|
// Front normal = local +Z rotated by lot.ry; the shopfront sits d/2 out from the lot centre, so we
|
|
// stand `dist` m IN FRONT OF THE SHOPFRONT (not the centre — deep lots like the dept anchor would
|
|
// otherwise put the camera inside the facade), offset slightly for a 3/4 angle, looking at it.
|
|
const lotOf = (shop) => plan.lots?.find((l) => l.id === shop.lot);
|
|
const poseForShop = (shop, dist = 10) => {
|
|
const lot = shop && lotOf(shop); if (!lot) return null;
|
|
const ry = lot.ry || 0, fx = Math.sin(ry), fz = Math.cos(ry); // front normal
|
|
const rx = Math.cos(ry), rz = -Math.sin(ry); // right vector
|
|
const half = (lot.d || 8) / 2, off = 1.0; // near dead-on (terraced neighbours flank the frame)
|
|
const frontX = lot.x + fx * half, frontZ = lot.z + fz * half; // shopfront centre
|
|
return {
|
|
pos: [frontX + fx * dist + rx * off, 2.0, frontZ + fz * dist + rz * off],
|
|
look: [frontX, 2.2, frontZ],
|
|
};
|
|
};
|
|
const byDist = () => [...(plan.shops || [])].sort((a, b) => {
|
|
const la = lotOf(a), lb = lotOf(b);
|
|
return Math.hypot(la?.x || 0, la?.z || 0) - Math.hypot(lb?.x || 0, lb?.z || 0);
|
|
});
|
|
// nearest shop matching pred, else nearest shop overall → a pose is always produced
|
|
const pickShop = (pred) => { const s = byDist(); return poseForShop(s.find(pred) || s[0]); };
|
|
|
|
// ── lot- and node-based poses (the district bookmarks aren't all shopfronts) ──
|
|
const poseForLot = (lot, dist = 11) => {
|
|
if (!lot) return null;
|
|
const ry = lot.ry || 0, fx = Math.sin(ry), fz = Math.cos(ry), rx = Math.cos(ry), rz = -Math.sin(ry);
|
|
const half = (lot.d || 8) / 2, off = 1.2;
|
|
const frontX = lot.x + fx * half, frontZ = lot.z + fz * half;
|
|
return { pos: [frontX + fx * dist + rx * off, 1.9, frontZ + fz * dist + rz * off], look: [frontX, 2.0, frontZ] };
|
|
};
|
|
const blockKindOfLot = (lot) => plan.blocks?.find((b) => b.id === lot.block)?.kind;
|
|
const lotsByDist = () => [...(plan.lots || [])].sort((a, b) => Math.hypot(a.x, a.z) - Math.hypot(b.x, b.z));
|
|
// nearest (or farthest = "fringe") lot matching pred, else nearest lot overall
|
|
const pickLot = (pred, farthest = false) => {
|
|
const L = lotsByDist(); const hit = L.filter(pred);
|
|
return poseForLot(hit.length ? (farthest ? hit[hit.length - 1] : hit[0]) : L[0]);
|
|
};
|
|
// stand on a street and look ALONG it (shops recede on both sides) — the classic strip shot.
|
|
// Dead-on shopfront poses jam the camera against a big neighbour (dept anchor / market shed),
|
|
// filling half the frame with a blank wall — that was the "letterbox". Looking down the street
|
|
// frames the whole strip and fills the frame.
|
|
const streetViewPose = (kinds = ['main'], { far = false } = {}) => {
|
|
const nn = new Map((plan.streets?.nodes || []).map((n) => [n.id, n]));
|
|
const es = (plan.streets?.edges || []).filter((e) => kinds.includes(e.kind) && nn.get(e.a) && nn.get(e.b));
|
|
if (!es.length) return crossroadsPose();
|
|
let best = null, bd = 1e18; // long edge, near the origin (or far, for the fringe)
|
|
for (const e of es) {
|
|
const a = nn.get(e.a), b = nn.get(e.b);
|
|
const len = Math.hypot(b.x - a.x, b.z - a.z) || 1;
|
|
const dist = Math.hypot((a.x + b.x) / 2, (a.z + b.z) / 2);
|
|
const score = (far ? -dist : dist) - len * 0.8; // favour long open runs; `far` picks the outer ring
|
|
if (score < bd) { bd = score; best = { a, b, len, width: e.width || 8 }; }
|
|
}
|
|
const { a, b, len, width } = best;
|
|
const ux = (b.x - a.x) / len, uz = (b.z - a.z) / len; // along the street
|
|
const px = -uz, pz = ux; // perpendicular (toward a footpath)
|
|
const off = Math.min(5, width * 0.2); // stay inside the corridor (narrow arcade → near-centre)
|
|
return {
|
|
pos: [a.x + ux * (len * 0.14) + px * off, 1.7, a.z + uz * (len * 0.14) + pz * off],
|
|
look: [a.x + ux * (len * 0.9) + px * off, 2.3, a.z + uz * (len * 0.9) + pz * off],
|
|
};
|
|
};
|
|
|
|
// stand back from the busiest intersection (max-degree node) and look through it down a street
|
|
const crossroadsPose = () => {
|
|
const edges = plan.streets?.edges || [], nodes = plan.streets?.nodes || [];
|
|
const deg = new Map(); for (const e of edges) { deg.set(e.a, (deg.get(e.a) || 0) + 1); deg.set(e.b, (deg.get(e.b) || 0) + 1); }
|
|
let node = null, bd = -1; for (const n of nodes) { const d = deg.get(n.id) || 0; if (d > bd) { bd = d; node = n; } }
|
|
if (!node) return pickShop(() => true);
|
|
const e = edges.find((ed) => ed.a === node.id || ed.b === node.id);
|
|
const o = e && nodes.find((n) => n.id === (e.a === node.id ? e.b : e.a));
|
|
let dx = 1, dz = 0; if (o) { const L = Math.hypot(o.x - node.x, o.z - node.z) || 1; dx = (o.x - node.x) / L; dz = (o.z - node.z) / L; }
|
|
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.
|
|
const BOOKMARKS = {
|
|
street_noon: { seg: 2, resolve: () => streetViewPose(['main']) }, // look down the main strip
|
|
arcade: { seg: 3, resolve: () => streetViewPose(['side']) }, // an open side street (the 5m arcade lane is too tight to frame cleanly from inside)
|
|
market_square: { seg: 1, resolve: () => pickShop((s) => s.type === 'stall' || s.type === 'market') },
|
|
milkbar_dusk: { seg: 4, resolve: () => pickShop((s) => s.type === 'milkbar') },
|
|
// the §3.5 money shot: the one openLate shop (video rental) glowing amid its CLOSED neighbours
|
|
night_neon: { seg: 5, resolve: () => { const v = (plan.shops || []).find((s) => s.hours && s.hours[1] >= 22); return v ? poseForShop(v, 12) : streetViewPose(['main']); } },
|
|
// the 3 poses Lane F's v1_tour also uses (LANE_F_NOTES §9.1) — were falling back to street_noon
|
|
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 = {
|
|
// true once the initial chunks are built and the queue is drained (textures may still decode)
|
|
get ready() { return chunks.count > 0 && chunks.pending === 0; },
|
|
bookmarks: Object.keys(BOOKMARKS),
|
|
info,
|
|
|
|
// time-of-day (hands the day cycle to the harness so captures are deterministic)
|
|
setSegment(seg) { lighting.setPaused(true); lighting.setSegment(seg | 0); return lighting.getClock(); },
|
|
|
|
// drive the soak walk: place the player and stream the destination in before the next step/shot
|
|
teleport(x, z, ry = 0) {
|
|
if (getMode() !== 'street') setMode('street');
|
|
player.teleport(x, z, ry);
|
|
chunks.warmup(player.position);
|
|
render();
|
|
return info();
|
|
},
|
|
|
|
// snap to a named bookmark, settle, and return the frame's stats
|
|
shot(name) {
|
|
const bm = BOOKMARKS[name] || BOOKMARKS.street_noon;
|
|
const pose = bm.resolve();
|
|
if (getMode() !== 'street') setMode('street');
|
|
if (bm.seg != null) { lighting.setPaused(true); lighting.setSegment(bm.seg); }
|
|
if (pose) {
|
|
player.teleport(pose.pos[0], pose.pos[2], 0);
|
|
camera.position.set(pose.pos[0], pose.pos[1], pose.pos[2]);
|
|
chunks.warmup(camera.position);
|
|
camera.lookAt(pose.look[0], pose.look[1], pose.look[2]);
|
|
camera.updateMatrixWorld();
|
|
}
|
|
render(); render(); // two frames so bloom + the segment change settle before capture
|
|
return { name, ...info() };
|
|
},
|
|
|
|
// scripted interior visit (drives the record_interior shot + the soak memory-baseline gate)
|
|
enterShop(shopId) {
|
|
const s = (plan.shops || []).find((x) => x.id === shopId);
|
|
if (!s) return { error: `no shop ${shopId}` };
|
|
enterShop(s.id, s.name);
|
|
return { entered: s.id, name: s.name };
|
|
},
|
|
exitShop() { window.dispatchEvent(new CustomEvent('procity:exitShop')); return { exited: true }; },
|
|
};
|
|
|
|
window.DBG = DBG;
|
|
console.log('[procity] DBG harness ready (?dbg=1) — shot/teleport/setSegment/enterShop/exitShop/info/ready');
|
|
return DBG;
|
|
}
|