The town is inhabited: 23 towns finally differ, 181 back yards have a Hills Hoist with washing on it, a magpie owns one street for six weeks of the year, and six point sources put a life off-screen. MEASURED, walked, not bookmarked — two port-isolated no-store servers (:8472 = `git archive HEAD`, :8471 = treatment, assets symlinked so E's mid-round wave is identical on both sides), 102 8-metre spine steps at yaw 0, each frame fully streamed, through the shell's own composer: worst draws 224 -> 225 (+1) worst tris 89,502 -> 93,298 (+3,796 of ~75k spare) sum draws over 102 frames 16,676 -> 16,663 (-13) The citizen sim and the tram are hidden identically on both sides, and that IS the measurement: with them live the same step read control 346 vs treatment 232, because peds spawn on wall-clock time. `?yards=0` reproduces HEAD exactly (224 / 89,502 / 16,676). Isolated at a fixed pose: washing +1 draw / +512 tris, magpie +1 draw / +182 tris, every yard box prop +0 draws. §1.2 THE CHARACTER VECTOR — +0 draws, +0 tris, classic byte-identical BY CONSTRUCTION. `townCharacter(null)` returns the frozen v1 literals and a null tint keeps the SAME material cache key, so classic gets the same object, not a copy of it: default-boot ground hashes identical to HEAD mesh-for-mesh, `?classic=1` hashes unmoved from R37. There is no `if (classic)` in the ground path. The sky feeds the BASE skyName, never `setSky` (a one-way latch that would have killed R32's dawn dome on all 23 towns). The vacuous arm is per-town: `newtown_godverse` / `redhill_godverse` carry `state: ''`, so a per-corpus arm would have dropped exactly those two silently. 21 distinct palettes across 23 towns. `gravel` and `reddust` were not unwired but UNWIREABLE — their `use` values named no slot ground.js had; making the slot addressable fixed it at the root, and both are selected now. E's three new grounds landed mid-round and the upgrade was a name change in one table, which is what procedural-first is for. §1.4 BACKYARD AUSTRALIA — inside boxMats, +0 draws. Houses inset off their boundaries so there is a yard; 36 `yard` lots that had rendered as blank shopfront sheds since v1 are now fenced blocks with a hoist, a shed and often a tank. Collision byte-identical (the lot rect is where the fence stands). THE WASHING SHIPS, and I wrote the inverted weight rather than dropping it. The shipped top-weighted mode moves a hem 0.10 cm calm / 0.67 cm gusty; `pegged` gives 4.5 cm / 31 cm — ~52x, and at the hem instead of the pegs. The `canopy` string is byte-for-byte unchanged, so the gum-tree covenant argument is untouched. It is ONE TOWN-WIDE InstancedMesh (1 draw at any N, cap 256, proximity-first off the R37 chunk seam) rather than per-chunk, which would have been +1 x 31 live chunks = 4x the whole margin. Zero on every real town (no hoists ⇒ no mesh) and zero at night. §1.3 THE MAGPIE — +1 draw on screen, exactly 0 otherwise; 182 tris of 900. Territory scales with the road graph: 6 on the 6.78 km synthetic town, 157 on bendigo's 188.6 km / 2,593 edges, nearest 120 m from spawn. The latch sets, the day roll clears it, localStorage gains no key and the save never mentions it. `swoopPos()` is exported and pure. The hat stays cut. MY OWN SEASON MECHANISM WAS VACUOUS AND THE SWEEP CAUGHT IT: keyed on citySeed alone it produced a CONSTANT, because the town key is deliberately off the plan — 24 of 24 towns were 214 days from swooping season, and it would have gated green because `?magpie=1` forces it. Re-keyed on (citySeed, townKey): `newtown_real` is in season on arrival on the default seed, marrickville is 8 days out, and 12.9% of 1,000 seeds put the synthetic default in season against a design ratio of 11.5%. §1.1 THE AUDIO LAYER — six new mixer layers, zero draws, zero tris, seeded anchors, distance falloff, AND A StereoPannerNode PER LAYER, so direction is real (measured spread -0.71..+0.85) instead of a limitation written into the notes. Day-of-week derived locally. All six anchored on the default boot; 3 of 6 report `no-anchor` on real towns rather than being faked onto shopfronts. The falsifiability control — one manifest key renamed away, served from a third port — makes that source report `no-entry`, 0 plays, never `audible`, with the other five unchanged and zero console errors. TWO DEFECTS MY OWN GREEN NUMBERS COULD NOT SEE, both caught by a screenshot: the washing shipped at 256 instances / +1 draw / cap respected and was INVISIBLE behind its own back fence (per-instance peg height, and a fence taller than the hoist), and the magpie perched on THIN AIR over the road. R37's lesson relearned at full price. Fence 1.60 / hoist 2.15 measured against the object; territories now snap to furniture.js's own streetlight rule. LANE E'S PROPS, ADJUDICATED: hoist + aerial permanently primitive (E's spike killed them, and primitive is also the +0-draw mechanism). `paling_fence` at 1,121 tris x 905 runs = 1.01M triangles — no. The `magpie` GLB is HELD FOR THE TINT: it reads as a crow, and a magpie seen for 1.2 s in peripheral vision IS its white bar. My 182-tri bird already carries it, at a fifth of the triangles and no fetch. furniture.js's "novelty_record is 26.5k tris" was stale by six epochs — corrected to 8,000. Zero fetch delta: `?noassets=1` fetches the same 23 assets, same list, on HEAD and on R38. 0 console errors on 10 boots (default, classic, yards=0, washing=0&magpie=0&ambient=0, noassets, magpie=1, darwin, hobart, bendigo, the patched-manifest control) beyond the known PointerLock ones. Shots: docs/shots/laneB_r38/. Detail + every number: LANE_B_NOTES §38.
849 lines
45 KiB
JavaScript
849 lines
45 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); });
|
||
}
|
||
|
||
// ── R38 §1.4 — BACKYARD AUSTRALIA ────────────────────────────────────────────────────────────────
|
||
// The default boot ships **181 house and yard lots with nothing in them** (measured: 145 `house`,
|
||
// 36 `yard` on seed 20261990). Worse, a `yard` lot has fallen through to `buildShopfront()` since
|
||
// v1 — 36 lots A explicitly labels "yard" render as blank 3.2 m sheds with a door and two windows.
|
||
// This puts a Hills Hoist, a paling fence, a TV aerial and a rainwater tank in them.
|
||
//
|
||
// **IT COSTS ZERO DRAWS, and that is the whole reason it lives in this file.** Every prop below is
|
||
// pushed into the EXISTING `boxMats` list — the same per-chunk InstancedMesh that already carries
|
||
// shells, parapets and verandah posts — so a yard adds instances to a draw call that was already
|
||
// being submitted. A `world/yards.js` could not reach `boxMats` and would have paid +1 draw per live
|
||
// chunk (×31 at the dispose window) against a margin of 8. Triangles are the resource being spent
|
||
// here (12 per box), and triangles are the cheap one (~75k spare on the pinned frame).
|
||
//
|
||
// Everything is a primitive by construction, so `?noassets` is unaffected and there is nothing to
|
||
// upgrade-if-ready: a per-instance texture is impossible in a shared-material InstancedMesh, which
|
||
// is exactly the mechanism that makes it free. What E's asset wave upgrades is the ground under it
|
||
// (§1.2) and the sound in it (§1.1), not these boxes.
|
||
//
|
||
// SYNTHETIC-ONLY THIS ROUND, and stated rather than faked: all 21 real towns are 100% `use:'shop'`
|
||
// in one `mainstreet` district (charter ruling 2 — the civic fetch is a later round), so a real town
|
||
// has zero house/yard lots and zero of this fires there. It ships on the default boot, which is
|
||
// still the boot every player gets, and extends to real towns with no further code the moment A's
|
||
// third fetch class lands districts.
|
||
//
|
||
// `?yards=0` reverts to the pre-R38 geometry permanently (the `?verge=0` pattern — a falsifiability
|
||
// control that costs nothing and stays forever); `?classic=1` forces it off (the museum keeps v2).
|
||
const YARDS = (() => {
|
||
try {
|
||
const p = new URLSearchParams(location.search);
|
||
const classic = p.has('classic') && p.get('classic') !== '0';
|
||
return !classic && p.get('yards') !== '0';
|
||
} catch (e) { return false; } // unparseable search ⇒ pre-R38 geometry, never a throw
|
||
})();
|
||
|
||
// Measured against the picture, not the spec sheet: a paling fence and a Hills Hoist are within
|
||
// 30 cm of each other in real life, and the first cut had the fence TALLER (1.75) than the washing
|
||
// line's drop, which hid an entire street of back yards behind their own fences from every angle a
|
||
// player can stand at. An older AU paling fence is 1.5–1.8 m and a rotary hoist is adjustable to
|
||
// 2.0–2.4 m, so: fence 1.60, hoist 2.15. A 1.25 m sheet then hangs 0.90–2.15 and reads as half a
|
||
// metre of washing over the palings, which is exactly what you actually see walking past.
|
||
const FENCE_H = 1.60, FENCE_T = 0.09, FRONT_FENCE_H = 0.95, GATE_W = 1.2;
|
||
// HOIST_ARM below is the HALF-span. Capped at 1.75 ⇒ 3.5 m across, a real hoist. The first cut
|
||
// capped the same number at 3.2 and drew a 6.4 m machine in a 5 m yard.
|
||
const HOIST_H = 2.15, HOIST_ARM_T = 0.05, HOIST_ARM_MAX = 1.75;
|
||
const TANK_H = 1.9, TANK_D = 1.55;
|
||
const AERIAL_H = 1.35;
|
||
const PALING_TINTS = ['#9c8c72', '#a4967c', '#8e8068', '#ab9c80', '#93866e'];
|
||
const GALV = '#aeb4b6'; // galvanised: hoist, aerial, tank bands
|
||
const TANK_TINTS = ['#8d968c', '#a2a49a', '#7f8b86'];
|
||
|
||
// The seed a house/yard lot has used since v1 — kept EXACTLY as `buildHouse` computed it so nothing
|
||
// re-rolls, and lifted out so the washing layer (a town-wide mesh in washing.js) can derive the same
|
||
// hoist from the same numbers instead of inventing a second placement rule. Two implementations of
|
||
// one thing is the disease this project keeps catching itself with; one exported pure function is
|
||
// the cure.
|
||
export function yardSeed(lot, citySeed) {
|
||
return (citySeed ^ (lot.x * 73856093) ^ (lot.z * 19349663)) >>> 0;
|
||
}
|
||
|
||
/**
|
||
* yardLayout(lot, citySeed) → the whole back yard as plain numbers, in LOT-LOCAL coordinates
|
||
* (+Z = the street-facing front, matching the canonical building convention at the top of this file).
|
||
* Pure, allocation-light, no THREE. Both the builder below and washing.js consume it.
|
||
*/
|
||
export function yardLayout(lot, citySeed) {
|
||
const seed = yardSeed(lot, citySeed);
|
||
const r = rng(seed, 'yard', 0);
|
||
const w = lot.w, d = lot.d;
|
||
const house = lot.use === 'house';
|
||
// The dwelling is pulled off its boundaries: a small front garden, side gaps a wheelie bin fits
|
||
// down, and the REST IS BACK YARD. Pre-R38 the shell was the whole lot (w × d), which is why 145
|
||
// houses had no yard to put anything in.
|
||
const frontSet = house ? 1.25 : 0;
|
||
const hw = house ? Math.max(3.5, w * 0.84) : 0;
|
||
const hd = house ? Math.max(3.0, d * 0.60) : 0;
|
||
const houseZ = house ? (d / 2 - frontSet - hd / 2) : 0; // local z of the dwelling centre
|
||
const yardBackZ = -d / 2 + FENCE_T; // inside face of the back fence
|
||
const yardFrontZ = house ? (houseZ - hd / 2) : (d / 2 - FENCE_T);
|
||
const yardDepth = Math.max(0.8, yardFrontZ - yardBackZ);
|
||
const hoistZ = yardBackZ + yardDepth * (house ? 0.55 : 0.5);
|
||
// Rotary hoist: HALF-span, fits the yard it stands in and never overhangs the fence.
|
||
const arm = Math.min(HOIST_ARM_MAX, Math.max(0.85, Math.min(w * 0.30, yardDepth * 0.42)));
|
||
const hasTank = r() < (house ? 0.36 : 0.55); // tanks cluster on the sheds
|
||
const hasAerial = house && r() < 0.72; // not every roof kept its aerial
|
||
const tankSide = r() < 0.5 ? -1 : 1;
|
||
const shedW = house ? 0 : Math.min(3.4, w * 0.52);
|
||
const shedD = house ? 0 : Math.min(2.8, yardDepth * 0.46);
|
||
return {
|
||
seed, house, w, d,
|
||
houseW: hw, houseD: hd, houseZ,
|
||
fenceH: FENCE_H, palingTint: PALING_TINTS[seed % PALING_TINTS.length],
|
||
hoist: { x: 0, z: hoistZ, h: HOIST_H, arm, yaw: (seed >>> 7) % 90 * (Math.PI / 180) },
|
||
tank: hasTank ? { x: tankSide * (w / 2 - TANK_D / 2 - 0.28), z: yardBackZ + TANK_D / 2 + 0.2, h: TANK_H, d: TANK_D,
|
||
tint: TANK_TINTS[(seed >>> 11) % TANK_TINTS.length] } : null,
|
||
aerial: hasAerial ? { x: (r() - 0.5) * hw * 0.5, z: houseZ - hd * 0.18, h: AERIAL_H } : null,
|
||
shed: shedW > 0 ? { x: (r() - 0.5) * (w - shedW - 0.6), z: yardBackZ + shedD / 2 + 0.15, w: shedW, d: shedD, h: 2.2 } : null,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* hoistAnchors(plan, citySeed) → [{ x, z, y, arm, yaw }] in world space, one per Hills Hoist in the
|
||
* town, in plan order. The town-wide washing layer pegs onto these. Pure function of the plan +
|
||
* seed, derived from the SAME yardLayout the geometry is built from, so a line of washing can never
|
||
* hang in mid-air next to a hoist that moved. Empty on every real town today (no house/yard lots).
|
||
*/
|
||
export function hoistAnchors(plan, citySeed) {
|
||
const out = [];
|
||
for (const lot of (plan.lots || [])) {
|
||
if (lot.use !== 'house' && lot.use !== 'yard') continue;
|
||
const L = yardLayout(lot, citySeed >>> 0);
|
||
const cos = Math.cos(lot.ry || 0), sin = Math.sin(lot.ry || 0);
|
||
out.push({
|
||
lotId: lot.id,
|
||
x: lot.x + (L.hoist.x * cos + L.hoist.z * sin),
|
||
z: lot.z + (-L.hoist.x * sin + L.hoist.z * cos),
|
||
y: L.hoist.h, arm: L.hoist.arm, yaw: (lot.ry || 0) + L.hoist.yaw, seed: L.seed,
|
||
});
|
||
}
|
||
return out;
|
||
}
|
||
|
||
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);
|
||
}
|
||
}
|
||
|
||
// ── R38 §1.4 helpers: every yard prop is ONE more instance in the EXISTING boxMats list ────────
|
||
// (see the header block above — this is the +0-draw mechanism, not a convenience.) Declared
|
||
// BEFORE the per-lot loop on purpose: the scratch vectors are `const`, and a hoisted function that
|
||
// closes over a `const` declared after its first call would hit the temporal dead zone.
|
||
const _yq = new THREE.Quaternion();
|
||
const _yp = new THREE.Vector3();
|
||
const _ys = new THREE.Vector3();
|
||
function yardBox(lot, lx, ly, lz, sx, sy, sz, color, extraYaw = 0) {
|
||
const ry = (lot.ry || 0);
|
||
const cos = Math.cos(ry), sin = Math.sin(ry);
|
||
_yp.set(lot.x + (lx * cos + lz * sin), ly, lot.z + (-lx * sin + lz * cos));
|
||
_yq.setFromAxisAngle(_up, ry + extraYaw);
|
||
_ys.set(sx, sy, sz);
|
||
boxMats.push({ matrix: new THREE.Matrix4().compose(_yp, _yq, _ys), color: new THREE.Color(color) });
|
||
}
|
||
// Paling fence around a lot: the two sides and the back at full height, the street frontage a low
|
||
// picket split around a gate gap. 5 boxes = 60 tris for a fenced block of land.
|
||
function fenceRuns(lot, L) {
|
||
const w = L.w, d = L.d, t = FENCE_T, h = L.fenceH, c = L.palingTint;
|
||
yardBox(lot, 0, h / 2, -d / 2 + t / 2, w, h, t, c); // back
|
||
yardBox(lot, -w / 2 + t / 2, h / 2, 0, t, h, d, c); // side
|
||
yardBox(lot, w / 2 - t / 2, h / 2, 0, t, h, d, c); // side
|
||
const fw = (w - GATE_W) / 2;
|
||
if (fw > 0.3) {
|
||
yardBox(lot, -(GATE_W / 2 + fw / 2), FRONT_FENCE_H / 2, d / 2 - t / 2, fw, FRONT_FENCE_H, t, c);
|
||
yardBox(lot, (GATE_W / 2 + fw / 2), FRONT_FENCE_H / 2, d / 2 - t / 2, fw, FRONT_FENCE_H, t, c);
|
||
}
|
||
}
|
||
// Hills Hoist: mast + two crossed arms = four arms in 3 boxes. The washing (washing.js, town-wide,
|
||
// its own draw) pegs onto the SAME arms via hoistAnchors(), which reads the same yardLayout.
|
||
function hillsHoist(lot, L) {
|
||
const H = L.hoist;
|
||
yardBox(lot, H.x, H.h / 2, H.z, 0.075, H.h, 0.075, GALV);
|
||
yardBox(lot, H.x, H.h, H.z, H.arm * 2, HOIST_ARM_T, HOIST_ARM_T, GALV, H.yaw);
|
||
yardBox(lot, H.x, H.h - 0.03, H.z, HOIST_ARM_T, HOIST_ARM_T, H.arm * 2, GALV, H.yaw);
|
||
}
|
||
// Rainwater tank: two boxes at 45° read as a round corrugated tank in silhouette for 24 tris.
|
||
function waterTank(lot, L) {
|
||
const T = L.tank; if (!T) return;
|
||
yardBox(lot, T.x, T.h / 2, T.z, T.d, T.h, T.d, T.tint);
|
||
yardBox(lot, T.x, T.h / 2, T.z, T.d * 0.92, T.h * 0.98, T.d * 0.92, T.tint, Math.PI / 4);
|
||
}
|
||
// TV aerial on the ridge: mast + two elements.
|
||
function tvAerial(lot, L, roofTopY) {
|
||
const A = L.aerial; if (!A) return;
|
||
yardBox(lot, A.x, roofTopY + A.h / 2, A.z, 0.05, A.h, 0.05, GALV);
|
||
yardBox(lot, A.x, roofTopY + A.h * 0.92, A.z, 1.15, 0.045, 0.045, GALV);
|
||
yardBox(lot, A.x, roofTopY + A.h * 0.66, A.z, 0.85, 0.045, 0.045, GALV);
|
||
}
|
||
|
||
// ── per-lot build ──
|
||
for (const lot of chunkData.lots) {
|
||
const shop = shopByLot.get(lot.id);
|
||
if (lot.use === 'house') { buildHouse(lot); continue; }
|
||
// [R38 §1.4] a `yard` lot is a YARD. Pre-R38 it fell through to buildShopfront() and rendered as
|
||
// a blank 3.2 m shed with a door and two windows — 36 of them on the default boot. Under
|
||
// ?yards=0 / ?classic=1 it keeps falling through, byte-identically.
|
||
if (lot.use === 'yard' && YARDS) { buildYardLot(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;
|
||
// [R38 §1.4] Under the ruling the dwelling is inset off its boundaries (front garden, side
|
||
// gaps, and A BACK YARD); with ?yards=0 / ?classic=1 it is the whole lot, exactly as since v1.
|
||
const L = YARDS ? yardLayout(lot, ctx.citySeed >>> 0) : null;
|
||
const w = L ? L.houseW : lot.w, d = L ? L.houseD : lot.d;
|
||
const cz = L ? L.houseZ : 0; // local z of the dwelling centre
|
||
const wx = lot.x + (cz * Math.sin(lot.ry || 0)), wz = lot.z + (cz * Math.cos(lot.ry || 0));
|
||
const wallC = new THREE.Color(WALL_TINTS[seed % WALL_TINTS.length]);
|
||
_p.set(wx, h / 2, wz); _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(wx, h + 0.5, wz); _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 = cz + 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")
|
||
));
|
||
}
|
||
// ── the yard itself: fence, hoist, tank, aerial — all instances in the EXISTING boxMats draw ──
|
||
if (L) { fenceRuns(lot, L); hillsHoist(lot, L); waterTank(lot, L); tvAerial(lot, L, h + 1.0); }
|
||
colliders.push(lotCollider(lot)); // unchanged: the collider is the LOT, i.e. the fence line
|
||
}
|
||
|
||
// [R38 §1.4] A `yard` lot: fenced block, Hills Hoist, a fibro shed, often a rainwater tank. No
|
||
// shell, no parapet, no facade, no door, no windows — which is why replacing the blank shed this
|
||
// lot used to render as costs a net ~100 tris rather than ~230, and removes a decorative door
|
||
// from a lot nobody could ever walk into. Collider unchanged (the lot rect = the fence line), so
|
||
// player collision on these 36 lots is byte-identical to pre-R38.
|
||
function buildYardLot(lot) {
|
||
const L = yardLayout(lot, ctx.citySeed >>> 0);
|
||
fenceRuns(lot, L);
|
||
hillsHoist(lot, L);
|
||
waterTank(lot, L);
|
||
if (L.shed) {
|
||
yardBox(lot, L.shed.x, L.shed.h / 2, L.shed.z, L.shed.w, L.shed.h, L.shed.d, '#9aa0a2');
|
||
yardBox(lot, L.shed.x, L.shed.h + 0.12, L.shed.z, L.shed.w + 0.35, 0.16, L.shed.d + 0.35, ROOF_TINT);
|
||
}
|
||
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 };
|
||
}
|