From 94d24205dc1febbb320915afee96cd7076cce672 Mon Sep 17 00:00:00 2001 From: ryanfitzpatrickio Date: Mon, 3 Aug 2026 07:35:04 -0500 Subject: [PATCH] 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. --- character.html | 16 +- src/anim/goalieAnimator.js | 712 ++++++++++++++++++++++++++++++++---- src/character/goalie.js | 47 ++- src/character/goalieGear.js | 69 ++-- src/studio/img2mesh.js | 663 +++++++++++++++++++++++++++++++-- test/goalie.mjs | 105 +++++- 6 files changed, 1470 insertions(+), 142 deletions(-) diff --git a/character.html b/character.html index eef707b..dda9523 100644 --- a/character.html +++ b/character.html @@ -26,9 +26,12 @@ } #panel button { cursor:pointer; } #panel button:hover { border-color:#4a8ab8; } + #panel button.on { border-color:#4caf7a; background:#12241c; color:#b8f0d0; } #panel .row { display:flex; gap:6px; } #panel .row button { flex:1; } #panel kbd { color:#8fb4d4; } + #panel .sep { margin:10px 0 6px; border-top:1px solid #1e3348; padding-top:8px; color:#6ea8dc; letter-spacing:1px; font-size:10px; } + #panel .hint { color:#6a8aa8; font-size:10px; margin-top:4px; line-height:1.4; } @@ -63,11 +66,22 @@ +
GOALIE SAVE DRILL
+ +
+ + +
+
+ Live AI + leg IK against a shooting machine.
+ Cycles glove / five-hole / blocker / pads. +
drag orbit · wheel zoom
1 player 2 goalie 3 both
[] pose · ,. view
- g gear bones · b bones + g gear bones · b bones
+ m puck machine · f fire once
IMG2MESH…
diff --git a/src/anim/goalieAnimator.js b/src/anim/goalieAnimator.js index 7dc4d80..f44bb26 100644 --- a/src/anim/goalieAnimator.js +++ b/src/anim/goalieAnimator.js @@ -15,10 +15,39 @@ import { * Goalie animator. * * Upper body is pose-authored; legs are two-bone IK onto mover-local foot - * targets so the pads stay on the ice. The paddle stick keeps its authored - * grip rotation (re-aiming it every frame is what made it thrash). + * targets so the pads stay on the ice. The paddle stick lives in *mover* + * space with heavy damping (it is the plant, not a hand prop). The blocker + * hand two-bone IKs to a grip on the shaft so the arm follows the stick. + * + * On a resolved save the active hand is two-bone IK'd to the puck: trapper + * (L) for a catch, blocker (R) for a deflect — during a block the stick + * lags rather than whipping with the wrist. + * + * Stance changes and save IK are damped — hard threshold snaps were reading as + * twitch when the machine fired rapid shots. */ +/** Exponential approach: rate is roughly "how many times per second toward target". */ +function damp(current, target, rate, dt) { + if (rate <= 0 || dt <= 0) return target; + const k = 1 - Math.exp(-rate * dt); + return current + (target - current) * k; +} + +function dampVec(current, target, rate, dt) { + const k = 1 - Math.exp(-rate * dt); + current.x += (target.x - current.x) * k; + current.y += (target.y - current.y) * k; + current.z += (target.z - current.z) * k; + return current; +} + +function dampQuat(current, target, rate, dt) { + const k = 1 - Math.exp(-rate * dt); + current.slerp(target, k); + return current; +} + export function buildGoalieAnimator(skelData, mover) { const B = skelData.bones; const LEN = { @@ -33,6 +62,19 @@ export function buildGoalieAnimator(skelData, mover) { L: B.footL.position.clone().normalize(), R: B.footR.position.clone().normalize(), }; + // Arm rest axes — child local offset at bind, same pattern as the skater. + const ARM = { + upper: B.forearmL.position.length(), + fore: B.handL.position.length(), + }; + const restUpperArmDir = { + L: B.forearmL.position.clone().normalize(), + R: B.forearmR.position.clone().normalize(), + }; + const restForearmDir = { + L: B.handL.position.clone().normalize(), + R: B.handR.position.clone().normalize(), + }; function newPose() { const p = { @@ -51,11 +93,45 @@ export function buildGoalieAnimator(skelData, mover) { const cur = newPose(); const frozen = newPose(); + /** + * Continuous blend of the four stance poses. Weights sum to 1 and are + * damped each frame so ready → reach → butterfly never pops. + */ + const stanceW = { ready: 1, shuffle: 0, butterfly: 0, reach: 0 }; + const stanceTarget = { ready: 1, shuffle: 0, butterfly: 0, reach: 0 }; + const STANCE_KEYS = ['ready', 'shuffle', 'butterfly', 'reach']; + + /** Smoothed pose drivers (lean, reach params, stick, save). */ + const drive = { + lean: 0, + reachSide: -1, + reachUp: 0.55, + shuffleDir: 1, + shuffleEffort: 0.5, + stickFly: 0, + /** Live stick plant in mover-local space (heavily damped). */ + stickPos: new THREE.Vector3(-0.33, 0.69, 0.5), + stickQuat: new THREE.Quaternion().setFromEuler(new THREE.Euler(0.15, 0.1, 0.2, 'XYZ')), + stickSeeded: false, + /** Desired save IK weight 0..1 (envelope); actual weight damps toward this. */ + saveWant: 0, + saveW: 0, + saveKind: 'catch', // 'catch' | 'block' + saveTarget: new THREE.Vector3(), + saveTargetLive: new THREE.Vector3(), + saveHasTarget: false, + saveAge: 0, + saveDuration: 0.95, + /** Facing locked at contact so a glove seal does not spin the torso. */ + lockYaw: 0, + hasLockYaw: false, + }; + const anim = { state: 'ready', blend: 1, - BLEND_TIME: 0.16, - transitionTime: 0.16, + BLEND_TIME: 0.28, + transitionTime: 0.28, time: 0, stateTime: 0, speed: 1, @@ -69,21 +145,57 @@ export function buildGoalieAnimator(skelData, mover) { puckDist: 8, threatened: 0, - /** Goalie paddle group, parented to handR. Grip is adjusted per stance. */ + /** + * Active save descriptor for HUD / callers, or null when fully blended out. + * `{ kind, target, age, duration }` — weight is continuous via `drive.saveW`. + */ + save: null, + + /** + * Goalie paddle group — parented to the *mover*, not the hand. + * Heavy plant; blocker hand IKs to `GRIP_LOCAL` on the shaft. + */ stick: null, }; - // Hand-local stick grips, tuned against the equipment reference: - // ready = paddle on ice in the five-hole, shaft up into the blocker hand; - // butterfly = same idea, flatter, so it does not spear the surface. - // Searched: nearly down-forward puts the paddle on the ice in the five-hole - // (minY ≈ 0.02–0.05) without spearing through. - const STICK_READY_E = new THREE.Euler(1.55, 0.3, 0.05, 'XYZ'); - const STICK_FLY_E = new THREE.Euler(1.65, 0.2, 0.0, 'XYZ'); - const STICK_READY_POS = new THREE.Vector3(0.04, -0.02, 0.04); - const STICK_FLY_POS = new THREE.Vector3(0.05, 0.02, 0.05); - const _stickEuler = new THREE.Euler(); - const _stickPos = new THREE.Vector3(); + // Mover-local stick plants. Shaft runs −Y into the paddle; blade along +Z + // in stick space. Tuned so the grip sits in the blocker hand and the paddle + // rests in the five-hole (~y 0.05). These move *slowly* — the stick is heavy. + const STICK_READY_POS = new THREE.Vector3(-0.33, 0.69, 0.5); + // Butterfly plant keeps the grip near the dropped blocker hand (~y 0.37). + const STICK_FLY_POS = new THREE.Vector3(-0.3, 0.4, 0.2); + const STICK_READY_E = new THREE.Euler(0.15, 0.1, 0.2, 'XYZ'); + const STICK_FLY_E = new THREE.Euler(0.42, 0.05, 0.12, 'XYZ'); + const STICK_READY_Q = new THREE.Quaternion().setFromEuler(STICK_READY_E); + const STICK_FLY_Q = new THREE.Quaternion().setFromEuler(STICK_FLY_E); + // Blocker hand grips just below the knob on the shaft (−Y down the stick). + // Tuned so hand bone origin lands on this point after two-bone arm IK. + const GRIP_LOCAL = new THREE.Vector3(0.0, -0.05, 0.016); + // How fast the stick plant eases (1/s). Low = heavy. Butterfly / block + // may move a little faster so the plant still reads. + const STICK_POS_RATE = 3.2; + const STICK_ROT_RATE = 2.6; + const STICK_FLY_POS_RATE = 4.5; + const STICK_FLY_ROT_RATE = 3.8; + const STICK_BLOCK_POS_RATE = 6.5; + const STICK_BLOCK_ROT_RATE = 5.0; + + const _stickPosTarget = new THREE.Vector3(); + const _stickQuatTarget = new THREE.Quaternion(); + const _stickGripWorld = new THREE.Vector3(); + const _stickLean = new THREE.Quaternion(); + const _stickLeanE = new THREE.Euler(); + const _stickSaveLocal = new THREE.Vector3(); + const _stickFromGrip = new THREE.Vector3(); + const _qTmp = new THREE.Quaternion(); + + // Scratch for multi-pose blend. + const _poseScratch = { + ready: newPose(), + shuffle: newPose(), + butterfly: newPose(), + reach: newPose(), + }; function applyMover() { mover.position.copy(anim.origin); @@ -105,25 +217,141 @@ export function buildGoalieAnimator(skelData, mover) { } } + /** + * Legacy single-state entry. Still used by the pose studio presets; live + * play prefers continuous stance weights via `setStanceTarget`. + */ anim.setState = function setState(name, blendTime = null) { - if (name === anim.state) return; + if (name === anim.state && anim.blend >= 0.99) { + // Already there — still nudge continuous weights so studio settles. + for (const k of STANCE_KEYS) stanceTarget[k] = k === name ? 1 : 0; + return; + } snapshot(); anim.state = name; anim.stateTime = 0; anim.blend = 0; anim.transitionTime = blendTime ?? anim.BLEND_TIME; + for (const k of STANCE_KEYS) stanceTarget[k] = k === name ? 1 : 0; }; - function chooseState() { - const low = anim.puckHeight < 0.38; - const high = anim.puckHeight > 0.75; - const close = anim.puckDist < 8; - const veryClose = anim.puckDist < 3.5; - const sliding = Math.abs(anim.lateralVel) > 1.2 || anim.moveSpeed > 1.6; + function setStanceTarget(name) { + for (const k of STANCE_KEYS) stanceTarget[k] = 0; + stanceTarget[name] = 1; + if (name !== anim.state) { + // Keep legacy state label in sync for HUD / tests without restarting a + // hard crossfade — continuous weights already own the blend. + anim.state = name; + anim.stateTime = 0; + } + } + + /** + * Kick a save IK response. `kind` is `catch` (glove) or `block` (blocker). + * `worldPos` is where the puck was at contact — the hand IK target. + * Blends in over time; calling again retargets without a hard restart. + */ + anim.playSave = function playSave(kind, worldPos, { duration = 0.95 } = {}) { + const k = kind === 'block' ? 'block' : 'catch'; + const y = worldPos.y ?? 0.5; + const x = worldPos.x; + const z = worldPos.z; + + // Soft retarget if a save is already live — don't snap age/weight back. + const continuing = drive.saveW > 0.08 || drive.saveWant > 0.08; + drive.saveKind = k; + drive.saveDuration = Math.max(0.2, duration); + if (!continuing) { + drive.saveAge = 0; + // Start partway in so the seal reads immediately without a hard pop. + drive.saveW = Math.max(drive.saveW, 0.2); + // Freeze body facing at the moment of contact. Tracking a puck that is + // then pinned to the glove makes atan2 chase the hand and spin the torso. + drive.lockYaw = anim.originYaw; + drive.hasLockYaw = true; + } + drive.saveWant = 1; + drive.saveTarget.set(x, y, z); + if (!drive.saveHasTarget) { + // First contact: seed live target near the goal, not at a stale hand + // position — the weight blend still eases the tip from the posed hand. + drive.saveTargetLive.copy(drive.saveTarget); + drive.saveHasTarget = true; + } + + anim.save = { + kind: k, + target: drive.saveTargetLive, + age: drive.saveAge, + duration: drive.saveDuration, + lockYaw: drive.lockYaw, + }; + + // Base stance eases in — long blend, no hard cut. + // Catch keeps a ready torso; the trapper IK does the work. Reach/block + // wind-ups were twisting the spine and reading as free rotation. + if (k === 'catch') setStanceTarget('ready'); + else if (y < 0.42) setStanceTarget('butterfly'); + else setStanceTarget('reach'); + }; + + anim.clearSave = function clearSave() { + drive.saveWant = 0; + drive.saveW = 0; + drive.saveAge = 0; + drive.saveHasTarget = false; + drive.hasLockYaw = false; + anim.save = null; + }; + + /** True while a save still owns body facing / arm IK. */ + anim.saveActive = function saveActive() { + return drive.saveW > 0.05 || drive.saveWant > 0.05; + }; + + /** Facing locked at contact, or null if free to track. */ + anim.saveLockYaw = function saveLockYaw() { + return drive.hasLockYaw && drive.saveW > 0.05 ? drive.lockYaw : null; + }; + + /** + * Desired stance from puck tracking, with hysteresis so a shot arcing past + * a threshold does not flicker ready ↔ butterfly every frame. + */ + function chooseState() { + // Hold the committed save stance until the hand is releasing. + if (drive.saveWant > 0.5 || drive.saveW > 0.35) { + if (drive.saveKind === 'catch') return 'ready'; + if (drive.saveKind === 'block' && drive.saveTarget.y < 0.42) return 'butterfly'; + if (anim.state === 'ready' || anim.state === 'shuffle') return 'reach'; + return anim.state; + } + + const h = anim.puckHeight; + const d = anim.puckDist; + const thr = anim.threatened; + const sliding = Math.abs(anim.lateralVel) > 1.2 || anim.moveSpeed > 1.6; + const cur = anim.state; + + // Hysteresis bands: enter on a tight condition, exit only after looser. + if (cur === 'butterfly') { + if (h < 0.5 && d < 9 && thr > 0.15) return 'butterfly'; + } else if (h < 0.34 && (d < 3.2 || (d < 7.5 && thr > 0.35))) { + return 'butterfly'; + } + + if (cur === 'reach') { + if (h > 0.55 && d < 9.5 && thr > 0.12) return 'reach'; + } else if (h > 0.78 && d < 7.5 && thr > 0.28) { + return 'reach'; + } + + if (cur === 'shuffle') { + if (sliding || Math.abs(anim.lateralVel) > 0.7) return 'shuffle'; + } else if (sliding) { + return 'shuffle'; + } - if (low && (veryClose || (close && anim.threatened > 0.3))) return 'butterfly'; - if (high && close && anim.threatened > 0.25) return 'reach'; - if (sliding) return 'shuffle'; return 'ready'; } @@ -142,10 +370,203 @@ export function buildGoalieAnimator(skelData, mover) { const _qF = new THREE.Quaternion(); const _qInv = new THREE.Quaternion(); const _worldFoot = new THREE.Vector3(); + const _ikTarget = new THREE.Vector3(); + const _ikGoal = new THREE.Vector3(); + const _handRest = new THREE.Vector3(); + const _handPoseQ = new THREE.Quaternion(); + const _saveBlendQ = { + upper: new THREE.Quaternion(), + fore: new THREE.Quaternion(), + hand: new THREE.Quaternion(), + }; const fwdOf = (yaw, out) => out.set(Math.sin(yaw), 0, Math.cos(yaw)); const rightOf = (yaw, out) => out.set(Math.cos(yaw), 0, -Math.sin(yaw)); + /** + * Desired mover-local stick plant from stance + lean. Slow, deliberate — + * the paddle is the anchor, not a wrist accessory. + * + * Block saves steer the *stick* toward the contact (hand stays on the + * shaft). Catch never moves the stick for the seal — trapper does that. + */ + function desiredStickPlant(outPos, outQuat) { + const k = clamp(drive.stickFly, 0, 1); + outPos.lerpVectors(STICK_READY_POS, STICK_FLY_POS, k); + outQuat.slerpQuaternions(STICK_READY_Q, STICK_FLY_Q, k); + // Shuffle lean: tip the paddle slightly with the body, never snap. + const lean = clamp(drive.lean, -1, 1); + outPos.x += lean * 0.04; + outPos.z += Math.abs(lean) * 0.02; + _stickLeanE.set(0, lean * 0.12, -lean * 0.1, 'XYZ'); + _stickLean.setFromEuler(_stickLeanE); + outQuat.multiply(_stickLean); + + // Block: carry the stick (and the hand on it) to the puck. The paddle / + // blocker face is near the grip, so we aim the grip at the contact point + // and back-solve the stick origin from GRIP_LOCAL. + if (drive.saveW > 0.04 && drive.saveKind === 'block' && drive.saveHasTarget) { + const w = smooth(clamp(drive.saveW, 0, 1)); + _stickSaveLocal.copy(drive.saveTargetLive); + mover.worldToLocal(_stickSaveLocal); + // Keep the seal slightly in front of the body so the stick does not + // bury through the torso. + _stickSaveLocal.z = Math.max(0.12, _stickSaveLocal.z); + _stickSaveLocal.y = clamp(_stickSaveLocal.y, 0.2, 1.55); + // origin = gripDesired - R * gripLocal + _stickFromGrip.copy(GRIP_LOCAL).applyQuaternion(outQuat); + _stickFromGrip.set( + _stickSaveLocal.x - _stickFromGrip.x, + _stickSaveLocal.y - _stickFromGrip.y, + _stickSaveLocal.z - _stickFromGrip.z, + ); + outPos.lerp(_stickFromGrip, w); + // Tip the shaft a touch toward the puck (pitch up on high shots). + const tip = clamp((_stickSaveLocal.y - 0.55) / 0.9, 0, 1) * w; + _stickLeanE.set(-0.25 * tip, 0, 0.08 * tip * Math.sign(_stickSaveLocal.x || 1), 'XYZ'); + _stickLean.setFromEuler(_stickLeanE); + outQuat.multiply(_stickLean); + } + return { outPos, outQuat }; + } + + /** + * Write the damped stick into mover-local space, then return the world-space + * grip the blocker hand should hold. Pulls the plant into arm reach so the + * hand can always lock without floating off the shaft. + */ + function updateStickPlant(dt) { + if (!anim.stick) return null; + desiredStickPlant(_stickPosTarget, _stickQuatTarget); + if (!drive.stickSeeded) { + drive.stickPos.copy(_stickPosTarget); + drive.stickQuat.copy(_stickQuatTarget); + drive.stickSeeded = true; + } else { + const blocking = drive.saveW > 0.15 && drive.saveKind === 'block'; + const fly = drive.stickFly > 0.35; + const posRate = blocking ? STICK_BLOCK_POS_RATE : fly ? STICK_FLY_POS_RATE : STICK_POS_RATE; + const rotRate = blocking ? STICK_BLOCK_ROT_RATE : fly ? STICK_FLY_ROT_RATE : STICK_ROT_RATE; + dampVec(drive.stickPos, _stickPosTarget, posRate, dt); + dampQuat(drive.stickQuat, _stickQuatTarget, rotRate, dt); + } + anim.stick.position.copy(drive.stickPos); + anim.stick.quaternion.copy(drive.stickQuat); + anim.stick.updateMatrixWorld(true); + + // Clamp grip into reachable envelope from the right shoulder so the hand + // never has to "miss" the shaft on a deep butterfly or long reach. + B.upperArmR.getWorldPosition(_H); + _stickGripWorld.copy(GRIP_LOCAL).applyMatrix4(anim.stick.matrixWorld); + const maxReach = ARM.upper + ARM.fore - 0.04; + _d.subVectors(_stickGripWorld, _H); + if (_d.length() > maxReach) { + _d.setLength(maxReach); + _stickGripWorld.copy(_H).add(_d); + // Back-solve stick origin in mover space so the plant matches the clamp. + mover.worldToLocal(_stickGripWorld); // grip now mover-local + _stickFromGrip.copy(GRIP_LOCAL).applyQuaternion(drive.stickQuat); + drive.stickPos.copy(_stickGripWorld).sub(_stickFromGrip); + anim.stick.position.copy(drive.stickPos); + anim.stick.updateMatrixWorld(true); + _stickGripWorld.copy(GRIP_LOCAL).applyMatrix4(anim.stick.matrixWorld); + } + return _stickGripWorld; + } + + /** + * Dominant hand always locks to the stick grip. Full solve — no pose blend + * residual that left the hand floating off the shaft. + */ + function ikHandToStick(gripWorld) { + if (!gripWorld) return; + solveArm('R', gripWorld); + // Light hand set so the blocker board sits on the shaft without spinning. + // Keep most of the posed hand; only a small settle. + E(_qF, -0.12, 0.05, -0.08); + B.handR.quaternion.slerp(_qF, 0.2); + } + + /** + * Two-bone arm IK. Elbow pole hangs down and a little outside the ribs so + * a catch does not collapse into a chicken-wing. + */ + function solveArm(side, targetPos) { + const upper = B[`upperArm${side}`]; + const fore = B[`forearm${side}`]; + upper.getWorldPosition(_H); + _d.subVectors(targetPos, _H); + let d = _d.length(); + const a = ARM.upper; + const b = ARM.fore; + // Never fully lock the elbow — a straight arm reads as a mannequin. + d = clamp(d, 0.12, a + b - 0.02); + _d.normalize(); + const cosA = clamp((a * a + d * d - b * b) / (2 * a * d), -1, 1); + const sinA = Math.sqrt(Math.max(0, 1 - cosA * cosA)); + + rightOf(anim.originYaw, _r); + _pole.set(0, -1, 0).addScaledVector(_r, side === 'L' ? 0.4 : -0.4); + // Slight forward pole so the elbow stays in front of the body on a high catch. + fwdOf(anim.originYaw, _f); + _pole.addScaledVector(_f, 0.15); + _e2.copy(_pole).addScaledVector(_d, -_pole.dot(_d)); + if (_e2.lengthSq() < 1e-8) _e2.set(0, -1, 0); + _e2.normalize(); + _knee.copy(_H).addScaledVector(_d, a * cosA).addScaledVector(_e2, a * sinA); + + _dir.subVectors(_knee, _H).normalize(); + _q1.setFromUnitVectors(restUpperArmDir[side], _dir); + upper.parent.getWorldQuaternion(_qP); + _qInv.copy(_qP).invert(); + upper.quaternion.copy(_qInv).multiply(_q1); + + _dir.subVectors(targetPos, _knee).normalize(); + _q2.setFromUnitVectors(restForearmDir[side], _dir); + _qInv.copy(_q1).invert(); + fore.quaternion.copy(_qInv).multiply(_q2); + } + + /** + * Catch save only: trapper (L) reaches the puck. Block saves never take + * the dominant hand off the stick — the stick plant steers instead. + */ + function applyCatchArmIK(weight) { + if (weight <= 0.001 || !drive.saveHasTarget) return; + if (drive.saveKind !== 'catch') return; + + const upper = B.upperArmL; + const fore = B.forearmL; + const hand = B.handL; + const w = smooth(clamp(weight, 0, 1)); + + _saveBlendQ.upper.copy(upper.quaternion); + _saveBlendQ.fore.copy(fore.quaternion); + _saveBlendQ.hand.copy(hand.quaternion); + hand.getWorldPosition(_handRest); + + _ikGoal.copy(drive.saveTargetLive); + upper.getWorldPosition(_H); + _d.subVectors(_ikGoal, _H); + const maxReach = ARM.upper + ARM.fore - 0.02; + if (_d.length() > maxReach) { + _d.setLength(maxReach); + _ikGoal.copy(_H).add(_d); + } + _ikGoal.y = clamp(_ikGoal.y, 0.12, 1.85); + + const tipW = w * w * (3 - 2 * w); + _ikTarget.lerpVectors(_handRest, _ikGoal, tipW); + solveArm('L', _ikTarget); + + if (w < 0.97) { + upper.quaternion.slerpQuaternions(_saveBlendQ.upper, upper.quaternion, tipW); + fore.quaternion.slerpQuaternions(_saveBlendQ.fore, fore.quaternion, tipW); + } + // Keep posed trapper orientation — no free euler spin. + hand.quaternion.copy(_saveBlendQ.hand); + } + function solveLeg(side, localX, localZ, toeYaw) { const thigh = B['thigh' + side]; const shin = B['shin' + side]; @@ -193,80 +614,215 @@ export function buildGoalieAnimator(skelData, mover) { B['toe' + side].quaternion.identity(); } + /** Author all four stances and slerp-blend into `cur` by continuous weights. */ + function buildBlendedPose(t) { + const lean = drive.lean; + poseReady(_poseScratch.ready, { lean: lean * 0.5, t }); + poseShuffle(_poseScratch.shuffle, { + dir: drive.shuffleDir, + effort: drive.shuffleEffort, + t, + }); + poseButterfly(_poseScratch.butterfly, { lean, t }); + poseReach(_poseScratch.reach, { + side: drive.reachSide >= 0 ? 1 : -1, + up: drive.reachUp, + lean, + t, + }); + + // Renormalise weights (float drift). + let sum = 0; + for (const k of STANCE_KEYS) sum += stanceW[k]; + const inv = sum > 1e-6 ? 1 / sum : 1; + + // Seed from ready, then slerp each other stance in by its weight. + // Using successive slerp with renormalised remaining mass keeps the blend + // well-behaved for 4-way mixes (not perfect geodesic, but smooth). + const wReady = stanceW.ready * inv; + const wShuffle = stanceW.shuffle * inv; + const wFly = stanceW.butterfly * inv; + const wReach = stanceW.reach * inv; + + // Accumulate: start at ready, blend toward each pose. + for (const n of GOALIE_UPPER) { + cur.q[n].copy(_poseScratch.ready.q[n]); + } + cur.rootOffset.copy(_poseScratch.ready.rootOffset); + cur.rootQuat.copy(_poseScratch.ready.rootQuat); + cur.feet.L = { ..._poseScratch.ready.feet.L }; + cur.feet.R = { ..._poseScratch.ready.feet.R }; + + function mixPose(src, w) { + if (w < 1e-4) return; + for (const n of GOALIE_UPPER) { + cur.q[n].slerp(src.q[n], w); + } + cur.rootOffset.lerp(src.rootOffset, w); + cur.rootQuat.slerp(src.rootQuat, w); + cur.feet.L.x = lerp(cur.feet.L.x, src.feet.L.x, w); + cur.feet.L.z = lerp(cur.feet.L.z, src.feet.L.z, w); + cur.feet.L.yaw = lerp(cur.feet.L.yaw, src.feet.L.yaw, w); + cur.feet.R.x = lerp(cur.feet.R.x, src.feet.R.x, w); + cur.feet.R.z = lerp(cur.feet.R.z, src.feet.R.z, w); + cur.feet.R.yaw = lerp(cur.feet.R.yaw, src.feet.R.yaw, w); + } + + // Order: larger secondary weights last so they dominate the final slerp. + // Normalise pairwise: after ready is the base, blend others by + // w_i / (w_ready + ... + w_i) cumulative form. + let acc = wReady; + const step = (src, w) => { + if (w < 1e-4) return; + acc += w; + mixPose(src, w / acc); + }; + step(_poseScratch.shuffle, wShuffle); + step(_poseScratch.butterfly, wFly); + step(_poseScratch.reach, wReach); + } + anim.update = function update(dt) { dt *= anim.speed; + if (dt <= 0) return; anim.time += dt; anim.stateTime += dt; - anim.blend = Math.min(1, anim.blend + dt / anim.transitionTime); + anim.blend = Math.min(1, anim.blend + dt / Math.max(1e-4, anim.transitionTime)); + + // ---- save envelope (want → damped weight) ---------------------------- + if (drive.saveWant > 0 || drive.saveW > 0.001 || drive.saveHasTarget) { + drive.saveAge += dt; + // Hold full through ~60% of the duration, ease out over the rest. + const outStart = drive.saveDuration * 0.6; + if (drive.saveAge <= outStart) { + drive.saveWant = 1; + } else if (drive.saveAge < drive.saveDuration) { + const u = (drive.saveAge - outStart) / Math.max(1e-4, drive.saveDuration - outStart); + // Smoothstep release — no linear cliff at the end of the hold. + drive.saveWant = 1 - smooth(clamp(u, 0, 1)); + } else { + drive.saveWant = 0; + } + } + // Attack faster than release so the seal still reads, but neither is a pop. + const saveRate = drive.saveWant > drive.saveW ? 16 : 7; + drive.saveW = damp(drive.saveW, drive.saveWant, saveRate, dt); + // Once the authored duration is past, force a clean finish so callers and + // tests do not wait forever on a long exponential tail. + if (drive.saveAge >= drive.saveDuration && drive.saveWant <= 0) { + drive.saveW = damp(drive.saveW, 0, 18, dt); + if (drive.saveW < 0.05 || drive.saveAge >= drive.saveDuration + 0.12) { + drive.saveW = 0; + } + } + if (drive.saveW < 0.004 && drive.saveWant <= 0) { + drive.saveW = 0; + drive.saveHasTarget = false; + drive.hasLockYaw = false; + anim.save = null; + } else if (drive.saveHasTarget || drive.saveWant > 0 || drive.saveW > 0.004) { + dampVec(drive.saveTargetLive, drive.saveTarget, 18, dt); + anim.save = { + kind: drive.saveKind, + target: drive.saveTargetLive, + age: drive.saveAge, + duration: drive.saveDuration, + lockYaw: drive.lockYaw, + }; + } + + // Hold body facing at contact while the seal is live (especially catch). + if (drive.hasLockYaw && drive.saveW > 0.08) { + anim.originYaw = drive.lockYaw; + } + + // ---- stance selection (hysteresis) + continuous weights -------------- + const desired = chooseState(); + setStanceTarget(desired); + + // Stance blend rate: saves commit a bit faster; idle is softer. + const stanceRate = drive.saveW > 0.2 ? 7 : 5; + for (const k of STANCE_KEYS) { + stanceW[k] = damp(stanceW[k], stanceTarget[k], stanceRate, dt); + } + + // ---- smooth pose drivers --------------------------------------------- + // During a catch, kill body lean/yaw from lateral shuffle — the arm does + // the work; torso twist was reading as a pointless spin. + let leanTarget = clamp(anim.lateralVel / 3.5, -1, 1); + if (drive.saveW > 0.1 && drive.saveKind === 'catch') leanTarget *= 0.15; + drive.lean = damp(drive.lean, leanTarget, 8, dt); + + let sideTarget = drive.lean > 0.2 ? 1 : drive.lean < -0.2 ? -1 : drive.reachSide; + if (drive.saveW > 0.05) sideTarget = drive.saveKind === 'block' ? 1 : -1; + // Sides are ±1; damp through continuous then snap sign for pose authoring. + drive.reachSide = damp(drive.reachSide, sideTarget, 6, dt); + + const upSrc = drive.saveW > 0.1 ? drive.saveTargetLive.y : anim.puckHeight; + const upTarget = clamp((upSrc - 0.55) / 0.85, 0.35, 1); + drive.reachUp = damp(drive.reachUp, upTarget, 7, dt); + + drive.shuffleDir = anim.lateralVel >= 0 ? 1 : -1; + const effortTarget = clamp(anim.moveSpeed / 3.5, 0.3, 1); + drive.shuffleEffort = damp(drive.shuffleEffort, effortTarget, 6, dt); + + const stickTarget = stanceW.butterfly > 0.5 || (anim.state === 'butterfly' && stanceW.butterfly > 0.25) + ? clamp(stanceW.butterfly, 0, 1) + : 0; + drive.stickFly = damp(drive.stickFly, stickTarget, 6, dt); - anim.setState(chooseState()); applyMover(); mover.updateMatrixWorld(true); - const lean = clamp(anim.lateralVel / 3.5, -1, 1); - const t = anim.time; - - for (const n of GOALIE_BONES) cur.q[n].identity(); - cur.rootOffset.set(0, 0, 0); - cur.rootQuat.identity(); - - if (anim.state === 'butterfly') { - poseButterfly(cur, { lean, t }); - } else if (anim.state === 'shuffle') { - poseShuffle(cur, { - dir: anim.lateralVel >= 0 ? 1 : -1, - effort: clamp(anim.moveSpeed / 3.5, 0.3, 1), - t, - }); - } else if (anim.state === 'reach') { - const side = lean > 0.25 ? 1 : -1; - poseReach(cur, { - side, - up: clamp((anim.puckHeight - 0.6) / 0.8, 0.4, 1), - lean, - t, - }); - } else { - poseReady(cur, { lean: lean * 0.5, t }); - } + buildBlendedPose(anim.time); + // Soft intro crossfade still available for studio setState(…, short). const w = smooth(anim.blend); - for (const n of GOALIE_UPPER) { - B[n].quaternion.slerpQuaternions(frozen.q[n], cur.q[n], w); + if (w < 0.999) { + for (const n of GOALIE_UPPER) { + B[n].quaternion.slerpQuaternions(frozen.q[n], cur.q[n], w); + } + B.root.position.lerpVectors(frozen.rootOffset, cur.rootOffset, w); + B.root.quaternion.slerpQuaternions(frozen.rootQuat, cur.rootQuat, w); + } else { + for (const n of GOALIE_UPPER) B[n].quaternion.copy(cur.q[n]); + B.root.position.copy(cur.rootOffset); + B.root.quaternion.copy(cur.rootQuat); } - // Legs identity mid-blend then IK — slerping free leg eulers fights the IK. for (const n of GOALIE_LEGS) B[n].quaternion.identity(); - B.root.position.lerpVectors(frozen.rootOffset, cur.rootOffset, w); - B.root.quaternion.slerpQuaternions(frozen.rootQuat, cur.rootQuat, w); mover.updateMatrixWorld(true); - // Blend foot targets in mover-local space, then IK. + // Blend foot targets, then leg IK. const fL = { - x: lerp(frozen.feet.L.x, cur.feet.L.x, w), - z: lerp(frozen.feet.L.z, cur.feet.L.z, w), - yaw: lerp(frozen.feet.L.yaw, cur.feet.L.yaw, w), + x: w < 0.999 ? lerp(frozen.feet.L.x, cur.feet.L.x, w) : cur.feet.L.x, + z: w < 0.999 ? lerp(frozen.feet.L.z, cur.feet.L.z, w) : cur.feet.L.z, + yaw: w < 0.999 ? lerp(frozen.feet.L.yaw, cur.feet.L.yaw, w) : cur.feet.L.yaw, }; const fR = { - x: lerp(frozen.feet.R.x, cur.feet.R.x, w), - z: lerp(frozen.feet.R.z, cur.feet.R.z, w), - yaw: lerp(frozen.feet.R.yaw, cur.feet.R.yaw, w), + x: w < 0.999 ? lerp(frozen.feet.R.x, cur.feet.R.x, w) : cur.feet.R.x, + z: w < 0.999 ? lerp(frozen.feet.R.z, cur.feet.R.z, w) : cur.feet.R.z, + yaw: w < 0.999 ? lerp(frozen.feet.R.yaw, cur.feet.R.yaw, w) : cur.feet.R.yaw, }; solveLeg('L', fL.x, fL.z, fL.yaw); solveLeg('R', fR.x, fR.z, fR.yaw); - // Stick grip: blend ready → butterfly so the paddle stays near the ice. - if (anim.stick) { - const k = anim.state === 'butterfly' ? Math.min(1, anim.stateTime / 0.14) : 0; - _stickEuler.set( - STICK_READY_E.x + (STICK_FLY_E.x - STICK_READY_E.x) * k, - STICK_READY_E.y + (STICK_FLY_E.y - STICK_READY_E.y) * k, - STICK_READY_E.z + (STICK_FLY_E.z - STICK_READY_E.z) * k, - 'XYZ', - ); - anim.stick.quaternion.setFromEuler(_stickEuler); - _stickPos.lerpVectors(STICK_READY_POS, STICK_FLY_POS, k); - anim.stick.position.copy(_stickPos); + // Stick plant first (heavy, mover-local). Block saves steer the plant + // toward the puck; the dominant hand never leaves the shaft. + mover.updateMatrixWorld(true); + const gripWorld = updateStickPlant(dt); + + // Catch: trapper IK to the puck (left hand only). + if (drive.saveW > 0.001 && drive.saveKind === 'catch') { + mover.updateMatrixWorld(true); + applyCatchArmIK(drive.saveW); + } + + // Dominant hand always locks to the stick grip — including during block + // saves (the stick moved to the puck; the hand rides along). + if (gripWorld) { + mover.updateMatrixWorld(true); + ikHandToStick(gripWorld); } mover.updateMatrixWorld(true); diff --git a/src/character/goalie.js b/src/character/goalie.js index b508e34..fe581ca 100644 --- a/src/character/goalie.js +++ b/src/character/goalie.js @@ -76,6 +76,11 @@ export function createGoalie(physics, scene, { const gearMats = buildGoalieMaterials(materials.team.jersey, materials.team.accent); const gear = buildGoalieGear(gearMats); gear.attachTo(skelData.bones); + // Stick is mover-space, not hand-socketed. A goalie stick is heavy: the + // paddle plants on the ice and the blocker hand IKs to the shaft. Parenting + // it to handR made every wrist twitch spin the paddle like a baton. + if (gear.stick.parent) gear.stick.parent.remove(gear.stick); + mover.add(gear.stick); const animator = buildGoalieAnimator(skelData, mover); animator.stick = gear.stick; @@ -163,9 +168,20 @@ export function createGoalie(physics, scene, { animator.moveSpeed = 0; animator.lateralVel = 0; animator.threatened = 0; + animator.clearSave(); animator.setState('ready', 0.05); }, + /** + * Resolve a save with hand IK. + * `kind`: `'catch'` (trapper seals on the puck) or `'block'` (blocker + * meets the puck and the rebound leaves the crease). `puck` is anything + * with `{x,y,z}` at the contact point. + */ + playSave(kind, puck, opts) { + animator.playSave(kind, puck, opts); + }, + /** * Track the puck. `dt` on the frame clock. * `puck` is anything with `{x,y,z}` — the shootout passes a Vector3. @@ -201,26 +217,37 @@ export function createGoalie(physics, scene, { goalieSpot(seen, end, GOALIE.depth, target); + // A catch/block seal owns facing — do not square up to a puck that is + // already glued to the glove (that spun the whole torso every frame). + const lockYaw = animator.saveLockYaw?.() ?? null; + const sealing = lockYaw != null; + const catchSeal = sealing && animator.save?.kind === 'catch'; + // A puck already behind them gets a desperation push across, which is // why a slow deke beats them and a fast one sometimes does not. const beaten = end > 0 ? px > pos.x : px < pos.x; - const speed = GOALIE.speed * (beaten ? 1 + GOALIE.desperation : 1); + const speed = GOALIE.speed * (beaten ? 1 + GOALIE.desperation : 1) + * (catchSeal ? 0.15 : sealing ? 0.4 : 1); const dx = target.x - pos.x; const dz = target.z - pos.z; const dist = Math.hypot(dx, dz); const step = speed * dt; const z0 = pos.z; - if (dist <= step || dist < 1e-6) { - pos.x = target.x; - pos.z = target.z; - } else { - pos.x += (dx / dist) * step; - pos.z += (dz / dist) * step; + if (!catchSeal) { + if (dist <= step || dist < 1e-6) { + pos.x = target.x; + pos.z = target.z; + } else { + pos.x += (dx / dist) * step; + pos.z += (dz / dist) * step; + } } - // Square up to the puck. - const yaw = Math.atan2(px - pos.x, pz - pos.z); + // Square up to the puck, unless a save has frozen facing at contact. + const yaw = lockYaw != null + ? lockYaw + : Math.atan2(px - pos.x, pz - pos.z); mover.position.set(pos.x, 0, pos.z); mover.rotation.y = yaw; @@ -244,7 +271,7 @@ export function createGoalie(physics, scene, { animator.setTransform(mover.position, yaw); animator.moveSpeed = Math.abs(latVel) + (dist > step ? speed * 0.25 : 0); - animator.lateralVel = latVel; + animator.lateralVel = catchSeal ? 0 : latVel; animator.puckHeight = py; animator.puckDist = puckDist; animator.threatened = threat; diff --git a/src/character/goalieGear.js b/src/character/goalieGear.js index a592bfb..3c4bad7 100644 --- a/src/character/goalieGear.js +++ b/src/character/goalieGear.js @@ -29,26 +29,45 @@ import { carvedShell, loft, mergeBars, tint, tube } from './gearMesh.js'; * its keys — so only the mask, whose surface is a formula, needs constants. */ export const GEAR = { + /** + * Mask, built to the CAD sheet. Every number here is off the drawing, in + * metres, so the model can be checked against it: + * + * front 265 wide × 230 tall, chin opening 110, eye port 127 across + * side 285 front to back, ear hole ⌀45 + * top 265 × 285 + * layup 3.5 shell + 15–20 impact foam + 10–15 comfort foam + * + * `ry` and `phi0` are the two that are not read straight off it: together + * they set the height, since the shell is a lat/long surface and its bottom + * edge sits at riseY − ry·cos(phi0). They satisfy ry·(1 + cos phi0) = 230. + */ mask: { /** Skull centre in head-bone-local space (head bone sits at the jaw hinge). */ - riseY: 0.094, - pushZ: -0.006, - rx: 0.118, - ry: 0.156, - rz: 0.128, - /** Polar angle the shell starts at — below the chin, open at the neck. */ - phi0: 0.52, - wall: 0.011, - /** Face opening, relative to the skull centre. */ - portW: 0.076, - portH: 0.054, - portY: -0.004, + riseY: 0.098, + pushZ: -0.008, + /** Half of the 265 width and the 285 depth. */ + rx: 0.1325, + rz: 0.1425, + ry: 0.123, + phi0: 0.516, + /** Shell plus both foam layers — the wall you see at the port edge. */ + wall: 0.024, + /** Chin opening, 110 across. */ + chinW: 0.110, + /** Eye port: 127 across, sized off the front view. */ + portW: 0.0635, + portH: 0.042, + portY: 0.004, + /** Ear hole, ⌀45. */ + earR: 0.0225, + earY: -0.03, /** Cage: an ellipse bowed out in front of the opening. */ - cageW: 0.092, - cageH: 0.070, - cageBase: 0.088, - cageBulge: 0.052, - barR: 0.0045, + cageW: 0.078, + cageH: 0.056, + cageBase: 0.098, + cageBulge: 0.05, + barR: 0.0024, }, }; @@ -539,8 +558,10 @@ export function buildGoalieGear(mats) { const floaterR = makeFloater('R'); // ---- goalie stick ------------------------------------------------------- - // Shaft down −Y from the blocker hand into a wide paddle, then a blade that - // sits flat on the ice across the five-hole. + // Built with shaft down −Y into a wide paddle and a blade across the crease. + // At runtime the stick is reparented to the mover (not the hand): it is the + // heavy plant the blocker hand IKs to, not a socketed prop that spins with + // every wrist twitch. const stick = new THREE.Group(); stick.name = 'goalieStick'; let paddleMesh = null; @@ -585,10 +606,10 @@ export function buildGoalieGear(mats) { 'stickKnob', )); - // Default grip: overwritten by the animator each frame, but a sane editor - // default (paddle toward the ice, slightly in front). - stick.position.set(0.03, -0.02, 0.04); - stick.rotation.set(0.9, 0.35, 0.55); + // Mover-local plant: paddle in the five-hole, shaft up into the blocker + // side. Animator damps from here and IKs the hand to the grip. + stick.position.set(-0.33, 0.69, 0.5); + stick.rotation.set(0.15, 0.1, 0.2); pieces.push(stick); paddleMesh = paddle; } @@ -611,7 +632,7 @@ export function buildGoalieGear(mats) { bones.shinR.add(padR); bones.handL.add(trapper); bones.handR.add(blocker); - bones.handR.add(stick); + // Stick is attached by createGoalie onto the mover — not the hand. bones.head.add(mask); bones.spine3.add(chest); bones.upperArmL.add(floaterL); diff --git a/src/studio/img2mesh.js b/src/studio/img2mesh.js index 4a1f080..66cafc2 100644 --- a/src/studio/img2mesh.js +++ b/src/studio/img2mesh.js @@ -1,14 +1,18 @@ import * as THREE from 'three'; import { createSkater } from '../character/skater.js'; -import { createGoalie } from '../character/goalie.js'; +import { createGoalie, GOALIE } from '../character/goalie.js'; import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; +import { goalLineX, isGoal } from '../../shared/net.js'; +import { PUCK } from '../physics/puck.js'; +import { buildPuckMesh } from '../render/rink.js'; +import { buildNetMesh } from '../physics/net.js'; /** * img2mesh — isolated character studio for equipment + animation iteration. * - * No match, no physics, no AI. Just a skater and a goalie on a ground plane, - * pose presets, fixed camera views, and a `window.img2mesh` API the headless - * capture tool drives to dump a shot sheet. + * Pose presets and fixed camera views for skater / goalie gear work, plus a + * puck shooting machine that drives the live goalie AI and leg IK so saves can + * be tuned without booting the full shootout. * * Open: http://localhost:5174/character.html * CLI: npm run img2mesh @@ -20,6 +24,9 @@ const hud = document.getElementById('hud'); const subjectSel = document.getElementById('subject'); const poseSel = document.getElementById('pose'); const viewSel = document.getElementById('view'); +const machineBtn = document.getElementById('machine'); +const fireOnceBtn = document.getElementById('fireOnce'); +const machineResetBtn = document.getElementById('machineReset'); // ---- renderer / scene ----------------------------------------------------- const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, powerPreference: 'high-performance' }); @@ -108,8 +115,549 @@ const state = { showBones: false, showGear: false, time: 0, + /** Live puck-machine drill (goalie AI + IK saves). */ + machine: false, }; +// ---- puck shooting machine ------------------------------------------------ +/** + * Studio drill: fire kinematic pucks at the real crease so the goalie AI + * tracks angle, picks stances (ready / shuffle / butterfly / reach), and the + * leg IK keeps pads on the ice. No Box3D — soft pad/body volumes deflect the + * disc so you can read a save without booting the match. + */ +const MACHINE_END = 1; +const MACHINE = { + /** Seconds between the end of one shot and the next fire. */ + interval: 1.35, + spawnDist: 11.5, + gravity: -14, + iceY: PUCK.thickness / 2, + /** How long a resolved puck (save / goal / miss) stays on screen. */ + holdAfter: 0.75, + /** Catch holds longer so the glove IK seal reads. */ + holdCatch: 0.9, +}; + +/** Target recipes cycled by the auto machine (and listed in the HUD). */ +const SHOT_RECIPES = [ + { id: 'fivehole', label: 'five-hole', y: 0.08, z: 0.0, speed: 18, loft: 0.4 }, + { id: 'glove', label: 'glove high', y: 1.18, z: -0.58, speed: 24, loft: 1.6 }, + { id: 'blocker', label: 'blocker high', y: 1.08, z: 0.58, speed: 24, loft: 1.5 }, + { id: 'padL', label: 'pad left', y: 0.12, z: -0.78, speed: 20, loft: 0.5 }, + { id: 'padR', label: 'pad right', y: 0.12, z: 0.78, speed: 20, loft: 0.5 }, + { id: 'chest', label: 'chest', y: 0.88, z: 0.08, speed: 26, loft: 0.9 }, + { id: 'stick', label: 'stick side', y: 0.18, z: 0.38, speed: 19, loft: 0.5 }, + { id: 'dekeL', label: 'deke far L', y: 0.1, z: -1.05, speed: 16, loft: 0.3 }, +]; + +const machine = { + on: false, + cooldown: 0, + recipeIndex: 0, + lastRecipe: null, + lastResult: null, + /** 'catch' | 'block' | null — last save kind for HUD / puck pin. */ + lastSaveKind: null, + /** When true the puck is sealed in the trapper until hold ends. */ + caught: false, + /** World point at catch contact — body tracks this, not the pinned disc. */ + catchTrack: null, + saves: 0, + goals: 0, + misses: 0, + shots: 0, + /** @type {{ pos: THREE.Vector3, vel: THREE.Vector3, alive: boolean, age: number, resolvedAt: number | null } | null} */ + puck: null, + /** @type {ReturnType | null} */ + puckView: null, + /** @type {THREE.Group | null} */ + netMesh: null, + /** Scratch for hit tests. */ + _local: new THREE.Vector3(), +}; + +function ensurePuckView() { + if (!machine.puckView) { + machine.puckView = buildPuckMesh(scene, PUCK); + machine.puckView.mesh.visible = false; + machine.puckView.ring.visible = false; + } + return machine.puckView; +} + +function ensureNet() { + if (!machine.netMesh) { + machine.netMesh = buildNetMesh(scene, MACHINE_END); + } + machine.netMesh.visible = true; + return machine.netMesh; +} + +function hideNet() { + if (machine.netMesh) machine.netMesh.visible = false; +} + +function studioOriginGround() { + ground.position.set(0, 0, 0); + grid.position.set(0, 0.002, 0); +} + +function machineCreaseGround() { + const x = goalLineX(MACHINE_END) - MACHINE_END * GOALIE.depth; + ground.position.set(x, 0, 0); + grid.position.set(x, 0.002, 0); +} + +function applyMachineCamera() { + const line = goalLineX(MACHINE_END); + const creaseX = line - MACHINE_END * GOALIE.depth; + // Shooter's eye: a few metres out, slightly up, looking into the net. + camera.position.set(creaseX - 7.5, 1.6, 2.8); + controls.target.set(creaseX + 0.4, 0.85, 0); + controls.update(); + state.view = 'machine'; + // Keep the select honest when we leave pose-studio views. + if (![...viewSel.options].some((o) => o.value === 'machine')) { + const opt = document.createElement('option'); + opt.value = 'machine'; + opt.textContent = 'machine (crease)'; + viewSel.appendChild(opt); + } + viewSel.value = 'machine'; +} + +function fireShot(recipe = null) { + if (!goalie) buildGoalie(); + ensurePuckView(); + ensureNet(); + + const rec = recipe ?? SHOT_RECIPES[machine.recipeIndex % SHOT_RECIPES.length]; + machine.recipeIndex = (SHOT_RECIPES.indexOf(rec) + 1) % SHOT_RECIPES.length; + machine.lastRecipe = rec; + machine.lastResult = null; + machine.lastSaveKind = null; + machine.caught = false; + machine.catchTrack = null; + machine.shots++; + if (goalie) goalie.animator.clearSave(); + + const line = goalLineX(MACHINE_END); + // Spawn toward centre ice, aim at a point just in front of the mouth. + const spawnX = line - MACHINE_END * MACHINE.spawnDist; + // Hand saves contact ~0.35 m in front of the goalie, not on the goal line — + // that way the glove/blocker volumes see the puck before the pads do. + const aimX = line - MACHINE_END * (GOALIE.depth + 0.35); + // Small lateral jitter so the same recipe does not look robotic. + const jitterZ = (Math.random() - 0.5) * 0.1; + const spawnZ = rec.z * 0.12 + jitterZ; + const aimZ = rec.z + jitterZ * 0.25; + // High shots leave the machine already elevated so gravity does not dump + // them into the pads before the hand volumes can resolve a catch/block. + const aimY = Math.max(MACHINE.iceY, rec.y); + const spawnY = aimY > 0.55 + ? Math.max(0.55, aimY * 0.72) + : Math.max(MACHINE.iceY + 0.02, aimY * 0.4 + 0.08); + + const dx = aimX - spawnX; + const dz = aimZ - spawnZ; + const horiz = Math.hypot(dx, dz) || 1; + // Horizontal speed from the recipe. High shots solve ballistic height so the + // glove/blocker volumes actually see the disc; low shots stay flat so they + // do not arc into the chest on the way in. + const speed = rec.speed; + const tFlight = horiz / Math.max(1e-3, speed); + const g = MACHINE.gravity; + let vy; + if (aimY > 0.55) { + vy = (aimY - spawnY - 0.5 * g * tFlight * tFlight) / tFlight; + vy += (rec.loft ?? 0) * 0.12; + } else { + vy = Math.min(1.0, (aimY - spawnY) * 0.8 + (rec.loft ?? 0) * 0.2); + } + const vel = new THREE.Vector3( + (dx / horiz) * speed, + vy, + (dz / horiz) * speed, + ); + + machine.puck = { + pos: new THREE.Vector3(spawnX, spawnY, spawnZ), + vel, + alive: true, + age: 0, + resolvedAt: null, + }; + + const view = machine.puckView; + view.mesh.visible = true; + view.ring.visible = true; + view.mesh.position.copy(machine.puck.pos); +} + +/** Goalie-local axes for the current puck sample. */ +function puckLocal(puckPos, out = { localX: 0, localZ: 0 }) { + const dx = puckPos.x - goalie.pos.x; + const dz = puckPos.z - goalie.pos.z; + const yaw = goalie.animator.originYaw; + const s = Math.sin(yaw); + const c = Math.cos(yaw); + // Forward = (sin yaw, cos yaw); right = (cos yaw, −sin yaw). + out.localX = dx * s + dz * c; + out.localZ = dx * c - dz * s; + return out; +} + +/** + * Soft pad / body / glove volumes in goalie-local space. + * Returns the part that hit, or null. + */ +function puckHitsGoalie(puckPos) { + if (!goalie) return null; + const { localX, localZ } = puckLocal(puckPos); + const y = puckPos.y; + const r = PUCK.radius; + + // Trapper (L / −localZ) and blocker (R / +localZ) — always first, so a high + // shot can resolve as a hand save before the pad stack claims it. + if (y > 0.48) { + const gloveR = goalie.animator.state === 'reach' || goalie.animator.save?.kind === 'catch' ? 0.42 : 0.36; + const blockR = goalie.animator.state === 'reach' || goalie.animator.save?.kind === 'block' ? 0.4 : 0.34; + // Wider vertical bands; lateral centers match the ready/reach hand sockets. + if (y < 1.8 && Math.hypot(localX - 0.12, localZ + 0.42) < gloveR) return 'glove'; + if (y < 1.75 && Math.hypot(localX - 0.1, localZ - 0.4) < blockR) return 'blocker'; + } + + // Pads: low, wide, thin in depth. Only claim low pucks so they cannot steal + // a glove/blocker save that dipped a centimetre into pad height. + if ( + y < GOALIE.padHeight + r + && Math.abs(localX) < GOALIE.padDepth / 2 + r + 0.06 + && Math.abs(localZ) < GOALIE.padWidth / 2 + r + 0.04 + ) { + return 'pad'; + } + + // Upper body capsule (axis along Y). + if (y > GOALIE.bodyLow - r && y < GOALIE.bodyHigh + r) { + if (Math.hypot(localX, localZ) < GOALIE.bodyRadius + r + 0.04) return 'body'; + } + + // Butterfly: pads cover more lateral width. + if (goalie.animator.state === 'butterfly' && y < 0.55) { + if (Math.abs(localX) < 0.28 && Math.abs(localZ) < 0.72) return 'pad'; + } + + if (goalie.covers(puckPos)) return 'cover'; + return null; +} + +/** + * Map a hit part to a hand-IK save kind. + * - glove side → catch (trapper seals on the puck) + * - blocker side → block (deflect off the line) + * - low pad → null (pads only, no arm IK) + */ +function classifySave(part, puckPos) { + if (part === 'glove') return 'catch'; + if (part === 'blocker') return 'block'; + const { localZ } = puckLocal(puckPos); + if (part === 'cover' || part === 'body') { + if (localZ < -0.06) return 'catch'; + if (localZ > 0.06) return 'block'; + // Chest / five-hole body — trapper smothers when high enough. + return puckPos.y > 0.65 ? 'catch' : null; + } + // Pads stay pad saves; no hand IK. + return null; +} + +function resolveShot(result) { + const p = machine.puck; + if (!p || p.resolvedAt != null) return; + p.resolvedAt = p.age; + p.alive = false; + machine.lastResult = result; + if (result.startsWith('save')) machine.saves++; + else if (result === 'GOAL') machine.goals++; + else machine.misses++; +} + +function holdDuration() { + return machine.caught ? MACHINE.holdCatch : MACHINE.holdAfter; +} + +/** + * Resolve a save: hand IK for catch/block, pad rebound otherwise. + * Catch freezes the puck in the trapper; block drives it back out of the net. + */ +function deflectPuck(part) { + const p = machine.puck; + if (!p || !goalie) return; + + const kind = classifySave(part, p.pos); + const { localZ } = puckLocal(p.pos); + + if (kind === 'catch') { + // Seal — no rebound. Puck pins to the glove after the animator IK lands. + // Freeze the tracking point at contact so the body does not yaw-chase the + // disc as it rides the trapper (that was spinning the whole goalie). + p.vel.set(0, 0, 0); + machine.caught = true; + machine.catchTrack = { x: p.pos.x, y: p.pos.y, z: p.pos.z }; + machine.lastSaveKind = 'catch'; + goalie.playSave('catch', p.pos); + resolveShot('save · catch'); + return; + } + + if (kind === 'block') { + // Deflect away from the net along the goalie's facing (+ a lateral kick + // so the rebound leaves the slot instead of sitting on the goal line). + const yaw = goalie.animator.originYaw; + const fx = Math.sin(yaw); + const fz = Math.cos(yaw); + const speed = 9 + Math.random() * 4; + p.vel.set( + fx * speed, + Math.max(1.2, Math.abs(p.vel.y) * 0.35 + 1.4), + fz * speed + localZ * 3.2, + ); + machine.caught = false; + machine.lastSaveKind = 'block'; + goalie.playSave('block', p.pos); + resolveShot('save · block'); + return; + } + + // Pad / low body — rebound only, no hand IK. + const out = -MACHINE_END; + p.vel.x = out * (6 + Math.random() * 5); + p.vel.y = Math.abs(p.vel.y) * 0.35 + 1.2; + p.vel.z = -localZ * 2.5 + (Math.random() - 0.5) * 1.2; + machine.caught = false; + machine.lastSaveKind = null; + resolveShot(`save · ${part}`); +} + +function resolvePuckOutcome() { + const p = machine.puck; + if (!p || p.resolvedAt != null) return; + if (isGoal(p.pos, MACHINE_END, PUCK.radius)) { + resolveShot('GOAL'); + return; + } + const line = goalLineX(MACHINE_END); + const past = MACHINE_END > 0 + ? p.pos.x - PUCK.radius > line + : p.pos.x + PUCK.radius < line; + if (past) resolveShot('miss · wide/high'); +} + +/** After the goalie animates, stick a caught puck to the trapper. */ +const _catchPin = new THREE.Vector3(); +function pinCaughtPuck() { + if (!machine.caught || !machine.puck || !goalie) return; + if (machine.puck.resolvedAt == null) return; + // Prefer the trapper mesh if present; fall back to the hand bone. + const trap = goalie.gear?.trapper; + if (trap) trap.getWorldPosition(_catchPin); + else goalie.skelData.bones.handL.getWorldPosition(_catchPin); + // Pocket sits a little proud of the hand so the disc does not sink into the mesh. + _catchPin.y += 0.02; + machine.puck.pos.copy(_catchPin); + machine.puck.vel.set(0, 0, 0); + if (machine.puckView) { + machine.puckView.mesh.position.copy(_catchPin); + machine.puckView.ring.visible = false; + } +} + +function stepPuck(dt) { + const p = machine.puck; + if (!p) return; + p.age += dt; + + // Keep integrating a beat after resolve so rebounds still read, then hide. + const hold = p.resolvedAt != null && p.age - p.resolvedAt > holdDuration(); + if (hold) { + if (machine.puckView) { + machine.puckView.mesh.visible = false; + machine.puckView.ring.visible = false; + } + machine.caught = false; + machine.catchTrack = null; + return; + } + + // Caught pucks skip free-flight integration — pinCaughtPuck owns the pose. + if (machine.caught && p.resolvedAt != null) { + if (machine.puckView) machine.puckView.mesh.position.copy(p.pos); + return; + } + + // Soft coast after a save; full flight while live. + const damp = p.resolvedAt != null ? 0.92 : 1; + p.vel.y += MACHINE.gravity * dt; + p.vel.x *= damp; + p.vel.z *= damp; + p.pos.x += p.vel.x * dt; + p.pos.y += p.vel.y * dt; + p.pos.z += p.vel.z * dt; + + // Ice. + if (p.pos.y < MACHINE.iceY) { + p.pos.y = MACHINE.iceY; + if (p.vel.y < 0) p.vel.y *= -0.25; + p.vel.x *= 0.992; + p.vel.z *= 0.992; + } + + if (p.resolvedAt == null) { + const part = puckHitsGoalie(p.pos); + if (part) deflectPuck(part); + else resolvePuckOutcome(); + + // Timed out far from the crease. + if (p.resolvedAt == null && p.age > 4.5) resolveShot('miss · timeout'); + } + + if (machine.puckView) { + machine.puckView.mesh.position.copy(p.pos); + // Spin for readability. + machine.puckView.mesh.rotation.y += dt * 14; + } +} + +function setMachine(on) { + machine.on = !!on; + state.machine = machine.on; + if (machineBtn) { + machineBtn.textContent = machine.on ? 'puck machine · ON' : 'puck machine · OFF'; + machineBtn.classList.toggle('on', machine.on); + } + + if (machine.on) { + if (!goalie) buildGoalie(); + // Live AI owns the goalie — park on the real crease, hide the skater. + state.subject = 'goalie'; + subjectSel.value = 'goalie'; + if (player) player.mover.visible = false; + goalie.mover.visible = true; + goalie.reset(); + machineCreaseGround(); + ensureNet(); + ensurePuckView(); + // Crease is ~25 m from origin; open fog / far plane so the drill reads. + scene.fog.near = 40; + scene.fog.far = 90; + camera.far = 120; + camera.updateProjectionMatrix(); + // Key light follows the crease so pads keep a readable shadow. + const creaseX = goalLineX(MACHINE_END) - MACHINE_END * GOALIE.depth; + key.position.set(creaseX + 4, 10, 6); + if (!key.target.parent) scene.add(key.target); + key.target.position.set(creaseX, 0, 0); + applyMachineCamera(); + machine.cooldown = 0.35; + machine.recipeIndex = 0; + machine.lastResult = null; + fillPoseSelect(); + } else { + // Back to pose studio at the origin. + hideNet(); + if (machine.puckView) { + machine.puckView.mesh.visible = false; + machine.puckView.ring.visible = false; + } + machine.puck = null; + studioOriginGround(); + scene.fog.near = 18; + scene.fog.far = 40; + camera.far = 80; + camera.updateProjectionMatrix(); + key.position.set(4, 10, 6); + if (goalie) { + // Repark at studio origin facing +Z (camera front). + const x = state.subject === 'both' ? 0.85 : 0; + goalie.pos.x = x; + goalie.pos.z = 0; + goalie.mover.position.set(x, 0, 0); + goalie.mover.rotation.y = 0; + goalie.animator.setTransform(goalie.mover.position, 0); + goalie.animator.threatened = 0.1; + goalie.animator.puckHeight = 0.5; + goalie.animator.puckDist = 12; + goalie.animator.setState('ready', 0.05); + } + layoutSubjects(); + applyPose(state.pose); + if (state.view === 'machine') applyView('threequarter'); + } +} + +function resetMachineStats() { + machine.saves = 0; + machine.goals = 0; + machine.misses = 0; + machine.shots = 0; + machine.lastResult = null; + machine.lastSaveKind = null; + machine.caught = false; + machine.catchTrack = null; + machine.recipeIndex = 0; + if (machine.on && goalie) { + goalie.reset(); + machine.cooldown = 0.4; + } + machine.puck = null; + if (machine.puckView) { + machine.puckView.mesh.visible = false; + machine.puckView.ring.visible = false; + } +} + +function puckReadyForNext() { + const p = machine.puck; + if (!p) return true; + if (p.resolvedAt == null) return false; + return p.age - p.resolvedAt >= holdDuration(); +} + +function updateMachine(dt) { + if (!machine.on || !goalie) return; + + machine.cooldown -= dt; + if (machine.cooldown <= 0 && puckReadyForNext()) { + fireShot(); + machine.cooldown = MACHINE.interval; + } + + stepPuck(dt); + + // Drive the real goalie brain off the live puck while it is in play. + // During a catch, track the *frozen* contact point — never the disc pinned + // to the glove (body yaw would chase the hand and spin). + let track; + if (machine.caught && machine.catchTrack) { + track = machine.catchTrack; + } else if (machine.puck && machine.puck.resolvedAt == null) { + track = machine.puck.pos; + } else if (machine.puck && machine.lastSaveKind === 'block' && machine.puck.resolvedAt != null) { + // Still look at the rebound briefly. + track = machine.puck.pos; + } else { + track = { + x: goalLineX(MACHINE_END) - MACHINE_END * 8, + y: 0.4, + z: 0, + }; + } + goalie.update(dt, track); + // Pin after IK so the disc sits in the trapper pocket, not where contact was. + pinCaughtPuck(); +} + // ---- pose catalogs -------------------------------------------------------- const PLAYER_POSES = { stand: { @@ -585,9 +1133,11 @@ function measure(sub) { function refreshHud() { const lines = [ - `img2mesh subject=${state.subject} pose=${state.pose} view=${state.view}`, + machine.on + ? `img2mesh PUCK MACHINE view=${state.view}` + : `img2mesh subject=${state.subject} pose=${state.pose} view=${state.view}`, ]; - if (player?.mover.visible) { + if (player?.mover.visible && !machine.on) { const m = measure(player); lines.push( `player anim=${m.anim} headY=${m.headY.toFixed(2)} hands=${m.handLY.toFixed(2)}/${m.handRY.toFixed(2)} feetY=${m.footLY.toFixed(2)} width=${m.stanceW.toFixed(2)}`, @@ -595,9 +1145,24 @@ function refreshHud() { } if (goalie?.mover.visible) { const m = measure(goalie); + const a = goalie.animator; lines.push( `goalie anim=${m.anim} headY=${m.headY.toFixed(2)} hands=${m.handLY.toFixed(2)}/${m.handRY.toFixed(2)} feetY=${m.footLY.toFixed(2)} width=${m.stanceW.toFixed(2)}`, ); + if (machine.on) { + const saveIk = a.save + ? `ik=${a.save.kind}@${a.save.age.toFixed(2)}s` + : 'ik=—'; + lines.push( + `track threat=${a.threatened.toFixed(2)} puckH=${a.puckHeight.toFixed(2)} puckD=${a.puckDist.toFixed(1)} lat=${a.lateralVel.toFixed(2)} ${saveIk}`, + ); + lines.push( + `drill ${machine.saves} save / ${machine.goals} goal / ${machine.misses} miss (${machine.shots} shots)`, + ); + if (machine.lastRecipe) { + lines.push(`shot ${machine.lastRecipe.label}${machine.lastResult ? ` → ${machine.lastResult}` : ' …in flight'}`); + } + } } hud.textContent = lines.join('\n'); } @@ -654,6 +1219,10 @@ window.img2mesh = { setSubject, applyPose, applyView, + setMachine, + fireShot, + resetMachineStats, + get machine() { return machine; }, get player() { return player; }, get goalie() { return goalie; }, /** Data URL of the current canvas (png). */ @@ -667,6 +1236,7 @@ window.img2mesh = { playerPoses: () => Object.fromEntries(Object.entries(PLAYER_POSES).map(([k, v]) => [k, v.label])), goaliePoses: () => Object.fromEntries(Object.entries(GOALIE_POSES).map(([k, v]) => [k, v.label])), views: () => Object.keys(VIEWS), + shotRecipes: () => SHOT_RECIPES.map((r) => ({ id: r.id, label: r.label })), }, }; @@ -676,23 +1246,39 @@ function cycle(list, cur, dir) { return list[(i + dir + list.length) % list.length]; } -subjectSel.addEventListener('change', () => setSubject(subjectSel.value)); -poseSel.addEventListener('change', () => applyPose(poseSel.value)); -viewSel.addEventListener('change', () => applyView(viewSel.value)); +subjectSel.addEventListener('change', () => { + if (machine.on) setMachine(false); + setSubject(subjectSel.value); +}); +poseSel.addEventListener('change', () => { + if (machine.on) setMachine(false); + applyPose(poseSel.value); +}); +viewSel.addEventListener('change', () => { + if (viewSel.value === 'machine') { + if (!machine.on) setMachine(true); + else applyMachineCamera(); + return; + } + applyView(viewSel.value); +}); document.getElementById('prevPose').onclick = () => { + if (machine.on) setMachine(false); applyPose(cycle(poseList(), state.pose, -1)); }; document.getElementById('nextPose').onclick = () => { + if (machine.on) setMachine(false); applyPose(cycle(poseList(), state.pose, 1)); }; document.getElementById('prevView').onclick = () => { - applyView(cycle(Object.keys(VIEWS), state.view, -1)); + applyView(cycle(Object.keys(VIEWS), state.view === 'machine' ? 'threequarter' : state.view, -1)); }; document.getElementById('nextView').onclick = () => { - applyView(cycle(Object.keys(VIEWS), state.view, 1)); + applyView(cycle(Object.keys(VIEWS), state.view === 'machine' ? 'threequarter' : state.view, 1)); }; document.getElementById('cycle').onclick = async () => { + if (machine.on) setMachine(false); const sheet = shotSheet({ subjects: [state.subject === 'both' ? 'player' : state.subject] }); for (const s of sheet.slice(0, 12)) { await captureShot(s); @@ -701,15 +1287,29 @@ document.getElementById('cycle').onclick = async () => { } }; +machineBtn?.addEventListener('click', () => setMachine(!machine.on)); +fireOnceBtn?.addEventListener('click', () => { + if (!machine.on) setMachine(true); + fireShot(); + machine.cooldown = MACHINE.interval; +}); +machineResetBtn?.addEventListener('click', () => resetMachineStats()); + window.addEventListener('keydown', (e) => { if (e.target.matches?.('select,input,textarea')) return; - if (e.key === '1') setSubject('player'); - if (e.key === '2') setSubject('goalie'); - if (e.key === '3') setSubject('both'); - if (e.key === '[') applyPose(cycle(poseList(), state.pose, -1)); - if (e.key === ']') applyPose(cycle(poseList(), state.pose, 1)); - if (e.key === ',') applyView(cycle(Object.keys(VIEWS), state.view, -1)); - if (e.key === '.') applyView(cycle(Object.keys(VIEWS), state.view, 1)); + if (e.key === '1') { if (machine.on) setMachine(false); setSubject('player'); } + if (e.key === '2') { if (machine.on) setMachine(false); setSubject('goalie'); } + if (e.key === '3') { if (machine.on) setMachine(false); setSubject('both'); } + if (e.key === '[') { if (machine.on) setMachine(false); applyPose(cycle(poseList(), state.pose, -1)); } + if (e.key === ']') { if (machine.on) setMachine(false); applyPose(cycle(poseList(), state.pose, 1)); } + if (e.key === ',') applyView(cycle(Object.keys(VIEWS), state.view === 'machine' ? 'threequarter' : state.view, -1)); + if (e.key === '.') applyView(cycle(Object.keys(VIEWS), state.view === 'machine' ? 'threequarter' : state.view, 1)); + if (e.key === 'm' || e.key === 'M') setMachine(!machine.on); + if (e.key === 'f' || e.key === 'F') { + if (!machine.on) setMachine(true); + fireShot(); + machine.cooldown = MACHINE.interval; + } if (e.key === 'b' || e.key === 'B') { state.showBones = !state.showBones; if (state.showBones) rebuildHelpers(); @@ -736,17 +1336,24 @@ function frame(now) { const dt = Math.min(0.05, (now - last) / 1000); last = now; state.time += dt; - // Live-update the current pose so stride cycles and breath read while idle. - if (player?.mover.visible) { - const id = state.pose.startsWith('g:') ? 'carry' : state.pose; - (PLAYER_POSES[id] ?? PLAYER_POSES.carry).apply(player, state.time); - } - if (goalie?.mover.visible) { - const id = state.pose.startsWith('g:') - ? state.pose.slice(2) - : (GOALIE_POSES[state.pose] ? state.pose : 'ready'); - (GOALIE_POSES[id] ?? GOALIE_POSES.ready).apply(goalie); + + if (machine.on) { + // Live AI + IK against the shooting machine — no static pose override. + updateMachine(dt); + } else { + // Live-update the current pose so stride cycles and breath read while idle. + if (player?.mover.visible) { + const id = state.pose.startsWith('g:') ? 'carry' : state.pose; + (PLAYER_POSES[id] ?? PLAYER_POSES.carry).apply(player, state.time); + } + if (goalie?.mover.visible) { + const id = state.pose.startsWith('g:') + ? state.pose.slice(2) + : (GOALIE_POSES[state.pose] ? state.pose : 'ready'); + (GOALIE_POSES[id] ?? GOALIE_POSES.ready).apply(goalie); + } } + if (state.showGear) { for (const c of gearHelpers.children) { if (c.isBoxHelper) c.update(); diff --git a/test/goalie.mjs b/test/goalie.mjs index b3ad08a..d88a84b 100644 --- a/test/goalie.mjs +++ b/test/goalie.mjs @@ -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');