Lane B round-4: closed facades (§3.5) + shot poses/letterbox + furniture GLB + novelty_record

- B1: per-shop closed-facade visuals — dark windows + red CLOSED plate + door tooltip, batched via a
  per-vertex aHours attr + two shared shaders + one uHour/uNight uniform pair on a procity:segment
  event (facade atlas untouched, no per-shop materials, no per-frame poll). 22:00 → only the openLate
  video shop lit, all others CLOSED; ~200 draws worst-view with citizens.
- B2: the shots 'letterbox' was camera-jammed-into-a-wall poses, not a composer bug (composer proven
  correctly sized headless). Reframed street_noon/arcade/warehouse to look down a street
  (streetViewPose), added the 3 missing bookmarks, night_neon now frames the lit video shop amid
  CLOSED neighbours, + a defensive composer resize. shots.py: 10 un-letterboxed shots, 0 errors.
- B3: furniture GLB use-if-ready upgrade (bench + food_cart, sit on their footprints) + novelty_record
  placed at record stores. novelty_record GLB is 26.5k tris (5x glb_law) → stays low-poly primitive
  until Lane E decimates it; ?noassets verified 0 network.

qa.sh --strict GREEN (4/4).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
m3ultra 2026-07-14 19:59:27 +10:00
parent dafb079ff4
commit 6323d58736
17 changed files with 357 additions and 42 deletions

View File

