PROCITY/web/js/world/buildings.js
m3ultra 2f4ab5912f Lane B R26 (v5.0-beta): the stocked shopfront reads from the footpath
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>
2026-07-17 09:49:54 +10:00

649 lines
33 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 ~1018 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 };
}