PROCITY/web/js/citizens/sim.js
m3ultra 888335d633 Lane D round 12: gig band + crowd + patronage surge (?gigs=1)
v3.0-alpha — Friday night at the pub. New web/js/citizens/band.js (GigCrew):
- Band trio on C's stage.bandPoses (deckY), distinct fleet peds (seeded per gig), each with a
  primitive instrument on the chest (guitar/mic/bass; E's GLBs drop in via opts.instrumentFor).
- Crowd (<=8) at C's watchPoints on the gate-protected spawnRig path: dance:false = weight-shift
  idle, dance:true = bounce/sway/step with seeded phase (no metronome).
- Deterministic from citySeed; placeholder crew under ?noassets.

sim.js: setGig(venueShopId, on) gig-night surge — patronage pulls peds from further (GIG_RANGE)
and ducks in harder (GIG_SURGE) at the venue; occupants drain at close per A's closing-time ruling.
Guarded by _gigVenue==null so gig-off is byte-identical (prime flag law; goldens unmoved).

Verified standalone (A withGigs + C buildInterior + D): band 3, crowd 8, dancing 3, all 11 figures
human-sized (1.60-1.83m under a 4.0m ceiling; R10 no-giants holds), band faces audience + crowd
faces stage, deterministic, leak-free (8 cycles, 0 delta). Crew ~40 draws. Surge: venue occupancy
1 -> peak 5, identity stable. Shot: docs/shots/laneD/r12_gig_night.jpg.

Flag for C/F: band.js flips C's watchPoints/bandPoses ry by pi (RY_FLIP) — the fleet mesh's visual
front is local +Z, not the -Z the comments assume. Remove RY_FLIP if C re-fronts the gig poses.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 19:49:07 +10:00

705 lines
35 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 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 (2570m): 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.551.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 (~4060%), 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.40.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 520s
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);
}
}