PROCITY/web/js/world/ground.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

105 lines
4.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

// PROCITY Lane B — ground.js
// Roads, footpaths, kerbs, the market plaza, and a base ground plane, built ONCE as merged strips
// per material (not per-chunk streamed). Rationale: the street graph is a handful of edges; merged
// flat quads are a few draw calls total and — unlike per-chunk road clipping — can never seam.
// UV tiling is baked into the geometry so one shared ground material serves strips of any length.
// (If Lane A ships a many-hundred-edge city, Lane F revisits streaming the ground.)
import * as THREE from 'three';
import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js';
import { resolveEdges } from './planutil.js';
const FOOT = 3.5; // footpath width
const TILE = 5; // metres per texture tile
const OVERLAP = 1.5; // road segments overrun their nodes so intersections fill
// Horizontal quad in XZ centred at (cx,cz), aligned to unit dir/perp, size len×wid, at height y.
// UVs baked to tile every TILE metres.
function hQuad(cx, cz, dir, perp, len, wid, y) {
const hl = len / 2, hw = wid / 2;
const ax = dir.x * hl, az = dir.z * hl; // along
const bx = perp.x * hw, bz = perp.z * hw; // across
const P = (sa, sb) => [cx + sa * ax + sb * bx, y, cz + sa * az + sb * bz];
const c0 = P(-1, -1), c1 = P(1, -1), c2 = P(1, 1), c3 = P(-1, 1);
const repU = Math.max(1, len / TILE), repV = Math.max(1, wid / TILE);
const g = new THREE.BufferGeometry();
// winding c0,c2,c1 / c0,c3,c2 so the geometric normal points +Y (up) — FrontSide stays visible.
g.setAttribute('position', new THREE.BufferAttribute(new Float32Array([
...c0, ...c2, ...c1, ...c0, ...c3, ...c2]), 3));
g.setAttribute('uv', new THREE.BufferAttribute(new Float32Array([
0, 0, repU, repV, repU, 0, 0, 0, 0, repV, repU, repV]), 2));
g.setAttribute('normal', new THREE.BufferAttribute(new Float32Array([
0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0]), 3));
return g;
}
// Axis-aligned rectangle helper (plaza, base plane).
function rectGeo(cx, cz, w, d, y, tile = TILE) {
return hQuad(cx, cz, new THREE.Vector3(1, 0, 0), new THREE.Vector3(0, 0, 1), w, d, y);
}
export function buildGround(plan, skins) {
const group = new THREE.Group();
group.name = 'ground';
const edges = resolveEdges(plan);
const roadGeos = [], footGeos = [], kerbGeos = [];
const dir = new THREE.Vector3(), perp = new THREE.Vector3();
for (const e of edges) {
const dx = e.bx - e.ax, dz = e.bz - e.az;
const len = Math.hypot(dx, dz) || 1;
dir.set(dx / len, 0, dz / len);
perp.set(-dir.z, 0, dir.x);
const cx = (e.ax + e.bx) / 2, cz = (e.az + e.bz) / 2;
const roadLen = len + OVERLAP * 2;
roadGeos.push(hQuad(cx, cz, dir, perp, roadLen, e.width, 0.0));
// footpaths flank the road
const off = e.width / 2 + FOOT / 2;
footGeos.push(hQuad(cx + perp.x * off, cz + perp.z * off, dir, perp, roadLen, FOOT, 0.02));
footGeos.push(hQuad(cx - perp.x * off, cz - perp.z * off, dir, perp, roadLen, FOOT, 0.02));
// low kerbs between road and footpath
const koff = e.width / 2 + 0.12;
kerbGeos.push(hQuad(cx + perp.x * koff, cz + perp.z * koff, dir, perp, roadLen, 0.24, 0.06));
kerbGeos.push(hQuad(cx - perp.x * koff, cz - perp.z * koff, dir, perp, roadLen, 0.24, 0.06));
}
// market plaza (brickpave) over the central square
const sq = (plan.districts || []).find((d) => d.kind === 'market');
const plazaGeos = [];
if (sq) plazaGeos.push(rectGeo(0, 0, 46, 46, 0.015));
// base ground plane under the whole town (grass/dirt), sized to the plan extent
let minX = -60, maxX = 60, minZ = -60, maxZ = 60;
for (const n of plan.streets.nodes) { minX = Math.min(minX, n.x); maxX = Math.max(maxX, n.x); minZ = Math.min(minZ, n.z); maxZ = Math.max(maxZ, n.z); }
for (const l of plan.lots) { minX = Math.min(minX, l.x); maxX = Math.max(maxX, l.x); minZ = Math.min(minZ, l.z); maxZ = Math.max(maxZ, l.z); }
const bw = (maxX - minX) + 120, bd = (maxZ - minZ) + 120;
const bcx = (minX + maxX) / 2, bcz = (minZ + maxZ) / 2;
const baseGeo = rectGeo(bcx, bcz, bw, bd, -0.03);
const meshes = [];
const add = (geos, material, receive = true) => {
if (!geos.length) return;
const merged = mergeGeometries(geos, false);
geos.forEach((g) => g.dispose());
const m = new THREE.Mesh(merged, material);
m.receiveShadow = receive; m.castShadow = false;
group.add(m); meshes.push(m);
};
const roadSkin = 'djsim-road', footSkin = 'djsim-footpath', grassSkin = 'grass', plazaSkin = 'brickpave';
add([baseGeo], skins.groundMat(grassSkin));
add(roadGeos, skins.groundMat(roadSkin));
add(footGeos, skins.groundMat(footSkin));
add(plazaGeos, skins.groundMat(plazaSkin));
const kerbMat = new THREE.MeshStandardMaterial({ color: 0x9a948c, roughness: 0.9 });
add(kerbGeos, kerbMat);
function dispose() {
for (const m of meshes) m.geometry.dispose();
kerbMat.dispose();
group.clear();
}
return { group, dispose };
}