Files
ryanfitzpatrickio 7d28facd68 Merge goalie-tester worktree: save drill, stick plant, and jersey gear.
Bring over img2mesh puck machine, catch/block IK, damped stance blends, and
mover-space paddle with hand-on-shaft IK. Keep main jersey skinned gear and
underlayer paint, with the stick reparented to the mover.
2026-08-03 07:36:30 -05:00

207 lines
7.7 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import * as THREE from 'three';
import { createGoalie } from '../src/character/goalie.js';
import { done, ok, section } from './harness.mjs';
/**
* Goalie presentation: skeleton, gear, and stance selection.
*
* Save logic and angle play are covered by shootout.mjs — this file pins the
* things that used to be a capsule-and-box placeholder.
*/
const DT = 1 / 60;
function make() {
const scene = new THREE.Group();
const goalie = createGoalie(null, scene, { end: 1, team: 1, seed: 42 });
return { scene, goalie };
}
section('goalie is a skinned skeleton, not a capsule');
{
const { goalie } = make();
ok(goalie.skelData.list.length >= 20, `has a full bone list (${goalie.skelData.list.length})`);
ok(goalie.bodyMesh?.isSkinnedMesh, 'body is a SkinnedMesh');
ok(goalie.gear.padL.parent === goalie.skelData.bones.shinL, 'left pad is on the left shin');
ok(goalie.gear.padR.parent === goalie.skelData.bones.shinR, 'right pad is on the right shin');
ok(goalie.gear.trapper.parent === goalie.skelData.bones.handL, 'trapper is on the left hand');
ok(goalie.gear.blocker.parent === goalie.skelData.bones.handR, 'blocker is on the right hand');
ok(goalie.gear.mask.parent === goalie.skelData.bones.head, 'mask is on the head');
ok(goalie.gear.stick.parent === goalie.mover, 'paddle is mover-planted (hand IKs to it)');
goalie.destroy();
}
section('nothing produces NaN while tracking');
{
const { goalie } = make();
let bad = false;
for (let i = 0; i < 180; i++) {
goalie.update(DT, {
x: 15 + i * 0.08,
y: 0.1 + 0.5 * Math.sin(i * 0.1),
z: Math.sin(i * 0.07) * 2,
});
for (const b of goalie.skelData.list) {
for (const e of b.matrixWorld.elements) {
if (!Number.isFinite(e)) bad = true;
}
}
}
ok(!bad, 'bone matrices stay finite');
goalie.destroy();
}
section('stances respond to the puck');
{
const { goalie } = make();
// Far out: ready.
for (let i = 0; i < 60; i++) goalie.update(DT, { x: 10, y: 0.5, z: 0 });
ok(goalie.animator.state === 'ready', `idle crease is ready (${goalie.animator.state})`);
// Low and closing: butterfly.
for (let i = 0; i < 90; i++) {
goalie.update(DT, { x: 12 + i * 0.15, y: 0.1, z: 0.2 });
}
ok(
goalie.animator.state === 'butterfly',
`low attack draws a butterfly (${goalie.animator.state})`,
);
// High and close: reach.
for (let i = 0; i < 45; i++) {
goalie.update(DT, { x: goalie.pos.x + 2, y: 1.25, z: goalie.pos.z });
}
ok(goalie.animator.state === 'reach', `high puck draws a reach (${goalie.animator.state})`);
goalie.destroy();
}
section('goalie drops low in the butterfly');
{
const { goalie } = make();
for (let i = 0; i < 40; i++) goalie.update(DT, { x: 18, y: 0.5, z: 0 });
const readyY = goalie.skelData.bones.root.position.y;
const foot = new THREE.Vector3();
goalie.skelData.bones.footL.getWorldPosition(foot);
ok(Math.abs(foot.y - 0.085) < 0.04, `ready feet are on the ice (y=${foot.y.toFixed(3)})`);
for (let i = 0; i < 80; i++) {
goalie.update(DT, { x: goalie.pos.x + 1.2, y: 0.08, z: 0 });
}
const flyY = goalie.skelData.bones.root.position.y;
ok(flyY < readyY - 0.1, `butterfly drops the hips (${readyY.toFixed(2)}${flyY.toFixed(2)})`);
goalie.skelData.bones.footL.getWorldPosition(foot);
ok(Math.abs(foot.y - 0.085) < 0.04, `butterfly feet stay on the ice (y=${foot.y.toFixed(3)})`);
// Pads flare wider than the ready stance.
const inv = new THREE.Matrix4().copy(goalie.mover.matrixWorld).invert();
const fL = foot.clone().applyMatrix4(inv);
goalie.skelData.bones.footR.getWorldPosition(foot);
const fR = foot.clone().applyMatrix4(inv);
ok(Math.abs(fL.x - fR.x) > 0.9, `butterfly opens the stance (width ${(fL.x - fR.x).toFixed(2)})`);
goalie.destroy();
}
section('catch save IKs the trapper onto the puck');
{
const { goalie } = make();
// Settle in ready at the crease.
for (let i = 0; i < 30; i++) goalie.update(DT, { x: goalie.pos.x - 8, y: 0.5, z: 0 });
const hand = new THREE.Vector3();
goalie.skelData.bones.handL.getWorldPosition(hand);
// Glove-side high puck (world Z is goalie left when facing X at end +1).
// Kept inside a reachable arm envelope so the test measures blend, not clamp.
const target = {
x: goalie.pos.x - 0.18,
y: 1.05,
z: goalie.pos.z - 0.3,
};
const before = hand.distanceTo(target);
goalie.playSave('catch', target);
ok(goalie.animator.save?.kind === 'catch', 'save state is a catch');
for (let i = 0; i < 40; i++) goalie.update(DT, target);
goalie.skelData.bones.handL.getWorldPosition(hand);
const after = hand.distanceTo(target);
ok(after < before - 0.08, `trapper moves onto the puck (${before.toFixed(2)}${after.toFixed(2)})`);
ok(after < 0.45, `trapper lands near the puck (err ${after.toFixed(2)})`);
let bad = false;
for (const b of goalie.skelData.list) {
for (const e of b.matrixWorld.elements) if (!Number.isFinite(e)) bad = true;
}
ok(!bad, 'catch IK keeps bone matrices finite');
goalie.destroy();
}
section('block save IKs the blocker onto the puck');
{
const { goalie } = make();
for (let i = 0; i < 30; i++) goalie.update(DT, { x: goalie.pos.x - 8, y: 0.5, z: 0 });
const hand = new THREE.Vector3();
goalie.skelData.bones.handR.getWorldPosition(hand);
// Blocker-side high puck (world +Z is goalie right when facing X).
const target = {
x: goalie.pos.x - 0.18,
y: 1.0,
z: goalie.pos.z + 0.3,
};
const before = hand.distanceTo(target);
goalie.playSave('block', target);
ok(goalie.animator.save?.kind === 'block', 'save state is a block');
for (let i = 0; i < 40; i++) goalie.update(DT, target);
goalie.skelData.bones.handR.getWorldPosition(hand);
const after = hand.distanceTo(target);
ok(after < before - 0.08, `blocker moves onto the puck (${before.toFixed(2)}${after.toFixed(2)})`);
ok(after < 0.5, `blocker lands near the puck (err ${after.toFixed(2)})`);
goalie.destroy();
}
section('save IK clears after the hold');
{
const { goalie } = make();
for (let i = 0; i < 20; i++) goalie.update(DT, { x: goalie.pos.x - 6, y: 0.5, z: 0 });
goalie.playSave('catch', { x: goalie.pos.x - 0.2, y: 1.1, z: goalie.pos.z - 0.4 }, { duration: 0.25 });
for (let i = 0; i < 45; i++) {
goalie.update(DT, { x: goalie.pos.x - 6, y: 0.5, z: 0 });
}
ok(goalie.animator.save == null, 'save state expires after duration');
goalie.destroy();
}
section('stick is a heavy plant the hand follows');
{
const { goalie } = make();
for (let i = 0; i < 45; i++) goalie.update(DT, { x: goalie.pos.x - 10, y: 0.5, z: 0 });
const stick = goalie.gear.stick;
const hand = new THREE.Vector3();
const grip = new THREE.Vector3();
const measure = () => {
grip.set(0, -0.05, 0.016).applyMatrix4(stick.matrixWorld);
goalie.skelData.bones.handR.getWorldPosition(hand);
return hand.distanceTo(grip);
};
// Grip is shaft-local; hand should sit on it after settle.
let dist = measure();
ok(dist < 0.08, `blocker hand holds the shaft (err ${dist.toFixed(2)})`);
// Stick should not whip when the torso shuffles — move laterally hard and
// the paddle lags rather than snapping with the hand bone.
const before = stick.position.clone();
for (let i = 0; i < 8; i++) {
goalie.update(DT, { x: goalie.pos.x - 4, y: 0.4, z: 1.2 + i * 0.05 });
}
const jump = stick.position.distanceTo(before);
ok(jump < 0.12, `stick plant lags under shuffle (Δ ${jump.toFixed(3)})`);
// Block save: dominant hand stays on the stick (stick steers to the puck).
const target = {
x: goalie.pos.x - 0.2,
y: 1.05,
z: goalie.pos.z + 0.35,
};
goalie.playSave('block', target);
for (let i = 0; i < 35; i++) goalie.update(DT, target);
dist = measure();
ok(dist < 0.1, `hand stays on shaft during block save (err ${dist.toFixed(2)})`);
goalie.destroy();
}
done('goalie');