// PROCITY Lane D — the citizen simulation (Vuntra's layered LOD idea, at hamlet scale). // // Deterministic per-citizen identity (same seed → same person walks the same beat), walkers pathing // along footpath lanes offset from the street graph, three distance tiers: // • NEAR (<25m): full rig + AnimationMixer (or a placeholder before the fleet loads), walking the // footpath, turning at nodes on a seeded choice, loitering at points of interest. // • MID (25–70m): no mixers ticking — an instanced billboard impostor (impostor.js). // • FAR (>70m): culled entirely. // Promotion/demotion has hysteresis + a hard near-cap so nobody pops and the frame budget holds. // // Built on the house fleet rule: shared base meshes (a small rig pool, reused across citizens of the // same ped type) + one canonical clip bank. Never a per-character rig. import * as THREE from 'three'; import { rng, seedFor } from '../core/prng.js'; import { pickRig, makeActor } from './rigs.js'; import { makePlaceholder } from './placeholder.js'; import { bakeImpostorAtlas, ImpostorLayer } from './impostor.js'; // ---- tuning (CITY_SPEC budgets) ---- const NEAR_ENTER = 24, NEAR_EXIT = 27; // m, hysteresis band for rig↔impostor const MID_ENTER = 68, MID_EXIT = 72; // m, hysteresis band for impostor↔culled const NEAR_MAX = 24; // ≤24 rigged actives (CITY_SPEC) const RIG_CAP = 30; // pooled rig instances (headroom above NEAR_MAX) const NEW_RIG_PER_FRAME = 3; // cap actor creation to avoid clone hitches const MIXER_ALWAYS = 8; // nearest 8 mixers update every frame const MIXER_EXTRA = 4; // ≤4 more mixers per frame (staggered), CITY_SPEC const NEAR_BIAS = 2.0; // currently-near citizens keep priority for a rig slot const PLACEHOLDER_VARIANTS = 8; // impostor atlas subjects in asset-free mode const FOOTPATH_MARGIN = 0.9; // m outside the carriageway edge const IMPOSTOR_MAX = 220; // instanced billboards ceiling const NIGHT_LIVELY_FLOOR = 0.5; // stream: "lively" chunks (open-late block) keep ≥this density at night const PATRON_RANGE = 18; // m — a ped ducks into a shop it's passing within this const PATRON_STRIDE = 10; // m walked between patronage checks (framerate-independent) const GIG_RANGE = 34; // m — a gig pulls peds from further than a normal shopfront const GIG_SURGE = 0.55; // patron chance at the venue while the gig is on (vs ~0.16 day) // time-of-day density curve: t01 in [0,1) over a day → crowd multiplier (CITY_SPEC: lunch rush, // near-empty at night). Sampled at 8 control points, linearly interpolated. const DAY_CURVE = [0.06, 0.10, 0.35, 0.85, 1.0, 0.75, 0.45, 0.18]; // 00,03,06,09,12,15,18,21h function densityAt(t01) { const x = ((t01 % 1) + 1) % 1 * DAY_CURVE.length; const i = Math.floor(x), f = x - i; return DAY_CURVE[i % DAY_CURVE.length] * (1 - f) + DAY_CURVE[(i + 1) % DAY_CURVE.length] * f; } const tierColor = { near: 0x66dd88, mid: 0xffcc44, far: 0x884466 }; // ---- chunk math (v2 chunk-streamed roster). CHUNK=64m is CITY_SPEC law and matches Lane B's // planutil.js exactly, so B's onChunkBuilt/onChunkDisposed keys line up with ours. Kept local so the // sim stays dependency-free (the standalone test page has no Lane B modules). ---- const CHUNK = 64; const chunkCoord = (v) => Math.floor(v / CHUNK); const chunkKey = (cx, cz) => `${cx},${cz}`; // ---- pure deterministic identity (no THREE, no GPU) — same (seed, id) → same person, forever ---- // Kept free-standing so the determinism check can recompute it independently of the live sim. export function identityOf(citySeed, edgeCount, id) { const r = rng(citySeed, 'citizen', id); const pedRoll = r(); const height = 1.55 + r() * 0.40; // 1.55–1.95 m (CITY_SPEC height range) const speed = 1.05 + r() * 0.75; // m/s const edge = (r() * edgeCount) | 0; const forward = r() < 0.5 ? 1 : -1; const sFrac = r(); // 0..1 along the edge const loiterTend = r(); const phase = r(); const pvar = (pedRoll * PLACEHOLDER_VARIANTS) | 0; return { pedRoll, height, speed, edge, forward, sFrac, loiterTend, phase, pvar }; } // stable signature string for one citizen (pedIndex assigned later, once the fleet is known) export function signatureOf(id, idn, pedIndex) { return `${id}:${pedIndex}:${idn.pvar}:${idn.height.toFixed(3)}:${idn.speed.toFixed(3)}:${idn.edge}:${idn.forward}`; } // ---- a small pool of rig actors, reused across citizens of the same ped type ---- class RigPool { constructor(fleet, clips) { this.fleet = fleet; this.clips = clips; this.free = new Map(); this.total = 0; } acquire(pedIndex) { const list = this.free.get(pedIndex); if (list && list.length) return list.pop(); if (this.total >= RIG_CAP) { // evict one free actor of another type for (const [k, arr] of this.free) { if (arr.length) { arr.pop().dispose(); this.total--; break; } } if (this.total >= RIG_CAP) return null; // everything in use — caller falls back to impostor } const rig = this.fleet.all[pedIndex]; if (!rig) return null; const actor = makeActor(rig, this.clips); if (actor) { actor.pedIndex = pedIndex; this.total++; } return actor; } release(actor) { if (!actor) return; let arr = this.free.get(actor.pedIndex); if (!arr) this.free.set(actor.pedIndex, arr = []); arr.push(actor); } dispose() { for (const arr of this.free.values()) arr.forEach(a => a.dispose()); this.free.clear(); this.total = 0; } } export class CitizenSim { constructor({ renderer, scene, camera, citySeed = 20261990, graph, fleet, group = null, chunkStream = null }) { this.renderer = renderer; this.scene = scene; this.camera = camera; this.citySeed = citySeed >>> 0; this.fleet = fleet; this.group = group || new THREE.Group(); this.group.name = 'citizens'; scene.add(this.group); this._setGraph(graph); this.roster = []; // v1: all generated citizens (identity + live state), indexed by id this.target = 0; // slider population // v2 chunk-streamed roster (behind ?roster=stream — see enableStream). Default OFF → v1 path. this.streamMode = false; this.chunkRosters = new Map(); // chunkKey → Citizen[] (owned by that chunk) this.chunkEdges = null; // chunkKey → [edge index] (built lazily on enable) this._hookDriven = false; // Lane B's onChunkBuilt/Disposed drive us → stop polling this._livelyChunks = new Set(); // chunkKeys that resist night thinning (e.g. the open-late block) this._encountered = new Set(); // cumulative identity signatures seen (determinism proof) // R8 shop patronage (default-on for the streamed roster; ?patronage=0 off). No-op until setShops. this.shopsByChunk = null; // chunkKey → [{ x, z, hours:[open,close], shopId }] door points this.patronageOn = true; this._gigVenue = null; // R12: venueShopId while a gig is on (F sets via setGig) → surge this.weather = { state: 'clear', intensity: 0 }; // Lane B's PROCITY.weather contract (shell feeds it) this._occupancy = new Map(); // R9 shopId → [{ seed, enteredAt, pedIndex }] — the interior-presence truth this._dropKeys = []; // scratch: chunks to drop this frame this._activeList = []; // this frame's active citizens (both modes) — test page reads it this.timeOfDay = 0.5; // noon this.debugTiers = false; this.mode = 'placeholder'; // 'placeholder' until the fleet is ready, then 'rig' this.rigPool = null; this.paused = false; // app sets this on a visibilitychange → hidden (pauses mixers) this._exposure = null; // shell's live renderer.toneMappingExposure (day/night) → impostor match this._mixerCursor = 0; // round-robin cursor for the staggered mixer budget this._nearList = []; // reused scratch this._midList = []; this._frustum = new THREE.Frustum(); this._pv = new THREE.Matrix4(); this.stats = { active: 0, rigged: 0, mid: 0, far: 0, mixerMs: 0, poolTotal: 0, mode: this.mode }; // bake the placeholder impostor atlas up front so the mid tier works from frame one this._buildPlaceholderImpostors(); // when the fleet lands, upgrade: bake rig atlas, swap actors, assign real ped types if (fleet && fleet.whenReady) fleet.whenReady.then(() => { if (fleet.ready) this._upgradeToRigs(); }); if (chunkStream) this.enableStream(chunkStream === true ? {} : chunkStream); } // ================= v2: chunk-streamed roster (behind ?roster=stream) ================= // Constant per-unit-street density that follows the camera, so streets stay lively arbitrarily far // from spawn (v1's fixed roster smears N over the whole town and thins out). Identity is keyed per // chunk (same seed + chunk → same residents, independent of town size / visit order). Everything // downstream — LOD tiers, the 24 near-cap, mixer stagger, rig pool, impostor layer — is already // per-citizen and chunk-agnostic and runs GLOBAL across live chunks, unchanged. // perChunk default 16 = full density. Affordable since Lane E's R7 merge made each ped ONE draw // (was 8 sub-meshes / ~7 draws): the worst continuous-walk street view now sits well under the 300 // budget even with a dense street of near-rigs. This is the shipping default for the R7 roster flip. enableStream({ radius = 2, perChunk = 16 } = {}) { if (this.streamMode) return; this.roster.forEach(c => this._releaseActor(c)); this.roster.length = 0; // tear down the v1 roster if (!this.chunkEdges) this._buildChunkIndex(); this.chunkRosters.clear(); this._dropKeys.length = 0; this._encountered.clear(); this._occupancy.clear(); this.streamRadius = radius; this.streamPerChunk = perChunk; this.streamMode = true; this._hookDriven = false; } disableStream() { if (!this.streamMode) return; for (const arr of this.chunkRosters.values()) arr.forEach(c => this._releaseActor(c)); this.chunkRosters.clear(); this._occupancy.clear(); this.streamMode = false; } // rasterise each edge's centreline to the 64m chunks it passes through → which edges seed each chunk _buildChunkIndex() { this.chunkEdges = new Map(); this.edges.forEach((e, ei) => { const steps = Math.max(1, Math.ceil(e.len / (CHUNK / 2))); const seen = new Set(); for (let i = 0; i <= steps; i++) { const t = i / steps, x = e.A.x + (e.B.x - e.A.x) * t, z = e.A.z + (e.B.z - e.A.z) * t; const k = chunkKey(chunkCoord(x), chunkCoord(z)); if (!seen.has(k)) { seen.add(k); let arr = this.chunkEdges.get(k); if (!arr) this.chunkEdges.set(k, arr = []); arr.push(ei); } } }); } // Lane B seam (F wires these onto ctx.onChunkBuilt/onChunkDisposed). First call flips us hook-driven. onChunkBuilt(key) { if (this.streamMode) { this._hookDriven = true; this.feedChunk(key); } } onChunkDisposed(key) { if (this.streamMode) { this._hookDriven = true; this.dropChunk(key); } } // poll-driven window (works with zero Lane B changes — test page + shell-until-F-wires-hooks): // the camera chunk + neighbours within radius R (R≥1 so a walker never enters an unloaded chunk). _pollChunks() { const cx = chunkCoord(this.camera.position.x), cz = chunkCoord(this.camera.position.z); const R = this.streamRadius; const want = new Set(); for (let dx = -R; dx <= R; dx++) for (let dz = -R; dz <= R; dz++) { const k = chunkKey(cx + dx, cz + dz); if (this.chunkEdges.has(k)) want.add(k); } for (const k of want) if (!this.chunkRosters.has(k)) this.feedChunk(k); this._dropKeys.length = 0; for (const k of this.chunkRosters.keys()) if (!want.has(k)) this._dropKeys.push(k); for (const k of this._dropKeys) this.dropChunk(k); } feedChunk(key) { if (this.chunkRosters.has(key)) return; const edgeList = this.chunkEdges.get(key); if (!edgeList || !edgeList.length) { this.chunkRosters.set(key, []); return; } const n = this._perChunkMax(key); const arr = new Array(n); for (let i = 0; i < n; i++) { const c = this._makeChunkCitizen(key, i, edgeList); arr[i] = c; this._encountered.add(this._sig(c)); } this.chunkRosters.set(key, arr); } dropChunk(key) { const arr = this.chunkRosters.get(key); if (!arr) return; for (const c of arr) { if (c._occShop != null) { this._removeOccupant(c._occShop, c.id); c._occShop = null; } this._releaseActor(c); } this.chunkRosters.delete(key); } // per-chunk population: base × district weight (more edges = busier junction) × seeded jitter. _perChunkMax(key) { const edges = this.chunkEdges.get(key) || []; const r = rng(this.citySeed, 'chunkpop', key); const busy = 0.7 + Math.min(1.3, edges.length * 0.12); return Math.max(4, Math.round(this.streamPerChunk * busy * (0.75 + r() * 0.5))); } // chunk-local identity: home edge is picked from THIS chunk's edges, so town size / visit order // never perturb who lives here. id is the string `${chunkKey}#${i}` (prng streams accept strings). _makeChunkCitizen(key, i, edgeList) { const id = `${key}#${i}`; const idn = identityOf(this.citySeed, edgeList.length, id); const edge = edgeList[idn.edge] ?? edgeList[0]; const c = { id, pedRoll: idn.pedRoll, height: idn.height, speed: idn.speed, loiterTend: idn.loiterTend, phase: idn.phase, pvar: idn.pvar, pedIndex: -1, subject: idn.pvar, edge0: edge, forward0: idn.forward, localEdge0: idn.edge, ownerChunk: key, edge, forward: idn.forward, s: idn.sFrac * this.edges[edge].len, loiter: 0, x: 0, z: 0, facing: 0, tier: 'far', actor: null, actorKind: null, _acc: 0, turn: rng(this.citySeed, 'turn', id), loit: rng(this.citySeed, 'loiter', id), patron: null, patronTarget: null, patronTimer: 0, patronRng: rng(this.citySeed, 'patron', id), }; if (this.mode === 'rig' && this.fleet.ready) { const pk = pickRig(this.fleet, c.pedRoll); if (pk) { c.pedIndex = pk.index; c.subject = pk.index; } } this._placeOnLane(c); return c; } _sig(c) { return signatureOf(c.id, { pvar: c.pvar, height: c.height, speed: c.speed, edge: c.localEdge0 ?? c.edge0, forward: c.forward0 }, c.pedIndex); } activeCitizens() { return this._activeList; } streamEncountered() { return [...this._encountered].sort(); } // hours-aware: mark chunk keys that stay lively at night (the open-late block). The shell computes // these from the plan (openLate shop's lot → chunkKey, + neighbours) and passes them — the sim is // graph-only so it can't derive them itself. Empty = every chunk thins uniformly by the day curve. setNightLivelyChunks(keys) { this._livelyChunks = new Set(keys); } chunkKeyAt(x, z) { return chunkKey(chunkCoord(x), chunkCoord(z)); } // ---- graph → footpath lanes ---- _setGraph(graph) { this.nodes = graph.nodes; this.edges = graph.edges.map(e => { const A = graph.nodes.find(n => n.id === e.a), B = graph.nodes.find(n => n.id === e.b); const dx = B.x - A.x, dz = B.z - A.z, len = Math.hypot(dx, dz) || 1e-3; const ux = dx / len, uz = dz / len; const off = (e.width || 4) * 0.5 + FOOTPATH_MARGIN; return { ...e, A, B, ux, uz, len, off }; }); // adjacency: node id → incident edge indices this.adj = new Map(); this.nodes.forEach(n => this.adj.set(n.id, [])); this.edges.forEach((e, i) => { this.adj.get(e.a).push(i); this.adj.get(e.b).push(i); }); } // ---- deterministic identity ---- _makeCitizen(id) { const idn = identityOf(this.citySeed, this.edges.length, id); const c = { id, pedRoll: idn.pedRoll, height: idn.height, speed: idn.speed, loiterTend: idn.loiterTend, phase: idn.phase, pvar: idn.pvar, pedIndex: -1, subject: idn.pvar, edge0: idn.edge, forward0: idn.forward, // immutable spawn beat (for the determinism signature) // live state (mutates as they walk + turn at nodes) edge: idn.edge, forward: idn.forward, s: idn.sFrac * this.edges[idn.edge].len, loiter: 0, x: 0, z: 0, facing: 0, tier: 'far', actor: null, actorKind: null, _acc: 0, turn: rng(this.citySeed, 'turn', id), loit: rng(this.citySeed, 'loiter', id), }; // assign a real ped type if the fleet is already up (roster can grow after upgrade) if (this.mode === 'rig' && this.fleet.ready) { const pk = pickRig(this.fleet, c.pedRoll); if (pk) { c.pedIndex = pk.index; c.subject = pk.index; } } this._placeOnLane(c); return c; } _ensureRoster(n) { while (this.roster.length < n) this.roster.push(this._makeCitizen(this.roster.length)); } // ---- public controls ---- setPopulation(n) { this.target = Math.max(0, n | 0); this._ensureRoster(this.target); } setTimeOfDay(t01) { this.timeOfDay = t01; } // Match mid-tier billboards to the shell's day/night exposure. The impostor atlas is baked at a // fixed exposure and self-tone-maps (toneMapped:false), so as the shell animates // renderer.toneMappingExposure across day segments the billboards would drift brighter/darker than // the near rigs. Call this each frame with the live exposure. Stored so an atlas re-bake (the // placeholder→rig upgrade) inherits it. (Exposure only — the atlas can't track per-segment sun/hemi // colour; that would need a re-bake. Close enough for the LOD swap.) setExposure(e) { this._exposure = e; if (this.impostor) this.impostor.setExposure(e); } setDebugTiers(on) { this.debugTiers = !!on; if (this.impostor) this.impostor.setTint(on ? tierColor.mid : 0xffffff); } // ---- R8 patronage + weather ---- // shell feeds door points + hours per chunk (computed from the plan — sim stays graph-only). // map: chunkKey → [{ x, z, hours:[open,close] }]. Without it, patronage is inert. setShops(shopsByChunk) { this.shopsByChunk = shopsByChunk; } setPatronage(on) { this.patronageOn = !!on; } // R12 gig-night surge (?gigs=1): while a gig is on, F points patronage at the venue so the street // converges on the pub — it pulls from further (GIG_RANGE) and ducks in harder (GIG_SURGE). Occupants // drain at close per A's closing-time ruling (no special handling — _openAt + the dwell timer do it). setGig(venueShopId, on = true) { this._gigVenue = on && venueShopId != null ? venueShopId : null; } setWeather(w) { if (w && typeof w.state === 'string') this.weather = { state: w.state, intensity: +w.intensity || 0 }; } _openAt(hours) { if (!hours) return true; const h = (this.timeOfDay % 1) * 24; return h >= hours[0] && h < hours[1]; } // rain thins the crowd (~40–60%), overcast a little; clear = v1. Applied to each chunk's active count. _weatherDensityMult() { const w = this.weather; if (w.state === 'rain') return 0.6 - 0.2 * w.intensity; // 0.4–0.6 if (w.state === 'overcast') return 1 - 0.15 * w.intensity; // ~0.9 return 1; } _speedMult() { return this.weather.state === 'rain' ? 1.0 + 0.18 * this.weather.intensity : 1; } // chance (per in-range check) a ped ducks into an open shop. Rain → shelter-seeking. As the streets // empty (night), bump it so the few peds out concentrate at the one open shop (the video shop draws // the night crowd instead of a dead street with a lone visitor). _patronChance() { let base = this.weather.state === 'rain' ? 0.22 + 0.25 * this.weather.intensity : 0.16; const day = densityAt(this.timeOfDay); if (day < 0.35) base = Math.min(0.6, base + (0.35 - day) * 1.3); return base; } // nearest OPEN shop the ped is currently passing (current chunk, ≤PATRON_RANGE), else null. // Hours-aware — at night only the openLate video shop qualifies, so its block draws the night crowd. _nearestOpenShop(c) { if (!this.shopsByChunk) return null; const list = this.shopsByChunk.get(this.chunkKeyAt(c.x, c.z)); if (!list || !list.length) return null; let best = null, bd = PATRON_RANGE; for (const s of list) { if (!this._openAt(s.hours)) continue; const d = Math.hypot(s.x - c.x, s.z - c.z); // gig night: the venue reaches further and wins over a nearer shop (follow the sound to the pub) if (this._gigVenue != null && s.shopId === this._gigVenue && d < GIG_RANGE) return s; if (d < bd) { bd = d; best = s; } } return best; } _beginVisit(c, shop) { c._savedWalk = { edge: c.edge, forward: c.forward, s: c.s }; // resume the footpath walk on the way out c.patronTarget = shop; c.patron = 'going'; } _enter(c) { c.patron = 'inside'; c.patronTimer = 5 + c.patronRng() * 15; // seeded dwell 5–20s c.x = c.patronTarget.x; c.z = c.patronTarget.z; // parked at the door while inside (hidden) // R9 occupancy truth: record who's inside which shop (F reads this to stand browser rigs) const id = c.patronTarget.shopId; if (id != null) { let occ = this._occupancy.get(id); if (!occ) this._occupancy.set(id, occ = []); occ.push({ seed: c.id, enteredAt: this.timeOfDay, pedIndex: c.pedIndex }); c._occShop = id; } } _emerge(c) { if (c._occShop != null) { this._removeOccupant(c._occShop, c.id); c._occShop = null; } const w = c._savedWalk; if (w) { c.edge = w.edge; c.forward = w.forward; c.s = w.s; } c.patron = null; c.patronTarget = null; c._savedWalk = null; this._placeOnLane(c); // back on the footpath, resumes the walk } _removeOccupant(shopId, seed) { const occ = this._occupancy.get(shopId); if (!occ) return; const i = occ.findIndex(o => o.seed === seed); if (i >= 0) occ.splice(i, 1); if (!occ.length) this._occupancy.delete(shopId); } // D's occupancy truth (the C→D→F seam): how many streamed peds patronage currently has inside this // shop, and who (seed + entry time + ped type). F reads this on interior build, then stands one // browser rig per browse point for min(count, 3). Count is pre-cap. occupancyOf(shopId) { const occ = this._occupancy.get(shopId) || []; return { count: occ.length, occupants: occ.map(o => ({ ...o })) }; } // stable identity signature of the active set — immutable spawn identity, NOT live position, so it // holds while citizens walk (the determinism gate: same seed → same crowd, twice). Both modes. identitySignature() { return this._activeList.map(c => this._sig(c)); } // ---- impostor atlases ---- _buildPlaceholderImpostors() { const subjects = []; for (let v = 0; v < PLACEHOLDER_VARIANTS; v++) { const ph = makePlaceholder(rng(this.citySeed, 'pvar', v), { height: 1.9 }); subjects.push({ object3D: ph.fig, height: 1.9 }); } const atlas = bakeImpostorAtlas(this.renderer, subjects, { yaws: 4, cell: 128, environment: this.scene.environment }); subjects.forEach(s => s.object3D.traverse(o => { if (o.isMesh) o.material.dispose?.(); })); this._setImpostorLayer(atlas); } _upgradeToRigs() { // bake one atlas cell-set per ped type, from a representative mid-stride pose const temps = [], subjects = []; for (let i = 0; i < this.fleet.all.length; i++) { const rig = this.fleet.all[i]; const spawned = makeActor(rig, { walkClip: this.fleet.walkClip, idleClip: this.fleet.idleClip, nominalHeight: 1.9 }); if (!spawned) continue; spawned.setMoving(true, 0); spawned.mixer.update(0.35 + i * 0.017); // desynced mid-stride so the atlas isn't all one pose temps.push(spawned); subjects.push({ object3D: spawned.fig, height: 1.9, pedIndex: i }); } if (!subjects.length) return; const atlas = bakeImpostorAtlas(this.renderer, subjects, { yaws: 4, cell: 128, environment: this.scene.environment }); temps.forEach(t => t.dispose()); this._setImpostorLayer(atlas); this.mode = 'rig'; this.rigPool = new RigPool(this.fleet, { walkClip: this.fleet.walkClip, idleClip: this.fleet.idleClip }); // assign real ped types to every citizen; drop placeholder near-actors so they re-acquire as rigs const assign = c => { const pk = pickRig(this.fleet, c.pedRoll); if (pk) { c.pedIndex = pk.index; c.subject = pk.index; } if (c.actor && c.actorKind === 'placeholder') { this._releaseActor(c); } }; this.roster.forEach(assign); for (const arr of this.chunkRosters.values()) arr.forEach(assign); // stream-mode rosters too // encountered signatures now carry pedIndex → refresh so the determinism proof matches post-upgrade if (this.streamMode) { this._encountered.clear(); for (const arr of this.chunkRosters.values()) arr.forEach(c => this._encountered.add(this._sig(c))); } } _setImpostorLayer(atlas) { if (this.impostor) { this.group.remove(this.impostor.mesh); this.impostor.dispose(); } this.impostor = new ImpostorLayer(atlas, { maxInstances: IMPOSTOR_MAX }); this.group.add(this.impostor.mesh); if (this.debugTiers) this.impostor.setTint(tierColor.mid); if (this._exposure != null) this.impostor.setExposure(this._exposure); // survive the upgrade re-bake } // ---- lane math ---- _placeOnLane(c) { const e = this.edges[c.edge]; const start = c.forward > 0 ? e.A : e.B; const tdx = c.forward * e.ux, tdz = c.forward * e.uz; // travel direction // right-perpendicular (dz,-dx): opposing walkers take opposite footpaths const px = tdz, pz = -tdx; c.x = start.x + tdx * c.s + px * e.off; c.z = start.z + tdz * c.s + pz * e.off; c.facing = Math.atan2(-tdx, -tdz); // rig front = local -Z } _advance(c, dt) { // ---- R8 patronage state machine (streamed citizens only; c.patron is undefined for v1) ---- if (c.patron === 'inside') { c.patronTimer -= dt; if (c.patronTimer <= 0) this._emerge(c); return; } if (c.patron === 'going') { // steer straight to the shop door const dx = c.patronTarget.x - c.x, dz = c.patronTarget.z - c.z; const dist = Math.hypot(dx, dz); if (dist < 1.8) { this._enter(c); return; } const step = c.speed * this._speedMult() * dt; c.x += (dx / dist) * step; c.z += (dz / dist) * step; c.facing = Math.atan2(-dx / dist, -dz / dist); return; } if (c.loiter > 0) { c.loiter -= dt; return; } const e = this.edges[c.edge]; const adv = c.speed * this._speedMult() * dt; c.s += adv; if (c.s >= e.len) { // arrived at the far node — pick the next edge (seeded), maybe a window-shop loiter const node = c.forward > 0 ? e.b : e.a; const inc = this.adj.get(node); let choices = inc.filter(i => i !== c.edge); if (!choices.length) choices = inc; // dead-end → U-turn const next = choices[(c.turn() * choices.length) | 0]; const ne = this.edges[next]; c.edge = next; c.forward = ne.a === node ? 1 : -1; c.s = Math.min(c.s - e.len, ne.len); // carry leftover distance if (c.loit() < 0.10 + c.loiterTend * 0.28) c.loiter = 1.4 + c.loit() * 3.2; // window-shop stop } this._placeOnLane(c); // patronage: every ~PATRON_STRIDE walked, IF a nearby open shop is in range, a seeded chance to // duck in (proximity-gated so the roll isn't wasted mid-block — this is what makes the sparse // night crowd reliably cluster at the open-late video shop). if (this.patronageOn && this.shopsByChunk && c.patronRng) { c._patronDist = (c._patronDist || 0) + adv; if (c._patronDist >= PATRON_STRIDE) { c._patronDist = 0; const shop = this._nearestOpenShop(c); const chance = (shop && this._gigVenue != null && shop.shopId === this._gigVenue) ? Math.max(this._patronChance(), GIG_SURGE) : this._patronChance(); if (shop && c.patronRng() < chance) this._beginVisit(c, shop); } } } // ---- actor lifecycle ---- _acquireActor(c) { if (this.mode === 'rig' && this.rigPool && c.pedIndex >= 0) { const a = this.rigPool.acquire(c.pedIndex); if (a) { a.setPhase(c.phase); a.setMoving(c.loiter <= 0, 0); // instant, no fade, so the first frame is posed a.mixer.update(0); // evaluate NOW — a fresh clone must never show bind-pose (T-pose) a.fig.scale.setScalar(c.height / (a.nominalHeight || 1.75)); this.group.add(a.fig); c.actor = a; c.actorKind = 'rig'; return true; } return false; // pool exhausted this frame → stay an impostor } // placeholder mode (asset-free) — build a unique-coloured figure const ph = makePlaceholder(rng(this.citySeed, 'body', c.id), { height: c.height }); this.group.add(ph.fig); c.actor = ph; c.actorKind = 'placeholder'; return true; } _releaseActor(c) { if (!c.actor) return; this.group.remove(c.actor.fig); if (c.actorKind === 'rig' && this.rigPool) this.rigPool.release(c.actor); else c.actor.dispose?.(); c.actor = null; c.actorKind = null; c._acc = 0; } setPaused(p) { this.paused = !!p; } // the set of citizens active this frame — v1: the roster prefix scaled by time-of-day; stream: the // union of live-chunk residents, each chunk thinned by the same time-of-day curve (hours-aware // density per unit street). Deactivates everyone else. Downstream code is identical for both. _activeSet() { const list = []; const density = densityAt(this.timeOfDay); if (this.streamMode) { const haveLively = this._livelyChunks.size > 0; const wm = this._weatherDensityMult(); // rain/overcast thins the crowd on top of the day curve for (const [key, arr] of this.chunkRosters) { // hours-aware: every chunk thins by the day curve, but "lively" chunks (the open-late block) // keep a night floor so that street never goes dead while its shop is still open. const base = (haveLively && this._livelyChunks.has(key)) ? Math.max(density, NIGHT_LIVELY_FLOOR) : density; const d = base * wm; const activeCount = Math.round(arr.length * d); for (let i = 0; i < arr.length; i++) { if (i < activeCount) list.push(arr[i]); else if (arr[i].tier !== 'far') { this._releaseActor(arr[i]); arr[i].tier = 'far'; } } } } else { const active = Math.min(this.roster.length, Math.round(this.target * density)); this._ensureRoster(active); for (let i = active; i < this.roster.length; i++) { const c = this.roster[i]; if (c.tier !== 'far') { this._releaseActor(c); c.tier = 'far'; } } for (let i = 0; i < active; i++) list.push(this.roster[i]); } return list; } // ---- the frame ---- update(dt) { if (this.paused) return this.stats; // tab hidden → mixers frozen (app drives this via events) dt = Math.min(dt, 0.1); // clamp long frames (tab refocus) so nobody teleports if (this.streamMode && !this._hookDriven) this._pollChunks(); // camera-follow window (or Lane B hooks) const activeCitizens = this._activeSet(); this._activeList = activeCitizens; // advance + measure distances for the active set const cam = this.camera; const camX = cam.position.x, camZ = cam.position.z; const near = this._nearList; near.length = 0; const cand = []; for (const c of activeCitizens) { this._advance(c, dt); const ddx = c.x - camX, ddz = c.z - camZ; c._d = Math.hypot(ddx, ddz); cand.push(c); } // choose the near set: hysteresis eligibility, then nearest-first up to the cap const eligible = []; for (const c of cand) { if (c.patron === 'inside') continue; // ducked into a shop → not rendered const wasNear = c.tier === 'near'; if (c._d < (wasNear ? NEAR_EXIT : NEAR_ENTER)) eligible.push(c); } eligible.sort((a, b) => (a._d - (a.tier === 'near' ? NEAR_BIAS : 0)) - (b._d - (b.tier === 'near' ? NEAR_BIAS : 0))); const nearSet = new Set(eligible.slice(0, NEAR_MAX)); // assign tiers + representations const mid = this._midList; mid.length = 0; let newRigs = 0; for (const c of cand) { let want; if (c.patron === 'inside') want = 'far'; // inside a shop → hidden (actor released below) else if (nearSet.has(c)) want = 'near'; else if (c._d < (c.tier === 'far' ? MID_ENTER : MID_EXIT)) want = 'mid'; else want = 'far'; if (want === 'near') { // acquire an actor (budgeted); if it fails this frame, ride as an impostor instead if (!c.actor) { if (newRigs < NEW_RIG_PER_FRAME && this._acquireActor(c)) newRigs++; else want = 'mid'; } else if (c.actorKind === 'placeholder' && this.mode === 'rig') { this._releaseActor(c); // upgrade path: swap placeholder → rig if (newRigs < NEW_RIG_PER_FRAME && this._acquireActor(c)) newRigs++; else want = 'mid'; } } if (want !== 'near' && c.actor) this._releaseActor(c); if (want === 'near' && c.actor) { near.push(c); const a = c.actor; a.fig.position.set(c.x, 0, c.z); a.fig.rotation.y = c.facing; a.setMoving?.(c.loiter <= 0); } else if (want === 'mid') { mid.push(c); } c.tier = want; } // near animation: rigs on a staggered mixer budget; placeholders tick cheaply every frame near.sort((a, b) => a._d - b._d); const t0 = (typeof performance !== 'undefined' ? performance.now() : 0); const extra = []; for (let i = 0; i < near.length; i++) { const c = near[i], a = c.actor; c._acc += dt; if (a.mixer) { if (i < MIXER_ALWAYS) { a.mixer.update(c._acc); c._acc = 0; } else extra.push(c); } else { a.tick?.(c._acc, c.loiter <= 0); c._acc = 0; // placeholder } } // round-robin the mixers beyond the nearest 8, ≤MIXER_EXTRA per frame if (extra.length) { if (this._mixerCursor >= extra.length) this._mixerCursor = 0; for (let k = 0; k < MIXER_EXTRA && k < extra.length; k++) { const c = extra[(this._mixerCursor + k) % extra.length]; c.actor.mixer.update(c._acc); c._acc = 0; } this._mixerCursor = (this._mixerCursor + MIXER_EXTRA) % extra.length; } const mixerMs = (typeof performance !== 'undefined' ? performance.now() : 0) - t0; // mid tier: one instanced draw call if (this.impostor) { const list = mid.length > IMPOSTOR_MAX ? mid.slice(0, IMPOSTOR_MAX) : mid; this.impostor.update(list, cam); } // debug tier tint on near actors (rig materials are shared → tint the whole group cheaply instead) const active = activeCitizens.length; this.stats = { active, rigged: near.length, mid: mid.length, far: active - near.length - mid.length, mixerMs, poolTotal: this.rigPool ? this.rigPool.total : 0, mode: this.mode, chunks: this.streamMode ? this.chunkRosters.size : 0, }; return this.stats; } dispose() { this.roster.forEach(c => this._releaseActor(c)); for (const arr of this.chunkRosters.values()) arr.forEach(c => this._releaseActor(c)); this.chunkRosters.clear(); if (this.rigPool) this.rigPool.dispose(); if (this.impostor) { this.group.remove(this.impostor.mesh); this.impostor.dispose(); } this.scene.remove(this.group); } }