§0.1 — ground.js adopts Lane A's corridor law. The road quad narrows from the whole
corridor to the carriageway and the EXISTING merged footGeos class extends inward to
meet it, consuming `vergeBand(e)[0]` (not `roadWidth/2` — per A's mid-round correction,
so the geometry and A's corrected gate read the same number).
MEASURED, fresh boots, port-isolated no-store servers (:8178 = HEAD control, :8177 =
treatment), 2560x1440:
synthetic ground 5 draws / 224 tris -> 5 draws / 216 tris
walked spine, 102 steps @ 8 m: worst 276 draws BOTH; sum of draws
20,081 BOTH (identical at every step); 8 bookmarks identical draws
katoomba_real ground 4 draws / 9,932 tris -> 4 draws / 9,644 tris
walk 120 steps: worst 100 draws BOTH; sum 7,703 BOTH
+0 draws is proven, not asserted. Tris go DOWN (-8 / -288): the charter's "~2k tris" was
an estimate against a class that already existed. Honest number, stated loudly.
Surfaces verified by RAYCAST, not by reading the code (offset from centreline, topmost hit):
main@28 road 0-4.75 | kerb 5 | footpath 5.25-17.5 (band [5,14])
main@24 road 0-5 | footpath 5.25-15.25 (band [5,12], real)
side@12 road 0-3 | footpath 3.25-9.5 (band [3,6] — 72% of the corpus)
lane@8 road 0-4 | footpath 4.25-9 DEGENERATE band [4,4] => pre-ruling geometry
lane@4 road 0-1.75 | kerb 2 | footpath 2.25-5.25 (synthetic, also unchanged)
arcade footpath 0-6.5, NO road quad, NO kerbs (1,102 corpus edges)
RESIDENTIAL side@14 road 0-2.75 | kerb 3 | GRASS 3.25-6.75 | footpath 7-10.5
The lane case branches on `outer <= inner`, never on a band literal.
Posters: 14 of 14 stood inside painted bitumen before; 0 of 14 after. Not one poster moved
— the ground did.
ONE DEFECT FOUND AND FIXED IN THE ROUND. The first cut painted every intersection shut:
a 12.5 m footpath crosses the perpendicular CARRIAGEWAY where a 3.5 m one only ever
clipped a far corner. Screenshotted, rejected, and fixed by putting the carriageway on
top (ROAD_Y 0 -> 0.04 under the flag): roads run through junctions, footpaths stop at the
kerb they meet. Verified on the real graph's worst 4.3-degree junction.
?verge=0 ships permanently — the gate's falsifiability control. Proven EXACT, not merely
similar: ground vertex+uv hashes identical to the HEAD build (c4c72181 / c111d968 /
1a80ebcd / 12c1241b / fd0d3211), and the same detector goes RED on the default boot.
CLASSIC IS FENCED, NOT AMENDED (the R31 dig-flip pattern). ?classic=1 forces verge off, so
the covenanted town's render does not move on a render-side ruling. Proven twice: identical
ground geometry hashes AND identical ground-only rendered pixels at three poses
(79df268c / 91564001 / a09d1e52), while the default boot differs at all three. If Fable
wants classic to take the footpath too, that is an amendment ruling and a one-token change.
§0.4 — the chunk lifecycle seam. The producer has fired since R3 and Lane D's consumer has
existed since R3, but nothing could connect them: createChunkManager never handed `ctx`
back, so a consumer holding only PROCITY.chunks had nowhere to attach (measured on HEAD:
`!!chunks.ctx === false`). Now `chunks.onChunkBuilt(fn) -> unsubscribe()` for many
consumers plus `chunks.ctx` for the original single slot. Proven live: 136 build / 144
dispose events over a 40-step walk; payload {cx,cz,buildings,furniture,data} with
key === `${cx},${cz}`; a second consumer does not clobber the first; the ctx slot fires;
unsubscribe silences; a THROWING subscriber does not take the streamer down.
ZERO behaviour change when nobody subscribes: a 102-step walk recording
[draws, tris, chunks] per step hashes ceb1ec66 on BOTH builds.
Files: web/js/world/ground.js, web/js/world/chunks.js, docs/shots/laneB_r37_kerb/*,
docs/LANES/LANE_B_NOTES.md, B-progress.md. sim.js untouched (Lane D's).
177 lines
7.8 KiB
JavaScript
177 lines
7.8 KiB
JavaScript
// PROCITY Lane B — chunks.js
|
|
// The streamer. Around the player it keeps chunks within Chebyshev radius R built and disposes
|
|
// anything past R+1, so the world is never rebuilt wholesale. Chunk builds are queued nearest-first
|
|
// and drained under a 4ms/frame budget so streaming never hitches. Each chunk aggregates its
|
|
// buildings (buildings.js) + furniture (furniture.js); ground/sky/lighting are global.
|
|
|
|
import { chunkIndex, chunkCoord, chunkKey, parseKey } from './planutil.js';
|
|
import { buildChunkBuildings, prefetchStockIndex } from './buildings.js';
|
|
import { buildChunkFurniture } from './furniture.js';
|
|
|
|
const BUILD_BUDGET_MS = 4;
|
|
|
|
export function createChunkManager(plan, scene, ctx) {
|
|
const R = ctx.radius ?? 3;
|
|
const DISPOSE_R = R + 1;
|
|
const index = chunkIndex(plan);
|
|
prefetchStockIndex(plan); // R26 #5: keyed plans only — warm the stock manifest before any chunk builds
|
|
|
|
const live = new Map(); // key → { buildings, furniture, colliders, cx, cz }
|
|
const doorMeshes = []; // live door meshes (for HUD raycast)
|
|
let queue = []; // keys pending build, nearest-first
|
|
let queued = new Set();
|
|
let lastCx = null, lastCz = null;
|
|
let lastBuildMs = 0; // rolling measure for the HUD/notes
|
|
|
|
const _colliders = []; // scratch, refilled each getColliders()
|
|
|
|
// ── the per-chunk lifecycle seam (ROUND37 §0.4) ──────────────────────────────────────────────
|
|
// The producer below has fired `ctx.onChunkBuilt` / `ctx.onChunkDisposed` since R3 and Lane D's
|
|
// sim has carried the matching `onChunkBuilt(key)` / `onChunkDisposed(key)` methods just as long —
|
|
// but nothing could ever connect them: `ctx` is the shell's local object and the manager never
|
|
// handed it back, so a consumer holding only the manager (which is all `PROCITY.chunks` is) had
|
|
// nowhere to attach. Two ways in now, both inert until someone uses them:
|
|
// • `chunks.onChunkBuilt(fn)` → returns an unsubscribe. MANY consumers, none clobbering another
|
|
// (the single `ctx` slot is last-writer-wins, and the v8 slate has several per-chunk systems).
|
|
// • `chunks.ctx.onChunkBuilt = fn` — the original single slot, still first to fire, and now
|
|
// reachable exactly as LANE_D_NOTES §35 documents it.
|
|
// Zero cost when nobody listens: the detail object is only allocated inside the guard, so a
|
|
// subscriber-free boot does the same two truthiness tests it has done since R3 and nothing else.
|
|
const builtSubs = [], disposedSubs = [];
|
|
const subscribe = (list, fn) => {
|
|
if (typeof fn !== 'function') return () => {};
|
|
list.push(fn);
|
|
return () => { const i = list.indexOf(fn); if (i >= 0) list.splice(i, 1); };
|
|
};
|
|
// A throwing subscriber must never take the streamer down with it — a half-built chunk leaks its
|
|
// groups and the player walks into a void. Report and carry on.
|
|
const fire = (fn, key, detail, what) => {
|
|
try { fn(key, detail); } catch (err) { console.warn(`[procity] ${what} subscriber threw for ${key}`, err); }
|
|
};
|
|
const emit = (slot, list, key, detail, what) => {
|
|
if (slot) fire(slot, key, detail, what);
|
|
for (let i = 0; i < list.length; i++) fire(list[i], key, detail, what);
|
|
};
|
|
|
|
function buildChunk(key) {
|
|
if (live.has(key)) return;
|
|
const data = index.get(key);
|
|
if (!data) return;
|
|
const { cx, cz } = parseKey(key);
|
|
const t0 = performance.now();
|
|
const buildings = buildChunkBuildings(data, ctx);
|
|
const furniture = buildChunkFurniture(data, ctx, cx, cz);
|
|
lastBuildMs = performance.now() - t0;
|
|
scene.add(buildings.group, furniture.group);
|
|
if (buildings.doorMesh) doorMeshes.push(buildings.doorMesh);
|
|
// player colliders = building/yard rects + any furniture keep-outs (bus shelters)
|
|
const colliders = furniture.colliders && furniture.colliders.length
|
|
? buildings.colliders.concat(furniture.colliders) : buildings.colliders;
|
|
live.set(key, { buildings, furniture, colliders, cx, cz });
|
|
// optional lifecycle hook (Lane D/F per-chunk spawning, ambient, LOD — LANE_F_NOTES §8).
|
|
if (ctx.onChunkBuilt || builtSubs.length) {
|
|
emit(ctx.onChunkBuilt, builtSubs, key,
|
|
{ cx, cz, buildings: buildings.group, furniture: furniture.group, data }, 'onChunkBuilt');
|
|
}
|
|
}
|
|
|
|
function disposeChunk(key) {
|
|
const b = live.get(key);
|
|
if (!b) return;
|
|
// fires BEFORE the groups are freed, so a consumer can still read what it is losing
|
|
if (ctx.onChunkDisposed || disposedSubs.length) {
|
|
emit(ctx.onChunkDisposed, disposedSubs, key, { cx: b.cx, cz: b.cz }, 'onChunkDisposed');
|
|
}
|
|
scene.remove(b.buildings.group, b.furniture.group);
|
|
if (b.buildings.doorMesh) {
|
|
const i = doorMeshes.indexOf(b.buildings.doorMesh);
|
|
if (i >= 0) doorMeshes.splice(i, 1);
|
|
}
|
|
b.buildings.dispose();
|
|
b.furniture.dispose();
|
|
live.delete(key);
|
|
}
|
|
|
|
function recompute(pcx, pcz) {
|
|
// dispose anything beyond R+1
|
|
for (const key of [...live.keys()]) {
|
|
const { cx, cz } = parseKey(key);
|
|
if (Math.max(Math.abs(cx - pcx), Math.abs(cz - pcz)) > DISPOSE_R) disposeChunk(key);
|
|
}
|
|
// enqueue desired-but-missing, nearest first
|
|
const want = [];
|
|
for (let dz = -R; dz <= R; dz++) {
|
|
for (let dx = -R; dx <= R; dx++) {
|
|
const key = chunkKey(pcx + dx, pcz + dz);
|
|
if (index.has(key) && !live.has(key)) want.push({ key, d: dx * dx + dz * dz });
|
|
}
|
|
}
|
|
want.sort((a, b) => a.d - b.d);
|
|
queue = want.map((w) => w.key);
|
|
queued = new Set(queue);
|
|
}
|
|
|
|
function drainQueue() {
|
|
if (!queue.length) return;
|
|
const t0 = performance.now();
|
|
while (queue.length && performance.now() - t0 < BUILD_BUDGET_MS) {
|
|
const key = queue.shift();
|
|
queued.delete(key);
|
|
buildChunk(key);
|
|
}
|
|
}
|
|
|
|
function update(playerPos) {
|
|
const pcx = chunkCoord(playerPos.x), pcz = chunkCoord(playerPos.z);
|
|
if (pcx !== lastCx || pcz !== lastCz) { lastCx = pcx; lastCz = pcz; recompute(pcx, pcz); }
|
|
drainQueue();
|
|
}
|
|
|
|
// Prebuild everything within radius synchronously (used once at spawn so the player never
|
|
// starts inside an unbuilt void).
|
|
function warmup(playerPos) {
|
|
const pcx = chunkCoord(playerPos.x), pcz = chunkCoord(playerPos.z);
|
|
lastCx = pcx; lastCz = pcz;
|
|
recompute(pcx, pcz);
|
|
while (queue.length) { const key = queue.shift(); queued.delete(key); buildChunk(key); }
|
|
}
|
|
|
|
// Colliders from the player's chunk + its 8 neighbours (refills a scratch array; no per-frame alloc).
|
|
function getColliders(x, z) {
|
|
const pcx = chunkCoord(x), pcz = chunkCoord(z);
|
|
_colliders.length = 0;
|
|
for (let dz = -1; dz <= 1; dz++) {
|
|
for (let dx = -1; dx <= 1; dx++) {
|
|
const b = live.get(chunkKey(pcx + dx, pcz + dz));
|
|
if (b) for (let i = 0; i < b.colliders.length; i++) _colliders.push(b.colliders[i]);
|
|
}
|
|
}
|
|
return _colliders;
|
|
}
|
|
|
|
function setNight(night) {
|
|
ctx.night = night;
|
|
for (const b of live.values()) { b.buildings.applyNight(night); b.furniture.applyNight(night); }
|
|
}
|
|
|
|
function dispose() {
|
|
for (const key of [...live.keys()]) disposeChunk(key);
|
|
queue = []; queued.clear();
|
|
}
|
|
|
|
return {
|
|
update, warmup, getColliders, setNight, dispose,
|
|
// §0.4 — the lifecycle seam, subscribable. `onChunkBuilt(fn)` → unsubscribe(); `ctx` is handed
|
|
// back so the original single-slot form works too. Keys are planutil chunk keys ("cx,cz"),
|
|
// which sim.js derives identically (sim.js:52).
|
|
onChunkBuilt: (fn) => subscribe(builtSubs, fn),
|
|
onChunkDisposed: (fn) => subscribe(disposedSubs, fn),
|
|
get subscriberCount() { return builtSubs.length + disposedSubs.length; },
|
|
ctx,
|
|
getDoorMeshes: () => doorMeshes,
|
|
get count() { return live.size; },
|
|
get pending() { return queue.length; },
|
|
get lastBuildMs() { return lastBuildMs; },
|
|
};
|
|
}
|