diff --git a/B-progress.md b/B-progress.md index 66d90dd..1b7afee 100644 --- a/B-progress.md +++ b/B-progress.md @@ -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 + `🔒 — 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 diff --git a/docs/LANES/LANE_B_NOTES.md b/docs/LANES/LANE_B_NOTES.md index 5377346..f2454b0 100644 --- a/docs/LANES/LANE_B_NOTES.md +++ b/docs/LANES/LANE_B_NOTES.md @@ -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 `🔒 — 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 diff --git a/docs/shots/laneB_r4/arcade.png b/docs/shots/laneB_r4/arcade.png new file mode 100644 index 0000000..f646117 Binary files /dev/null and b/docs/shots/laneB_r4/arcade.png differ diff --git a/docs/shots/laneB_r4/crossroads_busy.png b/docs/shots/laneB_r4/crossroads_busy.png new file mode 100644 index 0000000..d936a0f Binary files /dev/null and b/docs/shots/laneB_r4/crossroads_busy.png differ diff --git a/docs/shots/laneB_r4/market_square.png b/docs/shots/laneB_r4/market_square.png new file mode 100644 index 0000000..c6ce370 Binary files /dev/null and b/docs/shots/laneB_r4/market_square.png differ diff --git a/docs/shots/laneB_r4/milkbar_dusk.png b/docs/shots/laneB_r4/milkbar_dusk.png new file mode 100644 index 0000000..9ac4057 Binary files /dev/null and b/docs/shots/laneB_r4/milkbar_dusk.png differ diff --git a/docs/shots/laneB_r4/night_neon.png b/docs/shots/laneB_r4/night_neon.png new file mode 100644 index 0000000..680eaaa Binary files /dev/null and b/docs/shots/laneB_r4/night_neon.png differ diff --git a/docs/shots/laneB_r4/opshop_interior.png b/docs/shots/laneB_r4/opshop_interior.png new file mode 100644 index 0000000..c0d840e Binary files /dev/null and b/docs/shots/laneB_r4/opshop_interior.png differ diff --git a/docs/shots/laneB_r4/record_interior.png b/docs/shots/laneB_r4/record_interior.png new file mode 100644 index 0000000..f9ca4da Binary files /dev/null and b/docs/shots/laneB_r4/record_interior.png differ diff --git a/docs/shots/laneB_r4/residential_collar.png b/docs/shots/laneB_r4/residential_collar.png new file mode 100644 index 0000000..e21d680 Binary files /dev/null and b/docs/shots/laneB_r4/residential_collar.png differ diff --git a/docs/shots/laneB_r4/street_noon.png b/docs/shots/laneB_r4/street_noon.png new file mode 100644 index 0000000..4e35ba3 Binary files /dev/null and b/docs/shots/laneB_r4/street_noon.png differ diff --git a/docs/shots/laneB_r4/warehouse_fringe.png b/docs/shots/laneB_r4/warehouse_fringe.png new file mode 100644 index 0000000..fdd7856 Binary files /dev/null and b/docs/shots/laneB_r4/warehouse_fringe.png differ diff --git a/web/js/world/buildings.js b/web/js/world/buildings.js index e24b70e..c32b7b5 100644 --- a/web/js/world/buildings.js +++ b/web/js/world/buildings.js @@ -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 ', '#include \n\tvHours = aHours;'); + shader.fragmentShader = 'uniform float uHour;\nuniform float uNight;\nvarying vec2 vHours;\n' + + shader.fragmentShader.replace('#include ', + '#include \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) => { diff --git a/web/js/world/dbg.js b/web/js/world/dbg.js index acc3f89..452dc18 100644 --- a/web/js/world/dbg.js +++ b/web/js/world/dbg.js @@ -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 = { diff --git a/web/js/world/furniture.js b/web/js/world/furniture.js index 7f77b65..53c4ad0 100644 --- a/web/js/world/furniture.js +++ b/web/js/world/furniture.js @@ -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). diff --git a/web/js/world/hud.js b/web/js/world/hud.js index 2136d40..eb2d819 100644 --- a/web/js/world/hud.js +++ b/web/js/world/hud.js @@ -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) { diff --git a/web/js/world/lighting.js b/web/js/world/lighting.js index 0e17216..debe08f 100644 --- a/web/js/world/lighting.js +++ b/web/js/world/lighting.js @@ -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); }