// 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 | 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 ', '#include \n\tvHours = aHours;'); shader.fragmentShader = 'uniform float uHour;\nuniform float uNight;\nvarying vec2 vHours;\n' + shader.fragmentShader.replace('#include ', '#include \n\tfloat _open = step(vHours.x, uHour) * step(uHour + 0.0001, vHours.y);\n\ttotalEmissiveRadiance *= _open * uNight;'); }; _windowMat = m; return m; } // ── 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 }; }