483 lines
21 KiB
JavaScript
483 lines
21 KiB
JavaScript
import * as THREE from 'three';
|
||
import { buildSkeleton } from '../src/character/skeleton.js';
|
||
import { buildAnimator } from '../src/anim/skateAnimator.js';
|
||
import { shot1 } from '../src/anim/clips/shot1.js';
|
||
import { frameSpan } from '../src/anim/clip.js';
|
||
import { buildStick } from '../src/character/stick.js';
|
||
import { segDist } from '../src/core/math.js';
|
||
import { topHandFor } from '../shared/player.js';
|
||
import { done, ok, section } from './harness.mjs';
|
||
|
||
/**
|
||
* Animator checks, run headlessly.
|
||
*
|
||
* Nothing here needs a GPU: the skeleton is three.js Bones and the animator is
|
||
* maths. That makes the pose the one part of the render path that can be
|
||
* regression-tested, which is worth doing because "the skater looks wrong" is
|
||
* otherwise only ever caught by a human squinting at a screenshot.
|
||
*/
|
||
|
||
const DT = 1 / 60;
|
||
|
||
function rig(shotSide = 'right') {
|
||
const skelData = buildSkeleton();
|
||
const mover = new THREE.Group();
|
||
// Same hierarchy as createSkater: skeleton rides on the mover so body yaw
|
||
// carries the bones. Leaving the root unparented made every yaw test a lie —
|
||
// the stick target orbited in world space while the hand sat still.
|
||
mover.add(skelData.rootBone);
|
||
const anim = buildAnimator(skelData, mover);
|
||
anim.setShotSide(shotSide);
|
||
// The stick is part of the pose now — it hangs off the top hand for this
|
||
// shot side and the animator aims it, so a rig without one is not the rig
|
||
// the game runs.
|
||
const stick = buildStick(null, null, 0);
|
||
stick.attachTo(skelData.bones[`hand${topHandFor(shotSide)}`]);
|
||
anim.stick = stick;
|
||
return { skelData, mover, anim, stick };
|
||
}
|
||
|
||
/** Drive the animator for `seconds` under a fixed set of inputs. */
|
||
function drive(r, seconds, inputs) {
|
||
const steps = Math.round(seconds / DT);
|
||
for (let i = 0; i < steps; i++) {
|
||
Object.assign(r.anim, inputs);
|
||
r.anim.update(DT);
|
||
}
|
||
return r;
|
||
}
|
||
|
||
const _v = new THREE.Vector3();
|
||
/** World position of a bone, relative to the mover's own frame. */
|
||
function bonePos(r, name) {
|
||
r.mover.updateMatrixWorld(true);
|
||
r.skelData.bones[name].getWorldPosition(_v);
|
||
return _v.clone().sub(r.mover.position);
|
||
}
|
||
|
||
/** How far a limb sticks out sideways, as an angle from straight down. */
|
||
function spread(hip, hand) {
|
||
const dx = Math.abs(hand.x - hip.x);
|
||
const dy = hip.y - hand.y;
|
||
return Math.atan2(dx, Math.max(1e-6, dy));
|
||
}
|
||
|
||
const GLIDE = { moveSpeed: 6, bladeSpeed: 6, effort: 0, yawRate: 0, braking: false, originYaw: 0 };
|
||
const STRIDE = { moveSpeed: 6, bladeSpeed: 6, effort: 1, yawRate: 0, braking: false, originYaw: 0 };
|
||
const STAND = { moveSpeed: 0, bladeSpeed: 0, effort: 0, yawRate: 0, braking: false, originYaw: 0 };
|
||
const CARVE = { moveSpeed: 7, bladeSpeed: 7, effort: 0.8, yawRate: 1.2, braking: false, originYaw: 0 };
|
||
|
||
section('nothing produces NaN');
|
||
{
|
||
for (const [name, inputs] of Object.entries({ GLIDE, STRIDE, STAND, CARVE })) {
|
||
const r = drive(rig(), 4, inputs);
|
||
let bad = 0;
|
||
for (const b of r.skelData.list) {
|
||
for (const e of b.matrixWorld.elements) if (!Number.isFinite(e)) bad++;
|
||
}
|
||
ok(bad === 0, `${name} leaves every bone matrix finite`);
|
||
}
|
||
}
|
||
|
||
section('the skater stands on the ice, not in it or above it');
|
||
{
|
||
for (const [name, inputs] of Object.entries({ GLIDE, STRIDE, STAND, CARVE })) {
|
||
const r = drive(rig(), 4, inputs);
|
||
for (const side of ['L', 'R']) {
|
||
const foot = bonePos(r, `foot${side}`);
|
||
ok(foot.y > -0.02, `${name}: ${side} foot is not through the ice (y=${foot.y.toFixed(3)})`);
|
||
ok(foot.y < 0.35, `${name}: ${side} foot is not floating (y=${foot.y.toFixed(3)})`);
|
||
}
|
||
}
|
||
}
|
||
|
||
section('the skater is crouched, and more so under a stride');
|
||
{
|
||
const glide = drive(rig(), 4, GLIDE);
|
||
const stride = drive(rig(), 4, STRIDE);
|
||
const hipG = bonePos(glide, 'pelvis').y;
|
||
const hipS = bonePos(stride, 'pelvis').y;
|
||
// Rest pelvis height is 1.0; a hockey stance sits well under that.
|
||
ok(hipG < 0.95, `gliding hips are below rest height (${hipG.toFixed(3)})`);
|
||
ok(hipS < hipG, `a stride sits deeper than a glide (${hipS.toFixed(3)} vs ${hipG.toFixed(3)})`);
|
||
ok(hipS > 0.6, `but not folded in half (${hipS.toFixed(3)})`);
|
||
|
||
const head = bonePos(stride, 'head');
|
||
ok(head.y > hipS + 0.35, `the head is still well above the hips (${head.y.toFixed(3)})`);
|
||
}
|
||
|
||
section('the torso is pitched forward, but not folded over');
|
||
{
|
||
/** Angle of the pelvis→neck line from vertical, degrees. */
|
||
function torsoAngle(inputs) {
|
||
const r = drive(rig(), 4, inputs);
|
||
const hips = bonePos(r, 'pelvis');
|
||
const neck = bonePos(r, 'neck');
|
||
const up = neck.clone().sub(hips);
|
||
return Math.atan2(Math.hypot(up.x, up.z), up.y) * 57.3;
|
||
}
|
||
const stand = torsoAngle(STAND);
|
||
const stride = torsoAngle(STRIDE);
|
||
ok(stand > 3 && stand < 22, `a standing skater is slightly forward (${stand.toFixed(0)}°)`);
|
||
ok(stride > 25, `at speed they are properly over their skates (${stride.toFixed(0)}°)`);
|
||
ok(stride < 55, `but not bent double (${stride.toFixed(0)}°)`);
|
||
ok(stride > stand + 8, 'and more folded moving than standing');
|
||
|
||
// The head has to come back up, or they are skating looking at their boots.
|
||
// Measured off the head bone's own forward axis rather than off a bone
|
||
// offset: the head is a leaf, so there is no child position to read a
|
||
// direction from.
|
||
const r = drive(rig(), 4, STRIDE);
|
||
r.mover.updateMatrixWorld(true);
|
||
const gazeDir = new THREE.Vector3(0, 0, 1)
|
||
.applyQuaternion(r.skelData.bones.head.getWorldQuaternion(new THREE.Quaternion()));
|
||
const gaze = Math.asin(-gazeDir.y) * 57.3;
|
||
ok(gaze < stride - 8, `the eyes are up the ice, not on the boots (${gaze.toFixed(0)}° down vs a ${stride.toFixed(0)}° torso)`);
|
||
ok(gaze > -20, 'and not craned back at the roof');
|
||
}
|
||
|
||
section('arms hang by the body, not out in a T-pose');
|
||
{
|
||
for (const [name, inputs] of Object.entries({ GLIDE, STRIDE, STAND })) {
|
||
const r = drive(rig(), 4, inputs);
|
||
for (const side of ['L', 'R']) {
|
||
const shoulder = bonePos(r, `upperArm${side}`);
|
||
const hand = bonePos(r, `hand${side}`);
|
||
const angle = spread(shoulder, hand);
|
||
// Wider than the old free-arm limit on purpose: these hands are holding
|
||
// a stick out in front, which is not the same silhouette as a skater
|
||
// swinging their arms.
|
||
ok(
|
||
angle < 1.15,
|
||
`${name}: ${side} arm is within 66° of the body (${(angle * 57.3).toFixed(0)}°)`,
|
||
);
|
||
ok(hand.y < shoulder.y, `${name}: ${side} hand is below the shoulder`);
|
||
// Hands carried in front, the way a skater carries them.
|
||
ok(hand.z > -0.15, `${name}: ${side} hand is not trailing behind the back (z=${hand.z.toFixed(2)})`);
|
||
}
|
||
}
|
||
}
|
||
|
||
section('the elbows are bent');
|
||
{
|
||
const r = drive(rig(), 4, STRIDE);
|
||
for (const side of ['L', 'R']) {
|
||
const shoulder = bonePos(r, `upperArm${side}`);
|
||
const elbow = bonePos(r, `forearm${side}`);
|
||
const hand = bonePos(r, `hand${side}`);
|
||
const upper = elbow.clone().sub(shoulder).normalize();
|
||
const fore = hand.clone().sub(elbow).normalize();
|
||
const bend = Math.acos(Math.max(-1, Math.min(1, upper.dot(fore))));
|
||
ok(bend > 0.35, `${side} elbow is bent (${(bend * 57.3).toFixed(0)}°)`);
|
||
ok(bend < 2.2, `${side} elbow is not folded shut (${(bend * 57.3).toFixed(0)}°)`);
|
||
}
|
||
}
|
||
|
||
section('a stride moves the legs, a glide does not');
|
||
{
|
||
function footTravel(inputs) {
|
||
const r = rig();
|
||
let minZ = Infinity;
|
||
let maxZ = -Infinity;
|
||
for (let i = 0; i < 240; i++) {
|
||
Object.assign(r.anim, inputs);
|
||
r.anim.update(DT);
|
||
const f = bonePos(r, 'footL');
|
||
minZ = Math.min(minZ, f.z);
|
||
maxZ = Math.max(maxZ, f.z);
|
||
}
|
||
return maxZ - minZ;
|
||
}
|
||
const strideTravel = footTravel(STRIDE);
|
||
const glideTravel = footTravel(GLIDE);
|
||
ok(strideTravel > 0.3, `a stride swings the blade fore and aft (${strideTravel.toFixed(2)}m)`);
|
||
ok(glideTravel < 0.08, `a glide holds it still (${glideTravel.toFixed(2)}m)`);
|
||
}
|
||
|
||
section('the skater banks into a turn');
|
||
{
|
||
const straight = drive(rig(), 3, { ...CARVE, yawRate: 0 });
|
||
const right = drive(rig(), 3, { ...CARVE, yawRate: 1.4 });
|
||
const left = drive(rig(), 3, { ...CARVE, yawRate: -1.4 });
|
||
|
||
ok(Math.abs(straight.anim.bank) < 0.02, 'no bank on a straight line');
|
||
ok(right.anim.bank > 0.3, `a right-hand turn banks right (${right.anim.bank.toFixed(2)} rad)`);
|
||
ok(left.anim.bank < -0.3, `a left-hand turn banks left (${left.anim.bank.toFixed(2)} rad)`);
|
||
|
||
// The lean has to show up in the body, not just in the number.
|
||
const headR = bonePos(right, 'head');
|
||
const headS = bonePos(straight, 'head');
|
||
ok(headR.x > headS.x + 0.1, `the head leads into the turn (${headR.x.toFixed(2)} vs ${headS.x.toFixed(2)})`);
|
||
}
|
||
|
||
section('a hockey stop is a different pose');
|
||
{
|
||
const skate = drive(rig(), 3, { ...STRIDE, braking: false });
|
||
const stop = drive(rig(), 3, { ...STRIDE, braking: true });
|
||
ok(stop.anim.state === 'stop', 'braking at speed enters the stop state');
|
||
ok(skate.anim.state === 'skate', 'and not braking does not');
|
||
|
||
// Blades across the travel: the toes should be turned well off the body's
|
||
// forward axis, which is what actually scrapes the ice.
|
||
const l = stop.skelData.bones.footL.getWorldQuaternion(new THREE.Quaternion());
|
||
const fwd = new THREE.Vector3(0, 0, 1).applyQuaternion(l);
|
||
const off = Math.abs(Math.atan2(fwd.x, fwd.z));
|
||
ok(off > 0.7, `the blades are thrown across the travel (${(off * 57.3).toFixed(0)}°)`);
|
||
}
|
||
|
||
section('a slow skater does not enter the stop state');
|
||
{
|
||
const r = drive(rig(), 3, { ...STAND, braking: true });
|
||
ok(r.anim.state === 'skate', 'braking from a standstill is not a hockey stop');
|
||
}
|
||
|
||
section('feet stay under the body');
|
||
{
|
||
for (const [name, inputs] of Object.entries({ GLIDE, STRIDE, CARVE })) {
|
||
const r = drive(rig(), 4, inputs);
|
||
for (const side of ['L', 'R']) {
|
||
const foot = bonePos(r, `foot${side}`);
|
||
ok(Math.abs(foot.x) < 0.75, `${name}: ${side} blade is not splayed out (x=${foot.x.toFixed(2)})`);
|
||
ok(Math.abs(foot.z) < 0.6, `${name}: ${side} blade is not stretched out (z=${foot.z.toFixed(2)})`);
|
||
}
|
||
}
|
||
}
|
||
|
||
section('the blade is on the ice, ahead of the skater');
|
||
{
|
||
// The failure this pins: a socket rotation authored in hand space composes
|
||
// with whatever the arm is doing, so a grip tuned for one gait floats the
|
||
// blade half a metre up in another. Checked across every skating stance.
|
||
for (const [name, inputs] of Object.entries({ GLIDE, STRIDE, STAND, CARVE })) {
|
||
const r = drive(rig(), 4, { ...inputs, hasPuck: true });
|
||
const blade = new THREE.Vector3();
|
||
r.stick.bladeWorld(blade);
|
||
const inv = new THREE.Matrix4().copy(r.mover.matrixWorld).invert();
|
||
const local = blade.clone().applyMatrix4(inv);
|
||
ok(local.y > -0.02 && local.y < 0.16, `${name}: blade is on the ice (y=${local.y.toFixed(3)})`);
|
||
ok(local.z > 0.5, `${name}: and out in front (z=${local.z.toFixed(2)})`);
|
||
// Carry keeps the blade near the body midline, slightly forehand — not
|
||
// parked a metre off the hip.
|
||
ok(Math.abs(local.x) < 0.75, `${name}: not flung out sideways (x=${local.x.toFixed(2)})`);
|
||
}
|
||
}
|
||
|
||
section('the stick is held, not floating');
|
||
{
|
||
// Puck carry must be two-handed: top hand on the butt, lower hand on the
|
||
// shaft. The old pose parked the stick on the hip and left the off-hand
|
||
// ~25 cm short — the failure the motion-reference carry frame calls out.
|
||
for (const side of ['right', 'left']) {
|
||
const r = drive(rig(side), 4, { ...GLIDE, effort: 0.2, hasPuck: true });
|
||
r.mover.updateMatrixWorld(true);
|
||
const butt = new THREE.Vector3();
|
||
const heel = new THREE.Vector3();
|
||
r.stick.shaftSegment(butt, heel);
|
||
const top = new THREE.Vector3();
|
||
r.skelData.bones[`hand${r.anim.topHand}`].getWorldPosition(top);
|
||
ok(top.distanceTo(butt) < 0.12, `${side}: top hand is on the butt (${top.distanceTo(butt).toFixed(3)}m)`);
|
||
|
||
const lower = new THREE.Vector3();
|
||
const closest = new THREE.Vector3();
|
||
r.skelData.bones[`hand${r.anim.lowerHand}`].getWorldPosition(lower);
|
||
const gap = segDist(lower, butt, heel, closest);
|
||
ok(gap < 0.08, `${side}: lower hand is on the shaft (${gap.toFixed(3)}m)`);
|
||
|
||
// Stick sits in front of the body, not parked out on the hip.
|
||
const inv = new THREE.Matrix4().copy(r.mover.matrixWorld).invert();
|
||
const handLocal = top.clone().applyMatrix4(inv);
|
||
ok(Math.abs(handLocal.x) < 0.28, `${side}: top hand is in front of the torso (x=${handLocal.x.toFixed(2)})`);
|
||
ok(handLocal.z > 0.25, `${side}: top hand is out in front (z=${handLocal.z.toFixed(2)})`);
|
||
}
|
||
}
|
||
|
||
section('the stick stays in the socket when the body turns');
|
||
{
|
||
// Failure this pins: aiming with setFromUnitVectors in *world* space leaves a
|
||
// free twist around the shaft that does not cancel under parent yaw. The stick
|
||
// then rolls with every body turn instead of holding a fixed grip in the hand.
|
||
const r = rig();
|
||
// Settle derived quantities first so the spin only changes yaw.
|
||
drive(r, 2, { ...GLIDE, hasPuck: true, yawRate: 0 });
|
||
const local0 = new THREE.Quaternion();
|
||
const local = new THREE.Quaternion();
|
||
const handQ = new THREE.Quaternion();
|
||
const stickQ = new THREE.Quaternion();
|
||
let maxDelta = 0;
|
||
const topBone = r.skelData.bones[`hand${r.anim.topHand}`];
|
||
|
||
for (let i = 0; i < 48; i++) {
|
||
const yaw = (i / 48) * Math.PI * 2;
|
||
r.anim.setTransform(r.mover.position, yaw);
|
||
Object.assign(r.anim, { ...GLIDE, hasPuck: true, originYaw: yaw, yawRate: 0 });
|
||
r.anim.update(DT);
|
||
topBone.getWorldQuaternion(handQ);
|
||
r.stick.group.getWorldQuaternion(stickQ);
|
||
local.copy(handQ).invert().multiply(stickQ);
|
||
if (i === 0) local0.copy(local);
|
||
// 1 - |dot| is 0 for identical orientations (including double-cover).
|
||
maxDelta = Math.max(maxDelta, 1 - Math.abs(local0.dot(local)));
|
||
}
|
||
ok(maxDelta < 0.02, `stick local pose is stable across a full spin (delta ${maxDelta.toFixed(4)})`);
|
||
}
|
||
|
||
section('shot side puts the blade on the matching forehand');
|
||
{
|
||
// Right shot: blade on the skater's right (−X). Left shot: mirrored to +X.
|
||
const right = drive(rig('right'), 3, { ...GLIDE, hasPuck: true });
|
||
const left = drive(rig('left'), 3, { ...GLIDE, hasPuck: true });
|
||
const invR = new THREE.Matrix4().copy(right.mover.matrixWorld).invert();
|
||
const invL = new THREE.Matrix4().copy(left.mover.matrixWorld).invert();
|
||
const br = new THREE.Vector3();
|
||
const bl = new THREE.Vector3();
|
||
right.stick.bladeWorld(br);
|
||
left.stick.bladeWorld(bl);
|
||
br.applyMatrix4(invR);
|
||
bl.applyMatrix4(invL);
|
||
ok(br.x < -0.05, `right shot carries on the right (x=${br.x.toFixed(2)})`);
|
||
ok(bl.x > 0.05, `left shot carries on the left (x=${bl.x.toFixed(2)})`);
|
||
ok(right.anim.topHand === 'R' && right.anim.lowerHand === 'L', 'right shot: top R, lower L');
|
||
ok(left.anim.topHand === 'L' && left.anim.lowerHand === 'R', 'left shot: top L, lower R');
|
||
ok(right.anim.shotSign === 1 && left.anim.shotSign === -1, 'shotSign tracks the side');
|
||
}
|
||
|
||
section('stick actions run and finish');
|
||
{
|
||
for (const action of ['shoot', 'pass', 'poke']) {
|
||
const r = rig();
|
||
drive(r, 1, GLIDE);
|
||
r.anim.playAction(action, { power: 1 });
|
||
ok(r.anim.action === action, `${action} started`);
|
||
// Halfway through it must still be running.
|
||
for (let i = 0; i < 8; i++) {
|
||
Object.assign(r.anim, GLIDE);
|
||
r.anim.update(DT);
|
||
}
|
||
ok(r.anim.action === action, `${action} is still running mid-way`);
|
||
for (let i = 0; i < 60; i++) {
|
||
Object.assign(r.anim, GLIDE);
|
||
r.anim.update(DT);
|
||
}
|
||
ok(r.anim.action === null, `${action} finished and cleared`);
|
||
}
|
||
}
|
||
|
||
section('a wind-up uses the first half of saved shot1 and holds');
|
||
{
|
||
const r = rig('left');
|
||
drive(r, 1, { ...GLIDE, hasPuck: true });
|
||
const flat = new THREE.Vector3();
|
||
r.stick.bladeWorld(flat);
|
||
const inv = new THREE.Matrix4().copy(r.mover.matrixWorld).invert();
|
||
const flatLocal = flat.clone().applyMatrix4(inv);
|
||
|
||
r.anim.action = 'windup';
|
||
r.anim.actionTime = 0;
|
||
for (let i = 0; i < 90; i++) {
|
||
Object.assign(r.anim, { ...GLIDE, hasPuck: true, charge: 1 });
|
||
r.anim.action = 'windup';
|
||
r.anim.update(DT);
|
||
}
|
||
const back = new THREE.Vector3();
|
||
r.stick.bladeWorld(back);
|
||
const backLocal = back.clone().applyMatrix4(inv);
|
||
// The saved midpoint is the held load pose.
|
||
ok(backLocal.y > 0.28, `the blade is lifted (y=${backLocal.y.toFixed(2)})`);
|
||
ok(backLocal.y > flatLocal.y + 0.2, `well above the carry (${flatLocal.y.toFixed(2)} → ${backLocal.y.toFixed(2)})`);
|
||
ok(backLocal.z < flatLocal.z - 0.2, `and drawn back from the carry (z ${flatLocal.z.toFixed(2)} → ${backLocal.z.toFixed(2)})`);
|
||
const savedMidpoint = shot1.keyframes.find((frame) => frame.time === shot1.duration * 0.5);
|
||
const poseDelta = r.skelData.bones.upperArmL.quaternion.angleTo(
|
||
new THREE.Quaternion().fromArray(savedMidpoint.rotations.upperArmL),
|
||
);
|
||
ok(poseDelta < 1e-6, `held wind-up lands exactly on shot1's midpoint (Δ=${poseDelta.toFixed(6)})`);
|
||
ok(r.anim.action === 'windup', 'and the wind-up is held, not played once');
|
||
|
||
// Both hands stay on the shaft through the load.
|
||
const butt = new THREE.Vector3();
|
||
const heel = new THREE.Vector3();
|
||
r.stick.shaftSegment(butt, heel);
|
||
const handR = new THREE.Vector3();
|
||
r.skelData.bones.handR.getWorldPosition(handR);
|
||
const onShaft = segDist(handR, butt, heel, new THREE.Vector3());
|
||
ok(onShaft < 0.1, `lower hand stays on the stick (dist ${onShaft.toFixed(3)})`);
|
||
}
|
||
|
||
section('a regular shot plays the whole saved shot1 clip');
|
||
{
|
||
const r = rig('left');
|
||
drive(r, 1, { ...GLIDE, hasPuck: true });
|
||
r.anim.playAction('shoot', { power: 1 });
|
||
// The 6-second reference is compressed into the 0.33-second motion window;
|
||
// the remaining 0.09 seconds are the existing blend back to skating.
|
||
r.anim.actionTime = 0.33 - DT;
|
||
Object.assign(r.anim, { ...GLIDE, hasPuck: true });
|
||
r.anim.update(DT);
|
||
const savedEnd = shot1.keyframes.at(-1);
|
||
const poseDelta = r.skelData.bones.upperArmL.quaternion.angleTo(
|
||
new THREE.Quaternion().fromArray(savedEnd.rotations.upperArmL),
|
||
);
|
||
ok(poseDelta < 1e-5, `regular shot reaches shot1's final key before fading (Δ=${poseDelta.toFixed(6)})`);
|
||
|
||
const butt = new THREE.Vector3();
|
||
const heel = new THREE.Vector3();
|
||
const lowerHand = r.skelData.bones.handR.getWorldPosition(new THREE.Vector3());
|
||
r.stick.shaftSegment(butt, heel);
|
||
const onShaft = segDist(lowerHand, butt, heel, new THREE.Vector3());
|
||
ok(onShaft < 1e-5, `saved shot keeps the guide through both hands (dist ${onShaft.toFixed(6)})`);
|
||
}
|
||
|
||
section('releasing a held wind-up continues through shot1 second half');
|
||
{
|
||
const r = rig('left');
|
||
drive(r, 1, { ...GLIDE, hasPuck: true });
|
||
r.anim.action = 'windup';
|
||
drive(r, 0.2, { ...GLIDE, hasPuck: true, charge: 1 });
|
||
r.anim.playAction('shoot', { power: 1 });
|
||
Object.assign(r.anim, { ...GLIDE, hasPuck: true });
|
||
r.anim.update(DT);
|
||
|
||
const referenceTime = shot1.duration * (0.5 + 0.5 * DT / 0.33);
|
||
const span = frameSpan(shot1, referenceTime);
|
||
const expected = new THREE.Quaternion().slerpQuaternions(
|
||
new THREE.Quaternion().fromArray(span.a.rotations.upperArmL),
|
||
new THREE.Quaternion().fromArray(span.b.rotations.upperArmL),
|
||
span.alpha,
|
||
);
|
||
const poseDelta = r.skelData.bones.upperArmL.quaternion.angleTo(expected);
|
||
ok(r.anim.actionFromWindup, 'release remembers that the first half was already held');
|
||
ok(poseDelta < 1e-5, `release continues from the midpoint instead of replaying the load (Δ=${poseDelta.toFixed(6)})`);
|
||
}
|
||
|
||
section('Skill Stick right moves the blade to the skater\'s right');
|
||
{
|
||
// Local +X is the skater's *left*. Skill Stick +X is pad-right. Getting the
|
||
// sign wrong mirrored every deke.
|
||
const right = drive(rig(), 3, { ...GLIDE, hasPuck: true, handling: { x: 1, y: 0 } });
|
||
const left = drive(rig(), 3, { ...GLIDE, hasPuck: true, handling: { x: -1, y: 0 } });
|
||
const inv = new THREE.Matrix4().copy(right.mover.matrixWorld).invert();
|
||
const br = new THREE.Vector3();
|
||
const bl = new THREE.Vector3();
|
||
right.stick.bladeWorld(br);
|
||
left.stick.bladeWorld(bl);
|
||
br.applyMatrix4(inv);
|
||
bl.applyMatrix4(new THREE.Matrix4().copy(left.mover.matrixWorld).invert());
|
||
// Skater's right is −X: stick-right must land more negative than stick-left.
|
||
ok(br.x < bl.x - 0.3, `stick-right is on the right (x ${br.x.toFixed(2)} vs ${bl.x.toFixed(2)})`);
|
||
}
|
||
|
||
section('hustling changes the grip');
|
||
{
|
||
const settled = drive(rig(), 4, { ...GLIDE, effort: 0, moveSpeed: 1, hasPuck: true });
|
||
const flatOut = drive(rig(), 4, { ...STRIDE, hasPuck: false });
|
||
ok(settled.anim.hustleGrip < 0.35, `a settled skater keeps two hands on it (${settled.anim.hustleGrip.toFixed(2)})`);
|
||
ok(flatOut.anim.hustleGrip > 0.7, `a skater at full stride dangles it (${flatOut.anim.hustleGrip.toFixed(2)})`);
|
||
|
||
const a = new THREE.Vector3();
|
||
const b = new THREE.Vector3();
|
||
settled.stick.bladeWorld(a);
|
||
flatOut.stick.bladeWorld(b);
|
||
ok(b.z > a.z + 0.1, `and pushes the blade further out front (${a.z.toFixed(2)} → ${b.z.toFixed(2)})`);
|
||
}
|
||
|
||
done('pose');
|