From 7d28facd682d79ce45822b3831e03d1122aa683a Mon Sep 17 00:00:00 2001 From: ryanfitzpatrickio Date: Mon, 3 Aug 2026 07:36:30 -0500 Subject: [PATCH] Merge goalie-tester worktree: save drill, stick plant, and jersey gear. Bring over img2mesh puck machine, catch/block IK, damped stance blends, and mover-space paddle with hand-on-shaft IK. Keep main jersey skinned gear and underlayer paint, with the stick reparented to the mover. --- character.html | 16 +- src/anim/goalieAnimator.js | 712 ++++++++++++++++++++++++++++++++---- src/character/gearMesh.js | 19 + src/character/goalie.js | 59 ++- src/character/goalieGear.js | 418 +++++++++++++++------ src/character/jersey.js | 131 +++++++ src/character/skaterGear.js | 91 +---- src/studio/img2mesh.js | 663 +++++++++++++++++++++++++++++++-- test/goalie.mjs | 105 +++++- 9 files changed, 1898 insertions(+), 316 deletions(-) create mode 100644 src/character/jersey.js 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/gearMesh.js b/src/character/gearMesh.js index f9ade2e..34984c5 100644 --- a/src/character/gearMesh.js +++ b/src/character/gearMesh.js @@ -376,6 +376,25 @@ export function carvedShell({ return g; } +/** + * Smoothstep lookup through `[t, value]` keys, t ascending. + * + * A shell whose radius is a formula can only ever be a variation on an + * ellipsoid. Driving the radius from keys instead lets a profile do what a + * drawing says — taper to a chin, bulge at the temples — without inventing a + * new trigonometric term for every feature. + */ +export function keyed(keys, t) { + if (t <= keys[0][0]) return keys[0][1]; + const last = keys[keys.length - 1]; + if (t >= last[0]) return last[1]; + let i = 0; + while (i < keys.length - 2 && keys[i + 1][0] < t) i++; + const [t0, v0] = keys[i]; + const [t1, v1] = keys[i + 1]; + return lerp(v0, v1, smooth(clamp((t - t0) / Math.max(1e-6, t1 - t0), 0, 1))); +} + /** Colour helper — a solid tint for a whole loft section. */ export function tint(c) { return c instanceof THREE.Color ? c.clone() : new THREE.Color(c ?? WHITE); diff --git a/src/character/goalie.js b/src/character/goalie.js index b508e34..029f377 100644 --- a/src/character/goalie.js +++ b/src/character/goalie.js @@ -4,12 +4,14 @@ import { CAT, KIND, makeTag, quat, transform, vec3, xyz } from '../physics/bridg import { clamp } from '../../shared/scalar.js'; import { makeRng } from '../core/rng.js'; import { disposeObject } from '../core/math.js'; -import { buildMaterials, paintKit } from '../render/materials.js'; +import { buildMaterials, paintUnderLayer } from '../render/materials.js'; import { assertNoNaNBones, buildSkeleton } from './skeleton.js'; import { buildBodyGeometry, buildBodyMesh } from './body.js'; import { computeSkin } from './skinning.js'; import { buildGoalieAnimator } from '../anim/goalieAnimator.js'; import { buildGoalieGear, buildGoalieMaterials } from './goalieGear.js'; +import { JERSEY_COVERAGE } from './jersey.js'; +import { hideCoveredBody } from './skaterGear.js'; /** * A goalie. @@ -69,13 +71,20 @@ export function createGoalie(physics, scene, { const bodyGeo = buildBodyGeometry(rng, bodyStyle); computeSkin(bodyGeo, skelData); - paintKit(bodyGeo, { jersey: materials.team.jersey, skinColor: materials.skinColor }); + paintUnderLayer(bodyGeo, { skinColor: materials.skinColor }); const bodyMesh = buildBodyMesh(bodyGeo, skelData, materials); mover.add(bodyMesh); const gearMats = buildGoalieMaterials(materials.team.jersey, materials.team.accent); - const gear = buildGoalieGear(gearMats); - gear.attachTo(skelData.bones); + const gear = buildGoalieGear(gearMats, skelData, bodyGeo.userData.physique); + gear.attachTo(skelData.bones, mover); + // Torso and arms are the sweater's now. The legs stay painted: the pads only + // cover the shins, so what shows above them has to be the goalie's pants. + hideCoveredBody(bodyGeo, JERSEY_COVERAGE); + // 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. + 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 +172,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 +221,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 +275,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..2c8e75f 100644 --- a/src/character/goalieGear.js +++ b/src/character/goalieGear.js @@ -1,6 +1,7 @@ import * as THREE from 'three'; import { clamp, smooth } from '../core/math.js'; -import { carvedShell, loft, mergeBars, tint, tube } from './gearMesh.js'; +import { JERSEY_COVERAGE, jerseyParts, skinnedFrom } from './jersey.js'; +import { carvedShell, keyed, loft, mergeBars, tint, tube } from './gearMesh.js'; /** * Goalie equipment, socketed to skeleton bones. @@ -29,29 +30,76 @@ 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, sized by eye against `shots/img2mesh/ref/goalie-equipment.png`. + * + * These are not derived from a drawing. A goalie mask drawn to real + * millimetres looks wrong on this character — the head is a stylised + * ellipsoid, wider and much taller than a real skull — so what matters is + * how it reads next to the shoulders and the cage, and that is a judgement + * made from renders. + */ 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, - /** 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, + width: 0.252, + depth: 0.288, + height: 0.285, + /** Skull centre and crown, head-bone-local. */ + riseY: 0.10, + crownY: 0.218, + pushZ: -0.004, + /** How far the front of the bottom edge hangs below the sides — the jaw. */ + chinDrop: 0.056, + /** Wall you can see the thickness of at the port edge. */ + wall: 0.020, + /** Eye port: big, because at any distance the cage is the whole read. */ + portW: 0.086, + portH: 0.066, + portY: -0.020, + earR: 0.026, + barR: 0.0028, }, }; +/** + * Mask profile, as fractions of the maximum half-width at each height. + * + * v runs 0 at the jaw edge to 1 at the crown. This table is the front + * silhouette: wide across the temples, tapering to the chin. An ellipsoid of + * revolution cannot do this — it is symmetric about its equator, so it always + * comes out an egg — which is why the radius is keyed rather than derived. + */ +const MASK_WIDTH = [ + [0.00, 0.50], // chin + [0.08, 0.64], + [0.18, 0.79], + [0.32, 0.95], + [0.46, 1.00], // cheeks and temples, the widest band + [0.62, 0.98], + [0.76, 0.92], + [0.88, 0.78], + [0.96, 0.52], + [1.00, 0.0], +]; +/** Same idea front to back: the jaw is shallow, the crown domes over. */ +const MASK_DEPTH = [ + [0.00, 0.64], + [0.12, 0.78], + [0.30, 0.91], + [0.48, 0.98], + [0.66, 0.96], + [0.80, 0.87], + [0.92, 0.62], + [1.00, 0.0], +]; +/** Height easing, so rings bunch under the crown and it reads round. */ +const MASK_RISE = [ + [0.00, 0.0], + [0.50, 0.55], + [0.80, 0.85], + [0.93, 0.95], + [1.00, 1.0], +]; + /** Rest direction a bone's limb actually points, in that bone's local space. */ const ARM_DIR = { L: new THREE.Vector3(0.15, -0.252, 0.01).normalize(), @@ -96,10 +144,20 @@ function handGrip(side, roll) { * cage: THREE.Material, dark: THREE.Material, * }} mats */ -export function buildGoalieGear(mats) { +export function buildGoalieGear(mats, skelData, phys) { const pieces = []; + const skinned = []; const disposables = []; + /** Skin a set of rest-space pieces onto the body's skeleton. */ + function skin(parts, mat, name) { + const m = skinnedFrom(parts, mat, skelData, name); + disposables.push(m.geometry); + skinned.push(m); + pieces.push(m); + return m; + } + const PAL = { base: tint(mats.pad.color), accent: tint(mats.accent.color), @@ -218,76 +276,174 @@ export function buildGoalieGear(mats) { // straight out of the mesh (with a walled rim you can see the thickness of), // and the cage is bent over the hole on its own bowed ellipse. const M = GEAR.mask; + const RX = M.width / 2; + const RZ = M.depth / 2; + const HEIGHT = M.height; + const PORT_W = M.portW; + const EAR_R = M.earR; + const WALL = M.wall; + const CHIN_DROP = M.chinDrop; + /** Lowest point of the shell — the front of the chin. */ + const chinY = M.crownY - HEIGHT; + /** Bottom edge at the sides and back; the chin hangs below it. */ + const edgeY = chinY + CHIN_DROP; const skull = new THREE.Vector3(0, M.riseY, M.pushZ); function maskSurface(theta, v, out) { - const phi = M.phi0 + (Math.PI - M.phi0) * v; - const sp = Math.sin(phi); - const cp = Math.cos(phi); const f = Math.cos(theta); // +1 dead ahead const sx = Math.sin(theta); // ±1 at the ears const front = Math.max(0, f); const back = Math.max(0, -f); - // 1 down at the chin, 0 by the cheekbones. - const low = smooth(clamp((0.40 - v) / 0.34, 0, 1)); - let rx = M.rx; - let rz = M.rz; - // Jaw narrows off the cheekbones; cheeks themselves flare. - rx *= 1 - 0.26 * low; - rx *= 1 + 0.07 * Math.exp(-(((v - 0.40) / 0.17) ** 2)) * Math.abs(sx); + let rx = RX * keyed(MASK_WIDTH, v); + let rz = RZ * keyed(MASK_DEPTH, v); + let y = edgeY + (M.crownY - edgeY) * keyed(MASK_RISE, v); + // Back of the head carries the shell out over the occiput. - rz *= 1 + 0.13 * back * smooth(clamp((v - 0.10) / 0.5, 0, 1)); + rz *= 1 + 0.04 * back * smooth(clamp((v - 0.10) / 0.5, 0, 1)); // Face is a plate, not a dome — flatten the front through the eye band. - rz *= 1 - 0.13 * front * front * Math.exp(-(((v - 0.52) / 0.30) ** 2)); + rz *= 1 - 0.12 * front * front * Math.exp(-(((v - 0.52) / 0.26) ** 2)); + // Cheekbones, kept small: this band is already the widest part of the + // shell, so anything added here comes straight off the 265. + rx *= 1 + 0.012 * Math.exp(-(((v - 0.45) / 0.18) ** 2)) * Math.abs(sx); - let x = rx * sp * sx; - let y = -M.ry * cp; - let z = rz * sp * f; + let x = rx * sx; + let z = rz * f; + + // The chin. Everything below the cage on the side view is this: the front + // of the bottom edge runs down past the sides and scoops forward into a + // jaw, rather than curving back under to the neck. + const low = smooth(clamp((0.20 - v) / 0.20, 0, 1)); + const chin = low * front; + y -= CHIN_DROP * chin; + z += 0.050 * chin; + // Neck scallop: the bottom edge lifts at the back. + y += 0.034 * low * back; - // Chin cup pushes forward and tucks up under the face. - z += 0.032 * low * front; - y += 0.016 * low * front; // Brow ridge over the port. - const brow = Math.exp(-(((v - 0.60) / 0.085) ** 2)) * front ** 1.5; - z += 0.011 * brow; - y += 0.004 * brow; + const brow = Math.exp(-(((v - 0.58) / 0.09) ** 2)) * front ** 1.5; + z += 0.010 * brow; // Crown keel — the raised centre spine of a goalie shell. - const keel = Math.exp(-((sx / 0.30) ** 2)) * smooth(clamp((v - 0.45) / 0.4, 0, 1)); - y += 0.006 * keel; + const keel = Math.exp(-((sx / 0.30) ** 2)) * smooth(clamp((v - 0.5) / 0.4, 0, 1)); + y += 0.005 * keel; - return out.set(skull.x + x, skull.y + y, skull.z + z); + // y is already absolute in head-local space — the rise curve runs between + // chinY and crownY — so only x and z are relative to the skull centre. + return out.set(skull.x + x, y, skull.z + z); + } + + /** Height of the shell at parameter v, for placing things on it. */ + const yAt = (v) => edgeY + (M.crownY - edgeY) * keyed(MASK_RISE, v); + /** Inverse of the above — the surface parameter at a given height. */ + function vAtY(target) { + let lo = 0; + let hi = 1; + for (let i = 0; i < 24; i++) { + const mid = (lo + hi) / 2; + if (yAt(mid) < target) lo = mid; + else hi = mid; + } + return (lo + hi) / 2; } /** Squared-off ellipse over the eyes — the hole the cage covers. */ function portField(p) { - const dx = Math.abs(p.x) / M.portW; + const dx = Math.abs(p.x) / PORT_W; const dy = Math.abs(p.y - skull.y - M.portY) / M.portH; return dx ** 2.3 + dy ** 2.3; } - const inPort = (p) => p.z - skull.z > 0.03 && portField(p) < 1; - const maskColor = (p, kind) => { + /** Shortest angular distance, for holes placed in surface parameters. */ + const angTo = (theta, at) => { + let d = theta - at; + while (d > Math.PI) d -= Math.PI * 2; + while (d < -Math.PI) d += Math.PI * 2; + return d; + }; + /** + * Elongated hole in (theta, v). Vents and the ear port follow the shell, so + * placing them in surface parameters keeps them the right shape wherever the + * surface curves — far easier than intersecting solids in metres. + */ + const slot = (theta, v, atT, atV, halfT, halfV, exp = 2.4) => ( + Math.abs(angTo(theta, atT) / halfT) ** exp + Math.abs((v - atV) / halfV) ** exp < 1 + ); + + /** Crown, rear and temple vents, off the top, rear and side views. */ + const VENTS = [ + // Crown: three pairs either side of the keel, running front to back. + { t: 0.42, v: 0.70, ht: 0.10, hv: 0.055 }, + { t: -0.42, v: 0.70, ht: 0.10, hv: 0.055 }, + { t: 0.50, v: 0.82, ht: 0.11, hv: 0.05 }, + { t: -0.50, v: 0.82, ht: 0.11, hv: 0.05 }, + { t: 0.60, v: 0.92, ht: 0.13, hv: 0.04 }, + { t: -0.60, v: 0.92, ht: 0.13, hv: 0.04 }, + // Rear: four slots, two columns. + { t: Math.PI - 0.30, v: 0.38, ht: 0.075, hv: 0.055 }, + { t: Math.PI + 0.30, v: 0.38, ht: 0.075, hv: 0.055 }, + { t: Math.PI - 0.30, v: 0.55, ht: 0.075, hv: 0.055 }, + { t: Math.PI + 0.30, v: 0.55, ht: 0.075, hv: 0.055 }, + // Temple vents, above the ear. + { t: 1.15, v: 0.52, ht: 0.085, hv: 0.042 }, + { t: -1.15, v: 0.52, ht: 0.085, hv: 0.042 }, + ]; + + /** + * Ear port, ⌀45 on the side view, at the height of the ear. + * + * Metres convert to surface parameters through the local scale of each axis: + * a step in theta is worth the ring radius, a step in v is worth the height + * the rise curve covers there. + */ + const EAR_T = Math.PI / 2 + 0.30; + const EAR_V = vAtY(skull.y - 0.030); + const earHalfT = EAR_R / (RX * keyed(MASK_WIDTH, EAR_V)); + const earHalfV = EAR_R / ((yAt(EAR_V + 0.05) - yAt(EAR_V - 0.05)) / 0.1); + + /** + * Only the face opening and the two ear holes are *cut*. + * + * The vents are cut on the drawing too, but they are 12–18 mm slots, and a + * hole narrower than a grid cell cannot come out of a "drop every quad that + * touches it" rule as anything but a tear — worst of all over the crown, + * where the rings converge and a cell is a few millimetres of arc. Resolving + * them properly costs about triple the triangles for detail that is invisible + * past a couple of metres, so they are painted instead, at the same + * coordinates. The ear hole is big enough to cut honestly. + */ + const inPort = (p, theta, v) => { + if (p.z - skull.z > 0.03 && portField(p) < 1) return true; + // Set back from the widest point: at theta = pi/2 exactly the hole lands on + // the cheek, not over the ear. + if (slot(theta, v, EAR_T, EAR_V, earHalfT, earHalfV, 2)) return true; + if (slot(theta, v, -EAR_T, EAR_V, earHalfT, earHalfV, 2)) return true; + return false; + }; + + const maskColor = (p, kind, theta, v) => { if (kind === 'inner') return PAL.trim; const dy = p.y - skull.y; const dz = p.z - skull.z; // Dark trim ringing the face opening. - if (dz > 0.0 && portField(p) < 1.4) return PAL.trim; - // Chin cup and the neck edge below it. - if (dy < -0.095) return PAL.trim; + if (dz > 0.0 && portField(p) < 1.15) return PAL.trim; + // Vents, at the coordinates the top, rear and side views put them. + for (const s of VENTS) if (slot(theta, v, s.t, s.v, s.ht, s.hv)) return PAL.trim; // Keel stripe over the crown, front to back — the one graphic on the shell. - if (Math.abs(p.x) < 0.024 && dy > 0.0) return PAL.accent; + // Measured in theta, not in x: every ring collapses toward the pole, so an + // |x| test paints the whole crown instead of a stripe across it. + const keel = Math.min(Math.abs(angTo(theta, 0)), Math.abs(angTo(theta, Math.PI))); + if (keel < 0.17 && v > 0.34) return PAL.accent; return PAL.base; }; const mask = new THREE.Group(); mask.name = 'mask'; const shell = carvedShell({ - // Dense enough that the brow and jaw read in a goal-cam closeup, no denser - // — this is the one piece with a two-sided wall, so rows × cols doubles. + // Enough to resolve the ear hole and keep the jaw and brow smooth. This is + // the one piece with a two-sided wall, so rows × cols doubles. rows: 36, - cols: 48, - thickness: M.wall, + cols: 60, + thickness: WALL, center: skull, surface: maskSurface, port: inPort, @@ -295,31 +451,41 @@ export function buildGoalieGear(mats) { }); mask.add(mesh(shell, mats.painted, 'maskShell')); - // Cage: bars ride a forward-bowed ellipse so they stand off the face. + // Cage: bars ride a forward-bowed ellipse, standing off the shell's own front + // face at the port so it never sinks into the brow or floats off the chin. + const CAGE_W = PORT_W * 1.16; + const CAGE_H = M.portH * 1.24; + const _probe = new THREE.Vector3(); + maskSurface(0, vAtY(skull.y + M.portY), _probe); + const CAGE_BASE = _probe.z - skull.z - 0.008; + const CAGE_BULGE = 0.052; + const cageAt = (x, dy) => { - const k = 1 - (x / M.cageW) ** 2 - (dy / M.cageH) ** 2; - const z = skull.z + M.cageBase + M.cageBulge * Math.sqrt(Math.max(0, k)); + const k = 1 - (x / CAGE_W) ** 2 - (dy / CAGE_H) ** 2; + const z = skull.z + CAGE_BASE + CAGE_BULGE * Math.sqrt(Math.max(0, k)); return V(x, skull.y + M.portY + dy, z); }; const bars = []; - // Horizontal bars, densest across the eyes. - for (const dy of [-0.050, -0.028, -0.008, 0.014, 0.038, 0.058]) { - const span = M.cageW * Math.sqrt(Math.max(0, 1 - (dy / M.cageH) ** 2)); + // Horizontal bars, sized to the 127 eye port on the front view. + for (let i = 0; i < 5; i++) { + const dy = -CAGE_H * 0.78 + (CAGE_H * 1.56 * i) / 4; + const span = CAGE_W * Math.sqrt(Math.max(0, 1 - (dy / CAGE_H) ** 2)); if (span < 0.022) continue; const pts = []; - for (let i = 0; i <= 8; i++) { - const x = -span + (2 * span * i) / 8; + for (let j = 0; j <= 8; j++) { + const x = -span + (2 * span * j) / 8; pts.push(cageAt(clamp(x, -span * 0.995, span * 0.995), dy)); } bars.push(tube(pts, M.barR, { radial: 6 })); } - // Vertical bars. - for (const x of [-0.050, -0.018, 0.018, 0.050]) { - const span = M.cageH * Math.sqrt(Math.max(0, 1 - (x / M.cageW) ** 2)); + // Vertical bars — four, as drawn. + for (const fx of [-0.62, -0.21, 0.21, 0.62]) { + const x = CAGE_W * fx; + const span = CAGE_H * Math.sqrt(Math.max(0, 1 - (x / CAGE_W) ** 2)); if (span < 0.02) continue; const pts = []; - for (let i = 0; i <= 8; i++) { - const dy = -span + (2 * span * i) / 8; + for (let j = 0; j <= 8; j++) { + const dy = -span + (2 * span * j) / 8; pts.push(cageAt(x, clamp(dy, -span * 0.995, span * 0.995))); } bars.push(tube(pts, M.barR, { radial: 6 })); @@ -329,9 +495,7 @@ export function buildGoalieGear(mats) { const ring = []; for (let i = 0; i < 24; i++) { const a = (i / 24) * Math.PI * 2; - const x = M.cageW * 1.02 * Math.cos(a); - const dy = M.cageH * 1.02 * Math.sin(a); - const p = cageAt(x, dy); + const p = cageAt(CAGE_W * 1.02 * Math.cos(a), CAGE_H * 1.02 * Math.sin(a)); p.z -= 0.004; ring.push(p); } @@ -341,16 +505,16 @@ export function buildGoalieGear(mats) { // Throat dangler on its own strap, like the ref photo. const bib = loft([ - S(V(0, skull.y - 0.155, skull.z + 0.055), 0.055, 0.012, 4, PAL.accent), - S(V(0, skull.y - 0.20, skull.z + 0.058), 0.062, 0.013, 4), - S(V(0, skull.y - 0.245, skull.z + 0.05), 0.05, 0.012, 4), + S(V(0, chinY - 0.030, skull.z + 0.062), 0.055, 0.012, 4, PAL.accent), + S(V(0, chinY - 0.075, skull.z + 0.065), 0.062, 0.013, 4), + S(V(0, chinY - 0.118, skull.z + 0.056), 0.05, 0.012, 4), ], { radial: 12, sub: 4 }); mask.add(mesh(bib, mats.painted, 'maskBib')); mask.add(mesh( tube([ - V(-0.048, skull.y - 0.115, skull.z + 0.04), - V(0, skull.y - 0.135, skull.z + 0.06), - V(0.048, skull.y - 0.115, skull.z + 0.04), + V(-0.048, chinY + 0.012, skull.z + 0.046), + V(0, chinY - 0.008, skull.z + 0.066), + V(0.048, chinY + 0.012, skull.z + 0.046), ], 0.005, { radial: 5 }), mats.leather, 'maskBibStrap', @@ -483,52 +647,72 @@ export function buildGoalieGear(mats) { blocker.quaternion.copy(handGrip('R', GRIP_ROLL.blocker)); pieces.push(blocker); - // ---- chest protector ----------------------------------------------------- - // One shell from the collar down over the belly, wrapping the torso instead - // of floating in front of it. + // ---- chest protector, under the sweater --------------------------------- + // Worn under the jersey, the way it is in a dressing room. Kept a clear + // centimetre inside the sweater at every ring: it is rigid on the spine + // while the jersey is skinned, and anything that merely touches the inside + // of the cloth tears through it the moment the torso turns. What shows is + // the collar above the neckline and the bulk it gives the shoulders. const chest = new THREE.Group(); chest.name = 'chest'; { const body = loft([ - S(V(0, 0.15, 0.008), 0.066, 0.062, 3, PAL.trim), - S(V(0, 0.10, 0.012), 0.095, 0.082, 4, PAL.jersey), - S(V(0, 0.055, 0.014), 0.185, 0.115, 5), - S(V(0, -0.03, 0.018), 0.196, 0.126, 5), - S(V(0, -0.10, 0.02), 0.19, 0.126, 5, PAL.base), - S(V(0, -0.175, 0.018), 0.182, 0.122, 5), - S(V(0, -0.235, 0.014), 0.176, 0.116, 5, PAL.jersey), - S(V(0, -0.32, 0.01), 0.162, 0.108, 5), - S(V(0, -0.38, 0.004), 0.138, 0.095, 4, PAL.trim), - ], { radial: 24, sub: 5 }); + S(V(0, 0.15, 0.008), 0.062, 0.058, 3, PAL.trim), + S(V(0, 0.10, 0.012), 0.086, 0.075, 4, PAL.base), + S(V(0, 0.055, 0.014), 0.146, 0.094, 5), + S(V(0, -0.03, 0.018), 0.155, 0.102, 5), + S(V(0, -0.10, 0.02), 0.152, 0.102, 5), + S(V(0, -0.175, 0.018), 0.146, 0.098, 5), + S(V(0, -0.235, 0.014), 0.14, 0.094, 5), + S(V(0, -0.32, 0.01), 0.128, 0.086, 5), + S(V(0, -0.38, 0.004), 0.11, 0.076, 4, PAL.trim), + ], { radial: 20, sub: 4 }); chest.add(mesh(body, mats.painted, 'chestBody')); // Sternum plate, standing proud like a real chest-and-arm unit. const plate = loft([ - S(V(0, 0.05, 0.10), 0.088, 0.024, 4, PAL.base), - S(V(0, -0.04, 0.115), 0.10, 0.026, 4), - S(V(0, -0.14, 0.112), 0.096, 0.024, 4, PAL.accent), - S(V(0, -0.22, 0.10), 0.078, 0.02, 4), + S(V(0, 0.05, 0.075), 0.07, 0.02, 4, PAL.base), + S(V(0, -0.04, 0.088), 0.08, 0.022, 4), + S(V(0, -0.14, 0.085), 0.076, 0.02, 4), + S(V(0, -0.22, 0.074), 0.062, 0.016, 4), ], { radial: 14, sub: 4 }); chest.add(mesh(plate, mats.painted, 'chestPlate')); } pieces.push(chest); + // ---- jersey -------------------------------------------------------------- + // The same sweater a skater wears, cut roomier and hanging longer, because a + // goalie's goes over a chest protector rather than shoulder pads. + skin( + jerseyParts({ + palette: { jersey: PAL.jersey, accent: PAL.base, trim: PAL.trim }, + phys, + // Under 1 on purpose. A goalie's bodyStyle already pushes bulk and + // shoulderF well past a skater's, and the sweater multiplies by both, so + // asking for "roomier" on top of that compounds into a tent. + roomy: 0.85, + hemDrop: 0.02, + }), + mats.cloth, + 'goalieJersey', + ); + // Shoulder floaters — parented to the upper arms, aligned to the A-pose axis // so they actually sit on the arm instead of hovering beside it. function makeFloater(side) { const g = new THREE.Group(); g.name = `floater${side}`; const cap = loft([ - S(V(0, 0.055, 0.01), 0.075, 0.072, 3, PAL.base), - S(V(0, -0.015, 0.012), 0.094, 0.086, 4), - S(V(0, -0.075, 0.01), 0.088, 0.08, 4, PAL.jersey), - S(V(0, -0.145, 0.008), 0.076, 0.068, 3), + S(V(0, 0.05, 0.01), 0.068, 0.065, 3, PAL.base), + S(V(0, -0.02, 0.012), 0.084, 0.078, 4), + S(V(0, -0.08, 0.01), 0.079, 0.072, 4), + S(V(0, -0.145, 0.008), 0.068, 0.061, 3), ], { radial: 16, sub: 4 }); g.add(mesh(cap, mats.painted, `floater${side}Cap`)); const arm = loft([ - S(V(0, -0.16, 0.006), 0.072, 0.066, 3, PAL.jersey), - S(V(0, -0.26, 0.004), 0.066, 0.06, 3), - S(V(0, -0.315, 0.002), 0.052, 0.048, 3, PAL.trim), + S(V(0, -0.16, 0.006), 0.064, 0.059, 3, PAL.base), + S(V(0, -0.26, 0.004), 0.058, 0.053, 3), + S(V(0, -0.315, 0.002), 0.046, 0.042, 3, PAL.trim), ], { radial: 14, sub: 4 }); g.add(mesh(arm, mats.painted, `floater${side}Arm`)); alignTo(g, ARM_DIR[side]); @@ -539,8 +723,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 parented 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 +771,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; } @@ -606,12 +792,17 @@ export function buildGoalieGear(mats) { paddle: paddleMesh, pieces, - attachTo(bones) { + skinned, + + attachTo(bones, mover) { + for (const m of skinned) mover.add(m); bones.shinL.add(padL); bones.shinR.add(padR); bones.handL.add(trapper); bones.handR.add(blocker); - bones.handR.add(stick); + // Stick is mover-planted — hand IKs to the shaft (see createGoalie). + if (mover) mover.add(stick); + else bones.handR.add(stick); bones.head.add(mask); bones.spine3.add(chest); bones.upperArmL.add(floaterL); @@ -632,6 +823,13 @@ export function buildGoalieMaterials(teamJersey, teamAccent = 0xf0e6d2) { roughness: 0.72, metalness: 0.04, }), + /** The sweater — cloth, so matte where the shells are not. */ + cloth: new THREE.MeshStandardMaterial({ + color: 0xffffff, + vertexColors: true, + roughness: 0.88, + metalness: 0.0, + }), /** Vertex-coloured gear: pads, mask shell, chest, paddle all share it. */ painted: new THREE.MeshStandardMaterial({ color: 0xffffff, diff --git a/src/character/jersey.js b/src/character/jersey.js new file mode 100644 index 0000000..448c6a3 --- /dev/null +++ b/src/character/jersey.js @@ -0,0 +1,131 @@ +import * as THREE from 'three'; +import { mergeGeoms } from '../core/math.js'; +import { PART } from './body.js'; +import { computeSkin } from './skinning.js'; +import { loft } from './gearMesh.js'; + +/** + * The sweater, shared by skaters and goalies. + * + * A hockey jersey is the same garment either way — torso, long sleeves, hem + * past the waist — and the only real difference is how much room it is cut + * with. A goalie's hangs loose over a chest protector; a skater's sits closer + * over shoulder pads. That is one number, not a second implementation. + * + * It is skinned to the same skeleton the body uses, because it crosses both + * shoulders and the spine. Bolted to the chest bone it tears open at the + * shoulder the first time an arm swings. + */ + +/** + * Merge rest-space loft pieces, solve skin weights, and bind to the body's + * skeleton. + * + * `computeSkin` overwrites the colour attribute with its debug heatmap, so the + * kit colours are stashed and put back afterwards — the same dance `paintKit` + * does for the body. + */ +export function skinnedFrom(parts, mat, skelData, name) { + const geo = mergeGeoms(parts); + for (const p of parts) p.dispose(); + const colors = geo.attributes.color.array.slice(); + computeSkin(geo, skelData); + geo.userData.heatColors = geo.attributes.color.array.slice(); + geo.setAttribute('color', new THREE.BufferAttribute(colors, 3)); + geo.computeVertexNormals(); + + const m = new THREE.SkinnedMesh(geo, mat); + m.name = name; + m.castShadow = true; + m.receiveShadow = true; + m.frustumCulled = false; + // Bound before parenting, so the bind matrix is identity — matching the body + // mesh. The root bone stays parented to the body; a second mesh only borrows + // the skeleton. + m.updateMatrixWorld(true); + m.bind(skelData.skeleton, m.matrixWorld.clone()); + return m; +} + +/** + * Build the sweater's loft pieces, in rest space. + * + * Returns the parts rather than a mesh so a caller can merge them with other + * cloth before skinning — one solve, one draw call. + * + * @param {{ + * palette: { jersey: THREE.Color, accent: THREE.Color, trim: THREE.Color }, + * phys: { bulk:number, waistF:number, shoulderF:number, armF:number }, + * roomy?: number, hemDrop?: number, + * }} opts + */ +export function jerseyParts({ palette, phys, roomy = 1, hemDrop = 0 }) { + const bulk = (phys?.bulk ?? 1) * roomy; + const waist = (phys?.waistF ?? 1) * bulk; + const shoulder = (phys?.shoulderF ?? 1) * bulk; + const armF = (phys?.armF ?? 1) * roomy; + const { jersey, accent, trim } = palette; + + const V = (x, y, z = 0) => new THREE.Vector3(x, y, z); + const S = (c, rx, rz, e, col) => ({ c, rx, rz, e, col }); + /** Hem sections drop together, so a goalie sweater hangs longer. */ + const H = (y) => y - hemDrop; + + const parts = [loft([ + // Hem hangs over the pants, so it has to clear the widest part of them. + S(V(0, H(0.878), 0.004), 0.226 * bulk, 0.17 * bulk, 4, jersey), + S(V(0, H(0.905), 0.004), 0.232 * bulk, 0.174 * bulk, 4, accent), + S(V(0, H(0.94), 0.004), 0.233 * bulk, 0.175 * bulk, 4), + S(V(0, H(0.95), 0.004), 0.232 * bulk, 0.174 * bulk, 4, trim), + S(V(0, H(0.98), 0.005), 0.229 * bulk, 0.171 * bulk, 4), + S(V(0, H(0.99), 0.005), 0.228 * bulk, 0.17 * bulk, 4, jersey), + S(V(0, 1.075, 0.005), 0.207 * waist, 0.152 * waist, 4), + S(V(0, 1.165, 0.007), 0.202 * bulk, 0.148 * bulk, 4), + S(V(0, 1.255, 0.009), 0.212 * bulk, 0.155 * bulk, 4), + // Over the shoulder pads — the widest point of a dressed player. + S(V(0, 1.335, 0.01), 0.242 * shoulder, 0.16 * bulk, 5), + S(V(0, 1.395, 0.012), 0.222 * shoulder, 0.142 * bulk, 4), + S(V(0, 1.418, 0.013), 0.17 * shoulder, 0.12 * bulk, 4), + S(V(0, 1.432, 0.013), 0.108 * bulk, 0.098 * bulk, 3, trim), + S(V(0, 1.462, 0.014), 0.098 * bulk, 0.09 * bulk, 3), + ], { radial: 20, sub: 3, part: PART.TORSO, t0: 0.0, t1: 0.98 })]; + + for (const side of ['L', 'R']) { + const s = side === 'L' ? 1 : -1; + const P = (x, y, z = 0) => V(s * x, y, z); + parts.push(loft([ + // Wide enough at the top to swallow the deltoid ball, and buried in the + // torso shell so the shoulder seam never opens. + S(P(0.10, 1.415, 0.008), 0.108 * armF, 0.10 * armF, 3, jersey), + S(P(0.175, 1.385, 0.01), 0.118 * armF, 0.112 * armF, 3), + S(P(0.245, 1.325, 0.01), 0.105 * armF, 0.10 * armF, 3), + S(P(0.30, 1.27, 0.01), 0.09 * armF, 0.086 * armF, 3), + S(P(0.355, 1.16, 0.012), 0.072 * armF, 0.068 * armF, 3), + // Elbow cap under the sleeve. + S(P(0.397, 1.095, 0.013), 0.076 * armF, 0.072 * armF, 3), + S(P(0.447, 0.985, 0.016), 0.064 * armF, 0.06 * armF, 3), + S(P(0.472, 0.93, 0.018), 0.058 * armF, 0.055 * armF, 3, accent), + S(P(0.487, 0.898, 0.02), 0.057 * armF, 0.054 * armF, 3), + S(P(0.497, 0.876, 0.022), 0.056 * armF, 0.053 * armF, 3, trim), + S(P(0.512, 0.844, 0.024), 0.053 * armF, 0.05 * armF, 3), + ], { + radial: 14, + sub: 3, + part: side === 'L' ? PART.ARM_L : PART.ARM_R, + t0: 0.1, + t1: 0.94, + })); + } + return parts; +} + +/** What a sweater covers, for `hideCoveredBody`. */ +export const JERSEY_COVERAGE = { + // Torso up to the collar; the neck and above stay. + [PART.TORSO]: [0.0, 0.9], + // Sleeve to the cuff, where a glove takes over. The deltoid ball has to be + // in here: it is the widest thing on the arm and sits exactly where the + // sleeve meets the yoke, so leaving it visible shows it through the seam. + [PART.ARM_L]: [0.0, 1.0], + [PART.ARM_R]: [0.0, 1.0], +}; diff --git a/src/character/skaterGear.js b/src/character/skaterGear.js index 5b8a808..a7ffa40 100644 --- a/src/character/skaterGear.js +++ b/src/character/skaterGear.js @@ -1,7 +1,6 @@ import * as THREE from 'three'; -import { mergeGeoms } from '../core/math.js'; import { PART } from './body.js'; -import { computeSkin } from './skinning.js'; +import { JERSEY_COVERAGE, jerseyParts, skinnedFrom } from './jersey.js'; import { carvedShell, loft, mergeBars, tint, tube } from './gearMesh.js'; /** @@ -85,13 +84,7 @@ export const KIT = { * the collar or the cuff when the pose moves. */ export const COVERAGE = { - // Jersey and pants, up to the collar. The neck and above stay. - [PART.TORSO]: [0.0, 0.9], - // Sleeve and glove, deltoid to fingertips. The shoulder ball has to be in - // here: it is the widest thing on the arm and it sits exactly where the - // sleeve meets the yoke, so leaving it visible shows it through the seam. - [PART.ARM_L]: [0.0, 1.0], - [PART.ARM_R]: [0.0, 1.0], + ...JERSEY_COVERAGE, // Pants, socks and boots enclose the leg end to end. [PART.LEG_L]: [0.0, 1.0], [PART.LEG_R]: [0.0, 1.0], @@ -169,32 +162,10 @@ export function buildSkaterGear(mats, skelData, phys) { return group; } - /** - * Merge rest-space pieces, solve skin weights, and bind to the body's - * skeleton. `computeSkin` overwrites the colour attribute with its debug - * heatmap, so the kit colours are stashed and put back afterwards — same - * dance `paintKit` does for the body. - */ + /** Skin a set of rest-space pieces onto the body's skeleton. */ function skin(parts, mat, name) { - const geo = mergeGeoms(parts); - for (const p of parts) p.dispose(); - const colors = geo.attributes.color.array.slice(); - computeSkin(geo, skelData); - geo.userData.heatColors = geo.attributes.color.array.slice(); - geo.setAttribute('color', new THREE.BufferAttribute(colors, 3)); - geo.computeVertexNormals(); - - const m = new THREE.SkinnedMesh(geo, mat); - m.name = name; - m.castShadow = true; - m.receiveShadow = true; - m.frustumCulled = false; - // Bound before parenting, so the bind matrix is identity — matching the - // body mesh. The root bone stays parented to the body; a second mesh only - // borrows the skeleton. - m.updateMatrixWorld(true); - m.bind(skelData.skeleton, m.matrixWorld.clone()); - disposables.push(geo); + const m = skinnedFrom(parts, mat, skelData, name); + disposables.push(m.geometry); skinned.push(m); pieces.push(m); return m; @@ -240,56 +211,8 @@ export function buildSkaterGear(mats, skelData, phys) { const capR = makeCap('R'); // ---- 2. jersey ---------------------------------------------------------- - // Torso plus two long sleeves, merged into one skinned mesh. Waist stripes - // and cuff bands are cut the same way the goalie's pad bands are: two - // sections a centimetre apart. - const jerseyParts = []; - jerseyParts.push(loft([ - // Hem hangs over the pants, so it has to clear the widest part of them. - S(V(0, 0.878, 0.004), 0.226 * bulk, 0.17 * bulk, 4, PAL.jersey), - S(V(0, 0.905, 0.004), 0.232 * bulk, 0.174 * bulk, 4, PAL.accent), - S(V(0, 0.94, 0.004), 0.233 * bulk, 0.175 * bulk, 4), - S(V(0, 0.95, 0.004), 0.232 * bulk, 0.174 * bulk, 4, PAL.trim), - S(V(0, 0.98, 0.005), 0.229 * bulk, 0.171 * bulk, 4), - S(V(0, 0.99, 0.005), 0.228 * bulk, 0.17 * bulk, 4, PAL.jersey), - S(V(0, 1.075, 0.005), 0.207 * waist, 0.152 * waist, 4), - S(V(0, 1.165, 0.007), 0.202 * bulk, 0.148 * bulk, 4), - S(V(0, 1.255, 0.009), 0.212 * bulk, 0.155 * bulk, 4), - // Over the shoulder pads — the widest point of a dressed player. - S(V(0, 1.335, 0.01), 0.242 * shoulder, 0.16 * bulk, 5), - S(V(0, 1.395, 0.012), 0.222 * shoulder, 0.142 * bulk, 4), - S(V(0, 1.418, 0.013), 0.17 * shoulder, 0.12 * bulk, 4), - S(V(0, 1.432, 0.013), 0.108 * bulk, 0.098 * bulk, 3, PAL.trim), - S(V(0, 1.462, 0.014), 0.098 * bulk, 0.09 * bulk, 3), - ], { radial: 20, sub: 3, part: PART.TORSO, t0: 0.0, t1: 0.98 })); - - for (const side of ['L', 'R']) { - const s = side === 'L' ? 1 : -1; - const P = (x, y, z = 0) => V(s * x, y, z); - jerseyParts.push(loft([ - // Wide enough at the top to swallow the deltoid ball, and buried in the - // torso shell so the shoulder seam never opens. - S(P(0.10, 1.415, 0.008), 0.108 * armF, 0.10 * armF, 3, PAL.jersey), - S(P(0.175, 1.385, 0.01), 0.118 * armF, 0.112 * armF, 3), - S(P(0.245, 1.325, 0.01), 0.105 * armF, 0.10 * armF, 3), - S(P(0.30, 1.27, 0.01), 0.09 * armF, 0.086 * armF, 3), - S(P(0.355, 1.16, 0.012), 0.072 * armF, 0.068 * armF, 3), - // Elbow cap under the sleeve. - S(P(0.397, 1.095, 0.013), 0.076 * armF, 0.072 * armF, 3), - S(P(0.447, 0.985, 0.016), 0.064 * armF, 0.06 * armF, 3), - S(P(0.472, 0.93, 0.018), 0.058 * armF, 0.055 * armF, 3, PAL.accent), - S(P(0.487, 0.898, 0.02), 0.057 * armF, 0.054 * armF, 3), - S(P(0.497, 0.876, 0.022), 0.056 * armF, 0.053 * armF, 3, PAL.trim), - S(P(0.512, 0.844, 0.024), 0.053 * armF, 0.05 * armF, 3), - ], { - radial: 14, - sub: 3, - part: side === 'L' ? PART.ARM_L : PART.ARM_R, - t0: 0.1, - t1: 0.94, - })); - } - skin(jerseyParts, mats.cloth, 'jersey'); + // Same sweater the goalie wears, cut closer. See jersey.js. + skin(jerseyParts({ palette: PAL, phys, roomy: 1 }), mats.cloth, 'jersey'); // ---- 3. pants ----------------------------------------------------------- // Waist-high padded shorts: a hip shell plus two thigh tubes that stop above 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');