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.
This commit is contained in:
+634
-78
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
+45
-14
@@ -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;
|
||||
|
||||
+308
-110
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
};
|
||||
@@ -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
|
||||
|
||||
+635
-28
@@ -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<typeof buildPuckMesh> | null} */
|
||||
puckView: null,
|
||||
/** @type {THREE.Group | null} */
|
||||
netMesh: null,
|
||||
/** Scratch for hit tests. */
|
||||
_local: new THREE.Vector3(),
|
||||
};
|
||||
|
||||
function ensurePuckView() {
|
||||
if (!machine.puckView) {
|
||||
machine.puckView = buildPuckMesh(scene, PUCK);
|
||||
machine.puckView.mesh.visible = false;
|
||||
machine.puckView.ring.visible = false;
|
||||
}
|
||||
return machine.puckView;
|
||||
}
|
||||
|
||||
function ensureNet() {
|
||||
if (!machine.netMesh) {
|
||||
machine.netMesh = buildNetMesh(scene, MACHINE_END);
|
||||
}
|
||||
machine.netMesh.visible = true;
|
||||
return machine.netMesh;
|
||||
}
|
||||
|
||||
function hideNet() {
|
||||
if (machine.netMesh) machine.netMesh.visible = false;
|
||||
}
|
||||
|
||||
function studioOriginGround() {
|
||||
ground.position.set(0, 0, 0);
|
||||
grid.position.set(0, 0.002, 0);
|
||||
}
|
||||
|
||||
function machineCreaseGround() {
|
||||
const x = goalLineX(MACHINE_END) - MACHINE_END * GOALIE.depth;
|
||||
ground.position.set(x, 0, 0);
|
||||
grid.position.set(x, 0.002, 0);
|
||||
}
|
||||
|
||||
function applyMachineCamera() {
|
||||
const line = goalLineX(MACHINE_END);
|
||||
const creaseX = line - MACHINE_END * GOALIE.depth;
|
||||
// Shooter's eye: a few metres out, slightly up, looking into the net.
|
||||
camera.position.set(creaseX - 7.5, 1.6, 2.8);
|
||||
controls.target.set(creaseX + 0.4, 0.85, 0);
|
||||
controls.update();
|
||||
state.view = 'machine';
|
||||
// Keep the select honest when we leave pose-studio views.
|
||||
if (![...viewSel.options].some((o) => o.value === 'machine')) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = 'machine';
|
||||
opt.textContent = 'machine (crease)';
|
||||
viewSel.appendChild(opt);
|
||||
}
|
||||
viewSel.value = 'machine';
|
||||
}
|
||||
|
||||
function fireShot(recipe = null) {
|
||||
if (!goalie) buildGoalie();
|
||||
ensurePuckView();
|
||||
ensureNet();
|
||||
|
||||
const rec = recipe ?? SHOT_RECIPES[machine.recipeIndex % SHOT_RECIPES.length];
|
||||
machine.recipeIndex = (SHOT_RECIPES.indexOf(rec) + 1) % SHOT_RECIPES.length;
|
||||
machine.lastRecipe = rec;
|
||||
machine.lastResult = null;
|
||||
machine.lastSaveKind = null;
|
||||
machine.caught = false;
|
||||
machine.catchTrack = null;
|
||||
machine.shots++;
|
||||
if (goalie) goalie.animator.clearSave();
|
||||
|
||||
const line = goalLineX(MACHINE_END);
|
||||
// Spawn toward centre ice, aim at a point just in front of the mouth.
|
||||
const spawnX = line - MACHINE_END * MACHINE.spawnDist;
|
||||
// Hand saves contact ~0.35 m in front of the goalie, not on the goal line —
|
||||
// that way the glove/blocker volumes see the puck before the pads do.
|
||||
const aimX = line - MACHINE_END * (GOALIE.depth + 0.35);
|
||||
// Small lateral jitter so the same recipe does not look robotic.
|
||||
const jitterZ = (Math.random() - 0.5) * 0.1;
|
||||
const spawnZ = rec.z * 0.12 + jitterZ;
|
||||
const aimZ = rec.z + jitterZ * 0.25;
|
||||
// High shots leave the machine already elevated so gravity does not dump
|
||||
// them into the pads before the hand volumes can resolve a catch/block.
|
||||
const aimY = Math.max(MACHINE.iceY, rec.y);
|
||||
const spawnY = aimY > 0.55
|
||||
? Math.max(0.55, aimY * 0.72)
|
||||
: Math.max(MACHINE.iceY + 0.02, aimY * 0.4 + 0.08);
|
||||
|
||||
const dx = aimX - spawnX;
|
||||
const dz = aimZ - spawnZ;
|
||||
const horiz = Math.hypot(dx, dz) || 1;
|
||||
// Horizontal speed from the recipe. High shots solve ballistic height so the
|
||||
// glove/blocker volumes actually see the disc; low shots stay flat so they
|
||||
// do not arc into the chest on the way in.
|
||||
const speed = rec.speed;
|
||||
const tFlight = horiz / Math.max(1e-3, speed);
|
||||
const g = MACHINE.gravity;
|
||||
let vy;
|
||||
if (aimY > 0.55) {
|
||||
vy = (aimY - spawnY - 0.5 * g * tFlight * tFlight) / tFlight;
|
||||
vy += (rec.loft ?? 0) * 0.12;
|
||||
} else {
|
||||
vy = Math.min(1.0, (aimY - spawnY) * 0.8 + (rec.loft ?? 0) * 0.2);
|
||||
}
|
||||
const vel = new THREE.Vector3(
|
||||
(dx / horiz) * speed,
|
||||
vy,
|
||||
(dz / horiz) * speed,
|
||||
);
|
||||
|
||||
machine.puck = {
|
||||
pos: new THREE.Vector3(spawnX, spawnY, spawnZ),
|
||||
vel,
|
||||
alive: true,
|
||||
age: 0,
|
||||
resolvedAt: null,
|
||||
};
|
||||
|
||||
const view = machine.puckView;
|
||||
view.mesh.visible = true;
|
||||
view.ring.visible = true;
|
||||
view.mesh.position.copy(machine.puck.pos);
|
||||
}
|
||||
|
||||
/** Goalie-local axes for the current puck sample. */
|
||||
function puckLocal(puckPos, out = { localX: 0, localZ: 0 }) {
|
||||
const dx = puckPos.x - goalie.pos.x;
|
||||
const dz = puckPos.z - goalie.pos.z;
|
||||
const yaw = goalie.animator.originYaw;
|
||||
const s = Math.sin(yaw);
|
||||
const c = Math.cos(yaw);
|
||||
// Forward = (sin yaw, cos yaw); right = (cos yaw, −sin yaw).
|
||||
out.localX = dx * s + dz * c;
|
||||
out.localZ = dx * c - dz * s;
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft pad / body / glove volumes in goalie-local space.
|
||||
* Returns the part that hit, or null.
|
||||
*/
|
||||
function puckHitsGoalie(puckPos) {
|
||||
if (!goalie) return null;
|
||||
const { localX, localZ } = puckLocal(puckPos);
|
||||
const y = puckPos.y;
|
||||
const r = PUCK.radius;
|
||||
|
||||
// Trapper (L / −localZ) and blocker (R / +localZ) — always first, so a high
|
||||
// shot can resolve as a hand save before the pad stack claims it.
|
||||
if (y > 0.48) {
|
||||
const gloveR = goalie.animator.state === 'reach' || goalie.animator.save?.kind === 'catch' ? 0.42 : 0.36;
|
||||
const blockR = goalie.animator.state === 'reach' || goalie.animator.save?.kind === 'block' ? 0.4 : 0.34;
|
||||
// Wider vertical bands; lateral centers match the ready/reach hand sockets.
|
||||
if (y < 1.8 && Math.hypot(localX - 0.12, localZ + 0.42) < gloveR) return 'glove';
|
||||
if (y < 1.75 && Math.hypot(localX - 0.1, localZ - 0.4) < blockR) return 'blocker';
|
||||
}
|
||||
|
||||
// Pads: low, wide, thin in depth. Only claim low pucks so they cannot steal
|
||||
// a glove/blocker save that dipped a centimetre into pad height.
|
||||
if (
|
||||
y < GOALIE.padHeight + r
|
||||
&& Math.abs(localX) < GOALIE.padDepth / 2 + r + 0.06
|
||||
&& Math.abs(localZ) < GOALIE.padWidth / 2 + r + 0.04
|
||||
) {
|
||||
return 'pad';
|
||||
}
|
||||
|
||||
// Upper body capsule (axis along Y).
|
||||
if (y > GOALIE.bodyLow - r && y < GOALIE.bodyHigh + r) {
|
||||
if (Math.hypot(localX, localZ) < GOALIE.bodyRadius + r + 0.04) return 'body';
|
||||
}
|
||||
|
||||
// Butterfly: pads cover more lateral width.
|
||||
if (goalie.animator.state === 'butterfly' && y < 0.55) {
|
||||
if (Math.abs(localX) < 0.28 && Math.abs(localZ) < 0.72) return 'pad';
|
||||
}
|
||||
|
||||
if (goalie.covers(puckPos)) return 'cover';
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a hit part to a hand-IK save kind.
|
||||
* - glove side → catch (trapper seals on the puck)
|
||||
* - blocker side → block (deflect off the line)
|
||||
* - low pad → null (pads only, no arm IK)
|
||||
*/
|
||||
function classifySave(part, puckPos) {
|
||||
if (part === 'glove') return 'catch';
|
||||
if (part === 'blocker') return 'block';
|
||||
const { localZ } = puckLocal(puckPos);
|
||||
if (part === 'cover' || part === 'body') {
|
||||
if (localZ < -0.06) return 'catch';
|
||||
if (localZ > 0.06) return 'block';
|
||||
// Chest / five-hole body — trapper smothers when high enough.
|
||||
return puckPos.y > 0.65 ? 'catch' : null;
|
||||
}
|
||||
// Pads stay pad saves; no hand IK.
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveShot(result) {
|
||||
const p = machine.puck;
|
||||
if (!p || p.resolvedAt != null) return;
|
||||
p.resolvedAt = p.age;
|
||||
p.alive = false;
|
||||
machine.lastResult = result;
|
||||
if (result.startsWith('save')) machine.saves++;
|
||||
else if (result === 'GOAL') machine.goals++;
|
||||
else machine.misses++;
|
||||
}
|
||||
|
||||
function holdDuration() {
|
||||
return machine.caught ? MACHINE.holdCatch : MACHINE.holdAfter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a save: hand IK for catch/block, pad rebound otherwise.
|
||||
* Catch freezes the puck in the trapper; block drives it back out of the net.
|
||||
*/
|
||||
function deflectPuck(part) {
|
||||
const p = machine.puck;
|
||||
if (!p || !goalie) return;
|
||||
|
||||
const kind = classifySave(part, p.pos);
|
||||
const { localZ } = puckLocal(p.pos);
|
||||
|
||||
if (kind === 'catch') {
|
||||
// Seal — no rebound. Puck pins to the glove after the animator IK lands.
|
||||
// Freeze the tracking point at contact so the body does not yaw-chase the
|
||||
// disc as it rides the trapper (that was spinning the whole goalie).
|
||||
p.vel.set(0, 0, 0);
|
||||
machine.caught = true;
|
||||
machine.catchTrack = { x: p.pos.x, y: p.pos.y, z: p.pos.z };
|
||||
machine.lastSaveKind = 'catch';
|
||||
goalie.playSave('catch', p.pos);
|
||||
resolveShot('save · catch');
|
||||
return;
|
||||
}
|
||||
|
||||
if (kind === 'block') {
|
||||
// Deflect away from the net along the goalie's facing (+ a lateral kick
|
||||
// so the rebound leaves the slot instead of sitting on the goal line).
|
||||
const yaw = goalie.animator.originYaw;
|
||||
const fx = Math.sin(yaw);
|
||||
const fz = Math.cos(yaw);
|
||||
const speed = 9 + Math.random() * 4;
|
||||
p.vel.set(
|
||||
fx * speed,
|
||||
Math.max(1.2, Math.abs(p.vel.y) * 0.35 + 1.4),
|
||||
fz * speed + localZ * 3.2,
|
||||
);
|
||||
machine.caught = false;
|
||||
machine.lastSaveKind = 'block';
|
||||
goalie.playSave('block', p.pos);
|
||||
resolveShot('save · block');
|
||||
return;
|
||||
}
|
||||
|
||||
// Pad / low body — rebound only, no hand IK.
|
||||
const out = -MACHINE_END;
|
||||
p.vel.x = out * (6 + Math.random() * 5);
|
||||
p.vel.y = Math.abs(p.vel.y) * 0.35 + 1.2;
|
||||
p.vel.z = -localZ * 2.5 + (Math.random() - 0.5) * 1.2;
|
||||
machine.caught = false;
|
||||
machine.lastSaveKind = null;
|
||||
resolveShot(`save · ${part}`);
|
||||
}
|
||||
|
||||
function resolvePuckOutcome() {
|
||||
const p = machine.puck;
|
||||
if (!p || p.resolvedAt != null) return;
|
||||
if (isGoal(p.pos, MACHINE_END, PUCK.radius)) {
|
||||
resolveShot('GOAL');
|
||||
return;
|
||||
}
|
||||
const line = goalLineX(MACHINE_END);
|
||||
const past = MACHINE_END > 0
|
||||
? p.pos.x - PUCK.radius > line
|
||||
: p.pos.x + PUCK.radius < line;
|
||||
if (past) resolveShot('miss · wide/high');
|
||||
}
|
||||
|
||||
/** After the goalie animates, stick a caught puck to the trapper. */
|
||||
const _catchPin = new THREE.Vector3();
|
||||
function pinCaughtPuck() {
|
||||
if (!machine.caught || !machine.puck || !goalie) return;
|
||||
if (machine.puck.resolvedAt == null) return;
|
||||
// Prefer the trapper mesh if present; fall back to the hand bone.
|
||||
const trap = goalie.gear?.trapper;
|
||||
if (trap) trap.getWorldPosition(_catchPin);
|
||||
else goalie.skelData.bones.handL.getWorldPosition(_catchPin);
|
||||
// Pocket sits a little proud of the hand so the disc does not sink into the mesh.
|
||||
_catchPin.y += 0.02;
|
||||
machine.puck.pos.copy(_catchPin);
|
||||
machine.puck.vel.set(0, 0, 0);
|
||||
if (machine.puckView) {
|
||||
machine.puckView.mesh.position.copy(_catchPin);
|
||||
machine.puckView.ring.visible = false;
|
||||
}
|
||||
}
|
||||
|
||||
function stepPuck(dt) {
|
||||
const p = machine.puck;
|
||||
if (!p) return;
|
||||
p.age += dt;
|
||||
|
||||
// Keep integrating a beat after resolve so rebounds still read, then hide.
|
||||
const hold = p.resolvedAt != null && p.age - p.resolvedAt > holdDuration();
|
||||
if (hold) {
|
||||
if (machine.puckView) {
|
||||
machine.puckView.mesh.visible = false;
|
||||
machine.puckView.ring.visible = false;
|
||||
}
|
||||
machine.caught = false;
|
||||
machine.catchTrack = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// Caught pucks skip free-flight integration — pinCaughtPuck owns the pose.
|
||||
if (machine.caught && p.resolvedAt != null) {
|
||||
if (machine.puckView) machine.puckView.mesh.position.copy(p.pos);
|
||||
return;
|
||||
}
|
||||
|
||||
// Soft coast after a save; full flight while live.
|
||||
const damp = p.resolvedAt != null ? 0.92 : 1;
|
||||
p.vel.y += MACHINE.gravity * dt;
|
||||
p.vel.x *= damp;
|
||||
p.vel.z *= damp;
|
||||
p.pos.x += p.vel.x * dt;
|
||||
p.pos.y += p.vel.y * dt;
|
||||
p.pos.z += p.vel.z * dt;
|
||||
|
||||
// Ice.
|
||||
if (p.pos.y < MACHINE.iceY) {
|
||||
p.pos.y = MACHINE.iceY;
|
||||
if (p.vel.y < 0) p.vel.y *= -0.25;
|
||||
p.vel.x *= 0.992;
|
||||
p.vel.z *= 0.992;
|
||||
}
|
||||
|
||||
if (p.resolvedAt == null) {
|
||||
const part = puckHitsGoalie(p.pos);
|
||||
if (part) deflectPuck(part);
|
||||
else resolvePuckOutcome();
|
||||
|
||||
// Timed out far from the crease.
|
||||
if (p.resolvedAt == null && p.age > 4.5) resolveShot('miss · timeout');
|
||||
}
|
||||
|
||||
if (machine.puckView) {
|
||||
machine.puckView.mesh.position.copy(p.pos);
|
||||
// Spin for readability.
|
||||
machine.puckView.mesh.rotation.y += dt * 14;
|
||||
}
|
||||
}
|
||||
|
||||
function setMachine(on) {
|
||||
machine.on = !!on;
|
||||
state.machine = machine.on;
|
||||
if (machineBtn) {
|
||||
machineBtn.textContent = machine.on ? 'puck machine · ON' : 'puck machine · OFF';
|
||||
machineBtn.classList.toggle('on', machine.on);
|
||||
}
|
||||
|
||||
if (machine.on) {
|
||||
if (!goalie) buildGoalie();
|
||||
// Live AI owns the goalie — park on the real crease, hide the skater.
|
||||
state.subject = 'goalie';
|
||||
subjectSel.value = 'goalie';
|
||||
if (player) player.mover.visible = false;
|
||||
goalie.mover.visible = true;
|
||||
goalie.reset();
|
||||
machineCreaseGround();
|
||||
ensureNet();
|
||||
ensurePuckView();
|
||||
// Crease is ~25 m from origin; open fog / far plane so the drill reads.
|
||||
scene.fog.near = 40;
|
||||
scene.fog.far = 90;
|
||||
camera.far = 120;
|
||||
camera.updateProjectionMatrix();
|
||||
// Key light follows the crease so pads keep a readable shadow.
|
||||
const creaseX = goalLineX(MACHINE_END) - MACHINE_END * GOALIE.depth;
|
||||
key.position.set(creaseX + 4, 10, 6);
|
||||
if (!key.target.parent) scene.add(key.target);
|
||||
key.target.position.set(creaseX, 0, 0);
|
||||
applyMachineCamera();
|
||||
machine.cooldown = 0.35;
|
||||
machine.recipeIndex = 0;
|
||||
machine.lastResult = null;
|
||||
fillPoseSelect();
|
||||
} else {
|
||||
// Back to pose studio at the origin.
|
||||
hideNet();
|
||||
if (machine.puckView) {
|
||||
machine.puckView.mesh.visible = false;
|
||||
machine.puckView.ring.visible = false;
|
||||
}
|
||||
machine.puck = null;
|
||||
studioOriginGround();
|
||||
scene.fog.near = 18;
|
||||
scene.fog.far = 40;
|
||||
camera.far = 80;
|
||||
camera.updateProjectionMatrix();
|
||||
key.position.set(4, 10, 6);
|
||||
if (goalie) {
|
||||
// Repark at studio origin facing +Z (camera front).
|
||||
const x = state.subject === 'both' ? 0.85 : 0;
|
||||
goalie.pos.x = x;
|
||||
goalie.pos.z = 0;
|
||||
goalie.mover.position.set(x, 0, 0);
|
||||
goalie.mover.rotation.y = 0;
|
||||
goalie.animator.setTransform(goalie.mover.position, 0);
|
||||
goalie.animator.threatened = 0.1;
|
||||
goalie.animator.puckHeight = 0.5;
|
||||
goalie.animator.puckDist = 12;
|
||||
goalie.animator.setState('ready', 0.05);
|
||||
}
|
||||
layoutSubjects();
|
||||
applyPose(state.pose);
|
||||
if (state.view === 'machine') applyView('threequarter');
|
||||
}
|
||||
}
|
||||
|
||||
function resetMachineStats() {
|
||||
machine.saves = 0;
|
||||
machine.goals = 0;
|
||||
machine.misses = 0;
|
||||
machine.shots = 0;
|
||||
machine.lastResult = null;
|
||||
machine.lastSaveKind = null;
|
||||
machine.caught = false;
|
||||
machine.catchTrack = null;
|
||||
machine.recipeIndex = 0;
|
||||
if (machine.on && goalie) {
|
||||
goalie.reset();
|
||||
machine.cooldown = 0.4;
|
||||
}
|
||||
machine.puck = null;
|
||||
if (machine.puckView) {
|
||||
machine.puckView.mesh.visible = false;
|
||||
machine.puckView.ring.visible = false;
|
||||
}
|
||||
}
|
||||
|
||||
function puckReadyForNext() {
|
||||
const p = machine.puck;
|
||||
if (!p) return true;
|
||||
if (p.resolvedAt == null) return false;
|
||||
return p.age - p.resolvedAt >= holdDuration();
|
||||
}
|
||||
|
||||
function updateMachine(dt) {
|
||||
if (!machine.on || !goalie) return;
|
||||
|
||||
machine.cooldown -= dt;
|
||||
if (machine.cooldown <= 0 && puckReadyForNext()) {
|
||||
fireShot();
|
||||
machine.cooldown = MACHINE.interval;
|
||||
}
|
||||
|
||||
stepPuck(dt);
|
||||
|
||||
// Drive the real goalie brain off the live puck while it is in play.
|
||||
// During a catch, track the *frozen* contact point — never the disc pinned
|
||||
// to the glove (body yaw would chase the hand and spin).
|
||||
let track;
|
||||
if (machine.caught && machine.catchTrack) {
|
||||
track = machine.catchTrack;
|
||||
} else if (machine.puck && machine.puck.resolvedAt == null) {
|
||||
track = machine.puck.pos;
|
||||
} else if (machine.puck && machine.lastSaveKind === 'block' && machine.puck.resolvedAt != null) {
|
||||
// Still look at the rebound briefly.
|
||||
track = machine.puck.pos;
|
||||
} else {
|
||||
track = {
|
||||
x: goalLineX(MACHINE_END) - MACHINE_END * 8,
|
||||
y: 0.4,
|
||||
z: 0,
|
||||
};
|
||||
}
|
||||
goalie.update(dt, track);
|
||||
// Pin after IK so the disc sits in the trapper pocket, not where contact was.
|
||||
pinCaughtPuck();
|
||||
}
|
||||
|
||||
// ---- pose catalogs --------------------------------------------------------
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user