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.
207 lines
7.7 KiB
JavaScript
207 lines
7.7 KiB
JavaScript
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');
|