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
+15 -1
View File
@@ -26,9 +26,12 @@
} }
#panel button { cursor:pointer; } #panel button { cursor:pointer; }
#panel button:hover { border-color:#4a8ab8; } #panel button:hover { border-color:#4a8ab8; }
#panel button.on { border-color:#4caf7a; background:#12241c; color:#b8f0d0; }
#panel .row { display:flex; gap:6px; } #panel .row { display:flex; gap:6px; }
#panel .row button { flex:1; } #panel .row button { flex:1; }
#panel kbd { color:#8fb4d4; } #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; }
</style> </style>
</head> </head>
<body> <body>
@@ -63,11 +66,22 @@
<button id="nextView" type="button">view ▶</button> <button id="nextView" type="button">view ▶</button>
</div> </div>
<button id="cycle" type="button" style="margin-top:6px">cycle all shots</button> <button id="cycle" type="button" style="margin-top:6px">cycle all shots</button>
<div class="sep">GOALIE SAVE DRILL</div>
<button id="machine" type="button">puck machine · OFF</button>
<div class="row">
<button id="fireOnce" type="button">fire once</button>
<button id="machineReset" type="button">reset</button>
</div>
<div class="hint">
Live AI + leg IK against a shooting machine.<br>
Cycles glove / five-hole / blocker / pads.
</div>
<div style="margin-top:10px;color:#6a8aa8"> <div style="margin-top:10px;color:#6a8aa8">
drag orbit · wheel zoom<br> drag orbit · wheel zoom<br>
<kbd>1</kbd> player <kbd>2</kbd> goalie <kbd>3</kbd> both<br> <kbd>1</kbd> player <kbd>2</kbd> goalie <kbd>3</kbd> both<br>
<kbd>[</kbd><kbd>]</kbd> pose · <kbd>,</kbd><kbd>.</kbd> view<br> <kbd>[</kbd><kbd>]</kbd> pose · <kbd>,</kbd><kbd>.</kbd> view<br>
<kbd>g</kbd> gear bones · <kbd>b</kbd> bones <kbd>g</kbd> gear bones · <kbd>b</kbd> bones<br>
<kbd>m</kbd> puck machine · <kbd>f</kbd> fire once
</div> </div>
</div> </div>
<div id="boot">IMG2MESH&hellip;</div> <div id="boot">IMG2MESH&hellip;</div>
+633 -77
View File
@@ -15,10 +15,39 @@ import {
* Goalie animator. * Goalie animator.
* *
* Upper body is pose-authored; legs are two-bone IK onto mover-local foot * 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 * targets so the pads stay on the ice. The paddle stick lives in *mover*
* grip rotation (re-aiming it every frame is what made it thrash). * 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) { export function buildGoalieAnimator(skelData, mover) {
const B = skelData.bones; const B = skelData.bones;
const LEN = { const LEN = {
@@ -33,6 +62,19 @@ export function buildGoalieAnimator(skelData, mover) {
L: B.footL.position.clone().normalize(), L: B.footL.position.clone().normalize(),
R: B.footR.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() { function newPose() {
const p = { const p = {
@@ -51,11 +93,45 @@ export function buildGoalieAnimator(skelData, mover) {
const cur = newPose(); const cur = newPose();
const frozen = 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 = { const anim = {
state: 'ready', state: 'ready',
blend: 1, blend: 1,
BLEND_TIME: 0.16, BLEND_TIME: 0.28,
transitionTime: 0.16, transitionTime: 0.28,
time: 0, time: 0,
stateTime: 0, stateTime: 0,
speed: 1, speed: 1,
@@ -69,21 +145,57 @@ export function buildGoalieAnimator(skelData, mover) {
puckDist: 8, puckDist: 8,
threatened: 0, 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, stick: null,
}; };
// Hand-local stick grips, tuned against the equipment reference: // Mover-local stick plants. Shaft runs Y into the paddle; blade along +Z
// ready = paddle on ice in the five-hole, shaft up into the blocker hand; // in stick space. Tuned so the grip sits in the blocker hand and the paddle
// butterfly = same idea, flatter, so it does not spear the surface. // rests in the five-hole (~y 0.05). These move *slowly* — the stick is heavy.
// Searched: nearly down-forward puts the paddle on the ice in the five-hole const STICK_READY_POS = new THREE.Vector3(-0.33, 0.69, 0.5);
// (minY ≈ 0.020.05) without spearing through. // Butterfly plant keeps the grip near the dropped blocker hand (~y 0.37).
const STICK_READY_E = new THREE.Euler(1.55, 0.3, 0.05, 'XYZ'); const STICK_FLY_POS = new THREE.Vector3(-0.3, 0.4, 0.2);
const STICK_FLY_E = new THREE.Euler(1.65, 0.2, 0.0, 'XYZ'); const STICK_READY_E = new THREE.Euler(0.15, 0.1, 0.2, 'XYZ');
const STICK_READY_POS = new THREE.Vector3(0.04, -0.02, 0.04); const STICK_FLY_E = new THREE.Euler(0.42, 0.05, 0.12, 'XYZ');
const STICK_FLY_POS = new THREE.Vector3(0.05, 0.02, 0.05); const STICK_READY_Q = new THREE.Quaternion().setFromEuler(STICK_READY_E);
const _stickEuler = new THREE.Euler(); const STICK_FLY_Q = new THREE.Quaternion().setFromEuler(STICK_FLY_E);
const _stickPos = new THREE.Vector3(); // 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() { function applyMover() {
mover.position.copy(anim.origin); 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) { 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(); snapshot();
anim.state = name; anim.state = name;
anim.stateTime = 0; anim.stateTime = 0;
anim.blend = 0; anim.blend = 0;
anim.transitionTime = blendTime ?? anim.BLEND_TIME; anim.transitionTime = blendTime ?? anim.BLEND_TIME;
for (const k of STANCE_KEYS) stanceTarget[k] = k === name ? 1 : 0;
}; };
function chooseState() { function setStanceTarget(name) {
const low = anim.puckHeight < 0.38; for (const k of STANCE_KEYS) stanceTarget[k] = 0;
const high = anim.puckHeight > 0.75; stanceTarget[name] = 1;
const close = anim.puckDist < 8; if (name !== anim.state) {
const veryClose = anim.puckDist < 3.5; // Keep legacy state label in sync for HUD / tests without restarting a
const sliding = Math.abs(anim.lateralVel) > 1.2 || anim.moveSpeed > 1.6; // 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'; return 'ready';
} }
@@ -142,10 +370,203 @@ export function buildGoalieAnimator(skelData, mover) {
const _qF = new THREE.Quaternion(); const _qF = new THREE.Quaternion();
const _qInv = new THREE.Quaternion(); const _qInv = new THREE.Quaternion();
const _worldFoot = new THREE.Vector3(); 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 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)); 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) { function solveLeg(side, localX, localZ, toeYaw) {
const thigh = B['thigh' + side]; const thigh = B['thigh' + side];
const shin = B['shin' + side]; const shin = B['shin' + side];
@@ -193,80 +614,215 @@ export function buildGoalieAnimator(skelData, mover) {
B['toe' + side].quaternion.identity(); B['toe' + side].quaternion.identity();
} }
anim.update = function update(dt) { /** Author all four stances and slerp-blend into `cur` by continuous weights. */
dt *= anim.speed; function buildBlendedPose(t) {
anim.time += dt; const lean = drive.lean;
anim.stateTime += dt; poseReady(_poseScratch.ready, { lean: lean * 0.5, t });
anim.blend = Math.min(1, anim.blend + dt / anim.transitionTime); poseShuffle(_poseScratch.shuffle, {
dir: drive.shuffleDir,
anim.setState(chooseState()); effort: drive.shuffleEffort,
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, t,
}); });
} else if (anim.state === 'reach') { poseButterfly(_poseScratch.butterfly, { lean, t });
const side = lean > 0.25 ? 1 : -1; poseReach(_poseScratch.reach, {
poseReach(cur, { side: drive.reachSide >= 0 ? 1 : -1,
side, up: drive.reachUp,
up: clamp((anim.puckHeight - 0.6) / 0.8, 0.4, 1),
lean, lean,
t, t,
}); });
} else {
poseReady(cur, { lean: lean * 0.5, 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 / 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);
applyMover();
mover.updateMatrixWorld(true);
buildBlendedPose(anim.time);
// Soft intro crossfade still available for studio setState(…, short).
const w = smooth(anim.blend); const w = smooth(anim.blend);
if (w < 0.999) {
for (const n of GOALIE_UPPER) { for (const n of GOALIE_UPPER) {
B[n].quaternion.slerpQuaternions(frozen.q[n], cur.q[n], w); B[n].quaternion.slerpQuaternions(frozen.q[n], cur.q[n], w);
} }
// 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.position.lerpVectors(frozen.rootOffset, cur.rootOffset, w);
B.root.quaternion.slerpQuaternions(frozen.rootQuat, cur.rootQuat, 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);
}
for (const n of GOALIE_LEGS) B[n].quaternion.identity();
mover.updateMatrixWorld(true); mover.updateMatrixWorld(true);
// Blend foot targets in mover-local space, then IK. // Blend foot targets, then leg IK.
const fL = { const fL = {
x: lerp(frozen.feet.L.x, cur.feet.L.x, w), x: w < 0.999 ? lerp(frozen.feet.L.x, cur.feet.L.x, w) : cur.feet.L.x,
z: lerp(frozen.feet.L.z, cur.feet.L.z, w), z: w < 0.999 ? lerp(frozen.feet.L.z, cur.feet.L.z, w) : cur.feet.L.z,
yaw: lerp(frozen.feet.L.yaw, cur.feet.L.yaw, w), yaw: w < 0.999 ? lerp(frozen.feet.L.yaw, cur.feet.L.yaw, w) : cur.feet.L.yaw,
}; };
const fR = { const fR = {
x: lerp(frozen.feet.R.x, cur.feet.R.x, w), x: w < 0.999 ? lerp(frozen.feet.R.x, cur.feet.R.x, w) : cur.feet.R.x,
z: lerp(frozen.feet.R.z, cur.feet.R.z, w), z: w < 0.999 ? lerp(frozen.feet.R.z, cur.feet.R.z, w) : cur.feet.R.z,
yaw: lerp(frozen.feet.R.yaw, cur.feet.R.yaw, w), 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('L', fL.x, fL.z, fL.yaw);
solveLeg('R', fR.x, fR.z, fR.yaw); solveLeg('R', fR.x, fR.z, fR.yaw);
// Stick grip: blend ready → butterfly so the paddle stays near the ice. // Stick plant first (heavy, mover-local). Block saves steer the plant
if (anim.stick) { // toward the puck; the dominant hand never leaves the shaft.
const k = anim.state === 'butterfly' ? Math.min(1, anim.stateTime / 0.14) : 0; mover.updateMatrixWorld(true);
_stickEuler.set( const gripWorld = updateStickPlant(dt);
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, // Catch: trapper IK to the puck (left hand only).
STICK_READY_E.z + (STICK_FLY_E.z - STICK_READY_E.z) * k, if (drive.saveW > 0.001 && drive.saveKind === 'catch') {
'XYZ', mover.updateMatrixWorld(true);
); applyCatchArmIK(drive.saveW);
anim.stick.quaternion.setFromEuler(_stickEuler); }
_stickPos.lerpVectors(STICK_READY_POS, STICK_FLY_POS, k);
anim.stick.position.copy(_stickPos); // 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); mover.updateMatrixWorld(true);
+31 -4
View File
@@ -76,6 +76,11 @@ export function createGoalie(physics, scene, {
const gearMats = buildGoalieMaterials(materials.team.jersey, materials.team.accent); const gearMats = buildGoalieMaterials(materials.team.jersey, materials.team.accent);
const gear = buildGoalieGear(gearMats); const gear = buildGoalieGear(gearMats);
gear.attachTo(skelData.bones); 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); const animator = buildGoalieAnimator(skelData, mover);
animator.stick = gear.stick; animator.stick = gear.stick;
@@ -163,9 +168,20 @@ export function createGoalie(physics, scene, {
animator.moveSpeed = 0; animator.moveSpeed = 0;
animator.lateralVel = 0; animator.lateralVel = 0;
animator.threatened = 0; animator.threatened = 0;
animator.clearSave();
animator.setState('ready', 0.05); 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. * Track the puck. `dt` on the frame clock.
* `puck` is anything with `{x,y,z}` — the shootout passes a Vector3. * `puck` is anything with `{x,y,z}` — the shootout passes a Vector3.
@@ -201,16 +217,24 @@ export function createGoalie(physics, scene, {
goalieSpot(seen, end, GOALIE.depth, target); 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 // 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. // why a slow deke beats them and a fast one sometimes does not.
const beaten = end > 0 ? px > pos.x : px < pos.x; 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 dx = target.x - pos.x;
const dz = target.z - pos.z; const dz = target.z - pos.z;
const dist = Math.hypot(dx, dz); const dist = Math.hypot(dx, dz);
const step = speed * dt; const step = speed * dt;
const z0 = pos.z; const z0 = pos.z;
if (!catchSeal) {
if (dist <= step || dist < 1e-6) { if (dist <= step || dist < 1e-6) {
pos.x = target.x; pos.x = target.x;
pos.z = target.z; pos.z = target.z;
@@ -218,9 +242,12 @@ export function createGoalie(physics, scene, {
pos.x += (dx / dist) * step; pos.x += (dx / dist) * step;
pos.z += (dz / dist) * step; pos.z += (dz / dist) * step;
} }
}
// Square up to the puck. // Square up to the puck, unless a save has frozen facing at contact.
const yaw = Math.atan2(px - pos.x, pz - pos.z); const yaw = lockYaw != null
? lockYaw
: Math.atan2(px - pos.x, pz - pos.z);
mover.position.set(pos.x, 0, pos.z); mover.position.set(pos.x, 0, pos.z);
mover.rotation.y = yaw; mover.rotation.y = yaw;
@@ -244,7 +271,7 @@ export function createGoalie(physics, scene, {
animator.setTransform(mover.position, yaw); animator.setTransform(mover.position, yaw);
animator.moveSpeed = Math.abs(latVel) + (dist > step ? speed * 0.25 : 0); animator.moveSpeed = Math.abs(latVel) + (dist > step ? speed * 0.25 : 0);
animator.lateralVel = latVel; animator.lateralVel = catchSeal ? 0 : latVel;
animator.puckHeight = py; animator.puckHeight = py;
animator.puckDist = puckDist; animator.puckDist = puckDist;
animator.threatened = threat; animator.threatened = threat;
+45 -24
View File
@@ -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. * its keys — so only the mask, whose surface is a formula, needs constants.
*/ */
export const GEAR = { 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 + 1520 impact foam + 1015 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: { mask: {
/** Skull centre in head-bone-local space (head bone sits at the jaw hinge). */ /** Skull centre in head-bone-local space (head bone sits at the jaw hinge). */
riseY: 0.094, riseY: 0.098,
pushZ: -0.006, pushZ: -0.008,
rx: 0.118, /** Half of the 265 width and the 285 depth. */
ry: 0.156, rx: 0.1325,
rz: 0.128, rz: 0.1425,
/** Polar angle the shell starts at — below the chin, open at the neck. */ ry: 0.123,
phi0: 0.52, phi0: 0.516,
wall: 0.011, /** Shell plus both foam layers — the wall you see at the port edge. */
/** Face opening, relative to the skull centre. */ wall: 0.024,
portW: 0.076, /** Chin opening, 110 across. */
portH: 0.054, chinW: 0.110,
portY: -0.004, /** 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. */ /** Cage: an ellipse bowed out in front of the opening. */
cageW: 0.092, cageW: 0.078,
cageH: 0.070, cageH: 0.056,
cageBase: 0.088, cageBase: 0.098,
cageBulge: 0.052, cageBulge: 0.05,
barR: 0.0045, barR: 0.0024,
}, },
}; };
@@ -539,8 +558,10 @@ export function buildGoalieGear(mats) {
const floaterR = makeFloater('R'); const floaterR = makeFloater('R');
// ---- goalie stick ------------------------------------------------------- // ---- goalie stick -------------------------------------------------------
// Shaft down Y from the blocker hand into a wide paddle, then a blade that // Built with shaft down Y into a wide paddle and a blade across the crease.
// sits flat on the ice across the five-hole. // 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(); const stick = new THREE.Group();
stick.name = 'goalieStick'; stick.name = 'goalieStick';
let paddleMesh = null; let paddleMesh = null;
@@ -585,10 +606,10 @@ export function buildGoalieGear(mats) {
'stickKnob', 'stickKnob',
)); ));
// Default grip: overwritten by the animator each frame, but a sane editor // Mover-local plant: paddle in the five-hole, shaft up into the blocker
// default (paddle toward the ice, slightly in front). // side. Animator damps from here and IKs the hand to the grip.
stick.position.set(0.03, -0.02, 0.04); stick.position.set(-0.33, 0.69, 0.5);
stick.rotation.set(0.9, 0.35, 0.55); stick.rotation.set(0.15, 0.1, 0.2);
pieces.push(stick); pieces.push(stick);
paddleMesh = paddle; paddleMesh = paddle;
} }
@@ -611,7 +632,7 @@ export function buildGoalieGear(mats) {
bones.shinR.add(padR); bones.shinR.add(padR);
bones.handL.add(trapper); bones.handL.add(trapper);
bones.handR.add(blocker); bones.handR.add(blocker);
bones.handR.add(stick); // Stick is attached by createGoalie onto the mover — not the hand.
bones.head.add(mask); bones.head.add(mask);
bones.spine3.add(chest); bones.spine3.add(chest);
bones.upperArmL.add(floaterL); bones.upperArmL.add(floaterL);
+625 -18
View File
@@ -1,14 +1,18 @@
import * as THREE from 'three'; import * as THREE from 'three';
import { createSkater } from '../character/skater.js'; 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 { 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. * 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 and fixed camera views for skater / goalie gear work, plus a
* pose presets, fixed camera views, and a `window.img2mesh` API the headless * puck shooting machine that drives the live goalie AI and leg IK so saves can
* capture tool drives to dump a shot sheet. * be tuned without booting the full shootout.
* *
* Open: http://localhost:5174/character.html * Open: http://localhost:5174/character.html
* CLI: npm run img2mesh * CLI: npm run img2mesh
@@ -20,6 +24,9 @@ const hud = document.getElementById('hud');
const subjectSel = document.getElementById('subject'); const subjectSel = document.getElementById('subject');
const poseSel = document.getElementById('pose'); const poseSel = document.getElementById('pose');
const viewSel = document.getElementById('view'); const viewSel = document.getElementById('view');
const machineBtn = document.getElementById('machine');
const fireOnceBtn = document.getElementById('fireOnce');
const machineResetBtn = document.getElementById('machineReset');
// ---- renderer / scene ----------------------------------------------------- // ---- renderer / scene -----------------------------------------------------
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, powerPreference: 'high-performance' }); const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, powerPreference: 'high-performance' });
@@ -108,8 +115,549 @@ const state = {
showBones: false, showBones: false,
showGear: false, showGear: false,
time: 0, 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<typeof buildPuckMesh> | 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 -------------------------------------------------------- // ---- pose catalogs --------------------------------------------------------
const PLAYER_POSES = { const PLAYER_POSES = {
stand: { stand: {
@@ -585,9 +1133,11 @@ function measure(sub) {
function refreshHud() { function refreshHud() {
const lines = [ 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); const m = measure(player);
lines.push( 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)}`, `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) { if (goalie?.mover.visible) {
const m = measure(goalie); const m = measure(goalie);
const a = goalie.animator;
lines.push( 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)}`, `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'); hud.textContent = lines.join('\n');
} }
@@ -654,6 +1219,10 @@ window.img2mesh = {
setSubject, setSubject,
applyPose, applyPose,
applyView, applyView,
setMachine,
fireShot,
resetMachineStats,
get machine() { return machine; },
get player() { return player; }, get player() { return player; },
get goalie() { return goalie; }, get goalie() { return goalie; },
/** Data URL of the current canvas (png). */ /** 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])), 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])), goaliePoses: () => Object.fromEntries(Object.entries(GOALIE_POSES).map(([k, v]) => [k, v.label])),
views: () => Object.keys(VIEWS), 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]; return list[(i + dir + list.length) % list.length];
} }
subjectSel.addEventListener('change', () => setSubject(subjectSel.value)); subjectSel.addEventListener('change', () => {
poseSel.addEventListener('change', () => applyPose(poseSel.value)); if (machine.on) setMachine(false);
viewSel.addEventListener('change', () => applyView(viewSel.value)); 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 = () => { document.getElementById('prevPose').onclick = () => {
if (machine.on) setMachine(false);
applyPose(cycle(poseList(), state.pose, -1)); applyPose(cycle(poseList(), state.pose, -1));
}; };
document.getElementById('nextPose').onclick = () => { document.getElementById('nextPose').onclick = () => {
if (machine.on) setMachine(false);
applyPose(cycle(poseList(), state.pose, 1)); applyPose(cycle(poseList(), state.pose, 1));
}; };
document.getElementById('prevView').onclick = () => { 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 = () => { 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 () => { document.getElementById('cycle').onclick = async () => {
if (machine.on) setMachine(false);
const sheet = shotSheet({ subjects: [state.subject === 'both' ? 'player' : state.subject] }); const sheet = shotSheet({ subjects: [state.subject === 'both' ? 'player' : state.subject] });
for (const s of sheet.slice(0, 12)) { for (const s of sheet.slice(0, 12)) {
await captureShot(s); 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) => { window.addEventListener('keydown', (e) => {
if (e.target.matches?.('select,input,textarea')) return; if (e.target.matches?.('select,input,textarea')) return;
if (e.key === '1') setSubject('player'); if (e.key === '1') { if (machine.on) setMachine(false); setSubject('player'); }
if (e.key === '2') setSubject('goalie'); if (e.key === '2') { if (machine.on) setMachine(false); setSubject('goalie'); }
if (e.key === '3') setSubject('both'); if (e.key === '3') { if (machine.on) setMachine(false); setSubject('both'); }
if (e.key === '[') applyPose(cycle(poseList(), state.pose, -1)); if (e.key === '[') { if (machine.on) setMachine(false); applyPose(cycle(poseList(), state.pose, -1)); }
if (e.key === ']') 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, -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, 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') { if (e.key === 'b' || e.key === 'B') {
state.showBones = !state.showBones; state.showBones = !state.showBones;
if (state.showBones) rebuildHelpers(); if (state.showBones) rebuildHelpers();
@@ -736,6 +1336,11 @@ function frame(now) {
const dt = Math.min(0.05, (now - last) / 1000); const dt = Math.min(0.05, (now - last) / 1000);
last = now; last = now;
state.time += dt; state.time += dt;
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. // Live-update the current pose so stride cycles and breath read while idle.
if (player?.mover.visible) { if (player?.mover.visible) {
const id = state.pose.startsWith('g:') ? 'carry' : state.pose; const id = state.pose.startsWith('g:') ? 'carry' : state.pose;
@@ -747,6 +1352,8 @@ function frame(now) {
: (GOALIE_POSES[state.pose] ? state.pose : 'ready'); : (GOALIE_POSES[state.pose] ? state.pose : 'ready');
(GOALIE_POSES[id] ?? GOALIE_POSES.ready).apply(goalie); (GOALIE_POSES[id] ?? GOALIE_POSES.ready).apply(goalie);
} }
}
if (state.showGear) { if (state.showGear) {
for (const c of gearHelpers.children) { for (const c of gearHelpers.children) {
if (c.isBoxHelper) c.update(); if (c.isBoxHelper) c.update();
+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.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.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.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(); goalie.destroy();
} }
@@ -100,4 +100,107 @@ section('goalie drops low in the butterfly');
goalie.destroy(); 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'); done('goalie');