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>
705 lines
35 KiB
JavaScript
705 lines
35 KiB
JavaScript
// 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);
|
||
}
|
||
}
|