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>
346 lines
21 KiB
JavaScript
346 lines
21 KiB
JavaScript
// 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/.
|
||
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'],
|
||
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');
|
||
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)));
|
||
// 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.
|
||
export function loadPedFleet(base = 'models/peds/', { sit = false, look = false } = {}) {
|
||
const fleet = {
|
||
normal: [], comical: [], all: [], // all = normal ++ comical, stable index for the impostor atlas
|
||
walkClip: null, idleClip: null, sitClip: null, lookClip: null,
|
||
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).
|
||
const nSlots = new Array(PED_NAMES.normal.length).fill(null);
|
||
const cSlots = new Array(PED_NAMES.comical.length).fill(null);
|
||
const jobs = [];
|
||
PED_NAMES.normal.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); } }));
|
||
fleet.whenReady = Promise.all(jobs).then(() => {
|
||
fleet.normal = nSlots.filter(Boolean);
|
||
fleet.comical = cSlots.filter(Boolean);
|
||
fleet.all = [...fleet.normal, ...fleet.comical];
|
||
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);
|
||
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; });
|
||
}
|
||
}
|
||
function dispose() { mixer.stopAllAction(); mixer.uncacheRoot(inner); _disposeInner(inner); }
|
||
return { fig, inner, mixer, height, head, seatBone, 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);
|
||
const walkA = _action(mixer, inner, walkClip || rig.anims.find(c => /walk/i.test(c.name)));
|
||
const idleA = _action(mixer, inner, 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 sitA = sitClip ? _action(mixer, inner, 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 lookA = lookClip ? _action(mixer, inner, lookClip) : null;
|
||
let seatBone = null;
|
||
if (sitA) 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.
|
||
let footBones = null;
|
||
if (lookA) { const fb = []; inner.traverse(o => { if (o.isBone && /(Toe|Foot)/i.test(o.name)) fb.push(o); }); footBones = fb.length ? fb : null; }
|
||
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
|
||
}
|
||
// 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.
|
||
function setSitting(s) {
|
||
if (!sitA || s === sitting) return;
|
||
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 {
|
||
sitA.stop();
|
||
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.
|
||
function setLooking(l, fade = 0.3) {
|
||
if (!lookA || l === looking) return;
|
||
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(); lookA.crossFadeTo(idleA, fade, false); }
|
||
else lookA.setEffectiveWeight(0);
|
||
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
|
||
}
|
||
function dispose() { mixer.stopAllAction(); mixer.uncacheRoot(inner); _disposeInner(inner); }
|
||
|
||
return { fig, inner, mixer, head, seatBone, nominalHeight: nom, setMoving, setSitting, setLooking, plantFeet, setPhase, dispose,
|
||
hasClips: !!(walkA && idleA) };
|
||
}
|