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.skelData.bones.handR, 'paddle is in the blocker hand'); 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(); } done('goalie');