// PROCITY Lane D — the rig stack. Ported faithfully from 90sDJsim web/world/index.html ~405–500 // (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) }; }