// 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; }, }; }