PROCITY/web/js/citizens/rigs.js
m3ultra 78f49f7113 Lane D R41 §41.3: the town stops walking — 99.3% to 78.1%, at zero draws
THE ROUND IN ONE MEASUREMENT (12 samples x 146 active, ?clips=0 vs default — a new flag that
turns off the library and nothing else): walking 99.3% -> 78.1% · bench-sit 0 -> 9.5% · lean
0 -> 7.1% · stopped in own idle 0.4% -> 5.1% · DISTINCT CLIPS ACROSS THE CROWD 4 -> 20.
The town was 99.3% people walking because standing still had nowhere to happen.

Wiring: new postures.js + clipbank.js. idles.glb (10/10) drives a per-citizen deterministic
idle on every near-tier actor plus the seeded shopkeeper. locomotion gives 33.7% of walkers a
shopping bag. sitlean (8/8, lazy) puts 4 sits on Lane B's ACTUAL benches and 4 leans on
shopfront walls. browse (5/8, lazy) is a real BROWSE state at C's browse points, seeded per
(shopId, slot). venue (5/6, lazy) widens the gig crowd, plus a publican pouring and a record
keeper in headphones. social (0/8) is never fetched — two-person conversation needs a paired
state machine, filed to R42.

Cost: boot = 4 requests, 1.24 MB / 16 clips resident; the rest lazy on first need; heap delta
+3.34 MB; mixer median 0.1 ms both arms. ?clips=0 / ?classic=1 / ?noassets=1 fetch ZERO clips
— not even clipbank.js (dynamic import). No shell edit needed.

DRAWS: +0 on every bookmark (street_noon 193, crossroads 108, night_crowd 128, market_square
94, night_neon 111, interior 110 — identical both arms). Ruling 4 respected exactly.

DETERMINISM: 150 citizens, two fresh contexts, byte-equal posture signature. Controls: seed+1
differs; EVERY clip GLB delayed 2 s -> identical signature (posture is a pure function of
(citySeed, id), never of residency). 6 new streams collide with none of the 12 pre-R41 keys.

TWO FINDINGS THAT CHANGED THE DESIGN: the idle pool was INVISIBLE — wired only to the R17/R29
node loiter, so only 0.8% of citizens were ever stopped; and the lean never fired at all (0 in
a 9 s run). Both moved to the patronage stride check. Bench stations are GATED not trusted:
14/14 derived stations coincide with real instanced geometry within 2 cm, and the control
(same stations offset 2 m) matches 0/14. Filed to B: one benchStops(plan) export retires the
mirror, and furniture.js puts the bench's front ALONG the street rather than facing the road,
contradicting its own comment.

Leak: +0 geometries, +1 texture over 6 enter/exit cycles. Goldens 157,647/157,647, 0x5f76e76.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 18:11:30 +10:00

