Goalie save drill, hand/stick IK, and weighted paddle plant.

Add img2mesh puck machine for live goalie AI/save testing, catch/block arm IK
with smooth stance blends, and a mover-space stick the blocker hand follows.
This commit is contained in:
ryanfitzpatrickio
2026-08-03 10:28:11 -05:00
parent 6c08153e42
commit 94d24205dc
6 changed files with 1470 additions and 142 deletions
+104 -1
View File
@@ -27,7 +27,7 @@ section('goalie is a skinned skeleton, not a capsule');
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.skelData.bones.handR, 'paddle is in the blocker hand');
ok(goalie.gear.stick.parent === goalie.mover, 'paddle is mover-planted (hand IKs to it)');
goalie.destroy();
}
@@ -100,4 +100,107 @@ section('goalie drops low in the butterfly');
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');