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>
@ -4,6 +4,29 @@
|
|||||||
plus the game shell. It runs on my hand-written fixture **and** auto-integrates with Lane A's
|
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.
|
`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
|
## 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;
|
- **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
|
no config change needed. Drove the real loop (`chunks.update` + `citizens.update`, pop 140, up to
|
||||||
|
|||||||
@ -1,5 +1,42 @@
|
|||||||
# LANE B — NOTES (measured)
|
# 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`.
|
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.
|
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
|
`renderer.info.autoReset` is off and reset once/frame, so **draws/tris are the true per-frame
|
||||||
|
|||||||
BIN
docs/shots/laneB_r4/arcade.png
Normal file
|
After Width: | Height: | Size: 688 KiB |
BIN
docs/shots/laneB_r4/crossroads_busy.png
Normal file
|
After Width: | Height: | Size: 787 KiB |
BIN
docs/shots/laneB_r4/market_square.png
Normal file
|
After Width: | Height: | Size: 620 KiB |
BIN
docs/shots/laneB_r4/milkbar_dusk.png
Normal file
|
After Width: | Height: | Size: 706 KiB |
BIN
docs/shots/laneB_r4/night_neon.png
Normal file
|
After Width: | Height: | Size: 222 KiB |
BIN
docs/shots/laneB_r4/opshop_interior.png
Normal file
|
After Width: | Height: | Size: 815 KiB |
BIN
docs/shots/laneB_r4/record_interior.png
Normal file
|
After Width: | Height: | Size: 820 KiB |
BIN
docs/shots/laneB_r4/residential_collar.png
Normal file
|
After Width: | Height: | Size: 696 KiB |
BIN
docs/shots/laneB_r4/street_noon.png
Normal file
|
After Width: | Height: | Size: 818 KiB |
BIN
docs/shots/laneB_r4/warehouse_fringe.png
Normal file
|
After Width: | Height: | Size: 533 KiB |
@ -24,6 +24,71 @@ const WALL_TINTS = ['#c9b8a0', '#b0b8ab', '#c4b0b0', '#a8b4c0', '#c4bc9c', '#cab
|
|||||||
const ROOF_TINT = '#5b5148';
|
const ROOF_TINT = '#5b5148';
|
||||||
const AWNING_SKINS = ['red', 'green', 'blue'];
|
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 _m4 = new THREE.Matrix4();
|
||||||
const _q = new THREE.Quaternion();
|
const _q = new THREE.Quaternion();
|
||||||
const _up = new THREE.Vector3(0, 1, 0);
|
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;
|
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).
|
// Transform a local (lx,ly,lz) point to world via a lot's (x,z,ry).
|
||||||
function toWorld(lot, lx, ly, lz, out) {
|
function toWorld(lot, lx, ly, lz, out) {
|
||||||
const cos = Math.cos(lot.ry || 0), sin = Math.sin(lot.ry || 0);
|
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 facadeGeos = []; // every shopfront facade, UV-mapped into the shared atlas → 1 merged mesh
|
||||||
const doorGeos = []; // interactive shop/stall doors (raycast targets)
|
const doorGeos = []; // interactive shop/stall doors (raycast targets)
|
||||||
const decorDoorGeos = []; // decorative house doors (NOT raycastable → no mis-resolve to a shop)
|
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 signQuads = []; // { geo }
|
||||||
const colliders = [];
|
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]));
|
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, 0.02, zd, []), toWorld(lot, dw / 2, 0.02, zd, []),
|
||||||
toWorld(lot, dw / 2, dh, zd, []), toWorld(lot, -dw / 2, dh, 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 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)];
|
const offs = [-(dw / 2 + 0.4 + winW / 2), (dw / 2 + 0.4 + winW / 2)];
|
||||||
for (const ox of offs) {
|
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, []),
|
||||||
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, []),
|
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]) {
|
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, 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, []),
|
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));
|
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, 0.02, zf, []), toWorld(lot, w / 2, 0.02, zf, []),
|
||||||
toWorld(lot, w / 2, 1.9, zf, []), toWorld(lot, -w / 2, 1.9, 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);
|
drawSign(shop);
|
||||||
const uv = signUV.get(shop.id);
|
const uv = signUV.get(shop.id);
|
||||||
if (uv) {
|
if (uv) {
|
||||||
@ -258,6 +346,13 @@ export function buildChunkBuildings(chunkData, ctx) {
|
|||||||
uv,
|
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));
|
colliders.push(lotCollider(lot));
|
||||||
}
|
}
|
||||||
@ -316,19 +411,23 @@ export function buildChunkBuildings(chunkData, ctx) {
|
|||||||
group.add(mesh);
|
group.add(mesh);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── windows (merged, emissive at night) ──
|
// ── windows (merged, ONE shared material; emissive gated per-shop by the aHours shader) ──
|
||||||
let windowMat = null;
|
|
||||||
if (windowGeos.length) {
|
if (windowGeos.length) {
|
||||||
const merged = mergeGeometries(windowGeos, false);
|
const merged = mergeGeometries(windowGeos, false);
|
||||||
windowGeos.forEach((g) => g.dispose());
|
windowGeos.forEach((g) => g.dispose());
|
||||||
windowMat = new THREE.MeshStandardMaterial({
|
const mesh = new THREE.Mesh(merged, windowMaterial()); // shared → NOT disposed per chunk
|
||||||
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);
|
|
||||||
group.add(mesh);
|
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) ──
|
// ── signs (merged, one atlas texture) ──
|
||||||
@ -344,10 +443,9 @@ export function buildChunkBuildings(chunkData, ctx) {
|
|||||||
group.add(mesh);
|
group.add(mesh);
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyNight(night) {
|
// Windows + plates are now driven by the shared shop-hours uniforms (setFacadeHours on a segment
|
||||||
if (windowMat) { windowMat.emissiveIntensity = night ? 0.95 : 0; windowMat.opacity = night ? 0.9 : 0.36; }
|
// 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 */ }
|
||||||
applyNight(!!ctx.night);
|
|
||||||
|
|
||||||
function dispose() {
|
function dispose() {
|
||||||
group.traverse((o) => {
|
group.traverse((o) => {
|
||||||
|
|||||||
@ -13,9 +13,21 @@ export function installDBG(deps) {
|
|||||||
setMode, getMode, enterShop } = deps;
|
setMode, getMode, enterShop } = deps;
|
||||||
const CS = 64;
|
const CS = 64;
|
||||||
|
|
||||||
// reset the perf counters, then render one full composited frame (bloom + output) so the harness
|
// Force the renderer + composer + camera to the live viewport before every harness render, then
|
||||||
// captures exactly this frame and renderer.info reflects it — robust even when rAF is throttled.
|
// reset perf counters and render one full composited frame (bloom + output). Headless captures
|
||||||
const render = () => { renderer.info.reset(); composer.render(); };
|
// 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 info = () => {
|
||||||
const r = renderer.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
|
// 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]); };
|
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).
|
// 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
|
// 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.
|
// to the nearest shop, so every bookmark always yields a valid street pose.
|
||||||
const BOOKMARKS = {
|
const BOOKMARKS = {
|
||||||
street_noon: { seg: 2, resolve: () => pickShop(() => true) },
|
street_noon: { seg: 2, resolve: () => streetViewPose(['main']) }, // look down the main strip
|
||||||
arcade: { seg: 3, resolve: () => pickShop((s) => s.type === 'dept' || /arcade/.test(s.type)) },
|
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') },
|
market_square: { seg: 1, resolve: () => pickShop((s) => s.type === 'stall' || s.type === 'market') },
|
||||||
milkbar_dusk: { seg: 4, resolve: () => pickShop((s) => s.type === 'milkbar') },
|
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 = {
|
const DBG = {
|
||||||
|
|||||||
@ -3,17 +3,63 @@
|
|||||||
// bins, gum trees (crossed-plane billboards), bus stops. Seeded placement, one InstancedMesh per
|
// 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.
|
// 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.
|
// Every type has a PRIMITIVE fallback (house law) — the town is fully furnished with zero GLBs and
|
||||||
// A 3GOD-depot upgrade path (promise-cached loadGLB → hot-swap) is documented at the bottom but
|
// runs fully under ?noassets. Mapped types (bench + the novelty_record / food_cart landmark props)
|
||||||
// left OFF for v1 so async loads can never write into an unloaded chunk.
|
// 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 * as THREE from 'three';
|
||||||
import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js';
|
import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js';
|
||||||
|
import { loadGLB } from '../core/loaders.js';
|
||||||
import { rng, frange } from '../core/prng.js';
|
import { rng, frange } from '../core/prng.js';
|
||||||
import { chunkOf, resolveEdges } from './planutil.js';
|
import { chunkOf, resolveEdges } from './planutil.js';
|
||||||
|
|
||||||
const FOOT = 3.5;
|
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) ──
|
// ── shared primitive templates (built once, reused by every chunk; never disposed per-chunk) ──
|
||||||
let _tpl = null;
|
let _tpl = null;
|
||||||
function templates() {
|
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).translate(0, 2.2, 0),
|
||||||
new THREE.PlaneGeometry(3.2, 4.4).rotateY(Math.PI / 2).translate(0, 2.2, 0),
|
new THREE.PlaneGeometry(3.2, 4.4).rotateY(Math.PI / 2).translate(0, 2.2, 0),
|
||||||
], false);
|
], 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;
|
return _tpl;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -79,6 +137,8 @@ function materials() {
|
|||||||
bin: new THREE.MeshStandardMaterial({ color: 0x2f4a3a, roughness: 0.8 }),
|
bin: new THREE.MeshStandardMaterial({ color: 0x2f4a3a, roughness: 0.8 }),
|
||||||
shelter: new THREE.MeshStandardMaterial({ color: 0x8a8f96, roughness: 0.7, metalness: 0.2 }),
|
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 }),
|
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;
|
return _mats;
|
||||||
}
|
}
|
||||||
@ -100,7 +160,7 @@ export function buildChunkFurniture(chunkData, ctx, cx, cz) {
|
|||||||
const t = templates(), mat = materials();
|
const t = templates(), mat = materials();
|
||||||
const edges = ctx._edges || (ctx._edges = resolveEdges(ctx.plan));
|
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 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) => {
|
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 ims = [];
|
||||||
const emit = (geo, material, list, cast = true) => {
|
const emit = (geo, material, list, cast = true) => {
|
||||||
if (!list.length) return;
|
if (!list.length) return;
|
||||||
@ -165,12 +242,16 @@ export function buildChunkFurniture(chunkData, ctx, cx, cz) {
|
|||||||
im.castShadow = cast; im.receiveShadow = false; im.frustumCulled = true;
|
im.castShadow = cast; im.receiveShadow = false; im.frustumCulled = true;
|
||||||
group.add(im); ims.push(im);
|
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.pole, mat.dark, lists.pole);
|
||||||
emit(t.lamp, mat.lamp, lists.lamp, false);
|
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.bin, mat.bin, lists.bin);
|
||||||
emit(t.busstop, mat.shelter, lists.busstop);
|
emit(t.busstop, mat.shelter, lists.busstop);
|
||||||
emit(t.treeGeo, mat.tree, lists.tree, false);
|
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; }
|
function applyNight(night) { mat.lamp.emissiveIntensity = night ? 1.4 : 0; }
|
||||||
applyNight(!!ctx.night);
|
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
|
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).
|
|
||||||
|
|||||||
@ -67,7 +67,7 @@ export function createHUD({ camera, renderer, plan, getDoorMeshes, onEnterShop }
|
|||||||
const h = hits[0];
|
const h = hits[0];
|
||||||
const rects = h.object.userData.doorRects || [];
|
const rects = h.object.userData.doorRects || [];
|
||||||
const door = nearestDoor(rects, h.point);
|
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 {
|
} else {
|
||||||
hovered = null;
|
hovered = null;
|
||||||
}
|
}
|
||||||
@ -81,9 +81,16 @@ export function createHUD({ camera, renderer, plan, getDoorMeshes, onEnterShop }
|
|||||||
frame++;
|
frame++;
|
||||||
if (frame % 6 === 0) raycastDoor();
|
if (frame % 6 === 0) raycastDoor();
|
||||||
|
|
||||||
// tooltip
|
// tooltip — shows CLOSED (with opening hour) when the shop is shut at the current hour (§3.5)
|
||||||
if (hovered) { elTip.textContent = `🚪 ${hovered.name} — click to enter`; elTip.style.opacity = 1; }
|
if (hovered) {
|
||||||
else elTip.style.opacity = 0;
|
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
|
// debug readout
|
||||||
if (info) {
|
if (info) {
|
||||||
|
|||||||
@ -19,6 +19,7 @@ const SEG = [
|
|||||||
['NIGHT', 0.10, 0x8faaff, 0x2a3050, 0x14100c, 0.38, 0x1b2038, 0x0a0c18, 1.18, true, [-40, 66, 22]],
|
['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 = ['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
|
// 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
|
// 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;
|
if (renderer) renderer.toneMappingExposure = exposure;
|
||||||
const was = night; night = isNight; seg = i;
|
const was = night; night = isNight; seg = i;
|
||||||
if (isNight !== was && onNightChange) onNightChange(isNight);
|
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); }
|
function setSegment(i) { clock = ((i % SEG.length) + SEG.length) % SEG.length + 0.001; apply(i); }
|
||||||
|
|||||||