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>
117 lines
5.1 KiB
JavaScript
117 lines
5.1 KiB
JavaScript
// PROCITY Lane B — planutil.js
|
||
// Pure plan/geometry helpers over a CityPlan (no THREE). Chunk math (CITY_SPEC: 64m cells),
|
||
// node/lot/shop indices, edge resolution, and the oriented-rectangle collider for a lot.
|
||
// If Lane A later ships an official chunkIndex(plan) we swap the import; the shapes match.
|
||
|
||
export const CHUNK = 64; // metres (CITY_SPEC law)
|
||
|
||
export const chunkCoord = (v) => Math.floor(v / CHUNK);
|
||
export const chunkKey = (cx, cz) => `${cx},${cz}`;
|
||
export const chunkOf = (x, z) => ({ cx: chunkCoord(x), cz: chunkCoord(z) });
|
||
export const parseKey = (k) => { const [cx, cz] = k.split(',').map(Number); return { cx, cz }; };
|
||
|
||
// Fast lookup maps built once per plan.
|
||
export function indexPlan(plan) {
|
||
const nodes = new Map(plan.streets.nodes.map((n) => [n.id, n]));
|
||
const lots = new Map(plan.lots.map((l) => [l.id, l]));
|
||
const shopByLot = new Map(plan.shops.map((s) => [s.lot, s]));
|
||
const shopsById = new Map(plan.shops.map((s) => [s.id, s]));
|
||
return { nodes, lots, shopByLot, shopsById };
|
||
}
|
||
|
||
// Resolve street edges to world segments with widths.
|
||
export function resolveEdges(plan) {
|
||
const nodes = new Map(plan.streets.nodes.map((n) => [n.id, n]));
|
||
return plan.streets.edges.map((e) => {
|
||
const a = nodes.get(e.a), b = nodes.get(e.b);
|
||
return { id: e.id, kind: e.kind, width: e.width, ax: a.x, az: a.z, bx: b.x, bz: b.z };
|
||
});
|
||
}
|
||
|
||
/**
|
||
* chunkIndex(plan) → Map<chunkKey, { lots:[lot], shops:[shop], edges:[edgeId] }>
|
||
* A lot belongs to the chunk containing its centre. An edge is registered to every chunk its
|
||
* segment passes through (rasterised at CHUNK/4 steps) so the streamer can draw partial roads
|
||
* if it ever needs to. Shops ride along with their lot.
|
||
*/
|
||
export function chunkIndex(plan) {
|
||
const { shopByLot } = indexPlan(plan);
|
||
const map = new Map();
|
||
const cell = (cx, cz) => {
|
||
const k = chunkKey(cx, cz);
|
||
let c = map.get(k);
|
||
if (!c) { c = { lots: [], shops: [], edges: [] }; map.set(k, c); }
|
||
return c;
|
||
};
|
||
for (const lot of plan.lots) {
|
||
const { cx, cz } = chunkOf(lot.x, lot.z);
|
||
const c = cell(cx, cz);
|
||
c.lots.push(lot);
|
||
const s = shopByLot.get(lot.id);
|
||
if (s) c.shops.push(s);
|
||
}
|
||
const nodes = new Map(plan.streets.nodes.map((n) => [n.id, n]));
|
||
for (const e of plan.streets.edges) {
|
||
const a = nodes.get(e.a), b = nodes.get(e.b);
|
||
const dx = b.x - a.x, dz = b.z - a.z;
|
||
const len = Math.hypot(dx, dz) || 1;
|
||
const ux = dx / len, uz = dz / len, px = -uz, pz = ux; // along + perpendicular units
|
||
// Register every chunk the road BAND touches — not just the centreline — so furniture placed
|
||
// at a perpendicular offset (footpath/verge) can never fall in an unbuilt chunk and vanish.
|
||
const band = e.width / 2 + 10;
|
||
const along = Math.max(1, Math.ceil(len / (CHUNK / 4)));
|
||
const across = Math.max(1, Math.ceil((band * 2) / (CHUNK / 4)));
|
||
const seen = new Set();
|
||
for (let i = 0; i <= along; i++) {
|
||
const sx = a.x + dx * (i / along), sz = a.z + dz * (i / along);
|
||
for (let j = 0; j <= across; j++) {
|
||
const o = -band + (2 * band) * (j / across);
|
||
const { cx, cz } = chunkOf(sx + px * o, sz + pz * o);
|
||
const k = chunkKey(cx, cz);
|
||
if (!seen.has(k)) { seen.add(k); cell(cx, cz).edges.push(e.id); }
|
||
}
|
||
}
|
||
}
|
||
return map;
|
||
}
|
||
|
||
// Oriented-rectangle collider for a lot (local X = frontage w, local Z = depth d, rotated ry).
|
||
// Yards/houses get a fatter keep-out so you can't walk through private front gardens.
|
||
export function lotCollider(lot) {
|
||
const yard = lot.use === 'house' || lot.use === 'yard';
|
||
const pad = yard ? 2.5 : 0; // private-yard buffer in front of houses
|
||
return {
|
||
x: lot.x, z: lot.z, ry: lot.ry || 0,
|
||
hw: lot.w / 2, hd: lot.d / 2 + pad / 2,
|
||
use: lot.use,
|
||
};
|
||
}
|
||
|
||
// Test/point-push a circle of radius r at (px,pz) out of an oriented rect collider.
|
||
// Returns [nx, nz] corrected position (or the same point if no overlap). Reused every frame —
|
||
// pass scratch out-array to avoid allocation.
|
||
export function pushOutRect(px, pz, r, col, out) {
|
||
// Convention must match buildings.toWorld (local→world = RotY(ry)): dx = lx·cos + lz·sin,
|
||
// dz = −lx·sin + lz·cos. So world→local is the inverse (RotY(−ry)) below, and the push-out
|
||
// back-transform is RotY(ry). (Earlier this used the wrong sign and only happened to work for
|
||
// axis-aligned ry ∈ {0,±π/2,π}; arbitrary ry from generatePlan needs the correct inverse.)
|
||
const cos = Math.cos(col.ry), sin = Math.sin(col.ry);
|
||
const dx = px - col.x, dz = pz - col.z;
|
||
const lx = dx * cos - dz * sin; // world→local (RotY(−ry))
|
||
const lz = dx * sin + dz * cos;
|
||
const ex = col.hw + r, ez = col.hd + r;
|
||
if (lx > -ex && lx < ex && lz > -ez && lz < ez) {
|
||
// overlapping — push along axis of least penetration
|
||
const penX = ex - Math.abs(lx);
|
||
const penZ = ez - Math.abs(lz);
|
||
let nlx = lx, nlz = lz;
|
||
if (penX < penZ) nlx = lx < 0 ? -ex : ex;
|
||
else nlz = lz < 0 ? -ez : ez;
|
||
out[0] = col.x + (nlx * cos + nlz * sin); // local→world (RotY(ry)), inverse of the above
|
||
out[1] = col.z + (-nlx * sin + nlz * cos);
|
||
return true;
|
||
}
|
||
out[0] = px; out[1] = pz;
|
||
return false;
|
||
}
|