Initial commit

This commit is contained in:
ryanfitzpatrickio
2026-08-03 06:43:21 -05:00
commit 7ee3e9d02f
63 changed files with 15792 additions and 0 deletions
+674
View File
@@ -0,0 +1,674 @@
import * as THREE from 'three';
import { E, clamp, segDist, smooth } from '../core/math.js';
import { lerp, lerpAngle } from '../../shared/scalar.js';
import { poseSkate, poseStop } from './poses/skate.js';
import {
STICK_ARMS, STICK_BONES, STICK_SPINE,
poseCarry, posePass, posePoke, poseShot, poseWindup,
} from './poses/stickwork.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,
/** Eased 0..1 between the settled grip and the one-handed dangle. */
hustleGrip: 0,
};
/** 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;
/** Scratch pose the action layer writes into before being blended over. */
const overlay = newPose();
const _actionSpine = new THREE.Quaternion();
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.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);
poseWindup(overlay, { phase: anim.charge, aim: anim.actionAim });
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 w = t > 1 - ACTION_BLEND / duration
? Math.max(0, (1 - t) * duration / ACTION_BLEND)
: Math.min(1, anim.actionTime / (ACTION_BLEND * 0.5));
const args = { phase: t, power: anim.actionPower, aim: anim.actionAim };
if (anim.action === 'shoot') poseShot(overlay, args);
else if (anim.action === 'pass') posePass(overlay, args);
else posePoke(overlay, args);
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') {
const t = anim.actionTime / (ACTION_TIME.shoot);
return ['windup', 'follow', Math.min(1, t / 0.45)];
}
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 _handQuat = new THREE.Quaternion();
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,
});
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, because it hangs off the right hand and the arm has only
// just been posed. Then the lower hand is pulled onto the shaft, which
// needs the stick already placed — hence the second matrix refresh.
if (anim.stick) {
const [from, to, t] = gripFor();
const roll = anim.stick.stanceTarget(from, to, t, _stickTarget);
// 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.
if (anim.hasPuck) {
_stickTarget.x -= anim.handling.x * STICK_REACH.side;
_stickTarget.z += anim.handling.y * STICK_REACH.fwd;
}
_stickTarget.applyMatrix4(mover.matrixWorld);
B.handR.getWorldPosition(_handPos);
B.handR.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.
const twoHanded = (1 - anim.hustleGrip) * (anim.action === 'poke' ? 0.15 : 1);
if (twoHanded > 0.05) {
// 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.upperArmL.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('L', _shaftPoint);
mover.updateMatrixWorld(true);
}
}
};
anim.states = states;
anim.stateNames = Object.keys(states);
applyMover();
return anim;
}