788 lines
31 KiB
JavaScript
788 lines
31 KiB
JavaScript
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;
|
||
}
|