530 lines
34 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 rig stack. Ported faithfully from 90sDJsim web/world/index.html ~405500
// (loadRig / spawnRig / _canon / canonRig / _rotOnly / head-bone height-normalize / the shared
// walk+idle clip driving all 19 peds). This stack is proven; the changes for PROCITY are:
// • loaders come from core/loaders.js (promise-cached, fail-soft) instead of an inline one
// • randomness is injected (a seeded stream) — no Math.random() in identity/placement
// • the figure is wrapped in an outer Group whose origin sits at the feet (ground = local y=0),
// so the sim can move/rotate a walker without re-planting it every frame.
//
// House law: one canonical clip bank drives every character (shared skeleton namespace), never a
// per-character rig. SkeletonUtils.clone gives each spawn its own skeleton off a shared base mesh.
import * as THREE from 'three';
import { clone as skeletonClone } from 'three/addons/utils/SkeletonUtils.js';
import { loadGLB } from '../core/loaders.js';
// The 19 rigged peds (17 normal + 2 comical) copied byte-identical from 90sDJsim into web/models/peds/.
// [R36 wave 2.5 — Fable ruling on F's §36.4 hold] `normal` is the CLASSIC-COVENANTED base 17 (the exact
// pre-July-20 list, order preserved — pickRig maps r01 through pool.length, so the length IS the identity).
// The 5 trellis'd DJ bodies (b339402, fbx_to_ped, 4k tris) live in `djs` and join the roster only when
// loadPedFleet's `djs` opt is on — they are APPENDED, so the default-boot pool is byte-identical to the
// pre-ruling 22-list and `?classic=1` is byte-identical to pre-July-20 (fetch surface AND crowd identity).
export const PED_NAMES = {
normal: ['man_worker_hivis_01', 'man_suit_01', 'man_casual_01', 'man_casual_02', 'man_casual_03',
'man_grunge_01', 'man_elder_01', 'man_athletic_01', 'man_bouncer_01', 'man_soldier_ww2_01',
'man_dj_streetwear_01', 'woman_casual_01', 'woman_casual_02', 'woman_business_01',
'woman_athleisure_01', 'woman_smart_01', 'woman_chef_01'],
djs: ['woman_dj_01', 'woman_dj_02', 'woman_dj_03', 'dj_techno_01', 'dj_phrtt_01'], // gated: dance-scene bodies, NOT in classic's pool
comical: ['comical_luchador_01', 'comical_boy_01'],
};
// ---- Mixamo skeleton canonicalisation (the crown-jewel trick) ----
// mixamorig1Hips vs mixamorig4Hips → mixamorig Hips, so ANY clip binds to ANY character.
const _canon = s => s.replace(/mixamorig\d+/g, 'mixamorig');
// [R41 §41.3] exported so clipbank.js runs Lane E's 46 library clips through the IDENTICAL two-step
// (`_canon` then `_rotOnly`) that walk/idle/sit/look/dance have ridden since R2 — one code path, so a
// library clip cannot diverge from a base clip in how it binds. E measured 65 raw tracks → 64 after
// the filter on all 46; clipbank.js re-asserts non-empty per clip at load.
export const canonName = _canon;
function canonRig(r) {
if (!r) return r;
if (r.scene) r.scene.traverse(o => { o.name = _canon(o.name); });
if (r.anims) r.anims.forEach(a => a.tracks.forEach(t => { t.name = _canon(t.name); }));
return r;
}
// shared-clip filter: keep limb/spine rotations only — drop ALL position tracks (a different-scale
// source inflates/crumples the target) AND Hips.quaternion (a different-orientation source lays it
// flat). The character keeps its own upright bind root; the game translates walkers itself.
const _rotOnly = c => new THREE.AnimationClip(c.name, c.duration,
c.tracks.filter(t => t.name.endsWith('.quaternion') && !/Hips\.quaternion$/i.test(t.name)));
export const rotOnlyClip = _rotOnly; // [R41 §41.3] same filter for the library clips — see canonName
// R16 sit clip note: the sit pose ALSO rides `_rotOnly`. R16 first tried keeping `Hips.quaternion` (a
// `_rotWithHips` variant, per the R14 recon) to preserve the pelvic tilt — but the fleet rigs' bind-pose
// Hips orientation differs from the Mixamo source, so keeping the sit Hips.quaternion LAYS THE BODY FLAT
// (head folded to hip height — verified in-shell), exactly the failure the comment above documents for
// walk/idle. So the sit clip drops Hips.quaternion like every other clip; the seated read comes from the
// leg-bone bends + spawnRig's seated foot-replant. Reads seated from the crowd POV (the kit hides the legs).
// core loadGLB returns the whole gltf (scene + animations); adapt to the {scene, anims} rig shape.
function loadRig(ref) {
return loadGLB(ref).then(g => (g ? { scene: g.scene, anims: g.animations || [] } : null));
}
// loadPedFleet(base) → a live Fleet object, returned immediately (ready:false). Arrays fill in as
// GLBs land; fleet.whenReady resolves when every load settled. The town runs on placeholders until
// then, then upgradeStreetPeople swaps them (see sim.js). Missing files just leave the fleet smaller.
// `opts.sit` (R16): also load sit.glb → fleet.sitClip (the seated-drummer pose). GATED because a raw
// sit.glb fetch would break the ?classic=1 zero-fetch-delta covenant — F passes sit:true on the gig/default
// boot, sit:false (default) under ?classic. Absent/off → fleet.sitClip stays null and the drummer falls
// back to the standing-sunk hack, so this never breaks a boot. Does NOT touch fleet.ready / determinism.
// `opts.djs` (R36 w2.5): admit the 5 trellis'd DJ bodies into the normal roster. DEFAULTS TO `dance` —
// the shell already passes `dance: !CLASSIC`, so the classic-ness signal arrives without a shell edit and
// the bodies ride the same `!CLASSIC` gate as the sit/look/dance clips: `?classic=1` fetches none of the
// five and keeps the covenanted 17-pool; every other boot gets all 22. Separable for callers that want
// dance clips without the bodies (or vice versa).
// `opts.clips` (R41 §41.3): Lane E's 46-clip MOTION LIBRARY (web/models/clips/*.glb +
// web/assets/motion_manifest.json). DEFAULTS TO `dance` for exactly the reason `djs` does — the shell
// already passes `dance: !CLASSIC` (index.html:232), so the classic-ness signal arrives with ZERO
// shell edits and the library rides the same gate as the sit/look/dance clips. `?classic=1` ⇒ no
// manifest fetch, no clip-GLB fetch, and (because the module is reached by dynamic `import()`) not
// even a clipbank.js fetch: the zero-fetch-delta covenant is untouched. `?noassets=1` never calls
// loadPedFleet at all. On, the bank fetches THREE things at boot — the 16 KB manifest and the two
// eager groups (postures.js BOOT_GROUPS: idles.glb 1 029 496 B + locomotion.glb 212 776 B) — and
// everything else lazily on first demand. `fleet.ready` deliberately does NOT depend on the bank: a
// dead manifest or a 404 group leaves the R2 8-clip fleet running exactly as it does today.
// `?clips=0` is the CONTROL ARM, and it exists because `?classic=1` is a bad control for this: it
// changes the ped pool, the sit/look/dance clips, the fog, the game and half the shell at once, so a
// draw or heap delta measured against it says nothing about the motion library specifically. This
// flag turns off the library and NOTHING else. Read the same way furniture.js reads ?noassets.
const CLIPS_OFF = (() => { try { return new URLSearchParams(location.search).get('clips') === '0'; } catch { return false; } })();
export function loadPedFleet(base = 'models/peds/', { sit = false, look = false, dance = false, djs = dance,
clips = dance, clipBase = null, manifestUrl = null, clipGroups = null } = {}) {
if (CLIPS_OFF) clips = false;
const fleet = {
normal: [], comical: [], all: [], // all = normal ++ comical, stable index for the impostor atlas
walkClip: null, idleClip: null, sitClip: null, lookClip: null, danceClips: [],
bank: null, // R41 ClipBank | null (null ⇒ every consumer takes its base clip)
ready: false, whenReady: null,
};
// DETERMINISM: fill fixed slots by PED_NAMES index, NOT push-on-resolve — otherwise the array
// order (and thus every pickRig index + identity signature) depends on GLB load-completion timing,
// which varies per run/machine and silently breaks "same seed → same crowd". Compact after all
// settle (filter(Boolean) preserves the fixed relative order, so a missing file is deterministic too).
// R36 w2.5: the DJ bodies extend the roster AFTER the base 17, so gating them off shortens the pool
// without renumbering a single base slot — classic's pickRig mapping is the pre-July-20 one exactly.
const normalNames = djs ? [...PED_NAMES.normal, ...PED_NAMES.djs] : PED_NAMES.normal;
const nSlots = new Array(normalNames.length).fill(null);
const cSlots = new Array(PED_NAMES.comical.length).fill(null);
const jobs = [];
normalNames.forEach((n, i) => jobs.push(loadRig(`${base}${n}.glb`).then(r => {
if (r) { r.pedName = n; nSlots[i] = canonRig(r); } })));
PED_NAMES.comical.forEach((n, i) => jobs.push(loadRig(`${base}${n}.glb`).then(r => {
if (r) { r.pedName = n; cSlots[i] = canonRig(r); } })));
// shared clips: canonicalise track names, keep rotation only
jobs.push(loadRig(`${base}walk.glb`).then(r => {
const c = r && r.anims && r.anims[0];
if (c) { c.tracks.forEach(t => t.name = _canon(t.name)); fleet.walkClip = _rotOnly(c); } }));
jobs.push(loadRig(`${base}idle.glb`).then(r => {
const c = r && r.anims && r.anims[0];
if (c) { c.tracks.forEach(t => t.name = _canon(t.name)); fleet.idleClip = _rotOnly(c); } }));
if (sit) jobs.push(loadRig(`${base}sit.glb`).then(r => { // R16, gig/default boot only
const c = r && r.anims && r.anims[0];
if (c) { c.tracks.forEach(t => t.name = _canon(t.name)); fleet.sitClip = _rotOnly(c); } }));
// [R29 Spike 1] E's look.glb (mesh-free, 65 joints, 6.5 s 'Look'). Same gate as sit — the shell passes
// `look: !CLASSIC`, so ?classic fetches nothing and the zero-fetch-delta covenant holds. `_rotOnly` is
// correct here for the same reason as walk/idle: it's a STANDING clip, so there's no hip descent to
// recover and no foot re-plant needed (that trap was sit's alone — R16).
if (look) jobs.push(loadRig(`${base}look.glb`).then(r => {
const c = r && r.anims && r.anims[0];
if (c) { c.tracks.forEach(t => t.name = _canon(t.name)); fleet.lookClip = _rotOnly(c); } }));
// dance clips (mesh-free mixamorig): the club dancers play a REAL move instead of procedural sway.
// GATED like sit/look — ?classic fetches nothing so the zero-fetch-delta covenant holds. The per-dancer
// pick is deterministic (a separate seeded stream in _make), so 'same seed -> same crowd' is preserved.
const DANCE_CLIPS = ['dance_party', 'dance_medium', 'dance_drink', 'dance_sway'];
const danceSlots = new Array(DANCE_CLIPS.length).fill(null);
if (dance) DANCE_CLIPS.forEach((n, i) => jobs.push(loadRig(`${base}${n}.glb`).then(r => {
const c = r && r.anims && r.anims[0];
if (c) { c.tracks.forEach(t => t.name = _canon(t.name)); danceSlots[i] = _rotOnly(c); } })));
// [R41 §41.3] the motion library. Dynamic import ⇒ clipbank.js is not even fetched when the gate is
// off. Fail-soft twice over: the import and the boot both swallow, so `fleet.bank` stays null and
// every downstream `bank && bank.get(...)` falls straight through to the base clip.
// Published SYNCHRONOUSLY, before the import even starts: consumers that must make a STREAM-
// AFFECTING decision (the sim's bench/lean/pause rolls) need a boot-stable answer to "is the
// library on for this boot", not "has it landed yet" — otherwise the first second of a boot would
// draw a different number of randoms than the rest of it, and 'same seed → same crowd' would
// depend on network timing. `fleet.bank` stays the RESIDENCY question; this is the GATE question.
fleet.clipsRequested = !!clips;
if (clips) jobs.push(
Promise.all([import('./clipbank.js'), import('./postures.js')]).then(([cb, po]) => {
const bank = new cb.ClipBank({
clipBase: clipBase || base.replace(/peds\/?$/, 'clips/'),
manifestUrl: manifestUrl || base.replace(/models\/peds\/?$/, 'assets/motion_manifest.json'),
});
fleet.bank = bank; // published immediately: lazy groups can be
return bank.boot(clipGroups || po.BOOT_GROUPS); // kicked before the boot groups land
}).catch((e) => { console.warn('[rigs] motion library unavailable, base clips stay:', e && e.message); }));
fleet.whenReady = Promise.all(jobs).then(() => {
fleet.normal = nSlots.filter(Boolean);
fleet.comical = cSlots.filter(Boolean);
fleet.all = [...fleet.normal, ...fleet.comical];
fleet.danceClips = danceSlots.filter(Boolean);
fleet.ready = fleet.all.length > 0 && !!fleet.walkClip;
return fleet;
});
return fleet;
}
// deterministic ped choice: ~8% comical curveball, else a normal, indexed by a seeded stream.
// Returns { rig, index } — index is into fleet.all (stable per ped type → impostor atlas cell).
export function pickRig(fleet, r01) {
const comical = r01 < 0.08 && fleet.comical.length ? fleet.comical : null;
const pool = comical || fleet.normal;
if (!pool.length) return null;
const rig = pool[(r01 * 997 % pool.length) | 0];
const index = fleet.all.indexOf(rig);
return { rig, index: index < 0 ? 0 : index };
}
// ---- buildFigure: clone a rig, height-normalise off the head bone, plant feet ----
// Returns { fig, inner, head }. `fig` is an outer Group whose origin is at the feet (ground = y=0)
// — set fig.position to the ground point and fig.rotation.y to the heading. NOTE materials are
// shared across SkeletonUtils clones (cheap); don't mutate them per-instance.
const _wp = new THREE.Vector3();
const _bb = new THREE.Box3();
const _bs = new THREE.Vector3();
export function buildFigure(rig, height = 1.75) {
const inner = skeletonClone(rig.scene);
inner.traverse(o => { if (o.isMesh) { o.frustumCulled = false; o.castShadow = false; } });
// FACING NORMALISE (R13 debt #4 — the RY audit). The imported GLB fleet's visual front is local +Z,
// but EVERYTHING downstream assumes -Z: the sim's heading math (sim.js atan2(-dx,-dz)), C's pose ry
// (counter.stand / bandPoses / watchPoints / browse points), the placeholder (toes point -Z on
// purpose, placeholder.js:61), and the impostor bake/pick (impostor.js -Z convention). So the GLB was
// the lone outlier: keepers faced the back wall, browsers faced away, streamed walkers moon-walked
// (anti-travel), and band.js carried a local RY_FLIP=π to patch just the gig. Rotate the rig's own
// mesh 180° here so its front becomes -Z like the rest of the system — one place, everyone correct,
// near↔mid stays seamless (the impostor bakes this same figure), and the flip is purely visual (a
// Y-rotation leaves every bone's world-Y untouched → head-height/feet-plant + the no-giants gate and
// all seeded identity/determinism are byte-unchanged). See LANE_D_NOTES R13 + LANE_C_PUB.md pose contract.
inner.rotation.y = Math.PI;
const fig = new THREE.Group();
fig.add(inner);
fig.updateWorldMatrix(true, true);
// height-normalise so the crown sits at `height` above the feet, then plant feet.
// NB the Mixamo skeleton's ORIGIN is the hips (~mid-body), so the head bone's world Y in bind pose
// is head-above-hips (~half the standing height), NOT the standing height. Normalising by that alone
// makes every figure ~2× too tall (the R9 interior-giant blocker). Normalise by the feet→crown SPAN
// (top head bone minus lowest bone) so a requested 1.75 m rig is 1.75 m from sole to crown.
let headY = 0, minY = Infinity;
inner.traverse(o => {
if (o.isBone) {
o.getWorldPosition(_wp);
if (/head/i.test(o.name)) headY = Math.max(headY, _wp.y); // crown (HeadTop_End is highest)
minY = Math.min(minY, _wp.y); // sole (lowest foot/toe bone)
}
});
const span = headY - minY; // feet→crown standing height in the rig's native units
if (headY > 1e-4 && span > 1e-4) {
inner.scale.setScalar(height / span);
} else {
// no head bone found — fall back to bounding-box height so we never ship a giant/ant
_bb.setFromObject(inner); _bb.getSize(_bs);
if (_bs.y > 1e-4) inner.scale.setScalar(height / _bs.y);
}
fig.updateWorldMatrix(true, true);
minY = Infinity;
inner.traverse(o => { if (o.isBone) { o.getWorldPosition(_wp); minY = Math.min(minY, _wp.y); } });
if (minY === Infinity) { _bb.setFromObject(inner); minY = _bb.min.y; }
inner.position.y = -minY; // feet at outer y=0
let head = null;
inner.traverse(o => { if (!head && o.isBone && /head/i.test(o.name)) head = o; });
return { fig, inner, head, nominalHeight: height };
}
// bind a clip to an inner figure, keeping only tracks whose target bones exist (else three spams
// "No target node found" per missing bone). Returns the AnimationAction (not yet played).
function _action(mixer, inner, clip) {
if (!clip) return null;
const nodes = new Set();
inner.traverse(o => { if (o.name) nodes.add(o.name); });
const bindable = clip.tracks.filter(t => nodes.has(t.name.split('.')[0]));
const useClip = bindable.length === clip.tracks.length
? clip : new THREE.AnimationClip(clip.name, clip.duration, bindable);
return mixer.clipAction(useClip);
}
// Dispose only what a SkeletonUtils clone actually OWNS: its Skeleton's per-instance bone texture.
// Geometry + materials are SHARED by reference across every clone and the fleet base rig — disposing
// them here would tear down GPU resources still in use by all sibling citizens of the same ped type.
function _disposeInner(inner) {
const skels = new Set();
inner.traverse(o => { if (o.isSkinnedMesh && o.skeleton) skels.add(o.skeleton); });
skels.forEach(s => s.dispose?.());
}
// ---- spawnRig: single-clip figure (keepers, impostor baking). Height baked in. ----
export function spawnRig(rig, { ry = 0, clip = null, height = 1.75, phase = 0, seated = false } = {}) {
if (!rig || !rig.scene) return null;
const { fig, inner, head } = buildFigure(rig, height);
fig.rotation.y = ry;
const mixer = new THREE.AnimationMixer(inner);
const chosen = clip || rig.anims.find(c => /idle/i.test(c.name)) || rig.anims[1] || rig.anims[0];
const act = _action(mixer, inner, chosen);
const bindPlantY = inner.position.y; // R41: the STANDING feet-plant, to restore before any re-plant
let curAct = act;
let seatBone = null;
if (act) {
act.time = phase * (act.getClip().duration || 0); act.play();
// R16 SEATED re-plant (OPT-IN — standing spawns skip this, so keepers/browsers/band are byte-identical).
// buildFigure planted the BIND pose; a sit clip lowers the hips + swings the legs, so re-plant the POSED
// feet to y=0: tick the mixer once to bake the pose, find the lowest bone, drop the figure so the soles
// return to the deck. One-time (a drummer's feet don't travel); this recovers the hip descent that
// _rotWithHips leaves out by dropping the (scale-unsafe) Hips.position track.
if (seated) {
mixer.update(0);
inner.updateWorldMatrix(true, true);
let m = Infinity;
inner.traverse(o => { if (o.isBone) { o.getWorldPosition(_wp); m = Math.min(m, _wp.y); } });
if (isFinite(m)) inner.position.y -= m;
// R17 pelvic-lean: cache the mid-spine bone so seatedLean() can tilt the torso forward each frame (the
// sit clip can't — Hips.quaternion is unusable on these rigs). Seated-only; standing rigs get no seatBone.
inner.traverse(o => { if (!seatBone && o.isBone && /Spine1$/i.test(o.name)) seatBone = o; });
}
}
// [R41 §41.3] setClip — swap this single-clip figure onto a motion-library clip AFTER it was built.
// This is the self-healing seam: a keeper / browser / gig member is posed with whatever clip exists
// at spawn (the R2 idle in the worst case) and upgrades in place the frame its lazily-fetched group
// lands, so WHICH clip it was assigned never depends on load timing — only how soon it shows.
//
// The re-plant is the R29 lesson generalised. `_rotOnly` drops the Hips POSITION track (it must —
// a foreign-scale source would inflate the rig), so a clip's authored vertical motion comes out as
// the FEET moving instead of the hips: measured up to +0.205 m on look.glb. spawnRig's original
// seated re-plant samples ONCE at t=0, which is right for a fixed pose and wrong for a clip whose
// lowest bone travels. So this samples the posed skeleton at PLANT_SAMPLES points across the clip
// and plants the MINIMUM — the soles can then never sink through the floor, and the residual float
// is the clip's own authored range, not an arbitrary phase's error. Absolute (reset → measure →
// correct), never incremental, and scale-aware via fig.scale.y.
const PLANT_SAMPLES = 6;
function _plant() {
inner.position.y = bindPlantY;
const clipDur = (curAct && curAct.getClip().duration) || 0;
const t0 = curAct ? curAct.time : 0;
let lo = Infinity;
for (let i = 0; i < PLANT_SAMPLES; i++) {
if (curAct) curAct.time = clipDur * (i / PLANT_SAMPLES);
mixer.update(0);
inner.updateWorldMatrix(true, true);
inner.traverse(o => { if (o.isBone) { o.getWorldPosition(_wp); if (_wp.y < lo) lo = _wp.y; } });
if (clipDur <= 0) break;
}
if (curAct) { curAct.time = t0; mixer.update(0); }
if (!isFinite(lo)) return;
const S = fig.scale.y || 1;
inner.position.y -= (lo - fig.position.y) / S;
}
function setClip(next, { seated: st = seated, phase: ph = phase } = {}) {
if (!next) return false;
const na = _action(mixer, inner, next);
if (!na) return false;
if (curAct && curAct !== na) { curAct.stop(); curAct.setEffectiveWeight(0); }
if (next._pcLoop === 'pingpong') na.setLoop(THREE.LoopPingPong, Infinity);
curAct = na;
na.reset().play().setEffectiveWeight(1);
na.time = ph * (next.duration || 0);
_plant();
if (st) {
fig.userData.procitySeated = true;
seatBone = null;
inner.traverse(o => { if (!seatBone && o.isBone && /Spine1$/i.test(o.name)) seatBone = o; });
}
return true;
}
function dispose() { mixer.stopAllAction(); mixer.uncacheRoot(inner); _disposeInner(inner); }
return { fig, inner, mixer, height, head, get seatBone() { return seatBone; }, setClip, dispose };
}
// R17 pelvic-lean: tilt a seated fig's torso forward, applied AFTER the mixer each frame. The mixer resets
// the bone's quaternion from the clip every frame, so this is a CONSTANT offset (not an accumulation). Only
// seated figs (drummer, bench-sit loiterers) call it; standing rigs have no seatBone → never leaned.
const _LEAN_AXIS = new THREE.Vector3(1, 0, 0);
const _LEAN_Q = new THREE.Quaternion();
export function seatedLean(spine, angle = 0.34) {
if (spine) spine.quaternion.multiply(_LEAN_Q.setFromAxisAngle(_LEAN_AXIS, angle));
}
// ---- makeActor: near-tier pedestrian with walk↔idle crossfade (built at nominal height; the sim
// scales the outer Group per-citizen so pooled actors are height-agnostic and reusable). ----
export function makeActor(rig, { walkClip, idleClip, sitClip = null, lookClip = null, nominalHeight = 1.75 } = {}) {
if (!rig || !rig.scene) return null;
const { fig, inner, head, nominalHeight: nom } = buildFigure(rig, nominalHeight);
const bindPlantY = inner.position.y; // R17 bench-sit: the standing feet-plant, to restore after a sit
const mixer = new THREE.AnimationMixer(inner);
// [R41 §41.3] one AnimationAction per SOURCE clip, memoised. Pooled actors are recycled across
// citizens of the same ped type, so the per-citizen idle/walk cannot be baked in at construction —
// it is swapped on acquire (≤3/frame, NEW_RIG_PER_FRAME). Memoising by source clip means a pool
// slot that has seen five different idles pays five bindings, not five per acquire; three.js shares
// the PropertyMixer bindings per (root, track) across all of them, so the marginal cost of an extra
// action is its interpolants, not another copy of the skeleton.
const _acts = new Map();
function _act(clip) {
if (!clip) return null;
let a = _acts.get(clip);
if (a === undefined) {
a = _action(mixer, inner, clip);
// library clips whose measured loop seam does not close play ping-pong instead of popping
// (clipbank.js LOOP_SEAM_DEG) — `browse_pick_up` becomes crate-digging rather than a teleport.
if (a && clip._pcLoop === 'pingpong') a.setLoop(THREE.LoopPingPong, Infinity);
_acts.set(clip, a);
}
return a;
}
const baseWalkA = _act(walkClip || rig.anims.find(c => /walk/i.test(c.name)));
const baseIdleA = _act(idleClip || rig.anims.find(c => /idle/i.test(c.name)));
// R17 bench-sit: a third action, PLAYED ONLY when setSitting(true) — so walkers (and ?classic, where
// sitClip is null) are byte-identical: sitA never plays, the plant never moves, setSitting is a no-op.
const baseSitA = sitClip ? _act(sitClip) : null;
// [R29 Spike 1] the glance — same opt-in shape as sitA: PLAYED ONLY when setLooking(true), so walkers,
// placeholders and ?classic (lookClip null) are byte-identical — lookA never advances the mixer.
const baseLookA = lookClip ? _act(lookClip) : null;
// R41: the four live slots. They START at the base actions, so an actor nobody swaps is the R40
// actor exactly; setIdleClip/setWalkClip/setSitting(_,clip)/setLooking(_,_,clip) re-point them.
let walkA = baseWalkA, idleA = baseIdleA, sitA = baseSitA, lookA = baseLookA;
let seatBone = null;
if (baseSitA) inner.traverse(o => { if (!seatBone && o.isBone && /Spine1$/i.test(o.name)) seatBone = o; });
// [R29] foot bones, cached once for the per-frame plant (see plantFeet). Only built when a posed clip
// exists, so walkers/?classic pay nothing. R41: built lazily too, since a lean clip can arrive later.
let footBones = null;
function _footBones() {
if (footBones !== null) return footBones;
const fb = []; inner.traverse(o => { if (o.isBone && /(Toe|Foot)/i.test(o.name)) fb.push(o); });
footBones = fb.length ? fb : false;
return footBones;
}
if (baseLookA) _footBones();
let sitting = false, looking = false;
let moving = null; // tri-state so the first setMoving always applies
// both actions play; exactly one holds weight 1 at rest, setMoving transfers between them
if (walkA) { walkA.play(); walkA.setEffectiveWeight(1); }
if (idleA) { idleA.play(); idleA.setEffectiveWeight(0); }
// walk↔idle: instant on the first apply / fade<=0 (fresh spawn must be posted immediately),
// otherwise a real crossFade transfers weight from the resting action to the target.
function setMoving(m, fade = 0.28) {
if (m === moving) return;
const first = moving === null;
moving = m;
if (!(walkA && idleA)) return;
const to = m ? walkA : idleA, from = m ? idleA : walkA;
to.enabled = from.enabled = true;
if (first || fade <= 0) { to.setEffectiveWeight(1); from.setEffectiveWeight(0); }
else from.crossFadeTo(to, fade, false); // from holds weight 1 at rest → smooth transfer
}
// [R41 §41.3] the per-citizen resting/moving clips. Called on ACQUIRE only (the pool hands a recycled
// actor to a new citizen), before setPhase + setMoving(_, 0) — so `moving = null` here is enough to
// make the next setMoving apply instantly and no crossfade can be left mid-flight. Passing null
// restores the base clip, which is exactly what a citizen whose assigned clip has not landed yet
// (or whose boot has no bank at all) gets ⇒ the R40 actor, byte for byte.
function setIdleClip(clip) {
const a = clip ? (_act(clip) || baseIdleA) : baseIdleA;
if (!a || a === idleA) return;
if (idleA) { idleA.setEffectiveWeight(0); idleA.stop(); }
idleA = a; idleA.enabled = true; idleA.reset().play(); idleA.setEffectiveWeight(0);
moving = null;
}
function setWalkClip(clip) {
const a = clip ? (_act(clip) || baseWalkA) : baseWalkA;
if (!a || a === walkA) return;
if (walkA) { walkA.setEffectiveWeight(0); walkA.stop(); }
walkA = a; walkA.enabled = true; walkA.reset().play(); walkA.setEffectiveWeight(0);
moving = null;
}
// R17 bench-sit: snap the loitering ped into / out of the sit pose (no crossfade — a background ped
// sitting instantly is fine, and it avoids the crossfade fighting the discrete foot-replant). The sim's
// per-citizen scale is already on `fig`, so the re-plant is SCALE-AWARE: drop `inner` by the posed
// lowest-bone height / scale so the seated feet land on the footpath. Restore the standing plant on stand.
// R41: `clip` (optional) is a motion-library sit variant (sitlean.glb) for THIS citizen; omitted /
// null ⇒ the R16 sit.glb pose, unchanged. With neither, setSitting stays the no-op it has always
// been under ?classic.
function setSitting(s, clip = null) {
const target = s ? ((clip && _act(clip)) || baseSitA) : null;
if (s && !target) return; // nothing to sit with → inert (classic / no clip)
if (!s && !sitting) return; // already standing
if (s && sitting && target === sitA) return; // already in this exact pose
if (s && sitA && sitA !== target) sitA.stop(); // swapping pose mid-sit
if (s) sitA = target;
sitting = s;
moving = null; // force the next setMoving to re-apply after standing up
if (s) {
if (walkA) walkA.setEffectiveWeight(0);
if (idleA) idleA.setEffectiveWeight(0);
sitA.reset().play().setEffectiveWeight(1);
mixer.update(0); // bake the sit pose before measuring
inner.updateWorldMatrix(true, true);
let w = Infinity;
inner.traverse(o => { if (o.isBone) { o.getWorldPosition(_wp); w = Math.min(w, _wp.y); } });
const S = fig.scale.y || 1;
if (isFinite(w)) inner.position.y -= (w - fig.position.y) / S; // posed soles → the footpath
fig.userData.procitySeated = true;
} else {
if (sitA) sitA.stop();
sitA = baseSitA; // back to the base pose for the next citizen
inner.position.y = bindPlantY; // restore the standing feet-plant
if (idleA) idleA.setEffectiveWeight(1);
fig.userData.procitySeated = false;
}
}
// [R29 Spike 1] the standing glance during a window-shop loiter. Deliberately SIMPLER than setSitting:
// it's a standing clip, so there is no posed foot-replant and no scale trap — the feet never leave the
// bind plant. Unlike sit (a snap, which had to avoid a crossfade fighting the discrete replant), this
// crossfades: idle → idle-variant reads naturally, and the sim only calls it while the ped is stopped.
// No-op when lookClip is absent (?classic / ?noassets) ⇒ byte-identical by construction.
// R41: `clip` (optional) is any STANDING posed library clip for this citizen — a lean against a
// shopfront (sitlean.glb's four lean_wall_*) rides the identical seam as R29's glance, because it is
// the identical problem: a standing clip, no hip descent to recover, but a per-frame foot float that
// plantFeet() absorbs. Omitted / null ⇒ look.glb, unchanged.
function setLooking(l, fade = 0.3, clip = null) {
const target = l ? ((clip && _act(clip)) || baseLookA) : null;
if (l && !target) return; // no standing pose available → inert (classic)
if (!l && !looking) return;
if (l && looking && target === lookA) return; // already in this exact pose
if (l && lookA && lookA !== target) lookA.stop(); // swapping pose mid-look
if (l) { lookA = target; _footBones(); }
looking = l;
moving = null; // force the next setMoving to re-apply on the way out
if (l) {
lookA.reset().play();
if (idleA) idleA.crossFadeTo(lookA, fade, false);
else lookA.setEffectiveWeight(1);
if (walkA) walkA.setEffectiveWeight(0);
} else {
if (idleA) { idleA.reset().play(); if (lookA) lookA.crossFadeTo(idleA, fade, false); }
else if (lookA) lookA.setEffectiveWeight(0);
lookA = baseLookA; // back to the base pose for the next citizen
inner.position.y = bindPlantY; // drop the plant correction — back to the standing bind plant
}
}
// [R29] MEASURED, and it corrected my own assumption: I expected a standing clip to need no re-plant
// ("no hip descent to recover"). It does. `_rotOnly` drops the Hips POSITION track to stay rig-height
// independent, so look.glb's authored weight-shift dip cannot move the hips — it comes out as the FEET
// rising instead: lowest bone ranges 0.009…+0.205 m over the loop vs idle's 0.04…0.00. A 20 cm hover.
// Sit's one-time re-plant can't fix it because the float VARIES per frame, so this is the per-frame twin:
// reset to the bind plant, measure the posed feet, drop `inner` by the float / scale. Absolute (reset →
// measure → correct), never incremental, so it cannot drift. The result reproduces the authored motion
// exactly — the hips dip relative to the ground instead of the feet leaving it. Scale-aware like sit's.
// Cost is paid ONLY by glancing peds (a handful), and only on frames their mixer actually ran.
function plantFeet() {
if (!footBones || !looking) return;
inner.position.y = bindPlantY; // reference pose
inner.updateWorldMatrix(true, true);
let w = Infinity;
for (const b of footBones) { b.getWorldPosition(_wp); if (_wp.y < w) w = _wp.y; }
if (!isFinite(w)) return;
const S = fig.scale.y || 1;
inner.position.y -= (w - fig.position.y) / S; // posed soles → the footpath
}
// seed the clip phase so the crowd isn't in lockstep
function setPhase(p) {
if (walkA) walkA.time = p * (walkA.getClip().duration || 1);
if (idleA) idleA.time = p * (idleA.getClip().duration || 1);
if (lookA) lookA.time = p * (lookA.getClip().duration || 1); // R29: glances desync too
if (sitA) sitA.time = p * (sitA.getClip().duration || 1); // R41: so does a row of bench-sitters
}
function dispose() { mixer.stopAllAction(); mixer.uncacheRoot(inner); _acts.clear(); _disposeInner(inner); }
return { fig, inner, mixer, head, get seatBone() { return seatBone; }, nominalHeight: nom,
setMoving, setSitting, setLooking, setIdleClip, setWalkClip, plantFeet, setPhase, dispose,
hasClips: !!(walkA && idleA) };
}