animations
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
import * as THREE from 'three';
|
||||
import { buildSkeleton } from '../src/character/skeleton.js';
|
||||
import { buildStick } from '../src/character/stick.js';
|
||||
import {
|
||||
applyTiltAnimation,
|
||||
applyTiltClip,
|
||||
captureSkeletonKeyframe,
|
||||
clipAsModule,
|
||||
createTiltClip,
|
||||
deleteClipKeyframe,
|
||||
frameSpan,
|
||||
sanitizeTiltClip,
|
||||
sampleTiltStick,
|
||||
setClipKeyframe,
|
||||
smoothTiltClip,
|
||||
} from '../src/anim/clip.js';
|
||||
import { done, ok, section } from './harness.mjs';
|
||||
|
||||
section('Tilt clips capture and interpolate the native skeleton');
|
||||
{
|
||||
const skel = buildSkeleton();
|
||||
const clip = createTiltClip({ name: 'test-motion', loop: false, shotSide: 'left' });
|
||||
skel.bones.upperArmL.quaternion.setFromAxisAngle(new THREE.Vector3(1, 0, 0), 0);
|
||||
setClipKeyframe(clip, captureSkeletonKeyframe(skel, 0));
|
||||
skel.bones.upperArmL.quaternion.setFromAxisAngle(new THREE.Vector3(1, 0, 0), Math.PI / 2);
|
||||
setClipKeyframe(clip, captureSkeletonKeyframe(skel, 1));
|
||||
|
||||
skel.bones.upperArmL.quaternion.identity();
|
||||
ok(applyTiltClip(skel, clip, 0.5), 'a populated clip applies');
|
||||
const angle = 2 * Math.acos(skel.bones.upperArmL.quaternion.w);
|
||||
ok(Math.abs(angle - Math.PI / 4) < 1e-5, 'quaternions slerp halfway');
|
||||
ok(frameSpan(clip, 2).a.time === 1, 'non-looping clips clamp at the end');
|
||||
ok(sanitizeTiltClip(clip).shotSide === 'left', 'clip preserves its authored stick socket side');
|
||||
}
|
||||
|
||||
section('clip validation, replacement, smoothing, and export');
|
||||
{
|
||||
const skel = buildSkeleton();
|
||||
const clip = createTiltClip({ name: 'hip check' });
|
||||
setClipKeyframe(clip, captureSkeletonKeyframe(skel, 0));
|
||||
setClipKeyframe(clip, captureSkeletonKeyframe(skel, 0));
|
||||
ok(clip.keyframes.length === 1, 'setting the same time replaces a key');
|
||||
setClipKeyframe(clip, captureSkeletonKeyframe(skel, 0.5));
|
||||
setClipKeyframe(clip, captureSkeletonKeyframe(skel, 1));
|
||||
smoothTiltClip(clip);
|
||||
const copy = sanitizeTiltClip(JSON.parse(JSON.stringify(clip)));
|
||||
ok(copy.keyframes.length === 3 && copy.rig === 'tilt-23', 'serialized clips validate');
|
||||
const module = clipAsModule(copy);
|
||||
ok(module.includes('export const hip_check') && module.includes('applyTiltAnimation'), 'module export is drop-in runtime JavaScript');
|
||||
ok(deleteClipKeyframe(copy, 0.5) && copy.keyframes.length === 2, 'keys can be deleted');
|
||||
}
|
||||
|
||||
section('stick landmarks survive storage and interpolate with the pose');
|
||||
{
|
||||
const skel = buildSkeleton();
|
||||
const clip = createTiltClip({ loop: false });
|
||||
const a = captureSkeletonKeyframe(skel, 0);
|
||||
a.stick = { butt: [0.4, 0.2], grip: [0.45, 0.4], blade: [0.5, 0.8], target: [-0.2, 0.03, 0.6], roll: 0, confidence: 0.8 };
|
||||
const b = captureSkeletonKeyframe(skel, 1);
|
||||
b.stick = { butt: [0.6, 0.2], grip: [0.6, 0.4], blade: [0.8, 0.8], target: [0.4, 0.2, 1.0], roll: 0.4, confidence: 1 };
|
||||
setClipKeyframe(clip, a);
|
||||
setClipKeyframe(clip, b);
|
||||
const stick = sampleTiltStick(clip, 0.5);
|
||||
ok(Math.abs(stick.blade[0] - 0.65) < 1e-6, 'blade landmark interpolates');
|
||||
ok(Math.abs(stick.target[0] - 0.1) < 1e-6, 'rig-local blade target interpolates');
|
||||
ok(Number.isFinite(stick.angle), 'legacy butt/blade marks derive a stable shaft angle');
|
||||
ok(sanitizeTiltClip(JSON.parse(JSON.stringify(clip))).keyframes[0].stick.confidence === 0.8, 'stick metadata serializes');
|
||||
}
|
||||
|
||||
section('applied stick guides constrain the 3D shaft through both hands');
|
||||
{
|
||||
const skel = buildSkeleton();
|
||||
const mover = new THREE.Group();
|
||||
mover.add(skel.rootBone);
|
||||
const stick = buildStick(null, null, 0);
|
||||
stick.attachTo(skel.bones.handR);
|
||||
const clip = createTiltClip({ loop: false });
|
||||
const frame = captureSkeletonKeyframe(skel, 0);
|
||||
frame.stick = {
|
||||
butt: [0.2, 0.2], grip: [0.3, 0.3], blade: [0.8, 0.8],
|
||||
target: [0, 0, 0], angle: 0.5, roll: 0.2, alignHands: true, confidence: 1,
|
||||
};
|
||||
setClipKeyframe(clip, frame);
|
||||
const skater = { skelData: skel, mover, stick };
|
||||
ok(applyTiltAnimation(skater, clip, 0), 'two-hand guide applies');
|
||||
|
||||
const shaftA = new THREE.Vector3();
|
||||
const shaftB = new THREE.Vector3();
|
||||
const lowerHand = skel.bones.handL.getWorldPosition(new THREE.Vector3());
|
||||
stick.shaftSegment(shaftA, shaftB);
|
||||
const shaft = shaftB.clone().sub(shaftA);
|
||||
const t = THREE.MathUtils.clamp(lowerHand.clone().sub(shaftA).dot(shaft) / shaft.lengthSq(), 0, 1);
|
||||
const closest = shaftA.clone().addScaledVector(shaft, t);
|
||||
ok(shaftA.distanceTo(skel.bones.handR.getWorldPosition(new THREE.Vector3())) < 1e-6, 'shaft starts at the socket hand');
|
||||
ok(closest.distanceTo(lowerHand) < 1e-6, 'shaft crosses the lower hand in depth as well as screen space');
|
||||
ok(sanitizeTiltClip(JSON.parse(JSON.stringify(clip))).keyframes[0].stick.alignHands, 'two-hand constraint survives save/load');
|
||||
}
|
||||
|
||||
section('equivalent stick-line directions do not bake a false half-turn');
|
||||
{
|
||||
const skel = buildSkeleton();
|
||||
const clip = createTiltClip({ loop: false });
|
||||
for (const [time, angle] of [[0, 0.2], [0.5, Math.PI - 0.1], [1, 0.3]]) {
|
||||
const frame = captureSkeletonKeyframe(skel, time);
|
||||
frame.stick = {
|
||||
butt: [0.2, 0.2], grip: [0.3, 0.3], blade: [0.8, 0.8],
|
||||
target: [0, 0, 0], angle, roll: 0, confidence: 1,
|
||||
};
|
||||
setClipKeyframe(clip, frame);
|
||||
}
|
||||
const clean = sanitizeTiltClip(clip);
|
||||
const delta = Math.abs(clean.keyframes[1].stick.angle - clean.keyframes[0].stick.angle);
|
||||
ok(delta < Math.PI / 2, 'a PI-flipped detector result stays on the nearest shaft-line branch');
|
||||
}
|
||||
|
||||
section('stick playback follows the actual socket bone');
|
||||
{
|
||||
const skel = buildSkeleton();
|
||||
const mover = new THREE.Group();
|
||||
mover.add(skel.rootBone);
|
||||
const stickGroup = new THREE.Group();
|
||||
skel.bones.handL.add(stickGroup);
|
||||
const clip = createTiltClip({ loop: false, shotSide: 'left' });
|
||||
const frame = captureSkeletonKeyframe(skel, 0);
|
||||
frame.stick = {
|
||||
butt: [0.2, 0.2], grip: [0.3, 0.3], blade: [0.8, 0.8],
|
||||
target: [0, 0, 0], angle: -0.4, roll: 0, confidence: 1,
|
||||
};
|
||||
setClipKeyframe(clip, frame);
|
||||
let actualHand = null;
|
||||
const skater = {
|
||||
skelData: skel,
|
||||
mover,
|
||||
stick: {
|
||||
group: stickGroup,
|
||||
aimAt(_target, hand) { actualHand = hand.clone(); },
|
||||
},
|
||||
};
|
||||
applyTiltAnimation(skater, clip, 0);
|
||||
const expectedHand = skel.bones.handL.getWorldPosition(new THREE.Vector3());
|
||||
ok(actualHand?.distanceTo(expectedHand) < 1e-6, 'left socket playback anchors at handL instead of handR');
|
||||
}
|
||||
|
||||
done('animation clip');
|
||||
@@ -0,0 +1,43 @@
|
||||
import { parseFreeMoCapCsv } from '../src/studio/freemocapImport.js';
|
||||
import { done, ok, section } from './harness.mjs';
|
||||
|
||||
const points = [
|
||||
'left_ear', 'right_ear', 'left_shoulder', 'right_shoulder',
|
||||
'left_elbow', 'right_elbow', 'left_wrist', 'right_wrist',
|
||||
'left_hip', 'right_hip', 'left_knee', 'right_knee',
|
||||
'left_ankle', 'right_ankle', 'left_heel', 'right_heel',
|
||||
'left_foot_index', 'right_foot_index',
|
||||
];
|
||||
|
||||
section('FreeMoCap tidy XYZ data imports into MediaPipe landmark order');
|
||||
{
|
||||
const lines = ['frame,keypoint,x,y,z,model,trajectory,reprojection_error'];
|
||||
for (const trajectory of ['3d_xyz', 'rigid_3d_xyz']) {
|
||||
for (const frame of [0, 1]) {
|
||||
for (const [index, point] of points.entries()) {
|
||||
const base = trajectory === 'rigid_3d_xyz' ? 200 : 100;
|
||||
lines.push(`${frame},${point},${base + index},20,300,mediapipe_body,${trajectory},0.5`);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Non-body rows in the tidy all-data file must not contaminate the pose.
|
||||
lines.push('0,left_wrist,999,999,999,mediapipe_left_hand,rigid_3d_xyz,0.5');
|
||||
const result = parseFreeMoCapCsv(lines.join('\n'));
|
||||
ok(result.frames.length === 2, 'all complete body frames import');
|
||||
ok(result.trajectory === 'rigid_3d_xyz', 'rigid body trajectory is preferred when available');
|
||||
ok(result.tracker === 'MediaPipe', 'tracker metadata is reported');
|
||||
const leftEar = result.frames[0].landmarks[7];
|
||||
ok(leftEar.x === 200 && leftEar.y === -300 && leftEar.z === -20, 'FreeMoCap Z-up axes convert for the Tilt retargeter');
|
||||
}
|
||||
|
||||
section('FreeMoCap per-trajectory CSV and RTMPose toe names are accepted');
|
||||
{
|
||||
const rtmposePoints = points.map((point) => point.replace('foot_index', 'big_toe'));
|
||||
const lines = ['frame,keypoint,x,y,z'];
|
||||
for (const [index, point] of rtmposePoints.entries()) lines.push(`4,${point},${index},10,20`);
|
||||
const result = parseFreeMoCapCsv(lines.join('\r\n'));
|
||||
ok(result.frames[0].frame === 4, 'source frame numbers survive import');
|
||||
ok(result.frames[0].landmarks[31].visibility === 1, 'RTMPose big toe maps to the Tilt foot-index landmark');
|
||||
}
|
||||
|
||||
done('FreeMoCap import');
|
||||
+117
-29
@@ -1,8 +1,11 @@
|
||||
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';
|
||||
|
||||
/**
|
||||
@@ -16,7 +19,7 @@ import { done, ok, section } from './harness.mjs';
|
||||
|
||||
const DT = 1 / 60;
|
||||
|
||||
function rig() {
|
||||
function rig(shotSide = 'right') {
|
||||
const skelData = buildSkeleton();
|
||||
const mover = new THREE.Group();
|
||||
// Same hierarchy as createSkater: skeleton rides on the mover so body yaw
|
||||
@@ -24,10 +27,12 @@ function rig() {
|
||||
// the stick target orbited in world space while the hand sat still.
|
||||
mover.add(skelData.rootBone);
|
||||
const anim = buildAnimator(skelData, mover);
|
||||
// The stick is part of the pose now — it hangs off the hand and the animator
|
||||
// aims it, so a rig without one is not the rig the game runs.
|
||||
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.handR);
|
||||
stick.attachTo(skelData.bones[`hand${topHandFor(shotSide)}`]);
|
||||
anim.stick = stick;
|
||||
return { skelData, mover, anim, stick };
|
||||
}
|
||||
@@ -262,26 +267,28 @@ 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.
|
||||
const r = drive(rig(), 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 handR = new THREE.Vector3();
|
||||
r.skelData.bones.handR.getWorldPosition(handR);
|
||||
ok(handR.distanceTo(butt) < 0.12, `the top hand is on the butt of the stick (${handR.distanceTo(butt).toFixed(3)}m)`);
|
||||
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 handL = new THREE.Vector3();
|
||||
const closest = new THREE.Vector3();
|
||||
r.skelData.bones.handL.getWorldPosition(handL);
|
||||
const gap = segDist(handL, butt, heel, closest);
|
||||
ok(gap < 0.08, `the lower hand is on the shaft (${gap.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 = handR.clone().applyMatrix4(inv);
|
||||
ok(Math.abs(handLocal.x) < 0.28, `top hand is in front of the torso (x=${handLocal.x.toFixed(2)})`);
|
||||
ok(handLocal.z > 0.25, `top hand is out in front (z=${handLocal.z.toFixed(2)})`);
|
||||
// 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');
|
||||
@@ -297,13 +304,14 @@ section('the stick stays in the socket when the body turns');
|
||||
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);
|
||||
r.skelData.bones.handR.getWorldQuaternion(handQ);
|
||||
topBone.getWorldQuaternion(handQ);
|
||||
r.stick.group.getWorldQuaternion(stickQ);
|
||||
local.copy(handQ).invert().multiply(stickQ);
|
||||
if (i === 0) local0.copy(local);
|
||||
@@ -313,6 +321,26 @@ section('the stick stays in the socket when the body turns');
|
||||
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']) {
|
||||
@@ -334,9 +362,9 @@ section('stick actions run and finish');
|
||||
}
|
||||
}
|
||||
|
||||
section('a wind-up lifts the blade off the ice and holds');
|
||||
section('a wind-up uses the first half of saved shot1 and holds');
|
||||
{
|
||||
const r = rig();
|
||||
const r = rig('left');
|
||||
drive(r, 1, { ...GLIDE, hasPuck: true });
|
||||
const flat = new THREE.Vector3();
|
||||
r.stick.bladeWorld(flat);
|
||||
@@ -353,11 +381,71 @@ section('a wind-up lifts the blade off the ice and holds');
|
||||
const back = new THREE.Vector3();
|
||||
r.stick.bladeWorld(back);
|
||||
const backLocal = back.clone().applyMatrix4(inv);
|
||||
// High and back behind the head — not hanging blade-down at hip height.
|
||||
ok(backLocal.y > 1.1, `the blade is up high (y=${backLocal.y.toFixed(2)})`);
|
||||
ok(backLocal.y > flatLocal.y + 0.8, `well above the carry (${flatLocal.y.toFixed(2)} → ${backLocal.y.toFixed(2)})`);
|
||||
ok(backLocal.z < -0.15, `and back behind the body (z=${backLocal.z.toFixed(2)})`);
|
||||
// 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');
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { SKATE, applyIntent, createSkaterState, speedOf, stepSkater } from '../shared/skaterSim.js';
|
||||
import { RINK, insideRink } from '../shared/rink.js';
|
||||
import {
|
||||
normalizePlayer, normalizeShotSide, packPlayer, rollShotSide, shotSign, unpackPlayer,
|
||||
} from '../shared/player.js';
|
||||
import { done, near, ok, section } from './harness.mjs';
|
||||
|
||||
const DT = 1 / 120;
|
||||
@@ -234,6 +237,7 @@ section('intent from a controller is clamped before the sim sees it');
|
||||
|
||||
// The clamped state must still step without producing garbage.
|
||||
stepSkater(s, DT);
|
||||
|
||||
ok(Number.isFinite(s.x) && Number.isFinite(s.vx), 'and the sim steps cleanly afterwards');
|
||||
}
|
||||
|
||||
@@ -243,4 +247,28 @@ section('rink dimensions are the ones we think they are');
|
||||
near(RINK.halfZ * 2, 25.9, 0.02, 'and 85 feet wide');
|
||||
}
|
||||
|
||||
section('player definition carries shot side');
|
||||
{
|
||||
ok(normalizeShotSide('left') === 'left', 'left stays left');
|
||||
ok(normalizeShotSide('L') === 'left', 'L is left');
|
||||
ok(normalizeShotSide(-1) === 'left', '-1 is left');
|
||||
ok(normalizeShotSide('right') === 'right', 'right stays right');
|
||||
ok(normalizeShotSide('R') === 'right', 'R is right');
|
||||
ok(normalizeShotSide(undefined) === 'right', 'missing defaults to right (authored side)');
|
||||
ok(shotSign('left') === -1 && shotSign('right') === 1, 'shotSign is ±1');
|
||||
ok(rollShotSide(0.1) === 'left' && rollShotSide(0.9) === 'right', 'roll respects the NHL-ish split');
|
||||
|
||||
const packed = packPlayer({ shotSide: 'left' });
|
||||
ok(packed.ss === 'L', 'pack uses a short wire form');
|
||||
ok(unpackPlayer(packed).shotSide === 'left', 'unpack restores shot side');
|
||||
ok(normalizePlayer({ shotSide: 'left' }).shotSide === 'left', 'normalizePlayer keeps shot side');
|
||||
|
||||
const lefty = createSkaterState(0, START, { shotSide: 'left', name: 'Lefty' });
|
||||
const righty = createSkaterState(1, START, { shotSide: 'right' });
|
||||
const plain = createSkaterState(2, START);
|
||||
ok(lefty.shotSide === 'left', 'state stores left shot');
|
||||
ok(righty.shotSide === 'right', 'state stores right shot');
|
||||
ok(plain.shotSide === 'right', 'state defaults to right shot');
|
||||
}
|
||||
|
||||
done('skaterSim');
|
||||
|
||||
Reference in New Issue
Block a user