import * as THREE from 'three'; import { E, clamp, segDist, smooth } from '../core/math.js'; import { lerp, lerpAngle } from '../../shared/scalar.js'; import { lowerHandFor, normalizeShotSide, shotSign, topHandFor } from '../../shared/player.js'; import { poseSkate, poseStop } from './poses/skate.js'; import { STICK_ARMS, STICK_BONES, STICK_SPINE, mirrorStickwork, poseCarry, posePass, posePoke, } from './poses/stickwork.js'; import { frameSpan, sampleTiltStick } from './clip.js'; import { shot1 } from './clips/shot1.js'; import { STICK } from '../character/stick.js'; /** * Skating animator. * * Same architecture as the Ludus fighter animator — a pose buffer that states * write into, crossfaded on state changes, with two-bone analytic leg IK * resolving world-space foot targets — with two deliberate differences. * * 1. It does not integrate movement. In Ludus the animator owned the fighter's * position; here the sim plus the Box3D proxy own it, and the animator is * told where the body ended up (`setTransform`). Anything else would have * the pose fighting the collision response. * * 2. The feet are authored in *mover-local* space rather than planted in world * space. That is not a shortcut: a walking foot is stationary while it bears * weight, but a skate is gliding the entire time, including through the * push. Planting it would be the thing that made this read as running on * ice, which is exactly the failure mode we are trying to avoid. * * States exist so the next spike can add `shoot` / `stickhandle` and get the * crossfade for free. Today there are two: `skate` and `stop`. */ /** How far the Skill Stick can push the blade around the carrier, metres. */ const STICK_REACH = { side: 0.5, fwd: 0.34 }; /** Foot joint height above the ice — boot plus blade. */ const FOOT_SOLE = 0.085; const STRIDE = { /** Fraction of the cycle the leg spends pushing rather than recovering. */ pushFrac: 0.55, /** Half the width of a neutral glide stance, metres. */ narrow: 0.105, /** How far out to the side a full push extends the blade. */ reachSide: 0.3, /** Fore/aft travel of the blade through a push. */ reachFwd: 0.16, reachAft: 0.26, /** Blade clearance on the recovery. Skates barely leave the ice. */ lift: 0.07, /** Toe flare — the V a skater's blades make as the leg extends. */ toeOut: 0.55, toeGlide: 0.12, /** Stride cycle at a standstill and at top speed, seconds. */ cycleSlow: 1.15, cycleFast: 0.6, }; export function buildAnimator(skelData, mover) { const B = skelData.bones; const LEN = { thigh: B.shinL.position.length(), shin: B.footL.position.length() }; const restThighDir = { L: B.shinL.position.clone().normalize(), R: B.shinR.position.clone().normalize() }; const restShinDir = { L: B.footL.position.clone().normalize(), R: B.footR.position.clone().normalize() }; const UPPER = [ 'pelvis', 'spine1', 'spine2', 'spine3', 'neck', 'head', 'clavicleL', 'upperArmL', 'forearmL', 'handL', 'clavicleR', 'upperArmR', 'forearmR', 'handR', ]; const LEGS = ['thighL', 'shinL', 'footL', 'toeL', 'thighR', 'shinR', 'footR', 'toeR']; function newPose() { const p = { q: {}, rootOffset: new THREE.Vector3(), rootQuat: new THREE.Quaternion(), foot: { L: { pos: new THREE.Vector3(), yaw: 0 }, R: { pos: new THREE.Vector3(), yaw: 0 } }, }; for (const n of UPPER.concat(LEGS)) p.q[n] = new THREE.Quaternion(); return p; } const cur = newPose(); const frozen = newPose(); const anim = { state: 'skate', blend: 1, BLEND_TIME: 0.22, transitionTime: 0.22, time: 0, stateTime: 0, /** Playback rate, for slow motion later. */ speed: 1, // ---- written by the rig each frame, read by the poses ----------------- origin: new THREE.Vector3(), originYaw: 0, /** Planar speed, m/s. */ moveSpeed: 0, /** Signed speed along the blade — negative means gliding backwards. */ bladeSpeed: 0, /** How hard the skater is pushing, 0..1, straight off the sim. */ effort: 0, /** Rate the velocity vector is turning, rad/s. Drives the bank. */ yawRate: 0, braking: false, // ---- derived, smoothed -------------------------------------------------- /** Stride amplitude, 0 (pure glide) .. 1 (digging in). */ gait: 0, /** Lean into the turn, radians. Signed: positive is turning right. */ bank: 0, stridePhase: 0, /** Which shoulder leads a hockey stop; latched when the stop starts. */ stopDir: 1, /** Called on each blade bite, for ice spray and audio later. */ onStride: null, // ---- stickwork --------------------------------------------------------- /** True while this skater has the puck. Decides the resting grip. */ hasPuck: false, /** Skill Stick, -1..1. Moves the hands, which moves the blade. */ handling: { x: 0, y: 0 }, /** Held wind-up charge from the Skill Stick, 0..1. */ charge: 0, /** The stick, so the animator can drive its socket and IK onto its shaft. */ stick: null, /** * Current stick action: null, 'windup', 'shoot', 'pass' or 'poke'. * Wind-up is held; the other three run once and blend out. */ action: null, actionTime: 0, actionPower: 1, actionAim: 0, /** A released held wind-up starts shot1 at its midpoint, not frame zero. */ actionFromWindup: false, /** Eased 0..1 between the settled grip and the one-handed dangle. */ hustleGrip: 0, /** * Shot side — which hand is on top of the stick, which side the blade * lives on, and whether stickwork poses are mirrored. Authored content is * for `'right'`; `'left'` flips grips and arms across the body. */ shotSide: 'right', /** `+1` right (as authored), `-1` left (mirrored). */ shotSign: 1, /** Top hand bone suffix for this shot side: `'R'` or `'L'`. */ topHand: 'R', /** Lower hand bone suffix — the one IK pins to the shaft. */ lowerHand: 'L', }; /** Apply a player shot side. Call once at create (or if a roster swaps it). */ anim.setShotSide = function setShotSide(side) { anim.shotSide = normalizeShotSide(side); anim.shotSign = shotSign(anim.shotSide); anim.topHand = topHandFor(anim.shotSide); anim.lowerHand = lowerHandFor(anim.shotSide); }; /** How long each one-shot action runs, seconds. */ const ACTION_TIME = { shoot: 0.42, pass: 0.3, poke: 0.34 }; /** Seconds to blend the override in and out over the skating pose. */ const ACTION_BLEND = 0.09; /** shot1 reaches its final key before the normal action fade begins. */ const SHOT_MOTION_TIME = ACTION_TIME.shoot - ACTION_BLEND; /** Scratch pose the action layer writes into before being blended over. */ const overlay = newPose(); const _actionSpine = new THREE.Quaternion(); const _clipQa = new THREE.Quaternion(); const _clipQb = new THREE.Quaternion(); /** Map game action time onto the saved reference motion. */ function shot1Time() { if (anim.action === 'windup') return clamp(anim.charge, 0, 1) * shot1.duration * 0.5; if (anim.action === 'shoot') { const phase = clamp(anim.actionTime / SHOT_MOTION_TIME, 0, 1); const start = anim.actionFromWindup ? 0.5 : 0; return (start + phase * (1 - start)) * shot1.duration; } return null; } /** Sample shot1 into the upper-body action layer without replacing skating legs. */ function poseShot1(P, time) { const span = frameSpan(shot1, time); if (!span) return false; for (const name of STICK_BONES) { const a = span.a.rotations[name] ?? [0, 0, 0, 1]; const b = span.b.rotations[name] ?? a; P.q[name].slerpQuaternions(_clipQa.fromArray(a), _clipQb.fromArray(b), span.alpha); } return true; } const _localFoot = new THREE.Vector3(); function applyMover() { mover.position.copy(anim.origin); mover.rotation.set(0, anim.originYaw, 0); } /** Place the skater. Position and yaw come from the sim, never from here. */ anim.setTransform = function setTransform(position, yaw) { anim.origin.copy(position); anim.originYaw = yaw; }; /** * Mover-local foot target for one leg at cycle position `p`. * * The path is a flattened loop: out and back through the push, then in and * forward through the recovery. Scaling the whole thing by `amp` means a * glide collapses it to a pair of feet sitting under the hips, with no * separate "glide" authoring to keep in sync. */ function strideLocal(side, p, amp, out) { const sign = side === 'L' ? 1 : -1; const S = STRIDE; let x; let z; let y; let toe; if (p < S.pushFrac) { const u = smooth(p / S.pushFrac); x = sign * (S.narrow + S.reachSide * amp * u); z = lerp(S.reachFwd * amp, -S.reachAft * amp, u); y = 0; toe = sign * (S.toeGlide + S.toeOut * amp * u); } else { const u = smooth((p - S.pushFrac) / (1 - S.pushFrac)); x = sign * lerp(S.narrow + S.reachSide * amp, S.narrow * 0.8, u); z = lerp(-S.reachAft * amp, S.reachFwd * amp, u); y = S.lift * amp * Math.sin(Math.PI * u); toe = sign * lerp(S.toeGlide + S.toeOut * amp, S.toeGlide, u); } out.set(x, FOOT_SOLE + y, z); return toe; } /** Local foot placement for a hockey stop: blades thrown across the travel. */ function stopLocal(side, dir, bite, out) { const lead = side === 'L' ? 1 : -1; out.set( dir * (0.06 + 0.12 * bite) * (side === 'L' ? 1 : 0.4), FOOT_SOLE, lead * (0.19 + 0.06 * bite), ); return dir * (0.3 + 0.9 * bite); } const _worldFoot = new THREE.Vector3(); /** Write a local foot target into the pose buffer as a world-space target. */ function writeFoot(P, side, local, toeYaw) { _worldFoot.copy(local).applyMatrix4(mover.matrixWorld); // The ice is flat, so the sole height authored locally is the world height; // re-pin anyway so a future heightfield only has to change this line. _worldFoot.y = local.y; P.foot[side].pos.copy(_worldFoot); P.foot[side].yaw = anim.originYaw + toeYaw; } /** * Advance the derived, smoothed values every state shares. * * Smoothing lives here rather than in the sim because these are presentation * quantities: the sim's `effort` is allowed to change instantly when the AI * changes its mind, but a skater's legs cannot. */ function advanceCommon(dt) { // Gait chases effort quickly on the way up (a push starts now) and decays // slowly (the leg finishes its stroke). const target = clamp(anim.effort, 0, 1); const rate = target > anim.gait ? 5.5 : 2.2; anim.gait = lerp(anim.gait, target, Math.min(1, rate * dt)); // Bank: the lean that balances the centripetal force of the current turn. // atan(v·ω / g) is the real thing, and it behaves correctly at low speed — // spinning on the spot produces no lean, which is what you want. const bankTarget = clamp( Math.atan2(anim.moveSpeed * anim.yawRate, 9.81), -0.45, 0.45, ); anim.bank = lerp(anim.bank, bankTarget, Math.min(1, 6 * dt)); // Stride rate rises with speed; a standing skater shuffles slowly. const fast = clamp(anim.moveSpeed / 7.5, 0, 1); const cycle = lerp(STRIDE.cycleSlow, STRIDE.cycleFast, fast); const before = anim.stridePhase; // Only advance while there is a stride to throw, so a long glide holds the // legs where the last push left them instead of pedalling in mid-air. anim.stridePhase = (anim.stridePhase + (dt / cycle) * Math.max(anim.gait, 0.06)) % 1; // Blade bite: each leg starts its push half a cycle apart. if (anim.onStride) { if (before > anim.stridePhase) anim.onStride('L', anim.moveSpeed); else if (before < 0.5 && anim.stridePhase >= 0.5) anim.onStride('R', anim.moveSpeed); } } /** * Fire a one-shot stick action. Wind-up is started and stopped explicitly * instead, because it is held for as long as the stick is pulled back. */ anim.playAction = function playAction(name, { power = 1, aim = 0 } = {}) { anim.actionFromWindup = name === 'shoot' && anim.action === 'windup'; anim.action = name; anim.actionTime = 0; anim.actionPower = power; anim.actionAim = aim; }; /** * Advance the stick action clock and write the override pose. * * Returns the blend weight, 0 when nothing is happening. Kept separate from * the states because these are *layers*: a skater keeps striding through a * shot, so the action owns the arms and some spine and nothing else. */ function advanceAction(dt) { if (!anim.action) return 0; anim.actionTime += dt; if (anim.action === 'windup') { // Held. Blends in over ACTION_BLEND and then stays until released. const w = Math.min(1, anim.actionTime / ACTION_BLEND); poseShot1(overlay, shot1Time()); // shot1 was authored as a left shot. Mirror only when the runtime // skater uses the opposite socket side. if (anim.shotSide !== shot1.shotSide) mirrorStickwork(overlay); return w; } const duration = ACTION_TIME[anim.action] ?? 0.3; const t = anim.actionTime / duration; if (t >= 1) { anim.action = null; return 0; } // Snap in, ease out — a shot should look like it started the instant the // button did, and a slow blend in front of it steals that. const fading = t > 1 - ACTION_BLEND / duration; const w = fading ? Math.max(0, (1 - t) * duration / ACTION_BLEND) // A released held wind-up is already fully blended in. Dropping its // weight back to zero for the shoot action caused a one-frame snap to // carry before the second half of shot1 began. : anim.action === 'shoot' && anim.actionFromWindup ? 1 : Math.min(1, anim.actionTime / (ACTION_BLEND * 0.5)); const args = { phase: t, power: anim.actionPower, aim: anim.actionAim }; if (anim.action === 'shoot') poseShot1(overlay, shot1Time()); else if (anim.action === 'pass') posePass(overlay, args); else posePoke(overlay, args); if (anim.action === 'shoot') { if (anim.shotSide !== shot1.shotSide) mirrorStickwork(overlay); } else if (anim.shotSign < 0) mirrorStickwork(overlay); return w; } /** Which socket grip the stick should be using right now, and the blend. */ function gripFor() { if (anim.action === 'windup') return ['carry', 'windup', Math.min(1, anim.actionTime / 0.16)]; if (anim.action === 'shoot') { // Three beats matching poseShot: loaded → blade square on the ice → // follow-through. Contact is short and early so the bottom of the blade // is flush when the puck leaves, not halfway through a blend to high. const t = anim.actionTime / (ACTION_TIME.shoot); if (t < 0.28) return ['windup', 'contact', Math.min(1, t / 0.28)]; if (t < 0.55) return ['contact', 'follow', (t - 0.28) / 0.27]; return ['follow', 'follow', 1]; } if (anim.action === 'poke') return ['carry', 'poke', Math.min(1, anim.actionTime / 0.1)]; if (anim.action === 'pass') return ['carry', 'follow', Math.min(1, anim.actionTime / 0.2) * 0.5]; // Resting: hustling pushes the stick out in front on one hand. return ['carry', 'hustle', anim.hustleGrip]; } const states = { skate: { pre(dt) { advanceCommon(dt); applyMover(); mover.updateMatrixWorld(true); }, pose(P, t) { poseSkate(P, { gait: anim.gait, speed: anim.moveSpeed, bank: anim.bank, phase: anim.stridePhase, t, }); for (const side of ['L', 'R']) { const p = (anim.stridePhase + (side === 'R' ? 0.5 : 0)) % 1; const toe = strideLocal(side, p, anim.gait, _localFoot); writeFoot(P, side, _localFoot, toe); } }, }, stop: { blendTime: 0.12, enter() { // Which way the skater turns to plant depends on which edge is already // loaded, so a stop out of a right-hand turn continues that rotation. anim.stopDir = anim.bank >= 0 ? 1 : -1; }, pre(dt) { advanceCommon(dt); applyMover(); mover.updateMatrixWorld(true); }, pose(P, t) { poseStop(P, { speed: anim.moveSpeed, dir: anim.stopDir, t }); const bite = clamp(anim.moveSpeed / 6, 0.25, 1); for (const side of ['L', 'R']) { const toe = stopLocal(side, anim.stopDir, bite, _localFoot); writeFoot(P, side, _localFoot, toe); } }, }, }; function snapshot() { for (const n of UPPER.concat(LEGS)) frozen.q[n].copy(B[n].quaternion); frozen.rootOffset.copy(B.root.position); frozen.rootQuat.copy(B.root.quaternion); frozen.foot.L.pos.copy(cur.foot.L.pos); frozen.foot.L.yaw = cur.foot.L.yaw; frozen.foot.R.pos.copy(cur.foot.R.pos); frozen.foot.R.yaw = cur.foot.R.yaw; } const _footWorld = new THREE.Vector3(); /** * Restart the crossfade from whatever pose the skeleton is currently in. * * Used when physics hands the skeleton back after a knockdown: the bones are * wherever the ragdoll left them, and the animator would otherwise snap to a * skating pose on the next frame. Snapshotting the collapsed pose and easing * out of it is the get-up. */ anim.rebase = function rebase(blendTime = 0.6) { snapshot(); // `snapshot` takes the foot targets from `cur`, which for a skater who has // been lying on the ice still holds wherever their blades were before the // hit. Blending the IK out of a stale target drags the legs across the rink // to catch up. Read the feet where they actually are instead. mover.updateMatrixWorld(true); for (const side of ['L', 'R']) { B[`foot${side}`].getWorldPosition(_footWorld); frozen.foot[side].pos.copy(_footWorld); frozen.foot[side].yaw = anim.originYaw; } anim.blend = 0; anim.transitionTime = Math.max(0.05, blendTime); // The feet are wherever the body fell, not where the last stride put them, // so start the stride cycle from a planted stance rather than mid-push. anim.stridePhase = 0; anim.gait = 0; anim.bank = 0; }; anim.setState = function setState(name, blendTime = null) { if (name === anim.state || !states[name]) return; snapshot(); anim.state = name; anim.stateTime = 0; anim.blend = 0; anim.transitionTime = blendTime ?? states[name].blendTime ?? anim.BLEND_TIME; if (states[name].enter) states[name].enter(); }; // ---- two-bone analytic IK ------------------------------------------------ // Lifted from Ludus unchanged. The knee pole is the one skating-specific // detail: it points forward and *outward*, because a skater's knees track // over the outside of the blade rather than straight ahead. const _H = new THREE.Vector3(); const _d = new THREE.Vector3(); const _pole = new THREE.Vector3(); const _e2 = new THREE.Vector3(); const _knee = new THREE.Vector3(); const _dir = new THREE.Vector3(); const _f = new THREE.Vector3(); const _r = new THREE.Vector3(); const _qP = new THREE.Quaternion(); const _q1 = new THREE.Quaternion(); const _q2 = new THREE.Quaternion(); const _qF = new THREE.Quaternion(); const _qInv = new THREE.Quaternion(); const fwdOf = (yaw, out) => out.set(Math.sin(yaw), 0, Math.cos(yaw)); const rightOf = (yaw, out) => out.set(Math.cos(yaw), 0, -Math.sin(yaw)); function solveLeg(side, targetPos, targetYaw) { const thigh = B['thigh' + side]; const shin = B['shin' + side]; const foot = B['foot' + side]; thigh.getWorldPosition(_H); _d.subVectors(targetPos, _H); let d = _d.length(); const a = LEN.thigh; const b = LEN.shin; d = clamp(d, 0.12, a + b - 0.003); _d.normalize(); // Cosine rule for the angle between the thigh axis and the hip->target line. const cosA = clamp((a * a + d * d - b * b) / (2 * a * d), -1, 1); const sinA = Math.sqrt(Math.max(0, 1 - cosA * cosA)); fwdOf(anim.originYaw, _f); rightOf(anim.originYaw, _r); _pole.copy(_f).addScaledVector(_r, side === 'L' ? 0.34 : -0.34); _pole.y -= 0.2; _e2.copy(_pole).addScaledVector(_d, -_pole.dot(_d)); if (_e2.lengthSq() < 1e-8) _e2.copy(_f); _e2.normalize(); _knee.copy(_H).addScaledVector(_d, a * cosA).addScaledVector(_e2, a * sinA); _dir.subVectors(_knee, _H).normalize(); _q1.setFromUnitVectors(restThighDir[side], _dir); thigh.parent.getWorldQuaternion(_qP); _qInv.copy(_qP).invert(); thigh.quaternion.copy(_qInv).multiply(_q1); _dir.subVectors(targetPos, _knee).normalize(); _q2.setFromUnitVectors(restShinDir[side], _dir); _qInv.copy(_q1).invert(); shin.quaternion.copy(_qInv).multiply(_q2); E(_qF, 0, targetYaw, 0, 'YXZ'); _qInv.copy(_q2).invert(); foot.quaternion.copy(_qInv).multiply(_qF); B['toe' + side].quaternion.identity(); } // ---- two-bone arm IK ---------------------------------------------------- // Same solver as the legs, different pole. Used only to pin the lower hand // onto the shaft: a two-handed grip where the second hand merely hovers near // the stick is worse than not showing it at all, and no amount of authored // shoulder angle keeps a hand on a pole that the other arm is swinging. const ARM = { upper: B.forearmL.position.length(), fore: B.handL.position.length(), }; const restUpperArmDir = { L: B.forearmL.position.clone().normalize(), R: B.forearmR.position.clone().normalize(), }; const restForearmDir = { L: B.handL.position.clone().normalize(), R: B.handR.position.clone().normalize(), }; function solveArm(side, targetPos) { const upper = B[`upperArm${side}`]; const fore = B[`forearm${side}`]; upper.getWorldPosition(_H); _d.subVectors(targetPos, _H); let d = _d.length(); const a = ARM.upper; const b = ARM.fore; // Never fully lock the elbow — a straight arm reads as a mannequin. d = clamp(d, 0.12, a + b - 0.02); _d.normalize(); const cosA = clamp((a * a + d * d - b * b) / (2 * a * d), -1, 1); const sinA = Math.sqrt(Math.max(0, 1 - cosA * cosA)); // Elbow hangs below the shoulder and a little outside the ribs. rightOf(anim.originYaw, _r); _pole.set(0, -1, 0).addScaledVector(_r, side === 'L' ? 0.34 : -0.34); _e2.copy(_pole).addScaledVector(_d, -_pole.dot(_d)); if (_e2.lengthSq() < 1e-8) _e2.set(0, -1, 0); _e2.normalize(); _knee.copy(_H).addScaledVector(_d, a * cosA).addScaledVector(_e2, a * sinA); _dir.subVectors(_knee, _H).normalize(); _q1.setFromUnitVectors(restUpperArmDir[side], _dir); upper.parent.getWorldQuaternion(_qP); _qInv.copy(_qP).invert(); upper.quaternion.copy(_qInv).multiply(_q1); _dir.subVectors(targetPos, _knee).normalize(); _q2.setFromUnitVectors(restForearmDir[side], _dir); _qInv.copy(_q1).invert(); fore.quaternion.copy(_qInv).multiply(_q2); } // ---- per-frame update --------------------------------------------------- const _blendFoot = new THREE.Vector3(); const _shaftPoint = new THREE.Vector3(); const _stickTarget = new THREE.Vector3(); const _shaftA = new THREE.Vector3(); const _shaftB = new THREE.Vector3(); const _shaftDir = new THREE.Vector3(); const _handPos = new THREE.Vector3(); const _lowerHandPos = new THREE.Vector3(); const _handQuat = new THREE.Quaternion(); const _shot1Stick = {}; anim.update = function update(dt) { dt *= anim.speed; anim.time += dt; anim.stateTime += dt; anim.blend = Math.min(1, anim.blend + dt / anim.transitionTime); // A hockey stop is worth its own state; everything else is one pose driven // by continuous parameters. anim.setState(anim.braking && anim.moveSpeed > 1.2 ? 'stop' : 'skate'); const st = states[anim.state]; if (st.pre) st.pre(dt); for (const n of UPPER) cur.q[n].identity(); cur.rootOffset.set(0, 0, 0); cur.rootQuat.identity(); st.pose(cur, anim.stateTime); // ---- stickwork layer --------------------------------------------------- // The resting grip: hustling pushes the stick out in front on one hand, // and it eases rather than switching, so half-throttle is half-dangled. // With the puck, both hands stay on — the reference carry is two-handed // even at speed; only a real one-handed dangle (no puck) opens the grip. const hustleTarget = anim.state === 'skate' && !anim.hasPuck ? clamp(anim.effort * 0.6 + clamp(anim.moveSpeed / 7, 0, 1) * 0.6, 0, 1) : anim.hasPuck ? clamp(anim.effort * 0.08, 0, 0.2) : clamp(anim.effort * 0.25, 0, 1); anim.hustleGrip = lerp(anim.hustleGrip, hustleTarget, Math.min(1, 4 * dt)); // Carry pose first — the arms holding the stick at all — then any action // over the top of it. // // Arms are replaced and spine is *multiplied*. The spine already carries // the skating lean and the bank; a shot's coil is a twist on top of that. // Overwriting it was what stood everybody upright the moment they picked up // a stick. for (const n of STICK_BONES) overlay.q[n].identity(); poseCarry(overlay, { hustle: anim.hustleGrip, reach: anim.handling.y, lateral: anim.handling.x, }); // Authored for a right shot; left shots run the mirrored pose set. if (anim.shotSign < 0) mirrorStickwork(overlay); for (const n of STICK_ARMS) cur.q[n].copy(overlay.q[n]); for (const n of STICK_SPINE) cur.q[n].multiply(overlay.q[n]); for (const n of STICK_BONES) overlay.q[n].identity(); const actionWeight = advanceAction(dt); if (actionWeight > 0.001) { for (const n of STICK_ARMS) cur.q[n].slerp(overlay.q[n], actionWeight); for (const n of STICK_SPINE) { _actionSpine.identity().slerp(overlay.q[n], actionWeight); cur.q[n].multiply(_actionSpine); } } const w = smooth(anim.blend); for (const n of UPPER) B[n].quaternion.slerpQuaternions(frozen.q[n], cur.q[n], w); B.root.position.lerpVectors(frozen.rootOffset, cur.rootOffset, w); B.root.quaternion.slerpQuaternions(frozen.rootQuat, cur.rootQuat, w); // Feet are solved after the spine is posed and the matrices refreshed, or // the hip the IK measures from is a frame stale and the legs trail. mover.updateMatrixWorld(true); _blendFoot.lerpVectors(frozen.foot.L.pos, cur.foot.L.pos, w); solveLeg('L', _blendFoot, lerpAngle(frozen.foot.L.yaw, cur.foot.L.yaw, w)); _blendFoot.lerpVectors(frozen.foot.R.pos, cur.foot.R.pos, w); solveLeg('R', _blendFoot, lerpAngle(frozen.foot.R.yaw, cur.foot.R.yaw, w)); mover.updateMatrixWorld(true); // ---- the stick, last --------------------------------------------------- // Socket first: it hangs off the top hand for this shot side. Then the // lower hand is pulled onto the shaft, which needs the stick already // placed — hence the second matrix refresh. if (anim.stick) { const top = anim.topHand; const lower = anim.lowerHand; const clipTime = shot1Time(); const referenceStick = clipTime === null ? null : sampleTiltStick(shot1, clipTime, _shot1Stick); let referenceHandsAligned = false; // The applied studio guide carries a real 3D constraint: the shaft must // cross both saved hand sockets. Use that instead of reconstructing // camera depth from the 2D line, and keep the saved arm pose untouched. if (referenceStick?.alignHands && typeof anim.stick.aimThroughHands === 'function') { B[`hand${top}`].getWorldPosition(_handPos); B[`hand${lower}`].getWorldPosition(_lowerHandPos); B[`hand${top}`].getWorldQuaternion(_handQuat).invert(); referenceHandsAligned = anim.stick.aimThroughHands( _handPos, _lowerHandPos, _handQuat, referenceStick.roll, ); mover.updateMatrixWorld(true); } else { const [from, to, t] = gripFor(); let roll = anim.stick.stanceTarget(from, to, t, _stickTarget); // GRIP targets are authored for a right shot (forehand at −X). Flip the // blade across the body for a left shot, and the roll with it so the // face stays open the same way relative to the forehand. if (anim.shotSign < 0) { _stickTarget.x *= -1; roll = -roll; } // Stickhandling moves the *target*, not just the arm pose. Nudging only // the shoulders moved the blade by centimetres; the puck follows the // blade now, so the Skill Stick has to move the blade to mean anything. // // Lateral is *subtracted*: skater local +X is the left side, but the Skill // Stick's +X is "push right". Adding them lined the deke up mirrored — // stick right sent the puck to the skater's left. Screen-right stays // skater-right for both shot sides. if (anim.hasPuck) { _stickTarget.x -= anim.handling.x * STICK_REACH.side; _stickTarget.z += anim.handling.y * STICK_REACH.fwd; } _stickTarget.applyMatrix4(mover.matrixWorld); B[`hand${top}`].getWorldPosition(_handPos); B[`hand${top}`].getWorldQuaternion(_handQuat); _handQuat.invert(); anim.stick.aimAt(_stickTarget, _handPos, _handQuat, roll); mover.updateMatrixWorld(true); } // Two hands on it whenever the stick is being used for something, and // not while it is being dangled out on one. // // Skip the lower-hand IK while a pose crossfade is still running (get-up // from a knockdown, state change). IK overwrites the bone fully, so // applying it on top of a blend from a limp pose yanks the lower hand // onto the shaft mid-rise — measured as a ~0.9 m jump on handR for a // left shot, where the lower hand *is* the right. const twoHanded = (1 - anim.hustleGrip) * (anim.action === 'poke' ? 0.15 : 1) * (anim.blend >= 1 ? 1 : 0); if (twoHanded > 0.05 && !referenceHandsAligned) { // Preferred lower-hand grip is a bit down the shaft (hands apart, the // way the reference draws a carry). If that point is past the arm's // reach, slide up toward the butt until it is — never leave the hand // waving short of the stick, and never stack both hands on the butt. anim.stick.shaftSegment(_shaftA, _shaftB); B[`upperArm${lower}`].getWorldPosition(_H); _shaftDir.subVectors(_shaftB, _shaftA); const len = _shaftDir.length() || 1; const armReach = ARM.upper + ARM.fore - 0.03; // ~quarter of the way down when we can; closer when we must. let gripT = 0.28; _shaftPoint.copy(_shaftA).addScaledVector(_shaftDir, gripT); if (_H.distanceTo(_shaftPoint) > armReach) { gripT = 0.28; while (gripT > 0.12) { _shaftPoint.copy(_shaftA).addScaledVector(_shaftDir, gripT); if (_H.distanceTo(_shaftPoint) <= armReach) break; gripT -= 0.02; } // Last resort: nearest point on the reachable band of the shaft. if (_H.distanceTo(_shaftPoint) > armReach) { segDist(_H, _shaftA, _shaftB, _shaftPoint); const tNear = clamp( _shaftPoint.clone().sub(_shaftA).dot(_shaftDir) / (len * len), 0.12, 0.55, ); gripT = tNear; _shaftPoint.copy(_shaftA).addScaledVector(_shaftDir, gripT); } } solveArm(lower, _shaftPoint); mover.updateMatrixWorld(true); } } }; anim.states = states; anim.stateNames = Object.keys(states); applyMover(); return anim; }