Initial commit
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user