animations
This commit is contained in:
+138
-25
@@ -1,11 +1,15 @@
|
||||
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,
|
||||
poseCarry, posePass, posePoke, poseShot, poseWindup,
|
||||
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';
|
||||
|
||||
/**
|
||||
@@ -133,18 +137,68 @@ export function buildAnimator(skelData, mover) {
|
||||
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();
|
||||
|
||||
@@ -256,6 +310,7 @@ export function buildAnimator(skelData, mover) {
|
||||
* 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;
|
||||
@@ -276,7 +331,10 @@ export function buildAnimator(skelData, mover) {
|
||||
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 });
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -288,14 +346,23 @@ export function buildAnimator(skelData, mover) {
|
||||
}
|
||||
// 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
|
||||
const fading = t > 1 - ACTION_BLEND / duration;
|
||||
const w = fading
|
||||
? Math.max(0, (1 - t) * duration / ACTION_BLEND)
|
||||
: Math.min(1, anim.actionTime / (ACTION_BLEND * 0.5));
|
||||
// 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') poseShot(overlay, args);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -303,8 +370,13 @@ export function buildAnimator(skelData, mover) {
|
||||
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);
|
||||
return ['windup', 'follow', Math.min(1, t / 0.45)];
|
||||
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];
|
||||
@@ -530,7 +602,9 @@ export function buildAnimator(skelData, mover) {
|
||||
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;
|
||||
@@ -574,6 +648,8 @@ export function buildAnimator(skelData, mover) {
|
||||
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]);
|
||||
|
||||
@@ -602,40 +678,77 @@ export function buildAnimator(skelData, mover) {
|
||||
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.
|
||||
// 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 [from, to, t] = gripFor();
|
||||
const roll = anim.stick.stanceTarget(from, to, t, _stickTarget);
|
||||
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.
|
||||
if (anim.hasPuck) {
|
||||
_stickTarget.x -= anim.handling.x * STICK_REACH.side;
|
||||
_stickTarget.z += anim.handling.y * STICK_REACH.fwd;
|
||||
// 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);
|
||||
}
|
||||
_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) {
|
||||
//
|
||||
// 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.upperArmL.getWorldPosition(_H);
|
||||
B[`upperArm${lower}`].getWorldPosition(_H);
|
||||
_shaftDir.subVectors(_shaftB, _shaftA);
|
||||
const len = _shaftDir.length() || 1;
|
||||
const armReach = ARM.upper + ARM.fore - 0.03;
|
||||
@@ -661,7 +774,7 @@ export function buildAnimator(skelData, mover) {
|
||||
_shaftPoint.copy(_shaftA).addScaledVector(_shaftDir, gripT);
|
||||
}
|
||||
}
|
||||
solveArm('L', _shaftPoint);
|
||||
solveArm(lower, _shaftPoint);
|
||||
mover.updateMatrixWorld(true);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user