The spike reaches the game. E's look.glb (mesh-free, 65 joints, 6.5s 'Look') is bound
through the fleet gate; street peds now glance around during window-shop stops. `lean`
is absent and never existed (E's R28 measurement). Goldens untouched — clips sit outside
every plan hash; the synthetic anchor stays R27's amended 0x5f76e76.
WIRED: loadPedFleet(base, {sit, look}) -> fleet.lookClip, the exact twin of the R16 sit
gate; the shell passes look:!CLASSIC so ?classic fetches NOTHING (a raw fetch would have
breached the zero-fetch-delta covenant — E flagged it, the gate is the answer).
makeActor gains lookA, played only on setLooking(true) — crossfaded (0.3s), not snapped
like sit, since idle->idle-variant reads naturally and there's no replant for a fade to
fight. sim.js gains a dedicated `glance` rng stream, rolled ALWAYS (deterministic) but
only flipped when a clip exists => classic inert at the source. Mutually exclusive with
bench-sit (sit rolls first at 35%, glance takes 40% of the rest). RigPool threads
lookClip; release snaps the glance off so no rig is pooled mid-look.
THE FINDING — my own prediction was wrong, and the measurement caught it. I wrote that a
STANDING clip needs no foot replant ("no hip descent to recover — that trap was sit's
alone"). Measured over the full loop: idle keeps the lowest bone within 4cm of the ground
(-0.04..0.00); look.glb ranges -0.009..+0.205 — the ped HOVERS up to 20.5cm, drifting
through the loop. Same trap, different coat: _rotOnly drops the Hips POSITION track for
rig-height independence, so the clip's authored weight-shift dip can't move the hips and
comes out as the FEET RISING. Sit's one-time replant can't fix it — the float varies per
frame.
FIX: plantFeet() — the per-frame twin of sit's replant. Reset to bind plant -> measure
posed feet -> drop inner by float/scale. ABSOLUTE (reset->measure->correct), never
incremental, so it cannot drift. Reproduces the authored motion exactly: the hips dip
relative to the ground instead of the feet leaving it. Scale-aware like sit's. Foot bones
cached once; cost paid only by glancing peds, on frames their mixer ran (both mixer sites
— the drummer's post-mix lean taught that coupling rule).
PROOFS: foot float 0.205m -> 0.000m (pinned); head yaw sweep span 1.735 (-0.85..+0.89) so
the glance still animates; ?classic — no look fetch, no Look action on any rig, glance
never flips (0); identity signature unchanged, no glance term; sit AND glance never
simultaneous (0); 0 NaN; goldens 0x5f76e76 untouched. A glancing ped is NOT tagged
seated/posed — standing pose at standing stature, so F's no-giants gate needs no arm.
D->F: the pose gate HAS ITS SUBJECT — un-SKIP it. Fresh ?localdepot=1 boot shows a ped
glancing on the footpath, head sweeping, feet planted.
Spike 2 readiness: plantFeet() is the general machinery the brief asked for ("data, not
new code") — riffle and wall-lean should land as clip + roll; the lean's wall-contact
placement is the only genuinely new part.
FLAGGED (A's file, A rests): selfcheck.js:301 still says "the synthetic golden above stays
0x3fa36874" while line 267 pins the amended 0x5f76e76. A stale comment, not a rule — but
the same species as the epoch's five holds, so: flagged, not touched.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
809 lines
44 KiB
JavaScript
809 lines
44 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)
|
||
const BENCH_SIT_FRAC = 0.35; // R17: fraction of window-shop loiters that become a bench-sit (seeded)
|
||
const GLANCE_FRAC = 0.40; // R29: fraction of the REMAINING (standing) loiters that glance around
|
||
|
||
// 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;
|
||
if (actor.setSitting) actor.setSitting(false); // R17: never pool a rig mid-sit — restore the standing plant
|
||
if (actor.setLooking) actor.setLooking(false, 0); // R29: nor mid-glance — snap back to idle before pooling
|
||
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._gigVenues = new Set(); // R13: venueShopIds with a gig on (F sets per-venue via setGig) → multi-venue 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._venueRoster = new Map(); // R14 identity continuity: venueShopId → Map<key,{pedIndex,height}> — who
|
||
// entered a gig venue TONIGHT (surge occupants + F-relayed queue admits).
|
||
// GigCrew consumes it so the crowd IS the people who came in. Cleared when
|
||
// the gig ends (setGig off). Pure bookkeeping — no rng, no GPU.
|
||
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._venueRoster.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._venueRoster.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),
|
||
sit: false, sitRng: rng(this.citySeed, 'benchsit', id), // R17: dedicated stream — no shift to turn/loit/patron
|
||
glance: false, glanceRng: rng(this.citySeed, 'glance', id), // R29: ditto — independently keyed, signature untouched
|
||
};
|
||
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),
|
||
sit: false, sitRng: rng(this.citySeed, 'benchsit', id), // R17: dedicated stream — no shift to turn/loit/patron
|
||
glance: false, glanceRng: rng(this.citySeed, 'glance', id), // R29: ditto — independently keyed, signature untouched
|
||
};
|
||
// 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; }
|
||
// 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).
|
||
// R13 the district: per-venue, so several concurrent gigs each pull their own block. F calls this once
|
||
// per venue off its per-venue state; the alpha single-venue call (one id) is a subset — still works.
|
||
setGig(venueShopId, on = true) {
|
||
if (venueShopId == null) return;
|
||
if (on) this._gigVenues.add(venueShopId);
|
||
// gig ends (on→off transition) → disperse tonight's roster so the next night starts fresh + it stays
|
||
// bounded. Persists across interior exit/re-enter (the gig stays "on" through those) — only the real
|
||
// close clears it. .delete() returns true only on the transition, so re-calls while quiet are no-ops.
|
||
else if (this._gigVenues.delete(venueShopId)) this._venueRoster.delete(venueShopId);
|
||
}
|
||
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, else null. Hours-aware — at night only the openLate
|
||
// video shop qualifies, so its block draws the night crowd.
|
||
//
|
||
// [R23] The carried v4.x fix — GIG_RANGE (34 m) is a RADIUS, but this read only the ped's own 64 m chunk,
|
||
// so a gig venue genuinely in range, just across a chunk edge, was invisible. A's R21 cluster bias made
|
||
// that WORSE, not better: venues now sit INSIDE the retail cluster where peds converge from every side,
|
||
// so 42.6% of in-gig-range peds were chunk-blind (up from the 8.7% I filed at the old isolated pub).
|
||
//
|
||
// The fix is scoped to the GIG path on purpose. Extending the neighbour scan to ORDINARY shops as well
|
||
// was the literal brief, but measured it breaks the same brief's "byte-identical flags-off" clause: on
|
||
// the synthetic town (what ?classic=1 boots) 127 of 855 finds — 14.9% — would newly come from a
|
||
// neighbour chunk, i.e. the frozen v2 crowd would start shopping ~15% more. So:
|
||
// · ORDINARY shops: own chunk only — v2 semantics, frozen. A shopfront is noticed from its own block.
|
||
// · GIG venues: the full 34 m radius, honest across chunk edges — the gig is the "follow the sound"
|
||
// pull that is SUPPOSED to reach further; that is the whole point of GIG_RANGE > PATRON_RANGE.
|
||
// With no gig on, `_gigVenues` is empty and the neighbour loop is skipped entirely ⇒ ?classic and every
|
||
// flags-off boot take the identical code path AND identical cost to v2. Byte-identical by construction.
|
||
// Both ranges are < CHUNK (64), so a 3×3 sweep is exact, not an approximation. Fixed dz/dx order ⇒
|
||
// exact-distance ties resolve deterministically, as the old single-list scan did.
|
||
// Carried: PATRON_RANGE keeps v2's own chunk-blindness. Revisit only if the covenant is ever relaxed.
|
||
_nearestOpenShop(c) {
|
||
if (!this.shopsByChunk) return null;
|
||
const cx = chunkCoord(c.x), cz = chunkCoord(c.z);
|
||
let best = null, bd = PATRON_RANGE;
|
||
let gigBest = null, gbd = GIG_RANGE; // R13: the nearest gig venue in range — it wins over ordinary
|
||
const own = this.shopsByChunk.get(chunkKey(cx, cz)); // shops (follow the sound), and across venues.
|
||
if (own) for (const s of own) {
|
||
if (!this._openAt(s.hours)) continue;
|
||
const d = Math.hypot(s.x - c.x, s.z - c.z);
|
||
if (this._gigVenues.has(s.shopId) && d < gbd) { gbd = d; gigBest = s; }
|
||
if (d < bd) { bd = d; best = s; }
|
||
}
|
||
if (this._gigVenues.size) { // gig on → the 34 m pull reaches across chunk edges (gigs only)
|
||
for (let dz = -1; dz <= 1; dz++) {
|
||
for (let dx = -1; dx <= 1; dx++) {
|
||
if (dx === 0 && dz === 0) continue; // own chunk already scanned above
|
||
const list = this.shopsByChunk.get(chunkKey(cx + dx, cz + dz));
|
||
if (!list) continue;
|
||
for (const s of list) {
|
||
if (!this._gigVenues.has(s.shopId)) continue; // neighbours contribute gig venues ONLY
|
||
if (!this._openAt(s.hours)) continue;
|
||
const d = Math.hypot(s.x - c.x, s.z - c.z);
|
||
if (d < gbd) { gbd = d; gigBest = s; }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return gigBest || 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;
|
||
// R14 continuity: a surge occupant who ducked into a GIG venue joins tonight's roster (the crowd
|
||
// becomes the people who came in). The roster persists past this ped's short dwell (unlike occupancy,
|
||
// which clears on emerge) — "who came tonight", keyed by the ped's stable id so re-entry doesn't dup.
|
||
if (this._gigVenues.has(id)) this._recordRoster(id, c.id, c.pedIndex, c.height);
|
||
}
|
||
}
|
||
// record one entered identity into a venue's tonight roster (deduped by key, insertion-ordered, no rng).
|
||
_recordRoster(venueShopId, key, pedIndex, height) {
|
||
let m = this._venueRoster.get(venueShopId);
|
||
if (!m) this._venueRoster.set(venueShopId, m = new Map());
|
||
if (!m.has(key)) m.set(key, { pedIndex, height }); // first entry wins
|
||
}
|
||
_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 })) };
|
||
}
|
||
// R14 identity continuity — the D→F seam.
|
||
// recordVenueEntry: F relays a queue admit (VenueQueue.admitOne() → {pedIndex, height, key}) so the punter
|
||
// who walked in the door joins the crowd. Surge occupants self-record in _enter; this is the queue side.
|
||
recordVenueEntry(venueShopId, entry) {
|
||
if (venueShopId == null || !entry || entry.pedIndex == null) return;
|
||
const key = entry.key != null ? `q:${entry.key}` : `q:${entry.pedIndex}:${(+entry.height || 0).toFixed(3)}`;
|
||
this._recordRoster(venueShopId, key, entry.pedIndex, entry.height);
|
||
}
|
||
// tonightRoster: the night's entered identities, for GigCrew.spawn (F passes it in) + F's continuity
|
||
// smoke (assert crowd ⊇ roster ∩ cap). Insertion-ordered (entry order), deduped, bounded, pure read.
|
||
tonightRoster(venueShopId) {
|
||
const m = this._venueRoster.get(venueShopId);
|
||
return m ? [...m.values()].map(e => ({ pedIndex: e.pedIndex, height: e.height })) : [];
|
||
}
|
||
// 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, sitClip: this.fleet.sitClip, lookClip: this.fleet.lookClip }); // sit/lookClip null under ?classic → bench-sit + glance inert
|
||
// 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
|
||
// R17 bench-sit: a seeded few of those become a bench-sit. Roll ALWAYS (dedicated stream →
|
||
// deterministic, never shifts turn/loit/patron), but only flip c.sit when a sit clip is loaded
|
||
// so ?classic (no sit.glb) is inert at the source, not just at the render.
|
||
const wantsSit = c.sitRng ? (c.sitRng() < BENCH_SIT_FRAC) : false;
|
||
c.sit = wantsSit && !!(this.fleet && this.fleet.sitClip);
|
||
// [R29 Spike 1] a standing glance — the other way to spend a window-shop stop. Same discipline as
|
||
// the sit roll: draw ALWAYS from a dedicated stream (deterministic; never shifts turn/loit/patron),
|
||
// but only flip the flag when a clip exists ⇒ ?classic inert at the source. Mutually exclusive with
|
||
// the sit: you can't bench-sit and stand glancing, so the sit wins and the glance takes the rest.
|
||
const wantsGlance = c.glanceRng ? (c.glanceRng() < GLANCE_FRAC) : false;
|
||
c.glance = !c.sit && wantsGlance && !!(this.fleet && this.fleet.lookClip);
|
||
}
|
||
}
|
||
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._gigVenues.has(shop.shopId))
|
||
? 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;
|
||
// R17 bench-sit: a seeded few of the window-shop loiters sit (upright, on the verandah/footpath
|
||
// edge — no bench-position binding). No-op for walkers, placeholders, and ?classic (sitClip null),
|
||
// so every non-sitting ped is byte-identical.
|
||
const wantSit = !!(c.sit && c.loiter > 0 && a.setSitting);
|
||
if (a.setSitting) a.setSitting(wantSit);
|
||
// [R29 Spike 1] the glance rides the same seam: a stopped ped either sits, glances, or plain idles.
|
||
// No-op for walkers, placeholders and ?classic (no lookClip) ⇒ every non-glancing ped byte-identical.
|
||
const wantLook = !wantSit && !!(c.glance && c.loiter > 0 && a.setLooking);
|
||
if (a.setLooking) a.setLooking(wantLook);
|
||
if (!wantSit && !wantLook) 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) {
|
||
// [R29] plantFeet must follow the mixer that posed the feet — same coupling rule the drummer's
|
||
// post-mix lean taught us. Only glancing peds pay it; it self-guards on `looking`.
|
||
if (i < MIXER_ALWAYS) { a.mixer.update(c._acc); c._acc = 0; if (c.glance) a.plantFeet?.(); } 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;
|
||
if (c.glance) c.actor.plantFeet?.(); // R29: the round-robin tier plants on the frames its mixer ran
|
||
|
||
}
|
||
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);
|
||
}
|
||
}
|