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');