PROCITY/web/js/world/furniture.js
m3ultra 5313402e43 Lane B (Streetscape): chunk-streamed walkable town shell
First-person shell: chunk streaming, instanced buildings + facade texture atlas
(22 facade materials -> 1; 5 skin materials city-wide), signs, awnings, furniture,
day/night lighting, minimap, pixel-accurate collision (incl. arbitrary-angle lots),
door raycast -> procity:enterShop, DBG harness, all-fallback mode. Renders Lane A's
full generated town (seed 20261990 'Boolarra Heads', 493 shops); worst continuous-
walk view ~261 draws (<=300 gate). 6 adversarial-review bugs fixed (collision sign,
house doors, awning skin, sign atlas, furniture seed hash, shot-mode restore).

index.html and skins.js include Lane F's inline integration seam edits (marked
'[Lane F integration]'): interior/keeper/citizen wiring + facade filename fix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 14:28:47 +10:00

193 lines
8.5 KiB
JavaScript

// PROCITY Lane B — furniture.js
// Instanced street furniture along the footpaths: streetlights (emissive lamp at night), benches,
// bins, gum trees (crossed-plane billboards), bus stops. Seeded placement, one InstancedMesh per
// type per chunk so a chunk's furniture is ~6 draw calls and disposes cleanly with the chunk.
//
// Every type has a PRIMITIVE fallback (house law) — the town is fully furnished with zero GLBs.
// A 3GOD-depot upgrade path (promise-cached loadGLB → hot-swap) is documented at the bottom but
// left OFF for v1 so async loads can never write into an unloaded chunk.
import * as THREE from 'three';
import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js';
import { rng, frange } from '../core/prng.js';
import { chunkOf, resolveEdges } from './planutil.js';
const FOOT = 3.5;
// ── shared primitive templates (built once, reused by every chunk; never disposed per-chunk) ──
let _tpl = null;
function templates() {
if (_tpl) return _tpl;
const box = (w, h, d, x = 0, y = 0, z = 0) => {
const g = new THREE.BoxGeometry(w, h, d); g.translate(x, y, z); return g;
};
// streetlight pole + arm (dark). Lamp head is separate (emissive).
const pole = mergeGeometries([
box(0.12, 4.2, 0.12, 0, 2.1, 0),
box(0.09, 0.09, 1.1, 0, 4.1, 0.55),
], false);
const lamp = box(0.34, 0.18, 0.34, 0, 4.05, 1.05);
// bench: seat + back + two legs
const bench = mergeGeometries([
box(1.6, 0.08, 0.45, 0, 0.45, 0),
box(1.6, 0.5, 0.06, 0, 0.72, -0.2),
box(0.08, 0.45, 0.4, -0.7, 0.22, 0),
box(0.08, 0.45, 0.4, 0.7, 0.22, 0),
], false);
// bin: short cylinder
const bin = new THREE.CylinderGeometry(0.28, 0.24, 0.9, 10); bin.translate(0, 0.45, 0);
// bus stop shelter: two posts + flat roof + a small bench
const busstop = mergeGeometries([
box(0.1, 2.3, 0.1, -1.3, 1.15, -0.5), box(0.1, 2.3, 0.1, 1.3, 1.15, -0.5),
box(3.0, 0.12, 1.4, 0, 2.35, 0),
box(2.4, 0.08, 0.4, 0, 0.5, -0.4),
], false);
// gum tree: crossed alpha-textured planes
const treeGeo = mergeGeometries([
new THREE.PlaneGeometry(3.2, 4.4).translate(0, 2.2, 0),
new THREE.PlaneGeometry(3.2, 4.4).rotateY(Math.PI / 2).translate(0, 2.2, 0),
], false);
_tpl = { pole, lamp, bench, bin, busstop, treeGeo };
return _tpl;
}
// tree billboard texture (canvas — no font files, house law)
let _treeTex = null;
function treeTexture() {
if (_treeTex) return _treeTex;
const c = document.createElement('canvas'); c.width = c.height = 128;
const x = c.getContext('2d');
x.clearRect(0, 0, 128, 128);
x.fillStyle = '#6a5a44'; x.fillRect(58, 74, 12, 54); // trunk
const blobs = [[64, 44, 34], [44, 56, 24], [84, 56, 24], [64, 30, 22]];
for (const [bx, by, br] of blobs) {
x.fillStyle = ['#5c7a3e', '#6b8a48', '#4f6d38'][(bx + by) % 3];
x.beginPath(); x.arc(bx, by, br, 0, Math.PI * 2); x.fill();
}
_treeTex = new THREE.CanvasTexture(c); _treeTex.colorSpace = THREE.SRGBColorSpace;
return _treeTex;
}
// ── shared materials (module-level; not disposed per chunk) ──
let _mats = null;
function materials() {
if (_mats) return _mats;
_mats = {
dark: new THREE.MeshStandardMaterial({ color: 0x33322f, roughness: 0.7, metalness: 0.2 }),
lamp: new THREE.MeshStandardMaterial({ color: 0x2a2a26, roughness: 0.5, emissive: new THREE.Color(0xffd9a0), emissiveIntensity: 0 }),
wood: new THREE.MeshStandardMaterial({ color: 0x6b4a30, roughness: 0.8 }),
bin: new THREE.MeshStandardMaterial({ color: 0x2f4a3a, roughness: 0.8 }),
shelter: new THREE.MeshStandardMaterial({ color: 0x8a8f96, roughness: 0.7, metalness: 0.2 }),
tree: new THREE.MeshBasicMaterial({ map: treeTexture(), transparent: true, alphaTest: 0.5, side: THREE.DoubleSide }),
};
return _mats;
}
const _m = new THREE.Matrix4();
const _p = new THREE.Vector3();
const _q = new THREE.Quaternion();
const _s = new THREE.Vector3(1, 1, 1);
const _up = new THREE.Vector3(0, 1, 0);
/**
* buildChunkFurniture(chunkData, ctx, cx, cz) → { group, applyNight, dispose }
* ctx: { plan, citySeed, night }
* Places furniture along every street edge, keeping only items whose position lands in this chunk.
*/
export function buildChunkFurniture(chunkData, ctx, cx, cz) {
const group = new THREE.Group();
group.name = 'chunk-furniture';
const t = templates(), mat = materials();
const edges = ctx._edges || (ctx._edges = resolveEdges(ctx.plan));
const lists = { pole: [], lamp: [], bench: [], bin: [], busstop: [], tree: [] };
const here = (x, z) => { const c = chunkOf(x, z); return c.cx === cx && c.cz === cz; };
const pushYaw = (list, x, y, z, yaw, sx = 1, sy = 1, sz = 1) => {
_p.set(x, y, z); _q.setFromAxisAngle(_up, yaw); _s.set(sx, sy, sz);
list.push(_m.clone().compose(_p, _q, _s));
};
for (const e of edges) {
const dx = e.bx - e.ax, dz = e.bz - e.az;
const len = Math.hypot(dx, dz) || 1;
const ux = dx / len, uz = dz / len; // along
const px = -uz, pz = ux; // perp (unit)
const yaw = Math.atan2(ux, uz); // face along +Z→dir
const r = rng(ctx.citySeed, 'furn', hashEdge(e.id));
const halfRoad = e.width / 2;
// streetlights every ~18m, alternating sides, near the kerb
let side = 1;
for (let s = 8; s < len - 4; s += 18) {
const off = (halfRoad + 0.7) * side;
const x = e.ax + ux * s + px * off, z = e.az + uz * s + pz * off;
if (here(x, z)) {
const armYaw = side > 0 ? yaw + Math.PI : yaw; // arm reaches over the road
pushYaw(lists.pole, x, 0, z, armYaw);
pushYaw(lists.lamp, x, 0, z, armYaw);
}
side = -side;
}
// benches + bins every ~26m on the building side of the footpath, facing the road
for (let s = 14; s < len - 6; s += 26) {
const bside = (Math.floor(s / 26) % 2) ? 1 : -1;
const off = (halfRoad + FOOT - 0.8) * bside;
const bx = e.ax + ux * s + px * off, bz = e.az + uz * s + pz * off;
if (here(bx, bz)) pushYaw(lists.bench, bx, 0, bz, yaw + (bside > 0 ? Math.PI : 0));
const nx = bx + ux * 1.4, nz = bz + uz * 1.4;
if (here(nx, nz)) pushYaw(lists.bin, nx, 0, nz, 0);
}
// gum trees only along side/lane verges (main street has no verge room)
if (e.kind === 'side' || e.kind === 'lane') {
for (let s = 6; s < len - 4; s += 22) {
for (const vs of [1, -1]) {
const off = (halfRoad + FOOT + 1.2) * vs;
const x = e.ax + ux * s + px * off, z = e.az + uz * s + pz * off;
if (here(x, z)) pushYaw(lists.tree, x, 0, z, frange(r, 0, Math.PI), 1, 1 + r() * 0.3, 1);
}
}
}
// one bus stop near each main-street edge's near end
if (e.kind === 'main') {
const s = 10, off = (halfRoad + FOOT - 0.9);
const x = e.ax + ux * s + px * off, z = e.az + uz * s + pz * off;
if (here(x, z)) pushYaw(lists.busstop, x, 0, z, yaw + Math.PI);
}
}
const ims = [];
const emit = (geo, material, list, cast = true) => {
if (!list.length) return;
const im = new THREE.InstancedMesh(geo, material, list.length);
for (let i = 0; i < list.length; i++) im.setMatrixAt(i, list[i]);
im.instanceMatrix.needsUpdate = true;
im.castShadow = cast; im.receiveShadow = false; im.frustumCulled = true;
group.add(im); ims.push(im);
};
emit(t.pole, mat.dark, lists.pole);
emit(t.lamp, mat.lamp, lists.lamp, false);
emit(t.bench, mat.wood, lists.bench);
emit(t.bin, mat.bin, lists.bin);
emit(t.busstop, mat.shelter, lists.busstop);
emit(t.treeGeo, mat.tree, lists.tree, false);
function applyNight(night) { mat.lamp.emissiveIntensity = night ? 1.4 : 0; }
applyNight(!!ctx.night);
function dispose() {
for (const im of ims) { im.dispose && im.dispose(); group.remove(im); }
// shared templates/materials intentionally NOT disposed (reused by other chunks)
group.clear();
}
return { group, applyNight, dispose };
}
function hashEdge(id) { const s = String(id); let h = 0; for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0; return h >>> 0; } // String(): generatePlan uses numeric edge ids
// ── OPTIONAL 3GOD-depot upgrade (documented, off for v1) ──────────────────────────────────────
// import { loadGLB } from '../core/loaders.js';
// To swap a primitive for a real GLB prop: loadGLB('depot:bench.glb').then(g => { if(!g) return;
// ... build an InstancedMesh from g.scene's merged geometry and replace the primitive one ... });
// Guard against the chunk having been disposed before the promise resolves (check a live flag).