Ledger #5 — B's polish pick. A stocked godverse shop now tells you it's diggable before you reach the door: a crate of seeded spines under the shop name, and a warm-lamp-over-crates room behind the glass. Cost: +0 draws / +0 tris. The mark is painted into the chunk's EXISTING sign-atlas cell; the window cue rides the EXISTING per-shop aTint attribute. Nothing new is allocated or submitted. The manifest is the authority, deliberately NOT the godverseShopId key: keyed means "has a stock identity", the manifest means "a crate is actually here". Marking keyed-but-unstocked shops would advertise a crate the shop hasn't got — the street would lie. The mark does not leak tier: "you can dig here" is equally true of real and mint; provenance belongs on C's price card and in E's gates. (Second job for ledger #6's manifest, free.) Measured — true A/B at one pose, manifest 200 vs 404: spine-marked sign cells 0 → 3 (18,768 exact-palette px) stocked window tint verts 0 → 36 street_noon 65 draws / 115,502 tris → UNCHANGED Stocked shopfront (Goulds Books, footpath): 85 draws / 144,730 tris — inside street law (<=300 / <=200k). Cues agree with ground truth: 3 stocked shops live → 3 marked cells, 9 stocked window quads. Covenants re-proved (the prefetch is new code on the boot path), tested with the manifest present on disk so a stray fetch would 200 and be unmissable: ?classic=1 0 keyed, 0 stock fetches, 0 errors (zero-fetch covenant) default synthetic 0 keyed, 0 stock fetches, 0 errors manifest 404 18 keyed, fail-soft, 0 page errors Three traps, self-caught: (1) a module-load fetch would have put a fetch delta on ?classic=1 — restructured to a lazy reader + plan-driven prefetch, keyed plans only; (2) the lazy trigger alone raced the first chunk build, so the first stocked shop you approach built unmarked — prefetch moved to createChunkManager, before any chunk builds; (3) the first "zero cost" was VACUOUS (manifest never fetched, so nothing was applied) and the first pixel detector reported 60 marked cells with no treatment at all — replaced with an exact-palette signature that reads 0 in control. Vacuous-gate law applies to a lane's own verification too. Corollary: force-cache serves a deleted file (transferSize 0, status 200 while disk 404s) — poison the cache or you are testing your own memory. B consumes stock_godverse/index.json read-only and emits nothing into it; the temp manifest used to prove this round was deleted — the directory is G's. Shape-tolerant + fail-soft, so G's real emission needs no coordination with B. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
649 lines
33 KiB
JavaScript
649 lines
33 KiB
JavaScript
// PROCITY Lane B — buildings.js
|
||
// The facade kit. Builds ONE chunk's buildings into a small, disposable set of draw calls:
|
||
// • InstancedMesh shells + parapets + verandah posts + awning boxes (per-instance matrix/colour)
|
||
// • merged facade planes, grouped per skin → one mesh per skin, sharing a city-wide material
|
||
// • one 2048² sign atlas per chunk (every sign drawn once, planes UV-mapped into it)
|
||
// • merged door / window meshes (windows go emissive at night via applyNight)
|
||
// Everything is built in world space and collected, so a whole chunk is ~10–18 draw calls.
|
||
//
|
||
// Canonical building: front face at local +Z, footprint centred at origin, then translate to the
|
||
// lot centre (x,z) and rotate by lot.ry (see fixture_plan.js orientation note).
|
||
|
||
import * as THREE from 'three';
|
||
import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js';
|
||
import { rng, pick, frange, irange } from '../core/prng.js';
|
||
import { SHOP_REGISTRY } from './fixture_plan.js';
|
||
import { lotCollider } from './planutil.js';
|
||
|
||
const STOREY_H = 3.2;
|
||
const FACADE_H = 3.0; // ground-floor shopfront height the skin covers
|
||
const PARAPET_H = 0.5;
|
||
const AWNING_DEPTH = 2.2; // reach over the footpath
|
||
const AWNING_Y = 2.95;
|
||
const WALL_TINTS = ['#c9b8a0', '#b0b8ab', '#c4b0b0', '#a8b4c0', '#c4bc9c', '#cab89e', '#b8a890'];
|
||
const ROOF_TINT = '#5b5148';
|
||
const AWNING_SKINS = ['red', 'green', 'blue'];
|
||
|
||
// ── R26 #5 — the stocked shopfront (B's polish pick) ──────────────────────────────────────────────
|
||
// A crate-diggers' shop should read as one FROM THE FOOTPATH. The beta makes every keyed crate
|
||
// distinct *inside*; this closes the loop outside, so the player can spot a shop worth digging while
|
||
// walking past instead of door-roulette.
|
||
//
|
||
// THE MANIFEST IS THE AUTHORITY (ledger #6 — the same file F's wire consults). Deliberately NOT the
|
||
// `godverseShopId` key: keyed only means "has a stock identity", while the manifest means "a crate is
|
||
// actually here". Advertising a crate the shop hasn't got would make the street lie — the one thing
|
||
// this epoch keeps refusing to do. Fail-soft: no manifest (or ?noassets, or a shape we don't
|
||
// recognise) ⇒ no treatment ⇒ the street simply makes no promise. Never an error.
|
||
//
|
||
// The mark says "you can dig here", which is equally true of `real` and `mint` crates, so it does NOT
|
||
// leak tier — provenance belongs on C's price card and in the gates, not shouted from the footpath.
|
||
//
|
||
// Cost: ZERO extra draws / tris. The mark is drawn into the chunk's EXISTING sign-atlas cell and the
|
||
// window cue rides the EXISTING per-shop aTint attribute. Nothing new is allocated or submitted.
|
||
// The manifest is fetched LAZILY — only once a plan actually carries a keyed shop. A module-load fetch
|
||
// would have hit every boot: a pointless request (and, before G emits the file, a console 404) on the
|
||
// synthetic default, and a fetch delta on ?classic=1, whose covenant is zero-fetch. Godverse towns pay
|
||
// for it; nobody else knows it exists.
|
||
const STOCK_INDEX = 'assets/stock_godverse/index.json';
|
||
let STOCKED = null; // Set<godverseShopId> | null = "unknown" (⇒ no treatment)
|
||
let stockReq = null; // in-flight/settled marker — the fetch happens at most once per session
|
||
function ensureStockIndex() {
|
||
if (stockReq) return;
|
||
stockReq = (async () => {
|
||
try {
|
||
const p = new URLSearchParams(location.search);
|
||
if (p.get('noassets') != null && p.get('noassets') !== '0') return; // asset law: zero fetch
|
||
const r = await fetch(STOCK_INDEX, { cache: 'force-cache' });
|
||
if (!r.ok) return; // 404 before G emits it — fine
|
||
const j = await r.json();
|
||
// Tolerant of the exact shape (C pins §7 this round, G emits next wave): accept an array at
|
||
// .shops/.entries/.atlases or the root, of ids or of objects carrying godverseShopId|id.
|
||
const list = Array.isArray(j) ? j : (Array.isArray(j?.shops) ? j.shops
|
||
: Array.isArray(j?.entries) ? j.entries : Array.isArray(j?.atlases) ? j.atlases : null);
|
||
if (!list) return;
|
||
const set = new Set();
|
||
for (const e of list) {
|
||
const id = typeof e === 'number' ? e : (e && (e.godverseShopId ?? e.id));
|
||
if (Number.isSafeInteger(id) && id > 0) set.add(id);
|
||
}
|
||
if (set.size) STOCKED = set;
|
||
} catch { /* fail-soft by design */ }
|
||
})();
|
||
}
|
||
const isStocked = (shop) => !!(STOCKED && shop && Number.isSafeInteger(shop.godverseShopId) && STOCKED.has(shop.godverseShopId));
|
||
|
||
// Called once by the chunk manager at construction: start the manifest fetch as early as possible, but
|
||
// still ONLY for a plan that has keyed shops. Without this the fetch would start on the first keyed
|
||
// CHUNK — i.e. the first stocked shop you walk up to would be built before the manifest landed and
|
||
// would miss its mark. Synthetic / real / classic plans have no keyed shops ⇒ no request, ever.
|
||
export function prefetchStockIndex(plan) {
|
||
if ((plan?.shops || []).some((s) => Number.isSafeInteger(s && s.godverseShopId))) ensureStockIndex();
|
||
}
|
||
|
||
// ── 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;
|
||
}
|
||
|
||
// ── v2: parallax interior-mapping window glass (?winmap=1, default-off) ──────────────────────────
|
||
// Single-cube interior mapping: each glass fragment casts the view ray into a virtual room box and
|
||
// shades the surface it hits (back wall + shelf silhouettes, side walls, floor/ceiling) — a plausible
|
||
// room seen through the window with real parallax, zero extra geometry. One SHARED ShaderMaterial for
|
||
// all windows (per-shop variation via aTint/aSeed attributes), same 1 draw/chunk as v1. Respects §3.5
|
||
// via the same uHour/uNight + aHours: closed-at-night rooms go dark, open rooms glow warm. Fully
|
||
// procedural (no fetch). Flag-off path is untouched (v1 windowMaterial()).
|
||
// [Lane F R16 — THE FLIP] winmap DEFAULT-ON; ?winmap=0 opts out, ?classic=1 forces off (the v2 covenant).
|
||
const WINMAP = (() => { try { const p = new URLSearchParams(location.search); const classic = p.has('classic') && p.get('classic') !== '0'; return !classic && p.get('winmap') !== '0'; } catch (e) { return false; } })();
|
||
const ROOM_TINTS = [ // muted 90s interior wall colours (RGB 0..1), seeded per shop
|
||
[0.56, 0.44, 0.33], [0.42, 0.47, 0.52], [0.52, 0.47, 0.37], [0.47, 0.42, 0.46],
|
||
[0.54, 0.50, 0.41], [0.40, 0.45, 0.40], [0.58, 0.49, 0.40], [0.45, 0.48, 0.55],
|
||
];
|
||
// R26 #5: a stocked shop's window room reads warm-lamp-over-crates rather than a generic 90s wall —
|
||
// the second half of the footpath tell, and free (it rides the existing per-shop aTint attribute, so
|
||
// the winmap glass is still ONE shared material / one draw per chunk).
|
||
const STOCKED_ROOM_TINT = [0.50, 0.33, 0.22];
|
||
function roomTint(shop) {
|
||
if (isStocked(shop)) return STOCKED_ROOM_TINT;
|
||
return ROOM_TINTS[((shop ? shop.seed : 12345) >>> 0) % ROOM_TINTS.length];
|
||
}
|
||
|
||
let _winmapMat = null;
|
||
function interiorWindowMaterial() {
|
||
if (_winmapMat) return _winmapMat;
|
||
_winmapMat = new THREE.ShaderMaterial({
|
||
transparent: true,
|
||
uniforms: { uHour: _hoursUniforms.uHour, uNight: _hoursUniforms.uNight, uRoomDepth: { value: 0.85 } },
|
||
vertexShader: `
|
||
attribute vec2 aHours; attribute vec3 aTangent; attribute vec3 aTint; attribute float aSeed;
|
||
varying vec2 vUv; varying vec3 vWorldPos; varying vec3 vNormalW; varying vec3 vTangentW;
|
||
varying vec2 vHours; varying vec3 vTint; varying float vSeed;
|
||
void main(){
|
||
vUv = uv; vWorldPos = position; vNormalW = normal; vTangentW = aTangent;
|
||
vHours = aHours; vTint = aTint; vSeed = aSeed;
|
||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||
}`,
|
||
fragmentShader: `
|
||
precision highp float;
|
||
uniform float uHour; uniform float uNight; uniform float uRoomDepth;
|
||
varying vec2 vUv; varying vec3 vWorldPos; varying vec3 vNormalW; varying vec3 vTangentW;
|
||
varying vec2 vHours; varying vec3 vTint; varying float vSeed;
|
||
float h1(float n){ return fract(sin(n * 127.1) * 43758.5453); }
|
||
void main(){
|
||
vec3 N = normalize(vNormalW);
|
||
vec3 T = normalize(vTangentW);
|
||
vec3 Bb = normalize(cross(N, T));
|
||
vec3 rayDir = normalize(vWorldPos - cameraPosition);
|
||
vec3 rd = vec3(dot(rayDir, T), dot(rayDir, Bb), -dot(rayDir, N));
|
||
rd.z = max(rd.z, 1e-3);
|
||
vec2 uv = vUv;
|
||
float tz = uRoomDepth / rd.z; // back wall
|
||
float tx = (abs(rd.x) > 1e-4) ? (((rd.x > 0.0 ? 1.0 : 0.0) - uv.x) / rd.x) : 1e9;
|
||
float ty = (abs(rd.y) > 1e-4) ? (((rd.y > 0.0 ? 1.0 : 0.0) - uv.y) / rd.y) : 1e9;
|
||
if (tx <= 0.0) tx = 1e9; if (ty <= 0.0) ty = 1e9;
|
||
float t = min(tz, min(tx, ty));
|
||
vec3 hit = vec3(uv, 0.0) + rd * t;
|
||
float depth = clamp(hit.z / uRoomDepth, 0.0, 1.0);
|
||
vec3 wall = vTint;
|
||
vec3 col;
|
||
if (t >= tz - 1e-5) { // back wall + shelves
|
||
col = wall * 0.82;
|
||
for (int i = 0; i < 3; i++){
|
||
float fy = 0.22 + float(i) * 0.26 + (h1(vSeed + float(i) * 3.7) - 0.5) * 0.06;
|
||
float band = smoothstep(0.05, 0.03, abs(hit.y - fy));
|
||
col = mix(col, wall * 0.42, band * 0.7);
|
||
float bx = fract(hit.x * 6.0 + h1(vSeed + float(i)));
|
||
float prod = step(0.15, bx) * step(bx, 0.85) * step(fy, hit.y) * step(hit.y, fy + 0.11);
|
||
col = mix(col, wall * 1.3 + vec3(0.05, 0.03, 0.02), prod * 0.45);
|
||
}
|
||
} else if (t >= tx - 1e-5) {
|
||
col = wall * 0.6; // side wall
|
||
} else {
|
||
col = (rd.y < 0.0) ? wall * 0.5 : (wall * 1.05 + 0.04); // floor / ceiling
|
||
}
|
||
col *= mix(1.0, 0.55, depth); // depth falloff
|
||
float open = step(vHours.x, uHour) * step(uHour + 0.0001, vHours.y);
|
||
float lit = (uNight > 0.5) ? (open > 0.5 ? 1.0 : 0.10) : 0.72; // §3.5: closed@night dark
|
||
col *= lit;
|
||
if (uNight > 0.5 && open > 0.5) col = mix(col, col * vec3(1.0, 0.85, 0.6) * 1.7, 0.65); // warm glow
|
||
gl_FragColor = vec4(col, 0.94);
|
||
}`,
|
||
});
|
||
return _winmapMat;
|
||
}
|
||
|
||
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);
|
||
const _s = new THREE.Vector3();
|
||
const _p = new THREE.Vector3();
|
||
|
||
// Build a flat quad (two tris) from four world-space corners + a UV rect. Corners CCW seen from +Y-ish.
|
||
function quad(c0, c1, c2, c3, uv = [0, 0, 1, 1]) {
|
||
const g = new THREE.BufferGeometry();
|
||
const pos = new Float32Array([
|
||
c0[0], c0[1], c0[2], c1[0], c1[1], c1[2], c2[0], c2[1], c2[2],
|
||
c0[0], c0[1], c0[2], c2[0], c2[1], c2[2], c3[0], c3[1], c3[2],
|
||
]);
|
||
const [u0, v0, u1, v1] = uv;
|
||
const uvs = new Float32Array([u0, v0, u1, v0, u1, v1, u0, v0, u1, v1, u0, v1]);
|
||
g.setAttribute('position', new THREE.BufferAttribute(pos, 3));
|
||
g.setAttribute('uv', new THREE.BufferAttribute(uvs, 2));
|
||
g.computeVertexNormals();
|
||
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;
|
||
}
|
||
|
||
// A window quad. Under ?winmap it carries the extra interior-mapping attributes (aTangent = the
|
||
// window's world-space right vector, aTint = the seeded room colour, aSeed = shelf variation). With
|
||
// the flag off it is exactly the v1 hoursQuad, so flag-off is byte-identical.
|
||
function windowQuad(c0, c1, c2, c3, hours, shop) {
|
||
const g = hoursQuad(c0, c1, c2, c3, hours);
|
||
if (!WINMAP) return g;
|
||
const tx = c1[0] - c0[0], ty = c1[1] - c0[1], tz = c1[2] - c0[2];
|
||
const tl = Math.hypot(tx, ty, tz) || 1;
|
||
const tan = [tx / tl, ty / tl, tz / tl];
|
||
const tint = roomTint(shop);
|
||
const seed = shop ? ((shop.seed >>> 0) % 997) / 997 : 0.5;
|
||
const tanA = new Float32Array(18), tintA = new Float32Array(18), seedA = new Float32Array(6);
|
||
for (let i = 0; i < 6; i++) {
|
||
tanA[i * 3] = tan[0]; tanA[i * 3 + 1] = tan[1]; tanA[i * 3 + 2] = tan[2];
|
||
tintA[i * 3] = tint[0]; tintA[i * 3 + 1] = tint[1]; tintA[i * 3 + 2] = tint[2];
|
||
seedA[i] = seed;
|
||
}
|
||
g.setAttribute('aTangent', new THREE.BufferAttribute(tanA, 3));
|
||
g.setAttribute('aTint', new THREE.BufferAttribute(tintA, 3));
|
||
g.setAttribute('aSeed', new THREE.BufferAttribute(seedA, 1));
|
||
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);
|
||
out[0] = lot.x + (lx * cos + lz * sin);
|
||
out[1] = ly;
|
||
out[2] = lot.z + (-lx * sin + lz * cos);
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* buildChunkBuildings(chunkData, ctx) → chunk building bundle.
|
||
* chunkData: { lots:[], shops:[] }
|
||
* ctx: { skins, citySeed, night }
|
||
* returns { group, colliders, doorMesh|null, doorRects, applyNight, dispose }
|
||
*/
|
||
export function buildChunkBuildings(chunkData, ctx) {
|
||
const { skins } = ctx;
|
||
const group = new THREE.Group();
|
||
group.name = 'chunk-buildings';
|
||
// R26 #5: ask for the stock manifest only if this town has keyed shops at all (once per session).
|
||
// Synthetic / real / classic boots never carry a godverseShopId, so they never make the request.
|
||
if ((chunkData.shops || []).some((s) => Number.isSafeInteger(s && s.godverseShopId))) ensureStockIndex();
|
||
|
||
const boxMats = []; // {matrix, color} — shells + parapets + posts, ONE instanced draw/chunk
|
||
const awnings = []; // {matrix, color} — all awning/canopy skins, ONE instanced draw/chunk
|
||
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 = []; // 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, hours }
|
||
|
||
const shopByLot = new Map(chunkData.shops.map((s) => [s.lot, s]));
|
||
|
||
// ── sign atlas: lay every sign in this chunk into one 2048² canvas ──
|
||
// Atlas sized to the chunk's ACTUAL sign count (not a fixed 2048²): one row per 4 signs, so a
|
||
// typical chunk holds a ~2048×168 strip, not a 16MB square. Mipmaps off (saves ⅓ GPU + NPOT-safe).
|
||
const COLS = 4, CELL_W = 512, CELL_H = 168, ATLAS_W = COLS * CELL_W; // 2048 wide
|
||
const N_SIGNS = chunkData.shops.length; // shops+stalls each get one
|
||
const ATLAS_ROWS = Math.max(1, Math.ceil(N_SIGNS / COLS));
|
||
const ATLAS_H = ATLAS_ROWS * CELL_H;
|
||
let atlasCanvas = null, actx = null; // still lazy — shopless chunks allocate nothing
|
||
let signCell = 0;
|
||
const signUV = new Map(); // shopId → [u0,v0,u1,v1]
|
||
function drawSign(shop) {
|
||
if (signCell >= COLS * ATLAS_ROWS) return null;
|
||
if (!atlasCanvas) {
|
||
atlasCanvas = document.createElement('canvas');
|
||
atlasCanvas.width = ATLAS_W; atlasCanvas.height = ATLAS_H;
|
||
actx = atlasCanvas.getContext('2d');
|
||
actx.clearRect(0, 0, ATLAS_W, ATLAS_H);
|
||
}
|
||
const col = signCell % COLS, row = (signCell / COLS) | 0;
|
||
const px = col * CELL_W, py = row * CELL_H;
|
||
const reg = SHOP_REGISTRY[shop.type] || SHOP_REGISTRY.opshop;
|
||
actx.fillStyle = reg.signBg; actx.fillRect(px + 4, py + 4, CELL_W - 8, CELL_H - 8);
|
||
actx.fillStyle = reg.signFg;
|
||
actx.strokeStyle = reg.signFg; actx.lineWidth = 3;
|
||
actx.strokeRect(px + 10, py + 10, CELL_W - 20, CELL_H - 20);
|
||
// fit text
|
||
let fs = 74; const label = String(shop.name).slice(0, 22).toUpperCase();
|
||
actx.textAlign = 'center'; actx.textBaseline = 'middle';
|
||
do { actx.font = `bold ${fs}px Arial, sans-serif`; fs -= 4; }
|
||
while (actx.measureText(label).width > CELL_W - 40 && fs > 22);
|
||
actx.fillText(label, px + CELL_W / 2, py + CELL_H / 2);
|
||
if (isStocked(shop)) drawCrateMark(px, py, shop); // R26 #5 — same cell, no extra draw
|
||
const uv = [px / ATLAS_W, 1 - (py + CELL_H) / ATLAS_H, (px + CELL_W) / ATLAS_W, 1 - py / ATLAS_H];
|
||
signUV.set(shop.id, uv);
|
||
signCell++;
|
||
return uv;
|
||
}
|
||
|
||
// A crate of records seen end-on, along the sign's foot: the shop is worth digging. Seeded off
|
||
// shop.seed so two stocked shops don't wear the same spines (the beta's whole theme, on the
|
||
// street). Pure canvas into the cell already being drawn ⇒ zero draws, zero tris.
|
||
const SPINES = ['#2f2a26', '#7a2f28', '#2c4553', '#6b5a2e', '#3f2f45', '#8a6a3a', '#26463a'];
|
||
function drawCrateMark(px, py, shop) {
|
||
const y = py + CELL_H - 40, h = 24;
|
||
const x0 = px + 30, x1 = px + CELL_W - 30;
|
||
actx.fillStyle = 'rgba(0,0,0,0.30)'; // the crate itself, in shadow
|
||
actx.fillRect(x0 - 7, y - 5, (x1 - x0) + 14, h + 10);
|
||
let r = ((shop.seed >>> 0) || 1) ^ 0x5bf03635;
|
||
const rnd = () => ((r = (r * 1664525 + 1013904223) >>> 0) / 4294967296);
|
||
for (let x = x0; x < x1 - 3; x += 6) { // the spines: leaning, uneven, dug-through
|
||
const hh = h - ((rnd() * 7) | 0);
|
||
actx.fillStyle = SPINES[(rnd() * SPINES.length) | 0];
|
||
actx.fillRect(x, y + (h - hh), 4, hh);
|
||
}
|
||
}
|
||
|
||
// ── per-lot build ──
|
||
for (const lot of chunkData.lots) {
|
||
const shop = shopByLot.get(lot.id);
|
||
if (lot.use === 'house') { buildHouse(lot); continue; }
|
||
if (lot.use === 'stall') { buildStall(lot, shop); continue; }
|
||
buildShopfront(lot, shop);
|
||
}
|
||
|
||
function buildShopfront(lot, shop) {
|
||
const seed = shop ? shop.seed : ctx.citySeed ^ 0x9e37;
|
||
const r = rng(seed, 'bld', 0);
|
||
const storeys = (shop && shop.storeys) || 1;
|
||
const h = storeys * STOREY_H;
|
||
const w = lot.w, d = lot.d;
|
||
const wallC = new THREE.Color(WALL_TINTS[seed % WALL_TINTS.length]);
|
||
|
||
// shell (unit box scaled) centred at (x, h/2, z)
|
||
_p.set(lot.x, h / 2, lot.z); _q.setFromAxisAngle(_up, lot.ry || 0); _s.set(w, h, d);
|
||
boxMats.push({ matrix: new THREE.Matrix4().compose(_p, _q, _s), color: wallC.clone() });
|
||
|
||
// parapet cap along the front
|
||
_p.set(lot.x, h + PARAPET_H / 2, lot.z); _s.set(w, PARAPET_H, d * 0.6);
|
||
boxMats.push({ matrix: new THREE.Matrix4().compose(_p, _q, _s), color: new THREE.Color(ROOF_TINT) });
|
||
|
||
// facade plane on the ground-floor front (local +Z face) — UV-mapped into the shared facade atlas
|
||
const skin = (shop && shop.facadeSkin) || 'weatherboard';
|
||
const fh = Math.min(h, FACADE_H);
|
||
const zf = d / 2 + 0.02;
|
||
const hw = w * 0.48;
|
||
const c0 = toWorld(lot, -hw, 0.02, zf, []);
|
||
const c1 = toWorld(lot, hw, 0.02, zf, []);
|
||
const c2 = toWorld(lot, hw, fh, zf, []);
|
||
const c3 = toWorld(lot, -hw, fh, zf, []);
|
||
facadeGeos.push(quad(c0, c1, c2, c3, skins.facadeAtlasUV(skin)));
|
||
|
||
// door (dark) — centred, on the front, proud of the facade
|
||
const dw = 1.2, dh = 2.2, zd = zf + 0.03;
|
||
doorGeos.push(quad(
|
||
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, []),
|
||
));
|
||
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 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(windowQuad(
|
||
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, shop,
|
||
));
|
||
}
|
||
// 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],
|
||
));
|
||
}
|
||
|
||
// sign band overlaying the blank signboard region of the skin
|
||
if (shop) {
|
||
drawSign(shop);
|
||
const uv = signUV.get(shop.id);
|
||
if (uv) {
|
||
const sh = 0.5, sy = fh - 0.35, sw = w * 0.92, zs = zf + 0.05;
|
||
signQuads.push(quad(
|
||
toWorld(lot, -sw / 2, sy - sh / 2, zs, []), toWorld(lot, sw / 2, sy - sh / 2, zs, []),
|
||
toWorld(lot, sw / 2, sy + sh / 2, zs, []), toWorld(lot, -sw / 2, sy + sh / 2, zs, []),
|
||
uv,
|
||
));
|
||
}
|
||
}
|
||
|
||
// posted verandah + awning over the footpath (main-street shopfronts only)
|
||
if (lot.use === 'shop' || lot.use === 'anchor') {
|
||
const askin = AWNING_SKINS[(seed >>> 3) % AWNING_SKINS.length]; // unsigned: seed is uint32
|
||
// awning box: thin slab reaching over the footpath at the front
|
||
_p.copy(new THREE.Vector3().fromArray(toWorld(lot, 0, AWNING_Y, d / 2 + AWNING_DEPTH / 2, [])));
|
||
_s.set(w, 0.16, AWNING_DEPTH);
|
||
awnings.push({ matrix: new THREE.Matrix4().compose(_p, _q, _s), color: new THREE.Color(skins.awningColor(askin)) });
|
||
// two posts at the awning's outer edge
|
||
const postY = AWNING_Y / 2;
|
||
for (const ox of [-w / 2 + 0.25, w / 2 - 0.25]) {
|
||
_p.fromArray(toWorld(lot, ox, postY, d / 2 + AWNING_DEPTH - 0.2, []));
|
||
_s.set(0.12, AWNING_Y, 0.12);
|
||
boxMats.push({ matrix: new THREE.Matrix4().compose(_p, _q, _s), color: new THREE.Color('#2b2b2b') });
|
||
}
|
||
}
|
||
|
||
colliders.push(lotCollider(lot));
|
||
}
|
||
|
||
function buildHouse(lot) {
|
||
const seed = (ctx.citySeed ^ (lot.x * 73856093) ^ (lot.z * 19349663)) >>> 0;
|
||
const r = rng(seed, 'house', 0);
|
||
const h = 2.7, w = lot.w, d = lot.d;
|
||
const wallC = new THREE.Color(WALL_TINTS[seed % WALL_TINTS.length]);
|
||
_p.set(lot.x, h / 2, lot.z); _q.setFromAxisAngle(_up, lot.ry || 0); _s.set(w, h, d);
|
||
boxMats.push({ matrix: new THREE.Matrix4().compose(_p, _q, _s), color: wallC });
|
||
// simple hip roof: a squashed, slightly oversized box on top
|
||
_p.set(lot.x, h + 0.5, lot.z); _s.set(w + 0.6, 1.0, d + 0.6);
|
||
boxMats.push({ matrix: new THREE.Matrix4().compose(_p, _q, _s), color: new THREE.Color(ROOF_TINT) });
|
||
// front door + two windows (decorative — houses are not enterable, so keep the door out of
|
||
// the interactive doorMesh or the HUD raycast would mis-resolve it to the nearest shop)
|
||
const zf = d / 2 + 0.02;
|
||
decorDoorGeos.push(quad(
|
||
toWorld(lot, -0.5, 0.02, zf, []), toWorld(lot, 0.5, 0.02, 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]) {
|
||
windowGeos.push(windowQuad(
|
||
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], null, // homes glow at night (always "open")
|
||
));
|
||
}
|
||
colliders.push(lotCollider(lot));
|
||
}
|
||
|
||
function buildStall(lot, shop) {
|
||
const seed = shop ? shop.seed : 4242;
|
||
const w = lot.w, d = lot.d;
|
||
// trestle table
|
||
_p.set(lot.x, 0.4, lot.z); _q.setFromAxisAngle(_up, lot.ry || 0); _s.set(w, 0.8, d);
|
||
boxMats.push({ matrix: new THREE.Matrix4().compose(_p, _q, _s), color: new THREE.Color('#6b4a30') });
|
||
// striped canopy (awning skin) above
|
||
const askin = AWNING_SKINS[seed % AWNING_SKINS.length];
|
||
_p.set(lot.x, 2.2, lot.z); _s.set(w + 0.6, 0.14, d + 0.8);
|
||
awnings.push({ matrix: new THREE.Matrix4().compose(_p, _q, _s), color: new THREE.Color(skins.awningColor(askin)) });
|
||
// corner posts
|
||
for (const [ox, oz] of [[-w / 2, -d / 2], [w / 2, -d / 2], [-w / 2, d / 2], [w / 2, d / 2]]) {
|
||
_p.fromArray(toWorld(lot, ox, 1.1, oz, [])); _s.set(0.08, 2.2, 0.08);
|
||
boxMats.push({ matrix: new THREE.Matrix4().compose(_p, _q, _s), color: new THREE.Color('#3a3a3a') });
|
||
}
|
||
// interaction plane on the front so you can "enter" the stall
|
||
if (shop) {
|
||
const zf = d / 2 + 0.02;
|
||
doorGeos.push(quad(
|
||
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, hours: shop.hours, x: lot.x, z: lot.z });
|
||
drawSign(shop);
|
||
const uv = signUV.get(shop.id);
|
||
if (uv) {
|
||
signQuads.push(quad(
|
||
toWorld(lot, -w / 2, 2.0, zf + 0.05, []), toWorld(lot, w / 2, 2.0, zf + 0.05, []),
|
||
toWorld(lot, w / 2, 2.4, zf + 0.05, []), toWorld(lot, -w / 2, 2.4, zf + 0.05, []),
|
||
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));
|
||
}
|
||
|
||
// ── finalise instanced meshes ──
|
||
const disposables = [];
|
||
function makeInstanced(list, material, castShadow) {
|
||
if (!list.length) return null;
|
||
const geo = new THREE.BoxGeometry(1, 1, 1);
|
||
const im = new THREE.InstancedMesh(geo, material, list.length);
|
||
for (let i = 0; i < list.length; i++) {
|
||
im.setMatrixAt(i, list[i].matrix);
|
||
if (list[i].color && im.instanceColor !== undefined) im.setColorAt(i, list[i].color);
|
||
}
|
||
im.instanceMatrix.needsUpdate = true;
|
||
if (im.instanceColor) im.instanceColor.needsUpdate = true;
|
||
im.castShadow = !!castShadow; im.receiveShadow = true;
|
||
im.frustumCulled = true;
|
||
disposables.push(geo);
|
||
group.add(im);
|
||
return im;
|
||
}
|
||
makeInstanced(boxMats, skins.shellMaterial(), true); // shells + parapets + posts (per-instance colour)
|
||
makeInstanced(awnings, skins.awningMaterial(), true); // every awning/canopy skin (per-instance colour)
|
||
|
||
// ── finalise the facade: ALL skins merge into ONE mesh via the shared atlas (1 draw/chunk) ──
|
||
if (facadeGeos.length) {
|
||
const merged = mergeGeometries(facadeGeos, false);
|
||
facadeGeos.forEach((g) => g.dispose());
|
||
const mesh = new THREE.Mesh(merged, skins.facadeAtlasMaterial());
|
||
mesh.receiveShadow = true; mesh.castShadow = false;
|
||
disposables.push(merged);
|
||
group.add(mesh);
|
||
}
|
||
|
||
// ── doors ──
|
||
const doorMat = new THREE.MeshStandardMaterial({ color: 0x2a2018, roughness: 0.7 });
|
||
disposables.push(doorMat);
|
||
// interactive shop/stall doors — the HUD raycasts this mesh and maps hits to doorRects
|
||
let doorMesh = null;
|
||
if (doorGeos.length) {
|
||
const merged = mergeGeometries(doorGeos, false);
|
||
doorGeos.forEach((g) => g.dispose());
|
||
doorMesh = new THREE.Mesh(merged, doorMat);
|
||
doorMesh.userData.isDoorMesh = true;
|
||
doorMesh.userData.doorRects = doorRects;
|
||
disposables.push(merged);
|
||
group.add(doorMesh);
|
||
}
|
||
// decorative house doors — same look, but no userData so the HUD never picks them
|
||
if (decorDoorGeos.length) {
|
||
const merged = mergeGeometries(decorDoorGeos, false);
|
||
decorDoorGeos.forEach((g) => g.dispose());
|
||
const mesh = new THREE.Mesh(merged, doorMat);
|
||
disposables.push(merged);
|
||
group.add(mesh);
|
||
}
|
||
|
||
// ── windows (merged, ONE shared material; emissive gated per-shop by the aHours shader) ──
|
||
// ?winmap swaps in the parallax interior-mapping glass (v2); default is the v1 window material.
|
||
if (windowGeos.length) {
|
||
const merged = mergeGeometries(windowGeos, false);
|
||
windowGeos.forEach((g) => g.dispose());
|
||
const mesh = new THREE.Mesh(merged, WINMAP ? interiorWindowMaterial() : windowMaterial()); // shared → NOT disposed
|
||
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) ──
|
||
if (signQuads.length) {
|
||
const merged = mergeGeometries(signQuads.map((s) => s.geo || s), false);
|
||
signQuads.forEach((s) => (s.geo || s).dispose());
|
||
const tex = new THREE.CanvasTexture(atlasCanvas);
|
||
tex.colorSpace = THREE.SRGBColorSpace; tex.anisotropy = 4;
|
||
tex.generateMipmaps = false; tex.minFilter = THREE.LinearFilter; // ⅓ less GPU mem, NPOT-safe
|
||
const mat = new THREE.MeshBasicMaterial({ map: tex, transparent: true });
|
||
const mesh = new THREE.Mesh(merged, mat);
|
||
disposables.push(merged, tex, mat);
|
||
group.add(mesh);
|
||
}
|
||
|
||
// 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) => {
|
||
if (o.isInstancedMesh) o.dispose && o.dispose(); // frees instanceMatrix/instanceColor
|
||
if (o.isMesh || o.isInstancedMesh) o.geometry && o.geometry.dispose && o.geometry.dispose();
|
||
});
|
||
for (const d of disposables) d.dispose && d.dispose();
|
||
group.clear();
|
||
}
|
||
|
||
return { group, colliders, doorMesh, doorRects, applyNight, dispose };
|
||
}
|