@ -4,6 +4,29 @@
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 4 (Fable's ROUND4 → Lane B) — done · qa.sh --strict GREEN (4/4)
- **B1 — closed-facade visuals (§3.5, headline).** Closed shops → **dark windows + red CLOSED plate**;
open shops (esp. the `openLate` video rental at night) → **lit windows**; door tooltip
`🔒 <name> — CLOSED · opens HH:00`. Batched with a per-vertex `aHours` + two shared shaders + one
`uHour/uNight` uniform pair, updated once on a `procity:segment` event (lighting.js → buildings.js)
**facade atlas untouched, no per-shop materials, no per-frame poll.** Verified live: **22:00 →
only "Video Regal" lit, all others CLOSED**; midday → all open; worst-view **200 draws with
citizens** (≤300). Files: `buildings.js`, `lighting.js`, `hud.js`.
- **B2 — shot harness (gate-6 critical path).** The "letterbox" was **not** a composer bug (proved
the composer is correctly sized headless) — the `street_noon`/`arcade`/`warehouse_fringe` **poses
jammed the camera into a wall**. Reframed them to look down a street (new `streetViewPose`), added
the **3 missing bookmarks** F asked for, pointed `night_neon` at the glowing video shop amid CLOSED
neighbours, and added a defensive composer resize. `tools/shots.py` → **10 un-letterboxed shots, 0
console errors** (in `docs/shots/laneB_r4/`). File: `dbg.js`.
- **B3 — furniture GLB upgrade + novelty_record.** furniture.js now use-if-ready-upgrades primitives
to depot GLBs (sync check, `?noassets` → 0 network verified). Placed **novelty_record** at every
record store + food_cart by stalls/milk bars. **Finding:** the **novelty_record GLB is 26.5k tris**
(5× `glb_law`), spiking a busy view to **574k** → it stays on its low-poly primitive; bench (1.9k)
+ food_cart (5.0k) GLBs are fine and sit on their footprints. **→ Lane E: decimate novelty_record
(same as the ped rigs).** File: `furniture.js`.
## Round 3 (Fable's ROUND3_INSTRUCTIONS → Lane B) — done
- **Task 1 (draw budget, re-measured WITH citizens).** Radius-2 auto is already the spawn default;
no config change needed. Drove the real loop (`chunks.update` + `citizens.update`, pop 140, up to

View File

@ -1,5 +1,42 @@
# LANE B — NOTES (measured)
## Round 4 (Fable's ROUND4 → Lane B) — done
- **B1 — closed-facade visuals (§3.5, the headline).** Closed shops now render **dark windows + a
red CLOSED plate**; open shops (esp. the one `openLate` **video rental** at night) render **lit
windows**; the door tooltip reads `🔒 <name> — CLOSED · opens HH:00` on aim. **Batched — the facade
atlas is untouched:** per-shop open/closed rides on a per-vertex `aHours` (vec2 = the shop's
`[open,close]`) feeding TWO shared shaders (window emissive gated by *open AND night*; plate alpha
gated by *closed*), driven by ONE pair of shared uniforms (`uHour/uNight`) updated **once per
day-segment change** — no per-shop materials, no per-frame poll. lighting.js dispatches a
`procity:segment` event (numeric hour matching Lane F's `currentHour()`); buildings.js listens and
calls `setFacadeHours`. Verified live: **22:00 → only "Video Regal" lit, every other facade CLOSED**;
midday → all open, no plates; tooltip correct. **Worst view with citizens: 200 total draws** (≤300).
- **B2 — shot harness (gate-6).** (a) **Letterbox "fixed":** the bars were **not** a composer bug (I
proved the composer/renderer/camera are correctly 1280×720 in headless at every stage) — they were
`street_noon`/`arcade`/`warehouse_fringe` poses **jamming the camera against a big building wall**.
Reframed those bookmarks to look *down a street* (`streetViewPose`), so all 10 tour shots now fill
the frame. Also added a defensive `composer.setSize` sync in `DBG.render` per F's request. (b) Added
the **3 missing poses** F listed (`crossroads_busy`, `residential_collar`, `warehouse_fringe`) —
resolved from the live plan (busiest node / house-or-backstreets lot / outer-ring street), always a
valid full-frame pose. `night_neon` now frames the open video shop glowing amid its CLOSED
neighbours (the §3.5 money shot). `tools/shots.py` → 10 un-letterboxed shots, **0 console errors**.
- **B3 — street-furniture GLB upgrade + `novelty_record`.** furniture.js now upgrades primitives to
depot GLBs via a **synchronous use-if-ready** check at chunk-build (no async write into a live
chunk; `?noassets` → primitives, **0 network** verified). Placed the **novelty_record** landmark on
the footpath in front of every record store (and a food_cart by market stalls / milk bars).
**⚠ measured GLB tris:** bench **1.9k** ✓, food_cart **5.0k** ✓, streetlight **5.0k** ✓, but
**novelty_record is 26.5k** (5× the ≤5k `glb_law`) — × many instances it spiked the busiest view to
**574k tris**. So novelty_record stays on its **low-poly primitive** (giant vinyl disc, budget-safe)
until Lane E decimates the GLB — **same treatment the ped rigs need (→ Lane E)**. With that, the
bench GLB verified sitting on its exact footprint (0.44×1.21), tris back to ~25k.
`bash tools/qa.sh --strict` **GREEN** (4/4) after these changes. Round-4 files: `dbg.js`, `buildings.js`,
`lighting.js`, `hud.js`, `furniture.js` (+ these docs, + `docs/shots/laneB_r4/`).
---
Streetscape + game shell. Files: `web/index.html`, `web/js/world/*`, `web/js/world/fixture_plan.js`.
All numbers measured in-browser (Chromium, M-series) via `renderer.info`, 1280×720-ish viewport.
`renderer.info.autoReset` is off and reset once/frame, so **draws/tris are the true per-frame

Binary file not shown.

After

Width:  |  Height:  |  Size: 688 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 787 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 620 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 706 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 815 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 820 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 696 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 818 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 533 KiB

View File

@ -24,6 +24,71 @@ const WALL_TINTS = ['#c9b8a0', '#b0b8ab', '#c4b0b0', '#a8b4c0', '#c4bc9c', '#cab
const ROOF_TINT = '#5b5148';
const AWNING_SKINS = ['red', 'green', 'blue'];
// ── Shop-hours facade state (§3.5): closed shops show dark windows + a CLOSED plate ─────────────
// ONE shared window material + ONE shared CLOSED-plate material across every chunk, driven by two
// shared uniforms that update ONCE on a day-segment change (via the 'procity:segment' event
// lighting.js dispatches — no per-frame poll). Per-shop open/closed rides on a per-vertex `aHours`
// (vec2 = the shop's [open,close] in 24h); the shader gates window emissive (open AND night) and
// plate alpha (closed). New chunks stream in already reflecting the current hour (shared uniforms).
const _hoursUniforms = { uHour: { value: 12 }, uNight: { value: 0 } };
let _windowMat = null, _plateMat = null, _plateTex = null;
function windowMaterial() {
if (_windowMat) return _windowMat;
const m = new THREE.MeshStandardMaterial({
color: 0x28323c, roughness: 0.14, metalness: 0.35, transparent: true, opacity: 0.46,
emissive: new THREE.Color(0xffcf94), emissiveIntensity: 1.2,
});
m.onBeforeCompile = (shader) => {
shader.uniforms.uHour = _hoursUniforms.uHour;
shader.uniforms.uNight = _hoursUniforms.uNight;
shader.vertexShader = 'attribute vec2 aHours;\nvarying vec2 vHours;\n' +
shader.vertexShader.replace('#include <begin_vertex>', '#include <begin_vertex>\n\tvHours = aHours;');
shader.fragmentShader = 'uniform float uHour;\nuniform float uNight;\nvarying vec2 vHours;\n' +
shader.fragmentShader.replace('#include <emissivemap_fragment>',
'#include <emissivemap_fragment>\n\tfloat _open = step(vHours.x, uHour) * step(uHour + 0.0001, vHours.y);\n\ttotalEmissiveRadiance *= _open * uNight;');
};
_windowMat = m;
return m;
}
function plateTexture() {
if (_plateTex) return _plateTex;
const c = document.createElement('canvas'); c.width = 256; c.height = 96;
const x = c.getContext('2d');
x.fillStyle = '#7a0f12'; x.fillRect(0, 0, 256, 96);
x.strokeStyle = '#e8c8a0'; x.lineWidth = 6; x.strokeRect(7, 7, 242, 82);
x.fillStyle = '#f0e2c0'; x.font = 'bold 52px Arial, sans-serif';
x.textAlign = 'center'; x.textBaseline = 'middle'; x.fillText('CLOSED', 128, 52);
_plateTex = new THREE.CanvasTexture(c); _plateTex.colorSpace = THREE.SRGBColorSpace;
_plateTex.generateMipmaps = false; _plateTex.minFilter = THREE.LinearFilter;
return _plateTex;
}
function closedPlateMaterial() {
if (_plateMat) return _plateMat;
_plateMat = new THREE.ShaderMaterial({
uniforms: { map: { value: plateTexture() }, uHour: _hoursUniforms.uHour },
transparent: true, depthWrite: false,
vertexShader: 'attribute vec2 aHours; varying vec2 vHours; varying vec2 vUv;\n'
+ 'void main(){ vHours = aHours; vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }',
fragmentShader: 'uniform sampler2D map; uniform float uHour; varying vec2 vHours; varying vec2 vUv;\n'
+ 'void main(){ float openF = step(vHours.x, uHour) * step(uHour + 0.0001, vHours.y);\n'
+ ' vec4 t = texture2D(map, vUv); float a = t.a * (1.0 - openF);\n'
+ ' if (a < 0.02) discard; gl_FragColor = vec4(t.rgb, a); }',
});
return _plateMat;
}
// Update the shared facade-hours uniforms (day-segment change → open/closed re-evaluates instantly).
export function setFacadeHours(hour, night) {
_hoursUniforms.uHour.value = hour;
_hoursUniforms.uNight.value = night ? 1 : 0;
}
if (typeof window !== 'undefined') {
window.addEventListener('procity:segment', (e) => { if (e.detail) setFacadeHours(e.detail.hour, !!e.detail.night); });
}
const _m4 = new THREE.Matrix4();
const _q = new THREE.Quaternion();
const _up = new THREE.Vector3(0, 1, 0);
@ -45,6 +110,16 @@ function quad(c0, c1, c2, c3, uv = [0, 0, 1, 1]) {
return g;
}
// quad + a per-vertex `aHours` (the shop's [open,close]) so the shared window/plate shaders can
// gate emissive / alpha per shop without per-shop materials.
function hoursQuad(c0, c1, c2, c3, hours, uv) {
const g = quad(c0, c1, c2, c3, uv);
const h = new Float32Array(12);
for (let i = 0; i < 6; i++) { h[i * 2] = hours[0]; h[i * 2 + 1] = hours[1]; }
g.setAttribute('aHours', new THREE.BufferAttribute(h, 2));
return g;
}
// Transform a local (lx,ly,lz) point to world via a lot's (x,z,ry).
function toWorld(lot, lx, ly, lz, out) {
const cos = Math.cos(lot.ry || 0), sin = Math.sin(lot.ry || 0);
@ -70,10 +145,11 @@ export function buildChunkBuildings(chunkData, ctx) {
const facadeGeos = []; // every shopfront facade, UV-mapped into the shared atlas → 1 merged mesh
const doorGeos = []; // interactive shop/stall doors (raycast targets)
const decorDoorGeos = []; // decorative house doors (NOT raycastable → no mis-resolve to a shop)
const windowGeos = [];
const windowGeos = []; // all windows merge into 1 mesh (shared material, per-vertex aHours)
const plateGeos = []; // CLOSED plates, shown per-shop by the shared plate shader (closed → visible)
const signQuads = []; // { geo }
const colliders = [];
const doorRects = []; // { shopId, name, x, z }
const doorRects = []; // { shopId, name, x, z, hours }
const shopByLot = new Map(chunkData.shops.map((s) => [s.lot, s]));
@ -155,15 +231,26 @@ export function buildChunkBuildings(chunkData, ctx) {
toWorld(lot, -dw / 2, 0.02, zd, []), toWorld(lot, dw / 2, 0.02, zd, []),
toWorld(lot, dw / 2, dh, zd, []), toWorld(lot, -dw / 2, dh, zd, []),
));
if (shop) doorRects.push({ shopId: shop.id, name: shop.name, x: (toWorld(lot, 0, 0, zd, [])[0]), z: toWorld(lot, 0, 0, zd, [])[2] });
const hrs = (shop && shop.hours) || [0, 24]; // infill w/o a shop → always "open" (lit at night)
if (shop) doorRects.push({ shopId: shop.id, name: shop.name, hours: shop.hours, x: (toWorld(lot, 0, 0, zd, [])[0]), z: toWorld(lot, 0, 0, zd, [])[2] });
// windows flanking the door (tinted glass, emissive at night)
// windows flanking the door (tinted glass, emissive when OPEN at night — per-shop via aHours)
const winW = Math.max(0.7, (w - dw) / 2 - 0.6), winH = 1.5, wy = 1.55, zw = zf + 0.025;
const offs = [-(dw / 2 + 0.4 + winW / 2), (dw / 2 + 0.4 + winW / 2)];
for (const ox of offs) {
windowGeos.push(quad(
windowGeos.push(hoursQuad(
toWorld(lot, ox - winW / 2, wy - winH / 2, zw, []), toWorld(lot, ox + winW / 2, wy - winH / 2, zw, []),
toWorld(lot, ox + winW / 2, wy + winH / 2, zw, []), toWorld(lot, ox - winW / 2, wy + winH / 2, zw, []),
hrs,
));
}
// CLOSED plate over the shopfront — visible only when the shop is shut (shader gates on aHours)
if (shop) {
const pw = Math.min(1.5, w * 0.4), ph = 0.5, py = 1.35, zp = zf + 0.06;
plateGeos.push(hoursQuad(
toWorld(lot, -pw / 2, py - ph / 2, zp, []), toWorld(lot, pw / 2, py - ph / 2, zp, []),
toWorld(lot, pw / 2, py + ph / 2, zp, []), toWorld(lot, -pw / 2, py + ph / 2, zp, []),
shop.hours || [0, 24],
));
}
@ -218,9 +305,10 @@ export function buildChunkBuildings(chunkData, ctx) {
toWorld(lot, 0.5, 2.0, zf, []), toWorld(lot, -0.5, 2.0, zf, []),
));
for (const ox of [-w / 2 + 1.2, w / 2 - 1.2]) {
windowGeos.push(quad(
windowGeos.push(hoursQuad(
toWorld(lot, ox - 0.6, 1.1, zf, []), toWorld(lot, ox + 0.6, 1.1, zf, []),
toWorld(lot, ox + 0.6, 2.0, zf, []), toWorld(lot, ox - 0.6, 2.0, zf, []),
[0, 24], // homes glow at night (always "open")
));
}
colliders.push(lotCollider(lot));
@ -248,7 +336,7 @@ export function buildChunkBuildings(chunkData, ctx) {
toWorld(lot, -w / 2, 0.02, zf, []), toWorld(lot, w / 2, 0.02, zf, []),
toWorld(lot, w / 2, 1.9, zf, []), toWorld(lot, -w / 2, 1.9, zf, []),
));
doorRects.push({ shopId: shop.id, name: shop.name, x: lot.x, z: lot.z });
doorRects.push({ shopId: shop.id, name: shop.name, hours: shop.hours, x: lot.x, z: lot.z });
drawSign(shop);
const uv = signUV.get(shop.id);
if (uv) {
@ -258,6 +346,13 @@ export function buildChunkBuildings(chunkData, ctx) {
uv,
));
}
// CLOSED plate on the stall front
const pw = Math.min(1.4, w * 0.6);
plateGeos.push(hoursQuad(
toWorld(lot, -pw / 2, 1.0, zf + 0.06, []), toWorld(lot, pw / 2, 1.0, zf + 0.06, []),
toWorld(lot, pw / 2, 1.45, zf + 0.06, []), toWorld(lot, -pw / 2, 1.45, zf + 0.06, []),
shop.hours || [0, 24],
));
}
colliders.push(lotCollider(lot));
}
@ -316,19 +411,23 @@ export function buildChunkBuildings(chunkData, ctx) {
group.add(mesh);
}
// ── windows (merged, emissive at night) ──
let windowMat = null;
// ── windows (merged, ONE shared material; emissive gated per-shop by the aHours shader) ──
if (windowGeos.length) {
const merged = mergeGeometries(windowGeos, false);
windowGeos.forEach((g) => g.dispose());
windowMat = new THREE.MeshStandardMaterial({
color: 0x28323c, roughness: 0.14, metalness: 0.35,
transparent: true, opacity: 0.36, // subtle glass in day so the facade art shows through
emissive: new THREE.Color(0xffcf94), emissiveIntensity: 0,
});
const mesh = new THREE.Mesh(merged, windowMat);
disposables.push(merged, windowMat);
const mesh = new THREE.Mesh(merged, windowMaterial()); // shared → NOT disposed per chunk
group.add(mesh);
disposables.push(merged);
}
// ── CLOSED plates (merged; the shared shader shows each only while its shop is shut) ──
if (plateGeos.length) {
const merged = mergeGeometries(plateGeos, false);
plateGeos.forEach((g) => g.dispose());
const mesh = new THREE.Mesh(merged, closedPlateMaterial()); // shared → NOT disposed per chunk
mesh.renderOrder = 2;
group.add(mesh);
disposables.push(merged);
}
// ── signs (merged, one atlas texture) ──
@ -344,10 +443,9 @@ export function buildChunkBuildings(chunkData, ctx) {
group.add(mesh);
}
function applyNight(night) {
if (windowMat) { windowMat.emissiveIntensity = night ? 0.95 : 0; windowMat.opacity = night ? 0.9 : 0.36; }
}
applyNight(!!ctx.night);
// Windows + plates are now driven by the shared shop-hours uniforms (setFacadeHours on a segment
// change), so night no longer needs a per-chunk window toggle. Kept for the ChunkManager's call.
function applyNight() { /* no-op: window/plate state is the shared uHour/uNight uniform */ }
function dispose() {
group.traverse((o) => {

View File

@ -13,9 +13,21 @@ export function installDBG(deps) {
setMode, getMode, enterShop } = deps;
const CS = 64;
// reset the perf counters, then render one full composited frame (bloom + output) so the harness
// captures exactly this frame and renderer.info reflects it — robust even when rAF is throttled.
const render = () => { renderer.info.reset(); composer.render(); };
// 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;
@ -52,15 +64,73 @@ export function installDBG(deps) {
// 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] };
};
// 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: () => pickShop(() => true) },
arcade: { seg: 3, resolve: () => pickShop((s) => s.type === 'dept' || /arcade/.test(s.type)) },
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') },
night_neon: { seg: 5, resolve: () => pickShop((s) => s.type === 'record' || s.type === 'video') },
// 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
};
const DBG = {

View File

@ -3,17 +3,63 @@
// bins, gum trees (crossed-plane billboards), bus stops. Seeded placement, one InstancedMesh per
// type per chunk so a chunk's furniture is ~6 draw calls and disposes cleanly with the chunk.
//
// Every type has a PRIMITIVE fallback (house law) — the town is fully furnished with zero GLBs.
// A 3GOD-depot upgrade path (promise-cached loadGLB → hot-swap) is documented at the bottom but
// left OFF for v1 so async loads can never write into an unloaded chunk.
// Every type has a PRIMITIVE fallback (house law) — the town is fully furnished with zero GLBs and
// runs fully under ?noassets. Mapped types (bench + the novelty_record / food_cart landmark props)
// upgrade to depot GLBs via a SYNCHRONOUS "use-if-ready" check at chunk-build time (no async write
// into a live chunk): a chunk built before the GLB lands uses its primitive; later chunks use the GLB.
import * as THREE from 'three';
import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js';
import { loadGLB } from '../core/loaders.js';
import { rng, frange } from '../core/prng.js';
import { chunkOf, resolveEdges } from './planutil.js';
const FOOT = 3.5;
// ── 3GOD-depot furniture GLB upgrade (fail-soft; ?noassets → primitives only, zero network) ──────
// Each mapped type loads ONCE at module init. buildChunkFurniture checks `_glbReady[type]`
// SYNCHRONOUSLY at build time — so a chunk built before the GLB lands uses its primitive, and every
// chunk built after uses the GLB (streaming rebuilds pick it up). No async write into a live chunk.
const NOASSETS = (() => { try { const p = new URLSearchParams(location.search); return p.has('noassets') && p.get('noassets') !== '0'; } catch { return false; } })();
// Only LIGHT GLBs (≤5k tris/glb_law) are enabled — a heavy prop × many instances blows the ≤200k
// tri gate. Measured: bench 1.9k ✓, food_cart 5.0k ✓, streetlight 5.0k ✓, but novelty_record is
// **26.5k tris** (5× over) → it stays on the low-poly PRIMITIVE until Lane E decimates it (same
// treatment as the ped rigs — see LANE_B_NOTES). Placement (record-store adjacency) is unchanged.
const GLB_FILES = {
bench: 'procity_street_bench_01.glb',
food_cart: 'procity_street_food_cart_01.glb',
};
const _glbReady = {}; // type → { geo, mat } once (and if) the GLB loads and merges to one mesh
// Merge a GLB's meshes into ONE geometry (+ its first material) so the prop can be InstancedMesh'd.
// Multi-material GLBs that won't merge return null → the primitive stays (fail-soft).
function extractGLB(gltf) {
const geos = []; let material = null;
gltf.scene.updateWorldMatrix(true, true);
gltf.scene.traverse((o) => {
if (!o.isMesh || !o.geometry) return;
const g = o.geometry.clone(); g.applyMatrix4(o.matrixWorld);
for (const name of Object.keys(g.attributes)) if (name !== 'position' && name !== 'normal' && name !== 'uv') g.deleteAttribute(name);
g.morphAttributes = {};
if (!g.attributes.normal) g.computeVertexNormals();
if (!g.attributes.uv) g.setAttribute('uv', new THREE.BufferAttribute(new Float32Array(g.attributes.position.count * 2), 2));
geos.push(g);
if (!material) material = Array.isArray(o.material) ? o.material[0] : o.material;
});
if (!geos.length) return null;
let merged = null;
try { merged = mergeGeometries(geos, false); } catch (e) { merged = null; }
geos.forEach((g) => g.dispose());
if (!merged) return null; // heterogeneous → keep the primitive
return { geo: merged, mat: material || new THREE.MeshStandardMaterial({ color: 0x888888, roughness: 0.8 }) };
}
if (!NOASSETS) {
for (const [key, file] of Object.entries(GLB_FILES)) {
loadGLB('depot:' + file).then((g) => { if (g) { const ex = extractGLB(g); if (ex) _glbReady[key] = ex; } }).catch(() => {});
}
}
// ── shared primitive templates (built once, reused by every chunk; never disposed per-chunk) ──
let _tpl = null;
function templates() {
@ -47,7 +93,19 @@ function templates() {
new THREE.PlaneGeometry(3.2, 4.4).translate(0, 2.2, 0),
new THREE.PlaneGeometry(3.2, 4.4).rotateY(Math.PI / 2).translate(0, 2.2, 0),
], false);
_tpl = { pole, lamp, bench, bin, busstop, treeGeo };
// novelty record (record-store landmark): a giant vinyl disc facing +Z on a short post
const novelty = mergeGeometries([
box(0.14, 1.3, 0.14, 0, 0.65, 0), // post
new THREE.CylinderGeometry(0.85, 0.85, 0.07, 22).rotateX(Math.PI / 2).translate(0, 1.65, 0), // the record
new THREE.CylinderGeometry(0.16, 0.16, 0.09, 14).rotateX(Math.PI / 2).translate(0, 1.65, 0.01), // label
], false);
// food cart: body + canopy + two posts (primitive fallback for the market food_cart GLB)
const foodcart = mergeGeometries([
box(1.5, 0.9, 0.95, 0, 0.65, 0),
box(1.7, 0.1, 1.15, 0, 1.55, 0),
box(0.07, 0.65, 0.07, -0.75, 1.2, 0.5), box(0.07, 0.65, 0.07, 0.75, 1.2, 0.5),
], false);
_tpl = { pole, lamp, bench, bin, busstop, treeGeo, novelty, foodcart };
return _tpl;
}
@ -79,6 +137,8 @@ function materials() {
bin: new THREE.MeshStandardMaterial({ color: 0x2f4a3a, roughness: 0.8 }),
shelter: new THREE.MeshStandardMaterial({ color: 0x8a8f96, roughness: 0.7, metalness: 0.2 }),
tree: new THREE.MeshBasicMaterial({ map: treeTexture(), transparent: true, alphaTest: 0.5, side: THREE.DoubleSide }),
novelty: new THREE.MeshStandardMaterial({ color: 0x1a1a1a, roughness: 0.35, metalness: 0.1 }), // vinyl
foodcart: new THREE.MeshStandardMaterial({ color: 0x9a3b32, roughness: 0.7 }),
};
return _mats;
}
@ -100,7 +160,7 @@ export function buildChunkFurniture(chunkData, ctx, cx, cz) {
const t = templates(), mat = materials();
const edges = ctx._edges || (ctx._edges = resolveEdges(ctx.plan));
const lists = { pole: [], lamp: [], bench: [], bin: [], busstop: [], tree: [] };
const lists = { pole: [], lamp: [], bench: [], bin: [], busstop: [], tree: [], novelty: [], foodcart: [] };
const here = (x, z) => { const c = chunkOf(x, z); return c.cx === cx && c.cz === cz; };
const pushYaw = (list, x, y, z, yaw, sx = 1, sy = 1, sz = 1) => {
@ -156,6 +216,23 @@ export function buildChunkFurniture(chunkData, ctx, cx, cz) {
}
}
// ── landmark props keyed to a shop (in front of it on the footpath) ──
// Placed with their SHOP's chunk (the shop is in this chunk), so no here() gate → never dropped.
const lotById = new Map(chunkData.lots.map((l) => [l.id, l]));
for (const shop of (chunkData.shops || [])) {
const lot = lotById.get(shop.lot); if (!lot) continue;
const ry = lot.ry || 0, nx = Math.sin(ry), nz = Math.cos(ry); // front normal
const rx = Math.cos(ry), rz = -Math.sin(ry); // right along the frontage
const half = (lot.d || 8) / 2;
if (shop.type === 'record') { // novelty record outside the record store
const fx = lot.x + nx * (half + 1.3) + rx * 1.8, fz = lot.z + nz * (half + 1.3) + rz * 1.8;
pushYaw(lists.novelty, fx, 0, fz, ry); // record face toward the street
} else if (shop.type === 'stall' || shop.type === 'milkbar') { // food cart by the market / milk bar
const fx = lot.x + nx * (half + 1.6), fz = lot.z + nz * (half + 1.6);
pushYaw(lists.foodcart, fx, 0, fz, ry);
}
}
const ims = [];
const emit = (geo, material, list, cast = true) => {
if (!list.length) return;
@ -165,12 +242,16 @@ export function buildChunkFurniture(chunkData, ctx, cx, cz) {
im.castShadow = cast; im.receiveShadow = false; im.frustumCulled = true;
group.add(im); ims.push(im);
};
// GLB-if-ready: use the depot GLB geometry/material once it's loaded, else the primitive fallback.
const glb = (key, geo, material) => (_glbReady[key] ? [_glbReady[key].geo, _glbReady[key].mat] : [geo, material]);
emit(t.pole, mat.dark, lists.pole);
emit(t.lamp, mat.lamp, lists.lamp, false);
emit(t.bench, mat.wood, lists.bench);
emit(...glb('bench', t.bench, mat.wood), lists.bench);
emit(t.bin, mat.bin, lists.bin);
emit(t.busstop, mat.shelter, lists.busstop);
emit(t.treeGeo, mat.tree, lists.tree, false);
emit(...glb('novelty_record', t.novelty, mat.novelty), lists.novelty);
emit(...glb('food_cart', t.foodcart, mat.foodcart), lists.foodcart);
function applyNight(night) { mat.lamp.emissiveIntensity = night ? 1.4 : 0; }
applyNight(!!ctx.night);
@ -184,9 +265,3 @@ export function buildChunkFurniture(chunkData, ctx, cx, cz) {
}
function hashEdge(id) { const s = String(id); let h = 0; for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0; return h >>> 0; } // String(): generatePlan uses numeric edge ids
// ── OPTIONAL 3GOD-depot upgrade (documented, off for v1) ──────────────────────────────────────
// import { loadGLB } from '../core/loaders.js';
// To swap a primitive for a real GLB prop: loadGLB('depot:bench.glb').then(g => { if(!g) return;
// ... build an InstancedMesh from g.scene's merged geometry and replace the primitive one ... });
// Guard against the chunk having been disposed before the promise resolves (check a live flag).

View File

@ -67,7 +67,7 @@ export function createHUD({ camera, renderer, plan, getDoorMeshes, onEnterShop }
const h = hits[0];
const rects = h.object.userData.doorRects || [];
const door = nearestDoor(rects, h.point);
hovered = door ? { shopId: door.shopId, name: door.name } : null;
hovered = door ? { shopId: door.shopId, name: door.name, hours: door.hours } : null;
} else {
hovered = null;
}
@ -81,9 +81,16 @@ export function createHUD({ camera, renderer, plan, getDoorMeshes, onEnterShop }
frame++;
if (frame % 6 === 0) raycastDoor();
// tooltip
if (hovered) { elTip.textContent = `🚪 ${hovered.name} — click to enter`; elTip.style.opacity = 1; }
else elTip.style.opacity = 0;
// tooltip — shows CLOSED (with opening hour) when the shop is shut at the current hour (§3.5)
if (hovered) {
const h = hovered.hours;
const now = window.PROCITY && window.PROCITY.currentHour ? window.PROCITY.currentHour() : null;
const closed = h && now != null && !(now >= h[0] && now < h[1]);
elTip.textContent = closed
? `🔒 ${hovered.name} — CLOSED · opens ${String(h[0]).padStart(2, '0')}:00`
: `🚪 ${hovered.name} — click to enter`;
elTip.style.opacity = 1;
} else elTip.style.opacity = 0;
// debug readout
if (info) {

View File

@ -19,6 +19,7 @@ const SEG = [
['NIGHT', 0.10, 0x8faaff, 0x2a3050, 0x14100c, 0.38, 0x1b2038, 0x0a0c18, 1.18, true, [-40, 66, 22]],
];
const HOUR = ['06:30', '09:00', '12:30', '15:30', '18:30', '22:00'];
const HOUR_NUM = [6.5, 9, 12.5, 15.5, 18.5, 22]; // numeric form (matches Lane F's currentHour() parse)
// A tiny sky→horizon→ground vertical gradient as an equirect env source (canvas, no asset). PMREM'd
// into scene.environment so PBR content is lit by neutral IBL instead of rendering dark — Lane D's
@ -83,6 +84,10 @@ export function createLighting(scene, plan, skins, { radius = 3, shadows = true
if (renderer) renderer.toneMappingExposure = exposure;
const was = night; night = isNight; seg = i;
if (isNight !== was && onNightChange) onNightChange(isNight);
// announce the segment so facade shop-hours state (buildings.js) re-evaluates once, not per frame.
// hour is the segment's representative hour, matching Lane F's currentHour()/isOpen() convention.
if (typeof window !== 'undefined')
window.dispatchEvent(new CustomEvent('procity:segment', { detail: { seg: i, hour: HOUR_NUM[i], night: isNight } }));
}
function setSegment(i) { clock = ((i % SEG.length) + SEG.length) % SEG.length + 0.001; apply(i); }