Initial commit

This commit is contained in:
ryanfitzpatrickio
2026-08-03 06:43:21 -05:00
commit 7ee3e9d02f
63 changed files with 15792 additions and 0 deletions
+292
View File
@@ -0,0 +1,292 @@
import * as THREE from 'three';
import { E, clamp, smooth } from '../core/math.js';
import {
FOOT_Y,
GOALIE_BONES,
GOALIE_LEGS,
GOALIE_UPPER,
poseButterfly,
poseReach,
poseReady,
poseShuffle,
} from './poses/goalie.js';
/**
* 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).
*/
export function buildGoalieAnimator(skelData, mover) {
const B = skelData.bones;
const LEN = {
thigh: B.shinL.position.length(),
shin: B.footL.position.length(),
};
const restThighDir = {
L: B.shinL.position.clone().normalize(),
R: B.shinR.position.clone().normalize(),
};
const restShinDir = {
L: B.footL.position.clone().normalize(),
R: B.footR.position.clone().normalize(),
};
function newPose() {
const p = {
q: {},
rootOffset: new THREE.Vector3(),
rootQuat: new THREE.Quaternion(),
feet: {
L: { x: 0.28, z: 0.05, yaw: 0.15 },
R: { x: -0.28, z: 0.05, yaw: -0.15 },
},
};
for (const n of GOALIE_BONES) p.q[n] = new THREE.Quaternion();
return p;
}
const cur = newPose();
const frozen = newPose();
const anim = {
state: 'ready',
blend: 1,
BLEND_TIME: 0.16,
transitionTime: 0.16,
time: 0,
stateTime: 0,
speed: 1,
origin: new THREE.Vector3(),
originYaw: 0,
moveSpeed: 0,
lateralVel: 0,
puckHeight: 0.05,
puckDist: 8,
threatened: 0,
/** Goalie paddle group, parented to handR. Grip is adjusted per stance. */
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.020.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();
function applyMover() {
mover.position.copy(anim.origin);
mover.rotation.set(0, anim.originYaw, 0);
}
anim.setTransform = function setTransform(position, yaw) {
anim.origin.copy(position);
anim.originYaw = yaw;
};
function snapshot() {
for (const n of GOALIE_BONES) frozen.q[n].copy(B[n].quaternion);
frozen.rootOffset.copy(B.root.position);
frozen.rootQuat.copy(B.root.quaternion);
if (cur.feet) {
frozen.feet.L = { ...cur.feet.L };
frozen.feet.R = { ...cur.feet.R };
}
}
anim.setState = function setState(name, blendTime = null) {
if (name === anim.state) return;
snapshot();
anim.state = name;
anim.stateTime = 0;
anim.blend = 0;
anim.transitionTime = blendTime ?? anim.BLEND_TIME;
};
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;
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';
}
// ---- two-bone leg IK (same pattern as the skater) ------------------------
const _H = new THREE.Vector3();
const _d = new THREE.Vector3();
const _pole = new THREE.Vector3();
const _e2 = new THREE.Vector3();
const _knee = new THREE.Vector3();
const _dir = new THREE.Vector3();
const _f = new THREE.Vector3();
const _r = new THREE.Vector3();
const _qP = new THREE.Quaternion();
const _q1 = new THREE.Quaternion();
const _q2 = new THREE.Quaternion();
const _qF = new THREE.Quaternion();
const _qInv = new THREE.Quaternion();
const _worldFoot = new THREE.Vector3();
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));
function solveLeg(side, localX, localZ, toeYaw) {
const thigh = B['thigh' + side];
const shin = B['shin' + side];
const foot = B['foot' + side];
// Local foot → world via the mover (already at originYaw).
_worldFoot.set(localX, FOOT_Y, localZ).applyMatrix4(mover.matrixWorld);
_worldFoot.y = FOOT_Y;
thigh.getWorldPosition(_H);
_d.subVectors(_worldFoot, _H);
let d = _d.length();
const a = LEN.thigh;
const b = LEN.shin;
d = clamp(d, 0.12, a + b - 0.003);
_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));
fwdOf(anim.originYaw, _f);
rightOf(anim.originYaw, _r);
// Knee pole: forward and outward so butterfly pads open, not knock-knees.
_pole.copy(_f).addScaledVector(_r, side === 'L' ? 0.55 : -0.55);
_pole.y -= 0.15;
_e2.copy(_pole).addScaledVector(_d, -_pole.dot(_d));
if (_e2.lengthSq() < 1e-8) _e2.copy(_f);
_e2.normalize();
_knee.copy(_H).addScaledVector(_d, a * cosA).addScaledVector(_e2, a * sinA);
_dir.subVectors(_knee, _H).normalize();
_q1.setFromUnitVectors(restThighDir[side], _dir);
thigh.parent.getWorldQuaternion(_qP);
_qInv.copy(_qP).invert();
thigh.quaternion.copy(_qInv).multiply(_q1);
_dir.subVectors(_worldFoot, _knee).normalize();
_q2.setFromUnitVectors(restShinDir[side], _dir);
_qInv.copy(_q1).invert();
shin.quaternion.copy(_qInv).multiply(_q2);
const worldYaw = anim.originYaw + toeYaw;
E(_qF, 0, worldYaw, 0, 'YXZ');
_qInv.copy(_q2).invert();
foot.quaternion.copy(_qInv).multiply(_qF);
B['toe' + side].quaternion.identity();
}
anim.update = function update(dt) {
dt *= anim.speed;
anim.time += dt;
anim.stateTime += dt;
anim.blend = Math.min(1, anim.blend + dt / anim.transitionTime);
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 });
}
const w = smooth(anim.blend);
for (const n of GOALIE_UPPER) {
B[n].quaternion.slerpQuaternions(frozen.q[n], cur.q[n], w);
}
// Legs identity mid-blend then IK — slerping free leg eulers fights the IK.
for (const n of GOALIE_LEGS) B[n].quaternion.identity();
B.root.position.lerpVectors(frozen.rootOffset, cur.rootOffset, w);
B.root.quaternion.slerpQuaternions(frozen.rootQuat, cur.rootQuat, w);
mover.updateMatrixWorld(true);
// Blend foot targets in mover-local space, then 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),
};
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),
};
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);
}
mover.updateMatrixWorld(true);
};
function lerp(a, b, t) {
return a + (b - a) * t;
}
// Seed frozen from a ready pose so the first frame has real foot targets.
poseReady(frozen, { lean: 0, t: 0 });
poseReady(cur, { lean: 0, t: 0 });
for (const n of GOALIE_UPPER) B[n].quaternion.copy(frozen.q[n]);
for (const n of GOALIE_LEGS) B[n].quaternion.identity();
B.root.position.copy(frozen.rootOffset);
B.root.quaternion.copy(frozen.rootQuat);
applyMover();
mover.updateMatrixWorld(true);
solveLeg('L', frozen.feet.L.x, frozen.feet.L.z, frozen.feet.L.yaw);
solveLeg('R', frozen.feet.R.x, frozen.feet.R.z, frozen.feet.R.yaw);
return anim;
}
+173
View File
@@ -0,0 +1,173 @@
import { E } from '../../core/math.js';
import { clamp } from '../../../shared/scalar.js';
/**
* Goalie pose authoring.
*
* Upper body + root only. Feet are world targets the animator solves with the
* same two-bone IK the skater uses — free eulers on the legs put the pads in
* the air or through the ice the moment the root drops. Measured rest feet sit
* at y ≈ 0.07; every stance keeps them there.
*/
/** Bones written by the pose layer (legs are IK'd after). */
export const GOALIE_UPPER = [
'pelvis', 'spine1', 'spine2', 'spine3', 'neck', 'head',
'clavicleL', 'upperArmL', 'forearmL', 'handL',
'clavicleR', 'upperArmR', 'forearmR', 'handR',
];
export const GOALIE_LEGS = [
'thighL', 'shinL', 'footL', 'toeL',
'thighR', 'shinR', 'footR', 'toeR',
];
export const GOALIE_BONES = GOALIE_UPPER.concat(GOALIE_LEGS);
/** Foot sole height, metres. */
export const FOOT_Y = 0.085;
/**
* Ready stance foot targets in mover-local space.
* Open base like the equipment ref (half-butterfly ready), not a narrow crouch.
*/
export const FEET_READY = {
L: { x: 0.42, z: 0.02, yaw: 0.35 },
R: { x: -0.42, z: 0.02, yaw: -0.35 },
};
/**
* Butterfly foot targets: pads flared, feet out to the sides, still on ice.
* Width ~1.2 m so the pad faces cover the five-hole like the ref.
*/
export const FEET_BUTTERFLY = {
L: { x: 0.62, z: -0.06, yaw: 0.65 },
R: { x: -0.62, z: -0.06, yaw: -0.65 },
};
/**
* Ready: deep knee bend, chest up enough to track the puck, trapper open at
* the side, blocker + paddle down over the five-hole.
*/
export function poseReady(P, { lean = 0, t = 0 } = {}) {
const breath = Math.sin(t * 1.5) * 0.01;
const s = clamp(lean, -1, 1);
// Soft forward crouch; head counters so eyes stay on the play.
E(P.q.pelvis, 0.16 + breath, s * 0.06, -s * 0.1);
E(P.q.spine1, 0.14, -s * 0.05, -s * 0.06);
E(P.q.spine2, 0.1, -s * 0.04, -s * 0.05);
E(P.q.spine3, 0.06, -s * 0.03, -s * 0.03);
E(P.q.neck, -0.18, s * 0.08, 0);
E(P.q.head, -0.12, s * 0.1, 0);
// Trapper: out beside the hip, pocket toward the shooter (ref photo).
E(P.q.clavicleL, 0.06, 0.14, -0.12);
E(P.q.upperArmL, -0.35, 0.85, -0.55);
E(P.q.forearmL, -1.0, -0.1, 0.3);
E(P.q.handL, -0.1, 0.4, 0.55);
// Blocker + stick: low over the five-hole so the paddle can sit on the ice.
E(P.q.clavicleR, 0.04, -0.1, 0.1);
E(P.q.upperArmR, -0.95, -0.45, 0.45);
E(P.q.forearmR, -0.55, 0.15, 0.1);
E(P.q.handR, -0.2, 0.05, -0.2);
// Seed legs (IK overwrites thighs/shins/feet).
for (const n of GOALIE_LEGS) P.q[n].identity();
// Hips low enough that the pad faces fill the lower net (ref ready).
P.rootOffset.set(s * 0.03, -0.28 + breath * 0.25, 0.02);
E(P.rootQuat, 0.06, 0, -s * 0.08);
P.feet = {
L: { ...FEET_READY.L, x: FEET_READY.L.x + s * 0.04 },
R: { ...FEET_READY.R, x: FEET_READY.R.x + s * 0.04 },
};
}
/**
* Butterfly: torso stays tracking; feet flare wide on the ice via IK.
*/
export function poseButterfly(P, { lean = 0, t = 0 } = {}) {
const s = clamp(lean, -1, 1);
E(P.q.pelvis, 0.1, s * 0.08, -s * 0.14);
E(P.q.spine1, 0.22, -s * 0.06, -s * 0.08);
E(P.q.spine2, 0.16, -s * 0.05, -s * 0.06);
E(P.q.spine3, 0.1, -s * 0.04, -s * 0.04);
E(P.q.neck, -0.22, s * 0.1, 0);
E(P.q.head, -0.14, s * 0.12, 0);
// Arms stay active above the pads.
E(P.q.clavicleL, 0.08, 0.12, -0.1);
E(P.q.upperArmL, -0.25, 0.75, -0.75);
E(P.q.forearmL, -0.95, -0.1, 0.3);
E(P.q.handL, -0.1, 0.4, 0.5);
E(P.q.clavicleR, 0.06, -0.1, 0.08);
E(P.q.upperArmR, -0.35, -0.55, 0.55);
E(P.q.forearmR, -0.85, 0.15, 0.12);
E(P.q.handR, -0.12, 0.12, -0.18);
for (const n of GOALIE_LEGS) P.q[n].identity();
// Drop the hips so the pad faces can meet the ice when feet are wide.
P.rootOffset.set(s * 0.04, -0.48, 0.0);
E(P.rootQuat, 0.04, 0, -s * 0.1);
P.feet = {
L: { ...FEET_BUTTERFLY.L, x: FEET_BUTTERFLY.L.x + s * 0.05 },
R: { ...FEET_BUTTERFLY.R, x: FEET_BUTTERFLY.R.x + s * 0.05 },
};
}
/**
* Lateral shuffle: ready upper body, feet shift toward the push side.
*/
export function poseShuffle(P, { dir = 1, effort = 0.6, t = 0 } = {}) {
const d = dir >= 0 ? 1 : -1;
const e = clamp(effort, 0, 1);
poseReady(P, { lean: d * 0.45 * e, t });
E(P.q.pelvis, 0.14, d * 0.12 * e, -d * 0.18 * e);
E(P.q.spine1, 0.12, -d * 0.08 * e, -d * 0.1 * e);
// Lead foot steps out; trail foot loads under the hip.
const lead = d > 0 ? 'R' : 'L'; // dir +1 = toward X = right foot leads
const trail = lead === 'L' ? 'R' : 'L';
P.feet = {
L: { ...FEET_READY.L },
R: { ...FEET_READY.R },
};
P.feet[lead].x += d > 0 ? -0.12 * e : 0.12 * e;
P.feet[lead].z += 0.04 * e;
P.feet[trail].x += d > 0 ? 0.06 * e : -0.06 * e;
P.rootOffset.set(d * 0.06 * e, -0.22, 0.03);
E(P.rootQuat, 0.08, 0, -d * 0.12 * e);
}
/**
* High save reach. Feet stay in ready; one arm drives up.
*/
export function poseReach(P, { side = -1, up = 0.7, lean = 0, t = 0 } = {}) {
poseReady(P, { lean, t });
const u = clamp(up, 0, 1);
if (side < 0) {
E(P.q.clavicleL, -0.1 * u, 0.18 * u, -0.14 * u);
E(P.q.upperArmL, -0.4 + 1.15 * u, 0.65 + 0.25 * u, -0.65 - 0.35 * u);
E(P.q.forearmL, -1.05 + 0.65 * u, -0.15, 0.25);
E(P.q.handL, -0.1, 0.4, 0.5);
E(P.q.spine2, 0.1 - 0.06 * u, 0.1 * u, 0.05 * u);
} else {
E(P.q.clavicleR, -0.1 * u, -0.18 * u, 0.14 * u);
E(P.q.upperArmR, -0.85 + 1.25 * u, -0.3 - 0.3 * u, 0.4 + 0.3 * u);
E(P.q.forearmR, -0.7 + 0.5 * u, 0.12, 0.08);
E(P.q.handR, -0.15, 0.12, -0.12);
E(P.q.spine2, 0.1 - 0.06 * u, -0.1 * u, -0.05 * u);
}
P.rootOffset.y = -0.22 - 0.03 * u;
}
+135
View File
@@ -0,0 +1,135 @@
import { E } from '../../core/math.js';
import { clamp, lerp } from '../../../shared/scalar.js';
/**
* Upper-body authoring for skating.
*
* Split out from the animator for the same reason Ludus splits its stance
* poses: the runtime concerns (foot path, IK, blending) are fiddly and stable,
* while these numbers are pure feel and get tuned constantly.
*
* Everything keys off four scalars the sim already produces:
* gait 0..1 how much of a stride is being thrown (from effort + speed)
* speed m/s planar
* bank rad lean into the current turn, signed (+ = turning right)
* phase 0..1 stride cycle position
*/
export const SKATE_POSE = {
/** Knee bend at a standstill and at a full stride, in metres of root drop. */
crouchIdle: 0.1,
crouchStride: 0.26,
/**
* Forward pitch, radians, at a standstill and at speed.
*
* This is the *root* pitch; the spine adds roughly another half of it on top
* as it stacks up the chain, so the finished torso angle is around 1.5x these
* numbers. Authoring the final angle here instead would mean re-tuning every
* time a spine joint changed.
*/
leanIdle: 0.08,
leanFast: 0.3,
/** How much of the bank the torso takes; the rest is absorbed by the legs. */
bankTorso: 0.7,
/** Head stays closer to level than the body — a skater looks up the ice. */
bankHeadCounter: 0.55,
/** Arm swing amplitude, radians, at a full stride. */
armSwing: 0.72,
/** Elbow bend: skaters carry their hands, they don't run with straight arms. */
elbow: -0.62,
/** Roll that pulls the arms in from the skeleton's rest A-pose. */
armTuck: 0.34,
/** Hip / shoulder counter-rotation with the stride. */
hipTwist: 0.2,
shoulderTwist: 0.26,
};
/**
* The moving pose: crouched, pitched forward, twisting against the stride.
*
* `P` is the animator's pose buffer — quaternions per bone plus a root offset.
* Foot targets are not written here; they are world-space and belong to the
* stepper.
*/
export function poseSkate(P, { gait, speed, bank, phase, t }) {
const K = SKATE_POSE;
const fast = clamp(speed / 7, 0, 1);
const s1 = Math.sin(phase * Math.PI * 2);
const s2 = Math.sin(phase * Math.PI * 4);
// Idle breathing, so a stopped skater is not a statue.
const idle = (1 - gait) * Math.sin(t * 1.6) * 0.02;
const crouch = lerp(K.crouchIdle, K.crouchStride, gait) + idle;
const pitch = lerp(K.leanIdle, K.leanFast, fast);
const twist = K.hipTwist * gait;
// Pelvis rocks with the push — the hip on the pushing side drops and rotates
// open, which is most of what makes a stride read as a stride and not a run.
E(P.q.pelvis, pitch * 0.18, twist * s1, -bank * 0.25 + gait * 0.05 * s1);
E(P.q.spine1, pitch * 0.3, -twist * 0.35 * s1, -bank * K.bankTorso * 0.3);
E(P.q.spine2, pitch * 0.3, -twist * 0.45 * s1, -bank * K.bankTorso * 0.35);
E(P.q.spine3, pitch * 0.22 + idle, -K.shoulderTwist * gait * s1, -bank * K.bankTorso * 0.25);
// Neck and head pull back up: the torso is folded forward, the eyes are not.
E(P.q.neck, -pitch * 0.5, 0, bank * K.bankHeadCounter * 0.4);
E(P.q.head, -pitch * 0.42, K.shoulderTwist * 0.3 * gait * s1, bank * K.bankHeadCounter * 0.6);
// Arms swing opposite the legs and slightly across the chest. Amplitude is
// pure gait: a gliding skater's hands barely move.
// The rest skeleton is an A-pose, so the arms already sit ~30° off the body.
// Roll about local Z is what brings them in, and its sign is mirrored: the
// left arm tucks on negative Z, the right on positive. Getting that backwards
// is what turns a skater into a scarecrow, so it is written as `-m` once here
// rather than as a per-side constant.
const swing = K.armSwing * gait;
for (const side of ['L', 'R']) {
const m = side === 'L' ? 1 : -1;
const armPhase = side === 'L' ? s1 : -s1;
E(P.q[`clavicle${side}`], 0.04, 0, -m * (0.04 + 0.05 * gait));
E(
P.q[`upperArm${side}`],
// Shoulders sit forward of the ribs at speed, hands ahead of the chest.
-0.45 - 0.35 * fast + swing * armPhase,
m * (0.1 + 0.12 * gait),
-m * K.armTuck,
);
E(P.q[`forearm${side}`], K.elbow - 0.25 * gait - Math.abs(armPhase) * 0.12 * gait, 0, -m * 0.1);
E(P.q[`hand${side}`], -0.1, 0, -m * 0.06);
}
// Vertical bob is small and at twice the stride rate: the body rises over
// each push, not once per cycle.
P.rootOffset.set(-bank * 0.06, -crouch + gait * 0.018 * s2, gait * 0.02);
E(P.rootQuat, pitch, 0, -bank);
}
/**
* Hockey stop: both blades thrown across the direction of travel, weight
* dropped hard onto them, shoulders squared back up the ice.
*
* `dir` is +1 or -1 for which shoulder leads, so a stop has a side to it.
*/
export function poseStop(P, { speed, dir, t }) {
const bite = clamp(speed / 6, 0.25, 1);
const shake = Math.sin(t * 22) * 0.012 * bite;
E(P.q.pelvis, 0.12, dir * 0.55 * bite, dir * 0.18 * bite);
E(P.q.spine1, 0.16 + shake, -dir * 0.18 * bite, -dir * 0.12 * bite);
E(P.q.spine2, 0.16 + shake, -dir * 0.2 * bite, -dir * 0.14 * bite);
E(P.q.spine3, 0.1, -dir * 0.16 * bite, -dir * 0.1 * bite);
E(P.q.neck, -0.24, -dir * 0.2 * bite, 0);
E(P.q.head, -0.18, -dir * 0.24 * bite, 0);
for (const side of ['L', 'R']) {
const m = side === 'L' ? 1 : -1;
// Hands come out for balance against the deceleration — the one pose where
// the arms should leave the body, so the tuck roll relaxes toward zero.
E(P.q[`clavicle${side}`], 0, 0, -m * 0.04);
E(P.q[`upperArm${side}`], -0.72 * bite, m * 0.16, -m * (0.3 - 0.28 * bite));
E(P.q[`forearm${side}`], -0.5 - 0.3 * bite, 0, -m * 0.12);
E(P.q[`hand${side}`], -0.12, 0, 0);
}
// Deep sit into the stop, hips back over the heels.
P.rootOffset.set(dir * 0.05 * bite, -(0.2 + 0.12 * bite), -0.06 * bite);
E(P.rootQuat, 0.12, 0, dir * 0.28 * bite);
}
+223
View File
@@ -0,0 +1,223 @@
import { E } from '../../core/math.js';
import { clamp, lerp } from '../../../shared/scalar.js';
/**
* Upper-body authoring for everything done with the stick.
*
* These are *override* poses, not whole-body states. They write the arms and
* some spine, and the animator blends them over the skating pose by a weight —
* because you keep skating while you shoot, and a shot that stopped the legs
* would read as a cutscene.
*
* Each one is a function of a single phase 0..1 so the animator can drive it
* from a timer, hold it (wind-up), or run it once and blend out (shoot, pass,
* poke). The right arm carries the stick; the left joins it for two-handed
* work and is pinned onto the shaft by IK afterwards, so what is authored here
* for the left side is only a starting guess that the IK refines.
*/
/**
* Bones the stickwork layer *replaces*. The arms belong to the stick whenever
* it is being used — there is no meaningful blend between "swinging with the
* stride" and "holding a stick", they are different arms.
*/
export const STICK_ARMS = [
'clavicleR', 'upperArmR', 'forearmR', 'handR',
'clavicleL', 'upperArmL', 'forearmL', 'handL',
];
/**
* Bones the layer *adds to*. The spine is already carrying the skating lean and
* the bank; a shot's coil is a twist on top of that, not instead of it.
* Replacing these was what flattened the forward lean the moment a stick
* appeared, and stood everybody up.
*/
export const STICK_SPINE = ['spine1', 'spine2', 'spine3', 'neck', 'head'];
export const STICK_BONES = STICK_ARMS.concat(STICK_SPINE);
/**
* The neutral carry, and the hustle variant.
*
* `hustle` 0..1 slides between two-hands-ready and the one-handed dangle a
* skater falls into when they are just trying to move: the stick goes out in
* front, the left arm leaves it and swings with the stride.
*
* Carry is authored from the motion-reference sheet (ready stance / puck carry):
* both hands in front of the torso, shaft angled down to the ice, blade a
* little to the forehand side — not parked out on the hip with the off-hand
* floating. The right arm has to sit close enough that the left can actually
* reach the shaft: the arm is only ~0.54 m long, so a top hand 40 cm off
* centre puts the stick out of reach no matter what the IK does.
*/
export function poseCarry(P, { hustle = 0, reach = 0, lateral = 0 }) {
const h = clamp(hustle, 0, 1);
// Skill Stick +X is "push right"; bone +X is the skater's left. Negate so
// the arms lean the same way the blade goes.
const side = -lateral;
// Right arm: top hand. Across the body and out in front at about waist /
// lower-chest height. Hustle extends it forward and frees the left side.
// Roll signs are mirrored: right arm tucks on *positive* Z, left on negative.
E(P.q.clavicleR, 0.03, lerp(-0.04, -0.1, h), 0.06);
E(
P.q.upperArmR,
lerp(-0.42, -0.7, h) + reach * 0.2,
lerp(0.42, 0.05, h) + side * 0.28,
lerp(0.68, 0.38, h),
);
E(
P.q.forearmR,
lerp(-1.35, -0.75, h) - reach * 0.15,
lerp(0.02, 0.12, h),
lerp(0.32, 0.16, h),
);
E(P.q.handR, lerp(-0.12, -0.08, h), lerp(0.12, 0.04, h), lerp(0.04, -0.12, h));
// Left arm: lower hand on the shaft when settled. Seeded near the stick so
// the IK only has to finish the last few centimetres, not haul it across the
// body. At full hustle it leaves the stick and opens for the stride swing.
E(P.q.clavicleL, 0.03, lerp(0.06, 0.02, h), lerp(-0.06, -0.02, h));
E(
P.q.upperArmL,
lerp(-0.38, -0.66, h) + reach * 0.12,
lerp(0.1, 0.16, h) + side * 0.18,
lerp(-0.48, -0.2, h),
);
E(
P.q.forearmL,
lerp(-1.32, -0.72, h),
lerp(-0.08, 0, h),
lerp(0.18, 0.08, h),
);
E(P.q.handL, -0.1, 0, 0.08 * (1 - h));
// Soft coil over the stick when both hands are on it; opens up when hustling.
E(P.q.spine1, 0.02 * h, lerp(-0.04, 0.02, h) + side * 0.05, 0);
E(P.q.spine2, 0.02 * h, lerp(-0.05, 0.02, h) + side * 0.05, 0);
E(P.q.spine3, 0.02, lerp(-0.04, 0, h), 0);
}
/**
* Wind-up. `phase` 0..1 is how loaded the shot is, and it is *held* — the
* animator parks here for as long as the Skill Stick is pulled back.
*
* Hands high and back, stick raised behind the head — not hanging blade-down
* from waist height. The torso coils open so the follow-through has something
* to spend.
*/
export function poseWindup(P, { phase = 0, aim = 0 }) {
const w = clamp(phase, 0, 1);
// Aim on the Skill Stick is "push right"; bone +Y twist toward the skater's
// left is the opposite sign.
const side = -aim;
// Torso coils open, loading the shot side.
E(P.q.spine1, -0.06 - 0.08 * w, -0.18 - 0.42 * w + side * 0.08, -0.05 * w);
E(P.q.spine2, -0.07 - 0.1 * w, -0.22 - 0.48 * w + side * 0.1, -0.06 * w);
E(P.q.spine3, -0.04 - 0.07 * w, -0.18 - 0.38 * w + side * 0.08, -0.04 * w);
// Eyes stay on the target while the body turns away from it.
E(P.q.neck, 0.04, 0.28 + 0.42 * w - side * 0.2, 0);
E(P.q.head, 0.04, 0.22 + 0.32 * w - side * 0.25, 0);
// Top hand: high and back, roughly shoulder/head height, so the aimed stick
// can sit up behind the head instead of dangling at the hip.
E(P.q.clavicleR, -0.1 * w, -0.22 * w, -0.1);
E(P.q.upperArmR, 0.15 + 0.65 * w, -0.55 - 0.35 * w + side * 0.15, -0.55 - 0.35 * w);
E(P.q.forearmR, -0.45 - 0.25 * w, 0.22, -0.12);
E(P.q.handR, -0.05, 0.2, 0.22);
// Lower hand comes up with it; IK pins it to the shaft.
E(P.q.clavicleL, 0.04, 0.12 * w, 0.08);
E(P.q.upperArmL, -0.35 - 0.1 * w, 0.45 + 0.2 * w, 0.4 + 0.15 * w);
E(P.q.forearmL, -0.85 - 0.15 * w, -0.18, -0.12);
E(P.q.handL, -0.08, 0, -0.1);
}
/**
* Follow-through. `phase` 0..1 runs once, fast.
*
* The coil released: the torso whips through the shot, the stick sweeps across
* and finishes high. Front-loaded easing, so the contact reads at the start of
* the animation rather than in the middle of it.
*/
export function poseShot(P, { phase = 0, power = 1, aim = 0 }) {
const t = clamp(phase, 0, 1);
// Fast out of the coil, then settle.
const s = 1 - (1 - t) * (1 - t);
const p = clamp(power, 0.2, 1);
const twist = lerp(-0.42 * p, 0.44 * p, s);
E(P.q.spine1, -0.06 + 0.12 * s, twist * 0.9, 0.04 * s);
E(P.q.spine2, -0.07 + 0.14 * s, twist, 0.05 * s);
E(P.q.spine3, -0.05 + 0.1 * s, twist * 0.8, 0.03 * s);
E(P.q.neck, 0.02, -twist * 0.5 + aim * 0.2, 0);
E(P.q.head, 0.02, -twist * 0.4 + aim * 0.25, 0);
// Top hand drives through and finishes high across the body.
E(P.q.clavicleR, lerp(-0.05, 0.04, s), lerp(-0.14, 0.1, s), -0.06);
E(P.q.upperArmR, lerp(0.3, -1.05 * p, s), lerp(-0.94, 0.3, s), lerp(-0.72, -0.1, s));
E(P.q.forearmR, lerp(-1.12, -0.42, s), 0.16, -0.1);
E(P.q.handR, -0.1, 0.1, 0.16);
E(P.q.clavicleL, 0.03, lerp(0.1, -0.04, s), 0.06);
E(P.q.upperArmL, lerp(-0.86, -0.3, s), lerp(0.66, 0.12, s), lerp(0.4, 0.5, s));
E(P.q.forearmL, lerp(-1.36, -0.6, s), -0.28, -0.2);
E(P.q.handL, -0.08, 0, -0.14);
}
/**
* Pass: a flat sweep, no coil and no lift. Shorter and lower than a shot,
* because a pass that looks like a shot makes the two impossible to read apart
* at a glance — which matters more for a teammate watching than for the passer.
*/
export function posePass(P, { phase = 0, aim = 0 }) {
const t = clamp(phase, 0, 1);
const s = Math.sin(t * Math.PI); // out and back
const sweep = lerp(-0.18, 0.34, 1 - (1 - t) * (1 - t));
E(P.q.spine1, 0.03 * s, sweep * 0.6, 0);
E(P.q.spine2, 0.04 * s, sweep * 0.7, 0);
E(P.q.spine3, 0.03 * s, sweep * 0.5, 0);
E(P.q.neck, 0, -sweep * 0.4 + aim * 0.2, 0);
E(P.q.head, 0, -sweep * 0.3 + aim * 0.2, 0);
E(P.q.clavicleR, 0.02, -0.04, -0.05);
E(P.q.upperArmR, -0.34 - 0.3 * s, -0.34 + sweep * 0.5, -0.4 - 0.12 * s);
E(P.q.forearmR, -0.72 - 0.24 * s, 0.12, -0.12);
E(P.q.handR, -0.12, 0.06, 0.18);
E(P.q.clavicleL, 0.02, 0.05, 0.05);
E(P.q.upperArmL, -0.58 - 0.18 * s, 0.36 + sweep * 0.3, 0.3);
E(P.q.forearmL, -1.06 - 0.16 * s, -0.24, -0.18);
E(P.q.handL, -0.1, 0, -0.12);
}
/**
* Poke check: a stab. One hand, the whole arm extending forward with the body
* reaching after it, back almost as fast as it went out.
*/
export function posePoke(P, { phase = 0 }) {
const t = clamp(phase, 0, 1);
// Out fast, back slower.
const s = t < 0.35 ? t / 0.35 : 1 - (t - 0.35) / 0.65;
const jab = clamp(s, 0, 1);
E(P.q.spine1, 0.06 * jab, -0.14 * jab, 0);
E(P.q.spine2, 0.07 * jab, -0.18 * jab, 0);
E(P.q.spine3, 0.05 * jab, -0.14 * jab, 0);
E(P.q.neck, -0.04 * jab, 0.1 * jab, 0);
E(P.q.head, -0.04 * jab, 0.1 * jab, 0);
// Right arm thrusts out and down toward the ice.
E(P.q.clavicleR, 0.02 + 0.06 * jab, -0.06 - 0.16 * jab, -0.05);
E(P.q.upperArmR, -0.34 - 0.5 * jab, -0.28 - 0.12 * jab, -0.36 + 0.14 * jab);
E(P.q.forearmR, -0.78 + 0.66 * jab, 0.1, -0.12);
E(P.q.handR, -0.12, 0.06, 0.18);
// Left arm comes off the stick and back for balance.
E(P.q.clavicleL, 0.02, 0.04, 0.04);
E(P.q.upperArmL, -0.5 + 0.2 * jab, 0.28 - 0.2 * jab, 0.34 + 0.16 * jab);
E(P.q.forearmL, -0.9 + 0.3 * jab, -0.16, -0.14);
E(P.q.handL, -0.1, 0, -0.1);
}
+674
View File
@@ -0,0 +1,674 @@
import * as THREE from 'three';
import { E, clamp, segDist, smooth } from '../core/math.js';
import { lerp, lerpAngle } from '../../shared/scalar.js';
import { poseSkate, poseStop } from './poses/skate.js';
import {
STICK_ARMS, STICK_BONES, STICK_SPINE,
poseCarry, posePass, posePoke, poseShot, poseWindup,
} from './poses/stickwork.js';
import { STICK } from '../character/stick.js';
/**
* Skating animator.
*
* Same architecture as the Ludus fighter animator — a pose buffer that states
* write into, crossfaded on state changes, with two-bone analytic leg IK
* resolving world-space foot targets — with two deliberate differences.
*
* 1. It does not integrate movement. In Ludus the animator owned the fighter's
* position; here the sim plus the Box3D proxy own it, and the animator is
* told where the body ended up (`setTransform`). Anything else would have
* the pose fighting the collision response.
*
* 2. The feet are authored in *mover-local* space rather than planted in world
* space. That is not a shortcut: a walking foot is stationary while it bears
* weight, but a skate is gliding the entire time, including through the
* push. Planting it would be the thing that made this read as running on
* ice, which is exactly the failure mode we are trying to avoid.
*
* States exist so the next spike can add `shoot` / `stickhandle` and get the
* crossfade for free. Today there are two: `skate` and `stop`.
*/
/** How far the Skill Stick can push the blade around the carrier, metres. */
const STICK_REACH = { side: 0.5, fwd: 0.34 };
/** Foot joint height above the ice — boot plus blade. */
const FOOT_SOLE = 0.085;
const STRIDE = {
/** Fraction of the cycle the leg spends pushing rather than recovering. */
pushFrac: 0.55,
/** Half the width of a neutral glide stance, metres. */
narrow: 0.105,
/** How far out to the side a full push extends the blade. */
reachSide: 0.3,
/** Fore/aft travel of the blade through a push. */
reachFwd: 0.16,
reachAft: 0.26,
/** Blade clearance on the recovery. Skates barely leave the ice. */
lift: 0.07,
/** Toe flare — the V a skater's blades make as the leg extends. */
toeOut: 0.55,
toeGlide: 0.12,
/** Stride cycle at a standstill and at top speed, seconds. */
cycleSlow: 1.15,
cycleFast: 0.6,
};
export function buildAnimator(skelData, mover) {
const B = skelData.bones;
const LEN = { thigh: B.shinL.position.length(), shin: B.footL.position.length() };
const restThighDir = { L: B.shinL.position.clone().normalize(), R: B.shinR.position.clone().normalize() };
const restShinDir = { L: B.footL.position.clone().normalize(), R: B.footR.position.clone().normalize() };
const UPPER = [
'pelvis', 'spine1', 'spine2', 'spine3', 'neck', 'head',
'clavicleL', 'upperArmL', 'forearmL', 'handL',
'clavicleR', 'upperArmR', 'forearmR', 'handR',
];
const LEGS = ['thighL', 'shinL', 'footL', 'toeL', 'thighR', 'shinR', 'footR', 'toeR'];
function newPose() {
const p = {
q: {},
rootOffset: new THREE.Vector3(),
rootQuat: new THREE.Quaternion(),
foot: { L: { pos: new THREE.Vector3(), yaw: 0 }, R: { pos: new THREE.Vector3(), yaw: 0 } },
};
for (const n of UPPER.concat(LEGS)) p.q[n] = new THREE.Quaternion();
return p;
}
const cur = newPose();
const frozen = newPose();
const anim = {
state: 'skate',
blend: 1,
BLEND_TIME: 0.22,
transitionTime: 0.22,
time: 0,
stateTime: 0,
/** Playback rate, for slow motion later. */
speed: 1,
// ---- written by the rig each frame, read by the poses -----------------
origin: new THREE.Vector3(),
originYaw: 0,
/** Planar speed, m/s. */
moveSpeed: 0,
/** Signed speed along the blade — negative means gliding backwards. */
bladeSpeed: 0,
/** How hard the skater is pushing, 0..1, straight off the sim. */
effort: 0,
/** Rate the velocity vector is turning, rad/s. Drives the bank. */
yawRate: 0,
braking: false,
// ---- derived, smoothed --------------------------------------------------
/** Stride amplitude, 0 (pure glide) .. 1 (digging in). */
gait: 0,
/** Lean into the turn, radians. Signed: positive is turning right. */
bank: 0,
stridePhase: 0,
/** Which shoulder leads a hockey stop; latched when the stop starts. */
stopDir: 1,
/** Called on each blade bite, for ice spray and audio later. */
onStride: null,
// ---- stickwork ---------------------------------------------------------
/** True while this skater has the puck. Decides the resting grip. */
hasPuck: false,
/** Skill Stick, -1..1. Moves the hands, which moves the blade. */
handling: { x: 0, y: 0 },
/** Held wind-up charge from the Skill Stick, 0..1. */
charge: 0,
/** The stick, so the animator can drive its socket and IK onto its shaft. */
stick: null,
/**
* Current stick action: null, 'windup', 'shoot', 'pass' or 'poke'.
* Wind-up is held; the other three run once and blend out.
*/
action: null,
actionTime: 0,
actionPower: 1,
actionAim: 0,
/** Eased 0..1 between the settled grip and the one-handed dangle. */
hustleGrip: 0,
};
/** How long each one-shot action runs, seconds. */
const ACTION_TIME = { shoot: 0.42, pass: 0.3, poke: 0.34 };
/** Seconds to blend the override in and out over the skating pose. */
const ACTION_BLEND = 0.09;
/** Scratch pose the action layer writes into before being blended over. */
const overlay = newPose();
const _actionSpine = new THREE.Quaternion();
const _localFoot = new THREE.Vector3();
function applyMover() {
mover.position.copy(anim.origin);
mover.rotation.set(0, anim.originYaw, 0);
}
/** Place the skater. Position and yaw come from the sim, never from here. */
anim.setTransform = function setTransform(position, yaw) {
anim.origin.copy(position);
anim.originYaw = yaw;
};
/**
* Mover-local foot target for one leg at cycle position `p`.
*
* The path is a flattened loop: out and back through the push, then in and
* forward through the recovery. Scaling the whole thing by `amp` means a
* glide collapses it to a pair of feet sitting under the hips, with no
* separate "glide" authoring to keep in sync.
*/
function strideLocal(side, p, amp, out) {
const sign = side === 'L' ? 1 : -1;
const S = STRIDE;
let x;
let z;
let y;
let toe;
if (p < S.pushFrac) {
const u = smooth(p / S.pushFrac);
x = sign * (S.narrow + S.reachSide * amp * u);
z = lerp(S.reachFwd * amp, -S.reachAft * amp, u);
y = 0;
toe = sign * (S.toeGlide + S.toeOut * amp * u);
} else {
const u = smooth((p - S.pushFrac) / (1 - S.pushFrac));
x = sign * lerp(S.narrow + S.reachSide * amp, S.narrow * 0.8, u);
z = lerp(-S.reachAft * amp, S.reachFwd * amp, u);
y = S.lift * amp * Math.sin(Math.PI * u);
toe = sign * lerp(S.toeGlide + S.toeOut * amp, S.toeGlide, u);
}
out.set(x, FOOT_SOLE + y, z);
return toe;
}
/** Local foot placement for a hockey stop: blades thrown across the travel. */
function stopLocal(side, dir, bite, out) {
const lead = side === 'L' ? 1 : -1;
out.set(
dir * (0.06 + 0.12 * bite) * (side === 'L' ? 1 : 0.4),
FOOT_SOLE,
lead * (0.19 + 0.06 * bite),
);
return dir * (0.3 + 0.9 * bite);
}
const _worldFoot = new THREE.Vector3();
/** Write a local foot target into the pose buffer as a world-space target. */
function writeFoot(P, side, local, toeYaw) {
_worldFoot.copy(local).applyMatrix4(mover.matrixWorld);
// The ice is flat, so the sole height authored locally is the world height;
// re-pin anyway so a future heightfield only has to change this line.
_worldFoot.y = local.y;
P.foot[side].pos.copy(_worldFoot);
P.foot[side].yaw = anim.originYaw + toeYaw;
}
/**
* Advance the derived, smoothed values every state shares.
*
* Smoothing lives here rather than in the sim because these are presentation
* quantities: the sim's `effort` is allowed to change instantly when the AI
* changes its mind, but a skater's legs cannot.
*/
function advanceCommon(dt) {
// Gait chases effort quickly on the way up (a push starts now) and decays
// slowly (the leg finishes its stroke).
const target = clamp(anim.effort, 0, 1);
const rate = target > anim.gait ? 5.5 : 2.2;
anim.gait = lerp(anim.gait, target, Math.min(1, rate * dt));
// Bank: the lean that balances the centripetal force of the current turn.
// atan(v·ω / g) is the real thing, and it behaves correctly at low speed —
// spinning on the spot produces no lean, which is what you want.
const bankTarget = clamp(
Math.atan2(anim.moveSpeed * anim.yawRate, 9.81),
-0.45,
0.45,
);
anim.bank = lerp(anim.bank, bankTarget, Math.min(1, 6 * dt));
// Stride rate rises with speed; a standing skater shuffles slowly.
const fast = clamp(anim.moveSpeed / 7.5, 0, 1);
const cycle = lerp(STRIDE.cycleSlow, STRIDE.cycleFast, fast);
const before = anim.stridePhase;
// Only advance while there is a stride to throw, so a long glide holds the
// legs where the last push left them instead of pedalling in mid-air.
anim.stridePhase = (anim.stridePhase + (dt / cycle) * Math.max(anim.gait, 0.06)) % 1;
// Blade bite: each leg starts its push half a cycle apart.
if (anim.onStride) {
if (before > anim.stridePhase) anim.onStride('L', anim.moveSpeed);
else if (before < 0.5 && anim.stridePhase >= 0.5) anim.onStride('R', anim.moveSpeed);
}
}
/**
* Fire a one-shot stick action. Wind-up is started and stopped explicitly
* instead, because it is held for as long as the stick is pulled back.
*/
anim.playAction = function playAction(name, { power = 1, aim = 0 } = {}) {
anim.action = name;
anim.actionTime = 0;
anim.actionPower = power;
anim.actionAim = aim;
};
/**
* Advance the stick action clock and write the override pose.
*
* Returns the blend weight, 0 when nothing is happening. Kept separate from
* the states because these are *layers*: a skater keeps striding through a
* shot, so the action owns the arms and some spine and nothing else.
*/
function advanceAction(dt) {
if (!anim.action) return 0;
anim.actionTime += dt;
if (anim.action === 'windup') {
// Held. Blends in over ACTION_BLEND and then stays until released.
const w = Math.min(1, anim.actionTime / ACTION_BLEND);
poseWindup(overlay, { phase: anim.charge, aim: anim.actionAim });
return w;
}
const duration = ACTION_TIME[anim.action] ?? 0.3;
const t = anim.actionTime / duration;
if (t >= 1) {
anim.action = null;
return 0;
}
// Snap in, ease out — a shot should look like it started the instant the
// button did, and a slow blend in front of it steals that.
const w = t > 1 - ACTION_BLEND / duration
? Math.max(0, (1 - t) * duration / ACTION_BLEND)
: Math.min(1, anim.actionTime / (ACTION_BLEND * 0.5));
const args = { phase: t, power: anim.actionPower, aim: anim.actionAim };
if (anim.action === 'shoot') poseShot(overlay, args);
else if (anim.action === 'pass') posePass(overlay, args);
else posePoke(overlay, args);
return w;
}
/** Which socket grip the stick should be using right now, and the blend. */
function gripFor() {
if (anim.action === 'windup') return ['carry', 'windup', Math.min(1, anim.actionTime / 0.16)];
if (anim.action === 'shoot') {
const t = anim.actionTime / (ACTION_TIME.shoot);
return ['windup', 'follow', Math.min(1, t / 0.45)];
}
if (anim.action === 'poke') return ['carry', 'poke', Math.min(1, anim.actionTime / 0.1)];
if (anim.action === 'pass') return ['carry', 'follow', Math.min(1, anim.actionTime / 0.2) * 0.5];
// Resting: hustling pushes the stick out in front on one hand.
return ['carry', 'hustle', anim.hustleGrip];
}
const states = {
skate: {
pre(dt) {
advanceCommon(dt);
applyMover();
mover.updateMatrixWorld(true);
},
pose(P, t) {
poseSkate(P, {
gait: anim.gait,
speed: anim.moveSpeed,
bank: anim.bank,
phase: anim.stridePhase,
t,
});
for (const side of ['L', 'R']) {
const p = (anim.stridePhase + (side === 'R' ? 0.5 : 0)) % 1;
const toe = strideLocal(side, p, anim.gait, _localFoot);
writeFoot(P, side, _localFoot, toe);
}
},
},
stop: {
blendTime: 0.12,
enter() {
// Which way the skater turns to plant depends on which edge is already
// loaded, so a stop out of a right-hand turn continues that rotation.
anim.stopDir = anim.bank >= 0 ? 1 : -1;
},
pre(dt) {
advanceCommon(dt);
applyMover();
mover.updateMatrixWorld(true);
},
pose(P, t) {
poseStop(P, { speed: anim.moveSpeed, dir: anim.stopDir, t });
const bite = clamp(anim.moveSpeed / 6, 0.25, 1);
for (const side of ['L', 'R']) {
const toe = stopLocal(side, anim.stopDir, bite, _localFoot);
writeFoot(P, side, _localFoot, toe);
}
},
},
};
function snapshot() {
for (const n of UPPER.concat(LEGS)) frozen.q[n].copy(B[n].quaternion);
frozen.rootOffset.copy(B.root.position);
frozen.rootQuat.copy(B.root.quaternion);
frozen.foot.L.pos.copy(cur.foot.L.pos);
frozen.foot.L.yaw = cur.foot.L.yaw;
frozen.foot.R.pos.copy(cur.foot.R.pos);
frozen.foot.R.yaw = cur.foot.R.yaw;
}
const _footWorld = new THREE.Vector3();
/**
* Restart the crossfade from whatever pose the skeleton is currently in.
*
* Used when physics hands the skeleton back after a knockdown: the bones are
* wherever the ragdoll left them, and the animator would otherwise snap to a
* skating pose on the next frame. Snapshotting the collapsed pose and easing
* out of it is the get-up.
*/
anim.rebase = function rebase(blendTime = 0.6) {
snapshot();
// `snapshot` takes the foot targets from `cur`, which for a skater who has
// been lying on the ice still holds wherever their blades were before the
// hit. Blending the IK out of a stale target drags the legs across the rink
// to catch up. Read the feet where they actually are instead.
mover.updateMatrixWorld(true);
for (const side of ['L', 'R']) {
B[`foot${side}`].getWorldPosition(_footWorld);
frozen.foot[side].pos.copy(_footWorld);
frozen.foot[side].yaw = anim.originYaw;
}
anim.blend = 0;
anim.transitionTime = Math.max(0.05, blendTime);
// The feet are wherever the body fell, not where the last stride put them,
// so start the stride cycle from a planted stance rather than mid-push.
anim.stridePhase = 0;
anim.gait = 0;
anim.bank = 0;
};
anim.setState = function setState(name, blendTime = null) {
if (name === anim.state || !states[name]) return;
snapshot();
anim.state = name;
anim.stateTime = 0;
anim.blend = 0;
anim.transitionTime = blendTime ?? states[name].blendTime ?? anim.BLEND_TIME;
if (states[name].enter) states[name].enter();
};
// ---- two-bone analytic IK ------------------------------------------------
// Lifted from Ludus unchanged. The knee pole is the one skating-specific
// detail: it points forward and *outward*, because a skater's knees track
// over the outside of the blade rather than straight ahead.
const _H = new THREE.Vector3();
const _d = new THREE.Vector3();
const _pole = new THREE.Vector3();
const _e2 = new THREE.Vector3();
const _knee = new THREE.Vector3();
const _dir = new THREE.Vector3();
const _f = new THREE.Vector3();
const _r = new THREE.Vector3();
const _qP = new THREE.Quaternion();
const _q1 = new THREE.Quaternion();
const _q2 = new THREE.Quaternion();
const _qF = new THREE.Quaternion();
const _qInv = 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));
function solveLeg(side, targetPos, targetYaw) {
const thigh = B['thigh' + side];
const shin = B['shin' + side];
const foot = B['foot' + side];
thigh.getWorldPosition(_H);
_d.subVectors(targetPos, _H);
let d = _d.length();
const a = LEN.thigh;
const b = LEN.shin;
d = clamp(d, 0.12, a + b - 0.003);
_d.normalize();
// Cosine rule for the angle between the thigh axis and the hip->target line.
const cosA = clamp((a * a + d * d - b * b) / (2 * a * d), -1, 1);
const sinA = Math.sqrt(Math.max(0, 1 - cosA * cosA));
fwdOf(anim.originYaw, _f);
rightOf(anim.originYaw, _r);
_pole.copy(_f).addScaledVector(_r, side === 'L' ? 0.34 : -0.34);
_pole.y -= 0.2;
_e2.copy(_pole).addScaledVector(_d, -_pole.dot(_d));
if (_e2.lengthSq() < 1e-8) _e2.copy(_f);
_e2.normalize();
_knee.copy(_H).addScaledVector(_d, a * cosA).addScaledVector(_e2, a * sinA);
_dir.subVectors(_knee, _H).normalize();
_q1.setFromUnitVectors(restThighDir[side], _dir);
thigh.parent.getWorldQuaternion(_qP);
_qInv.copy(_qP).invert();
thigh.quaternion.copy(_qInv).multiply(_q1);
_dir.subVectors(targetPos, _knee).normalize();
_q2.setFromUnitVectors(restShinDir[side], _dir);
_qInv.copy(_q1).invert();
shin.quaternion.copy(_qInv).multiply(_q2);
E(_qF, 0, targetYaw, 0, 'YXZ');
_qInv.copy(_q2).invert();
foot.quaternion.copy(_qInv).multiply(_qF);
B['toe' + side].quaternion.identity();
}
// ---- two-bone arm IK ----------------------------------------------------
// Same solver as the legs, different pole. Used only to pin the lower hand
// onto the shaft: a two-handed grip where the second hand merely hovers near
// the stick is worse than not showing it at all, and no amount of authored
// shoulder angle keeps a hand on a pole that the other arm is swinging.
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 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));
// Elbow hangs below the shoulder and a little outside the ribs.
rightOf(anim.originYaw, _r);
_pole.set(0, -1, 0).addScaledVector(_r, side === 'L' ? 0.34 : -0.34);
_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);
}
// ---- per-frame update ---------------------------------------------------
const _blendFoot = new THREE.Vector3();
const _shaftPoint = new THREE.Vector3();
const _stickTarget = new THREE.Vector3();
const _shaftA = new THREE.Vector3();
const _shaftB = new THREE.Vector3();
const _shaftDir = new THREE.Vector3();
const _handPos = new THREE.Vector3();
const _handQuat = new THREE.Quaternion();
anim.update = function update(dt) {
dt *= anim.speed;
anim.time += dt;
anim.stateTime += dt;
anim.blend = Math.min(1, anim.blend + dt / anim.transitionTime);
// A hockey stop is worth its own state; everything else is one pose driven
// by continuous parameters.
anim.setState(anim.braking && anim.moveSpeed > 1.2 ? 'stop' : 'skate');
const st = states[anim.state];
if (st.pre) st.pre(dt);
for (const n of UPPER) cur.q[n].identity();
cur.rootOffset.set(0, 0, 0);
cur.rootQuat.identity();
st.pose(cur, anim.stateTime);
// ---- stickwork layer ---------------------------------------------------
// The resting grip: hustling pushes the stick out in front on one hand,
// and it eases rather than switching, so half-throttle is half-dangled.
// With the puck, both hands stay on — the reference carry is two-handed
// even at speed; only a real one-handed dangle (no puck) opens the grip.
const hustleTarget = anim.state === 'skate' && !anim.hasPuck
? clamp(anim.effort * 0.6 + clamp(anim.moveSpeed / 7, 0, 1) * 0.6, 0, 1)
: anim.hasPuck
? clamp(anim.effort * 0.08, 0, 0.2)
: clamp(anim.effort * 0.25, 0, 1);
anim.hustleGrip = lerp(anim.hustleGrip, hustleTarget, Math.min(1, 4 * dt));
// Carry pose first — the arms holding the stick at all — then any action
// over the top of it.
//
// Arms are replaced and spine is *multiplied*. The spine already carries
// the skating lean and the bank; a shot's coil is a twist on top of that.
// Overwriting it was what stood everybody upright the moment they picked up
// a stick.
for (const n of STICK_BONES) overlay.q[n].identity();
poseCarry(overlay, {
hustle: anim.hustleGrip,
reach: anim.handling.y,
lateral: anim.handling.x,
});
for (const n of STICK_ARMS) cur.q[n].copy(overlay.q[n]);
for (const n of STICK_SPINE) cur.q[n].multiply(overlay.q[n]);
for (const n of STICK_BONES) overlay.q[n].identity();
const actionWeight = advanceAction(dt);
if (actionWeight > 0.001) {
for (const n of STICK_ARMS) cur.q[n].slerp(overlay.q[n], actionWeight);
for (const n of STICK_SPINE) {
_actionSpine.identity().slerp(overlay.q[n], actionWeight);
cur.q[n].multiply(_actionSpine);
}
}
const w = smooth(anim.blend);
for (const n of 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);
// Feet are solved after the spine is posed and the matrices refreshed, or
// the hip the IK measures from is a frame stale and the legs trail.
mover.updateMatrixWorld(true);
_blendFoot.lerpVectors(frozen.foot.L.pos, cur.foot.L.pos, w);
solveLeg('L', _blendFoot, lerpAngle(frozen.foot.L.yaw, cur.foot.L.yaw, w));
_blendFoot.lerpVectors(frozen.foot.R.pos, cur.foot.R.pos, w);
solveLeg('R', _blendFoot, lerpAngle(frozen.foot.R.yaw, cur.foot.R.yaw, w));
mover.updateMatrixWorld(true);
// ---- the stick, last ---------------------------------------------------
// Socket first, because it hangs off the right hand and the arm has only
// just been posed. Then the lower hand is pulled onto the shaft, which
// needs the stick already placed — hence the second matrix refresh.
if (anim.stick) {
const [from, to, t] = gripFor();
const roll = anim.stick.stanceTarget(from, to, t, _stickTarget);
// Stickhandling moves the *target*, not just the arm pose. Nudging only
// the shoulders moved the blade by centimetres; the puck follows the
// blade now, so the Skill Stick has to move the blade to mean anything.
//
// Lateral is *subtracted*: skater local +X is the left side, but the Skill
// Stick's +X is "push right". Adding them lined the deke up mirrored —
// stick right sent the puck to the skater's left.
if (anim.hasPuck) {
_stickTarget.x -= anim.handling.x * STICK_REACH.side;
_stickTarget.z += anim.handling.y * STICK_REACH.fwd;
}
_stickTarget.applyMatrix4(mover.matrixWorld);
B.handR.getWorldPosition(_handPos);
B.handR.getWorldQuaternion(_handQuat);
_handQuat.invert();
anim.stick.aimAt(_stickTarget, _handPos, _handQuat, roll);
mover.updateMatrixWorld(true);
// Two hands on it whenever the stick is being used for something, and
// not while it is being dangled out on one.
const twoHanded = (1 - anim.hustleGrip) * (anim.action === 'poke' ? 0.15 : 1);
if (twoHanded > 0.05) {
// Preferred lower-hand grip is a bit down the shaft (hands apart, the
// way the reference draws a carry). If that point is past the arm's
// reach, slide up toward the butt until it is — never leave the hand
// waving short of the stick, and never stack both hands on the butt.
anim.stick.shaftSegment(_shaftA, _shaftB);
B.upperArmL.getWorldPosition(_H);
_shaftDir.subVectors(_shaftB, _shaftA);
const len = _shaftDir.length() || 1;
const armReach = ARM.upper + ARM.fore - 0.03;
// ~quarter of the way down when we can; closer when we must.
let gripT = 0.28;
_shaftPoint.copy(_shaftA).addScaledVector(_shaftDir, gripT);
if (_H.distanceTo(_shaftPoint) > armReach) {
gripT = 0.28;
while (gripT > 0.12) {
_shaftPoint.copy(_shaftA).addScaledVector(_shaftDir, gripT);
if (_H.distanceTo(_shaftPoint) <= armReach) break;
gripT -= 0.02;
}
// Last resort: nearest point on the reachable band of the shaft.
if (_H.distanceTo(_shaftPoint) > armReach) {
segDist(_H, _shaftA, _shaftB, _shaftPoint);
const tNear = clamp(
_shaftPoint.clone().sub(_shaftA).dot(_shaftDir) / (len * len),
0.12,
0.55,
);
gripT = tNear;
_shaftPoint.copy(_shaftA).addScaledVector(_shaftDir, gripT);
}
}
solveArm('L', _shaftPoint);
mover.updateMatrixWorld(true);
}
}
};
anim.states = states;
anim.stateNames = Object.keys(states);
applyMover();
return anim;
}
+245
View File
@@ -0,0 +1,245 @@
import * as THREE from 'three';
import { physiqueFromBodyStyle } from '../../shared/bodyStyle.js';
import { FWD, V3, clamp, lerp, mergeGeoms, smooth } from '../core/math.js';
export const PART = { TORSO: 0, HEAD: 1, ARM_L: 2, ARM_R: 3, LEG_L: 4, LEG_R: 5 };
const _t1 = new THREE.Vector3();
const _t2 = new THREE.Vector3();
const _t3 = new THREE.Vector3();
/**
* Loft a tube along keyframed rings.
* keys: [{ t, c: Vector3, rx, rz }] — cross-section radii along the ring basis
* u/w, which is derived from the path tangent. `shape` harmonics deform the
* silhouette so no two seeds share a profile.
*/
export function loftPart(keys, ringCount, radial, partId, shape) {
const rings = [];
for (let i = 0; i < ringCount; i++) {
const t = i / (ringCount - 1);
let k = 0;
while (k < keys.length - 2 && keys[k + 1].t < t) k++;
const a = keys[k];
const b = keys[k + 1];
const ft = smooth(clamp((t - a.t) / Math.max(1e-6, b.t - a.t), 0, 1));
rings.push({ t, c: a.c.clone().lerp(b.c, ft), rx: lerp(a.rx, b.rx, ft), rz: lerp(a.rz, b.rz, ft) });
}
for (let i = 0; i < ringCount; i++) {
const p0 = rings[Math.max(0, i - 1)].c;
const p1 = rings[Math.min(ringCount - 1, i + 1)].c;
const tan = _t1.subVectors(p1, p0).normalize();
let u = _t2.crossVectors(tan, FWD);
if (u.lengthSq() < 1e-6) u = _t2.set(1, 0, 0);
else u.normalize();
const w = _t3.crossVectors(tan, u).normalize();
rings[i].u = u.clone();
rings[i].w = w.clone();
}
const pos = [], uv = [], aPart = [], aT = [], idx = [];
const cols = radial + 1;
for (let i = 0; i < ringCount; i++) {
const r = rings[i];
for (let j = 0; j <= radial; j++) {
const th = (j / radial) * Math.PI * 2;
const ct = Math.cos(th);
const st = Math.sin(th);
let sh = 1;
if (shape) sh += shape.a1 * Math.cos(2 * th + shape.p1) + shape.a2 * Math.cos(3 * th + shape.p2);
const px = r.rx * ct * sh;
const pz = r.rz * st * sh;
pos.push(
r.c.x + r.u.x * px + r.w.x * pz,
r.c.y + r.u.y * px + r.w.y * pz,
r.c.z + r.u.z * px + r.w.z * pz,
);
uv.push(j / radial, r.t);
aPart.push(partId);
aT.push(r.t);
}
}
for (let i = 0; i < ringCount - 1; i++) {
for (let j = 0; j < radial; j++) {
const a = i * cols + j;
const b = a + cols;
idx.push(a, a + 1, b, b, a + 1, b + 1);
}
}
const cap = (ringIdx, flip) => {
const r = rings[ringIdx];
const ci = pos.length / 3;
pos.push(r.c.x, r.c.y, r.c.z);
uv.push(0.5, r.t);
aPart.push(partId);
aT.push(r.t);
for (let j = 0; j < radial; j++) {
const a = ringIdx * cols + j;
const b = ringIdx * cols + j + 1;
if (flip) idx.push(ci, b, a);
else idx.push(ci, a, b);
}
};
cap(0, true);
cap(ringCount - 1, false);
const g = new THREE.BufferGeometry();
g.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
g.setAttribute('uv', new THREE.Float32BufferAttribute(uv, 2));
g.setAttribute('aPart', new THREE.Float32BufferAttribute(aPart, 1));
g.setAttribute('aT', new THREE.Float32BufferAttribute(aT, 1));
g.setIndex(idx);
g.computeVertexNormals();
return g;
}
/**
* Full body geometry for one fighter. `build` carries the physique parameters
* so they can be reported to the physics layer: reach, centre of mass and limb
* mass all follow from the same numbers that shaped the mesh (GDD 8).
*
* @param {*} rng seeded RNG (small natural jitter)
* @param {{ mass?: number, muscle?: number, fat?: number } | null} [bodyStyle]
* loadout body sliders (dreamfall-style mass / muscle / fat)
*/
export function buildBodyGeometry(rng, bodyStyle = null) {
const phy = physiqueFromBodyStyle(bodyStyle, rng);
const { bulk, waistF, shoulderF, headF, armF, legF } = phy;
const parts = [];
// Girdle half-width: follows physique, but floors so extreme lean never
// collapses the clavicle to a point the deltoid cannot meet.
const girdleRx = Math.max(0.105, 0.176 * bulk * shoulderF);
const collarRx = Math.max(0.092, 0.15 * bulk * shoulderF);
const tKeys = [
{ t: 0.0, c: V3(0, 0.885, 0.002), rx: 0.15 * bulk, rz: 0.1 * bulk },
{ t: 0.08, c: V3(0, 0.935, 0.004), rx: 0.172 * bulk, rz: 0.118 * bulk },
{ t: 0.18, c: V3(0, 1.0, 0.005), rx: 0.164 * bulk, rz: 0.108 * bulk },
{ t: 0.32, c: V3(0, 1.075, 0.004), rx: 0.15 * bulk * waistF, rz: 0.1 * bulk * waistF },
{ t: 0.48, c: V3(0, 1.165, 0.006), rx: 0.156 * bulk, rz: 0.104 * bulk },
{ t: 0.62, c: V3(0, 1.255, 0.008), rx: 0.168 * bulk, rz: 0.116 * bulk },
{ t: 0.76, c: V3(0, 1.335, 0.009), rx: girdleRx, rz: 0.12 * bulk },
{ t: 0.88, c: V3(0, 1.405, 0.01), rx: collarRx, rz: 0.105 * bulk },
{ t: 0.95, c: V3(0, 1.445, 0.012), rx: 0.078 * bulk, rz: 0.072 * bulk },
{ t: 1.0, c: V3(0, 1.475, 0.013), rx: 0.058 * bulk, rz: 0.056 * bulk },
];
parts.push(
loftPart(tKeys, 36, 24, PART.TORSO, {
a1: rng.range(-0.03, 0.03), p1: rng.range(0, 6.28),
a2: rng.range(-0.02, 0.02), p2: rng.range(0, 6.28),
}),
);
const hKeys = [
{ t: 0.0, c: V3(0, 1.425, 0.012), rx: 0.056, rz: 0.058 },
{ t: 0.14, c: V3(0, 1.47, 0.014), rx: 0.06 * headF, rz: 0.064 * headF },
{ t: 0.3, c: V3(0, 1.52, 0.02), rx: 0.074 * headF, rz: 0.08 * headF },
{ t: 0.48, c: V3(0, 1.575, 0.026), rx: 0.088 * headF, rz: 0.094 * headF },
{ t: 0.64, c: V3(0, 1.625, 0.024), rx: 0.094 * headF, rz: 0.1 * headF },
{ t: 0.8, c: V3(0, 1.668, 0.016), rx: 0.084 * headF, rz: 0.088 * headF },
{ t: 0.92, c: V3(0, 1.7, 0.01), rx: 0.052 * headF, rz: 0.054 * headF },
{ t: 1.0, c: V3(0, 1.716, 0.008), rx: 0.012, rz: 0.012 },
];
parts.push(loftPart(hKeys, 24, 20, PART.HEAD, { a1: rng.range(-0.02, 0.02), p1: rng.range(0, 6.28), a2: 0, p2: 0 }));
// ---- Arms + spherical shoulder sockets ---------------------------------
//
// Extreme skinny (mass/muscle floors) used to leave a hole between a thin
// torso and a fixed arm root at x=0.15 — the "spike" sockets in the kit
// preview. Rebuild the deltoid as a sphere that always spans from the
// clavicle root (inside the torso half-width) out to the upper-arm shaft.
//
// shoulderHalf matches the torso girdle ring (same floor as girdleRx).
const shoulderHalf = girdleRx;
// Deltoid boulder radius: floors hard so lean builds still have a round
// joint; grows with bulk/arm muscle for heavy / cut.
const deltoidR = Math.max(
0.064,
0.072 * Math.sqrt(Math.max(bulk, 0.55)) * (0.72 + 0.38 * Math.min(armF, 1.45)),
);
// Clavicle / socket layout in the coronal plane (absolute X later mirrored).
const clavY = 1.402;
const clavZ = 0.008;
// Root sits inside the torso so the sphere always meets clavicle + neck.
const clavRootX = Math.max(0.038, shoulderHalf * 0.42);
// Sphere centre sits on the torso shoulder edge.
const socketX = Math.max(shoulderHalf * 0.92, clavRootX + deltoidR * 0.55);
// Outer deltoid / upper-arm takeoff — past the boulder equator.
const armRootX = socketX + deltoidR * 0.72;
for (const s of [1, -1]) {
const partId = s > 0 ? PART.ARM_L : PART.ARM_R;
const P = (x, y, z) => V3(s * x, y, z);
// Near-equal rx/rz + short arc through one centre ⇒ spherical deltoid.
// Mild shape harmonics only on the shaft so the boulder stays round.
const aKeys = [
// Clavicle root — buried in the torso, always connected.
{ t: 0.0, c: P(clavRootX, clavY + 0.012, clavZ + 0.004), rx: deltoidR * 0.92, rz: deltoidR * 0.88 },
// Inner hemisphere (toward neck / traps).
{ t: 0.05, c: P(socketX * 0.78, clavY + 0.006, clavZ), rx: deltoidR * 1.02, rz: deltoidR * 0.98 },
// Deltoid equator — the shoulder boulder.
{ t: 0.11, c: P(socketX, clavY, clavZ), rx: deltoidR, rz: deltoidR },
// Outer hemisphere → upper-arm takeoff.
{ t: 0.18, c: P(armRootX, clavY - 0.012, clavZ + 0.002), rx: deltoidR * 0.86, rz: deltoidR * 0.82 },
// Upper arm shaft (path kept close to the original A-pose reach).
{ t: 0.28, c: P(Math.max(0.28, armRootX + 0.06), 1.30, 0.008), rx: 0.056 * armF, rz: 0.052 * armF },
{ t: 0.40, c: P(0.355, 1.16, 0.01), rx: 0.048 * armF, rz: 0.044 * armF },
{ t: 0.50, c: P(0.392, 1.098, 0.011), rx: 0.041 * armF, rz: 0.039 * armF },
{ t: 0.66, c: P(0.445, 0.985, 0.014), rx: 0.045 * armF, rz: 0.042 * armF },
{ t: 0.78, c: P(0.48, 0.905, 0.017), rx: 0.035 * armF, rz: 0.032 * armF },
{ t: 0.86, c: P(0.5, 0.855, 0.02), rx: 0.038, rz: 0.026 },
{ t: 0.95, c: P(0.52, 0.805, 0.024), rx: 0.034, rz: 0.02 },
{ t: 1.0, c: P(0.53, 0.778, 0.026), rx: 0.012, rz: 0.01 },
];
parts.push(
// Extra rings through the deltoid so the sphere reads smooth, not faceted.
loftPart(aKeys, 32, 20, partId, {
a1: rng.range(-0.02, 0.02), p1: rng.range(0, 6.28),
a2: rng.range(-0.01, 0.01), p2: rng.range(0, 6.28),
}),
);
}
for (const s of [1, -1]) {
const partId = s > 0 ? PART.LEG_L : PART.LEG_R;
const P = (x, y, z) => V3(s * x, y, z);
const lKeys = [
{ t: 0.0, c: P(0.088, 1.02, 0.004), rx: 0.108 * bulk, rz: 0.102 * bulk },
{ t: 0.1, c: P(0.112, 0.93, 0.006), rx: 0.104 * legF, rz: 0.098 * legF },
{ t: 0.28, c: P(0.125, 0.76, 0.008), rx: 0.088 * legF, rz: 0.084 * legF },
{ t: 0.44, c: P(0.13, 0.6, 0.009), rx: 0.068 * legF, rz: 0.064 * legF },
{ t: 0.52, c: P(0.13, 0.512, 0.008), rx: 0.058 * legF, rz: 0.056 * legF },
{ t: 0.64, c: P(0.132, 0.38, 0.006), rx: 0.064 * legF, rz: 0.06 * legF },
{ t: 0.78, c: P(0.133, 0.22, 0.002), rx: 0.05 * legF, rz: 0.046 * legF },
{ t: 0.86, c: P(0.132, 0.11, -0.004), rx: 0.042, rz: 0.038 },
{ t: 0.92, c: P(0.13, 0.062, 0.03), rx: 0.044, rz: 0.034 },
{ t: 0.97, c: P(0.128, 0.04, 0.095), rx: 0.042, rz: 0.028 },
{ t: 1.0, c: P(0.126, 0.032, 0.155), rx: 0.02, rz: 0.014 },
];
parts.push(
loftPart(lKeys, 30, 18, partId, {
a1: rng.range(-0.03, 0.03), p1: rng.range(0, 6.28),
a2: rng.range(-0.015, 0.015), p2: rng.range(0, 6.28),
}),
);
}
const merged = mergeGeoms(parts);
merged.computeVertexNormals();
merged.userData.physique = { bulk, waistF, shoulderF, headF, armF, legF, style: phy.style };
return merged;
}
export function buildBodyMesh(geo, skelData, materials) {
const mesh = new THREE.SkinnedMesh(geo, materials.skin);
mesh.castShadow = true;
mesh.receiveShadow = true;
mesh.frustumCulled = false;
mesh.add(skelData.bones.root);
mesh.updateMatrixWorld(true);
mesh.bind(skelData.skeleton, mesh.matrixWorld.clone());
mesh.userData.heatMat = new THREE.MeshBasicMaterial({ vertexColors: true });
mesh.userData.origMat = materials.skin;
return mesh;
}
+382
View File
@@ -0,0 +1,382 @@
import * as THREE from 'three';
import { clamp, lerp, mergeGeoms, smooth, stripAttrs } from '../core/math.js';
/**
* Geometry toolkit for equipment.
*
* Gear used to be stacks of BoxGeometry, which reads as a pile of crates the
* moment the camera gets close. Three builders replace that:
*
* loft() — one continuous skinned-looking tube through keyed
* cross-sections. Pads, gloves, chest, paddle.
* carvedShell() — a hand-indexed lat/long shell with real wall thickness and
* a hole cut through it. The goalie mask.
* tube() — a swept bar along a curve. Cage bars, rims, straps.
*
* Everything comes back as a plain indexed BufferGeometry in the local space of
* whatever bone it will hang off, so the caller only ever sets a position.
*/
const _u = new THREE.Vector3();
const _w = new THREE.Vector3();
const _tan = new THREE.Vector3();
const _a = new THREE.Vector3();
const _b = new THREE.Vector3();
const _n = new THREE.Vector3();
const _d = new THREE.Vector3();
const REF_X = new THREE.Vector3(1, 0, 0);
const WHITE = new THREE.Color(1, 1, 1);
/**
* Superellipse profile point on the unit section.
*
* `e` = 2 is an ellipse; larger values square it off. Pads and blocker boards
* are rounded rectangles in cross-section, not ovals — that edge is most of
* what makes a pad read as a pad.
*/
function profile(theta, e) {
const c = Math.cos(theta);
const s = Math.sin(theta);
if (e === 2) return [c, s];
const k = 2 / e;
return [Math.sign(c) * Math.abs(c) ** k, Math.sign(s) * Math.abs(s) ** k];
}
/**
* Loft a closed tube through keyed cross-sections.
*
* Sections are `{ c: Vector3, rx, rz, e?, col? }`:
* c — centre of the ring on the path
* rx — half-width along the ring's `u` axis (world X for a straight run)
* rz — half-depth along `w` (the path's forward side)
* e — superellipse exponent, 2 = oval … 8 = nearly a box
* col — vertex colour from this ring on; interpolates to the next
*
* The path is a Catmull-Rom through the section centres so a bend (a pad's toe
* kick, a thumb) curves instead of creasing. Radii ease with smoothstep, which
* means two sections at the same centre give a hard step — that is how the
* stripes and the boot break are cut.
*
* `part` / `t0` / `t1` write the `aPart` and `aT` attributes the skinning solver
* reads. Cloth — a jersey, a pant leg, a sock — has to bend at the joints it
* crosses, so it is skinned to the skeleton rather than bolted to one bone, and
* those two attributes are what keep the left sleeve off the right arm.
*/
export function loft(sections, {
radial = 20,
sub = 5,
ref = REF_X,
capStart = true,
capEnd = true,
tension = 0.5,
part = null,
t0 = 0,
t1 = 1,
} = {}) {
const n = sections.length;
if (n < 2) throw new Error('loft needs at least two sections');
// Fill colours forward then backward so a single tinted section paints the
// whole run up to the next one.
const cols = sections.map((s) => s.col ?? null);
const painted = cols.some(Boolean);
if (painted) {
for (let i = 1; i < n; i++) if (!cols[i]) cols[i] = cols[i - 1];
for (let i = n - 2; i >= 0; i--) if (!cols[i]) cols[i] = cols[i + 1];
}
const curve = new THREE.CatmullRomCurve3(
sections.map((s) => s.c.clone()),
false,
'catmullrom',
tension,
);
const rings = Math.max(2, (n - 1) * sub);
const pos = [];
const uv = [];
const col = [];
const aPart = [];
const aT = [];
const idx = [];
const stride = radial + 1; // seam column duplicated so UVs stay sane
const centres = [];
for (let r = 0; r <= rings; r++) {
const t = r / rings;
const p = (n - 1) * t;
const i0 = Math.min(n - 2, Math.floor(p));
const f = smooth(clamp(p - i0, 0, 1));
const s0 = sections[i0];
const s1 = sections[i0 + 1];
const c = curve.getPoint(t);
_tan.copy(curve.getTangent(t)).normalize();
_w.crossVectors(_tan, ref);
if (_w.lengthSq() < 1e-10) _w.set(0, 0, 1);
_w.normalize();
_u.crossVectors(_w, _tan).normalize();
const rx = lerp(s0.rx, s1.rx, f);
const rz = lerp(s0.rz, s1.rz, f);
const e = lerp(s0.e ?? 2, s1.e ?? 2, f);
const tint = painted ? new THREE.Color().lerpColors(cols[i0], cols[i0 + 1], f) : null;
centres.push({ c: c.clone(), t });
for (let j = 0; j <= radial; j++) {
const [px, pz] = profile((j / radial) * Math.PI * 2, e);
pos.push(
c.x + _u.x * px * rx + _w.x * pz * rz,
c.y + _u.y * px * rx + _w.y * pz * rz,
c.z + _u.z * px * rx + _w.z * pz * rz,
);
uv.push(j / radial, t);
if (painted) col.push(tint.r, tint.g, tint.b);
if (part != null) {
aPart.push(part);
aT.push(t0 + (t1 - t0) * t);
}
}
}
for (let i = 0; i < rings; i++) {
for (let j = 0; j < radial; j++) {
const a = i * stride + j;
const b = a + stride;
idx.push(a, a + 1, b, b, a + 1, b + 1);
}
}
const cap = (ring, flip) => {
const { c, t } = centres[ring];
const ci = pos.length / 3;
pos.push(c.x, c.y, c.z);
uv.push(0.5, t);
if (painted) {
const base = (ring * stride) * 3;
col.push(col[base], col[base + 1], col[base + 2]);
}
if (part != null) {
aPart.push(part);
aT.push(t0 + (t1 - t0) * t);
}
for (let j = 0; j < radial; j++) {
const a = ring * stride + j;
const b = a + 1;
if (flip) idx.push(ci, b, a);
else idx.push(ci, a, b);
}
};
if (capStart) cap(0, true);
if (capEnd) cap(rings, false);
const g = new THREE.BufferGeometry();
g.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
g.setAttribute('uv', new THREE.Float32BufferAttribute(uv, 2));
if (painted) g.setAttribute('color', new THREE.Float32BufferAttribute(col, 3));
if (part != null) {
g.setAttribute('aPart', new THREE.Float32BufferAttribute(aPart, 1));
g.setAttribute('aT', new THREE.Float32BufferAttribute(aT, 1));
}
g.setIndex(idx);
g.computeVertexNormals();
return g;
}
/** Sweep a bar of `radius` along a Catmull-Rom through `points`. */
export function tube(points, radius, {
closed = false,
radial = 7,
segments = null,
tension = 0.4,
} = {}) {
const curve = new THREE.CatmullRomCurve3(
points.map((p) => p.clone()),
closed,
'catmullrom',
tension,
);
const seg = segments ?? Math.max(10, points.length * 4);
return new THREE.TubeGeometry(curve, seg, radius, radial, closed);
}
/** Fold a pile of bars into one geometry (one draw call, one material). */
export function mergeBars(list) {
const merged = mergeGeoms(list.map(stripAttrs));
for (const g of list) g.dispose();
merged.computeVertexNormals();
return merged;
}
/**
* Push a quad as two triangles, wound so its face points along `dir`.
*
* Winding on a hand-built grid depends on which way the parametrisation runs,
* and getting it backwards means the surface renders inside-out. Deciding per
* quad from the geometry is cheap and removes the guesswork.
*/
function pushQuad(idx, pos, a, b, c, d, dir) {
_a.set(pos[b * 3] - pos[a * 3], pos[b * 3 + 1] - pos[a * 3 + 1], pos[b * 3 + 2] - pos[a * 3 + 2]);
_b.set(pos[c * 3] - pos[a * 3], pos[c * 3 + 1] - pos[a * 3 + 1], pos[c * 3 + 2] - pos[a * 3 + 2]);
_n.crossVectors(_a, _b);
if (_n.lengthSq() < 1e-16) return;
if (_n.dot(dir) >= 0) idx.push(a, b, c, a, c, d);
else idx.push(a, c, b, a, d, c);
}
/**
* A shell with thickness and an optional hole cut through it.
*
* `surface(theta, v, out)` writes the outer skin for the lat/long parameter
* pair — theta wraps, v runs 0 (open bottom edge) → 1 (closed crown). Vertex
* normals come from the parametric tangents, and the inner skin is the outer
* one pushed back along them, so the wall has an honest thickness you can see
* through the hole.
*
* `port(p, theta, v)` marks outer vertices that fall inside a hole; every quad
* touching one is dropped and the exposed border is walled with a rim. That is
* what turns a lump into a mask you can see a face through. The surface
* parameters come through alongside the position because holes that follow the
* shell — vent slots, an ear port — are far easier to place in (theta, v) than
* in metres.
*/
export function carvedShell({
rows = 40,
cols = 48,
thickness = 0.012,
surface,
port = null,
color = null,
center = new THREE.Vector3(),
bottomRim = true,
}) {
const outer = [];
const param = [];
for (let i = 0; i <= rows; i++) {
const v = i / rows;
for (let j = 0; j < cols; j++) {
const theta = (j / cols) * Math.PI * 2;
outer.push(surface(theta, v, new THREE.Vector3()));
param.push(theta, v);
}
}
const at = (i, j) => outer[i * cols + (((j % cols) + cols) % cols)];
// Parametric normals: dV × dTheta, flipped to face away from the centre.
const normals = [];
for (let i = 0; i <= rows; i++) {
for (let j = 0; j < cols; j++) {
_a.subVectors(at(i, j + 1), at(i, j - 1));
_b.subVectors(at(Math.min(rows, i + 1), j), at(Math.max(0, i - 1), j));
_n.crossVectors(_b, _a);
_d.subVectors(at(i, j), center);
if (_n.lengthSq() < 1e-14) _n.copy(_d);
_n.normalize();
if (_n.dot(_d) < 0) _n.negate();
normals.push(_n.clone());
}
}
const nOuter = outer.length;
const pos = new Array(nOuter * 6);
const nor = new Array(nOuter * 6);
const uvs = new Array(nOuter * 4);
const cols3 = color ? new Array(nOuter * 6) : null;
for (let k = 0; k < nOuter; k++) {
const p = outer[k];
const n = normals[k];
const i = Math.floor(k / cols);
const j = k % cols;
const inner = _d.copy(p).addScaledVector(n, -thickness);
pos[k * 3] = p.x; pos[k * 3 + 1] = p.y; pos[k * 3 + 2] = p.z;
pos[(nOuter + k) * 3] = inner.x;
pos[(nOuter + k) * 3 + 1] = inner.y;
pos[(nOuter + k) * 3 + 2] = inner.z;
nor[k * 3] = n.x; nor[k * 3 + 1] = n.y; nor[k * 3 + 2] = n.z;
nor[(nOuter + k) * 3] = -n.x;
nor[(nOuter + k) * 3 + 1] = -n.y;
nor[(nOuter + k) * 3 + 2] = -n.z;
uvs[k * 2] = j / cols; uvs[k * 2 + 1] = i / rows;
uvs[(nOuter + k) * 2] = j / cols;
uvs[(nOuter + k) * 2 + 1] = i / rows;
if (cols3) {
const co = color(p, 'outer', param[k * 2], param[k * 2 + 1]);
const ci = color(p, 'inner', param[k * 2], param[k * 2 + 1]);
cols3[k * 3] = co.r; cols3[k * 3 + 1] = co.g; cols3[k * 3 + 2] = co.b;
cols3[(nOuter + k) * 3] = ci.r;
cols3[(nOuter + k) * 3 + 1] = ci.g;
cols3[(nOuter + k) * 3 + 2] = ci.b;
}
}
const holed = port ? outer.map((p, k) => port(p, param[k * 2], param[k * 2 + 1])) : null;
const O = (i, j) => i * cols + (((j % cols) + cols) % cols);
const I = (i, j) => nOuter + O(i, j);
const dropped = (i, j) => {
if (!holed) return false;
return holed[O(i, j)] || holed[O(i, j + 1)] || holed[O(i + 1, j)] || holed[O(i + 1, j + 1)];
};
const idx = [];
const mid = new THREE.Vector3();
const midOf = (i, j, out) => out
.copy(at(i, j)).add(at(i, j + 1)).add(at(i + 1, j)).add(at(i + 1, j + 1)).multiplyScalar(0.25);
for (let i = 0; i < rows; i++) {
for (let j = 0; j < cols; j++) {
if (dropped(i, j)) continue;
midOf(i, j, mid);
_d.copy(normals[O(i, j)]);
pushQuad(idx, pos, O(i, j), O(i, j + 1), O(i + 1, j + 1), O(i + 1, j), _d);
_d.negate();
pushQuad(idx, pos, I(i, j), I(i, j + 1), I(i + 1, j + 1), I(i + 1, j), _d);
}
}
// Wall the hole: every dropped quad that borders a kept one gets a rim face
// on the shared edge, pointing into the opening.
const holeMid = new THREE.Vector3();
const keptMid = new THREE.Vector3();
const rim = (i, j, ni, nj, ea, eb) => {
if (ni < 0 || ni >= rows) return;
if (!dropped(ni, nj)) {
midOf(i, j, holeMid);
midOf(ni, nj, keptMid);
_d.subVectors(holeMid, keptMid).normalize();
pushQuad(idx, pos, ea[0], ea[1], eb[1], eb[0], _d);
}
};
if (holed) {
for (let i = 0; i < rows; i++) {
for (let j = 0; j < cols; j++) {
if (!dropped(i, j)) continue;
rim(i, j, i, j - 1, [O(i, j), O(i + 1, j)], [I(i, j), I(i + 1, j)]);
rim(i, j, i, j + 1, [O(i, j + 1), O(i + 1, j + 1)], [I(i, j + 1), I(i + 1, j + 1)]);
rim(i, j, i - 1, j, [O(i, j), O(i, j + 1)], [I(i, j), I(i, j + 1)]);
rim(i, j, i + 1, j, [O(i + 1, j), O(i + 1, j + 1)], [I(i + 1, j), I(i + 1, j + 1)]);
}
}
}
// Open bottom edge gets its own rim so the shell reads as a shell.
if (bottomRim) {
for (let j = 0; j < cols; j++) {
_d.subVectors(at(0, j), at(1, j)).normalize();
pushQuad(idx, pos, O(0, j), O(0, j + 1), I(0, j + 1), I(0, j), _d);
}
}
const g = new THREE.BufferGeometry();
g.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
g.setAttribute('normal', new THREE.Float32BufferAttribute(nor, 3));
g.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));
if (cols3) g.setAttribute('color', new THREE.Float32BufferAttribute(cols3, 3));
g.setIndex(idx);
return g;
}
/** 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);
}
+289
View File
@@ -0,0 +1,289 @@
import * as THREE from 'three';
import { NET, goalLineX, goalieSpot } from '../../shared/net.js';
import { CAT, KIND, makeTag, quat, transform, vec3, xyz } from '../physics/bridge.js';
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 { 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';
/**
* A goalie.
*
* Deliberately *not* a skater. The skating sim is a carve model — momentum
* dragged onto a blade line — and a goalie almost never carves. They shuffle
* along an arc, square to the puck, and their whole job is to be in the right
* place rather than to travel.
*
* Presentation matches the skaters: same skeleton, skinned body, bone-socketed
* gear (pads, trapper, blocker, mask, paddle). Locomotion and saves stay
* purpose-built — angle tracking with a reaction lag, kinematic pad/body
* colliders the puck bounces off. No save-percentage roll anywhere.
*/
export const GOALIE = {
/** How far out of the net they play. Deeper is safer, shallower cuts angle. */
depth: 0.62,
/** Lateral speed, m/s. Real goalies are quick but not instant. */
speed: 4.4,
/** Seconds of reaction lag on the target. This is the beatable part. */
lag: 0.11,
/** Pad stack: low and wide — the physics shape, not the visual pad. */
padWidth: 0.92,
padHeight: 0.46,
padDepth: 0.22,
/** Upper body plus arms/glove/blocker, as one capsule. */
bodyRadius: 0.30,
bodyLow: 0.46,
bodyHigh: 1.24,
/** How far they lunge at a puck that is already past them. */
desperation: 0.45,
/**
* How close / fast a puck has to be before they commit to butterfly/reach.
* Tuned so idle crease work stays in ready stance.
*/
threatDist: 9,
threatSpeed: 6,
};
export function createGoalie(physics, scene, {
end = 1,
index = 40,
team = 1,
seed = 9000 + Math.abs(end) * 17 + team * 3,
} = {}) {
const line = goalLineX(end);
const rng = makeRng(seed);
const materials = buildMaterials(rng, team);
// Goalies are bulkier in the pads than skaters are in pants.
const bodyStyle = { mass: 0.55, muscle: 0.6, fat: 0.45 };
const skelData = buildSkeleton();
const mover = new THREE.Group();
mover.name = 'goalie:' + end;
scene.add(mover);
const bodyGeo = buildBodyGeometry(rng, bodyStyle);
computeSkin(bodyGeo, skelData);
paintKit(bodyGeo, { jersey: materials.team.jersey, 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 animator = buildGoalieAnimator(skelData, mover);
animator.stick = gear.stick;
const spawnX = line - end * GOALIE.depth;
const facing = end > 0 ? -Math.PI / 2 : Math.PI / 2;
mover.position.set(spawnX, 0, 0);
mover.rotation.y = facing;
animator.setTransform(mover.position, facing);
mover.updateMatrixWorld(true);
assertNoNaNBones(skelData);
// ---- colliders ----------------------------------------------------------
// Kinematic: the puck bounces off, the goalie does not get pushed around.
// Kept as simple pad+body shapes rather than 18 bone capsules — a goalie's
// job is to be a wall the puck can hit, not a ragdoll that falls over.
let body = null;
const api = physics?.api;
if (physics) {
const bd = api.b3DefaultBodyDef();
bd.type = api.b3BodyType.b3_kinematicBody;
bd.position = xyz(spawnX, 0, 0);
bd.enableSleep = false;
body = api.b3CreateBody(physics.world, bd);
const sd = api.b3DefaultShapeDef();
sd.enableContactEvents = true;
sd.baseMaterial.friction = 0.5;
// Pads absorb. A puck pinging off a goalie like a wall is the single most
// arcade-looking thing a hockey game can do.
sd.baseMaterial.restitution = 0.18;
sd.baseMaterial.userMaterialId = makeTag(KIND.BODY, index, 0);
sd.filter.categoryBits = CAT.skater(index % 12);
// Puck and skaters only — never the rink, which a kinematic body ignores.
sd.filter.maskBits = CAT.PUCK | CAT.PROXY;
api.b3CreateBoxShape(body, sd, GOALIE.padDepth / 2, GOALIE.padHeight / 2, GOALIE.padWidth / 2);
api.b3CreateCapsuleShape(body, sd, {
center1: xyz(0, GOALIE.bodyLow, 0),
center2: xyz(0, GOALIE.bodyHigh, 0),
radius: GOALIE.bodyRadius,
});
}
const target = { x: spawnX, z: 0 };
const pos = { x: spawnX, z: 0 };
/** Lagged puck position, which is what they actually react to. */
const seen = { x: 0, z: 0 };
/** Last raw puck sample, for a cheap velocity estimate. */
const lastPuck = { x: 0, y: 0.05, z: 0 };
let seenInit = false;
let placed = false;
let hadPuck = false;
const _p = new THREE.Vector3();
const _q = new THREE.Quaternion();
const _scale = new THREE.Vector3();
const _up = new THREE.Vector3(0, 1, 0);
return {
end,
index,
team,
/** @deprecated use mover — kept so older callers that read .group still work */
get group() { return mover; },
mover,
body,
pos,
animator,
skelData,
gear,
bodyMesh,
/** Reset to the middle of the crease. */
reset() {
pos.x = line - end * GOALIE.depth;
pos.z = 0;
seenInit = false;
hadPuck = false;
placed = false;
const yaw = end > 0 ? -Math.PI / 2 : Math.PI / 2;
mover.position.set(pos.x, 0, pos.z);
mover.rotation.y = yaw;
animator.setTransform(mover.position, yaw);
animator.moveSpeed = 0;
animator.lateralVel = 0;
animator.threatened = 0;
animator.setState('ready', 0.05);
},
/**
* Track the puck. `dt` on the frame clock.
* `puck` is anything with `{x,y,z}` — the shootout passes a Vector3.
* Returns the current position so callers can watch it.
*/
update(dt, puck) {
const px = puck.x;
const py = puck.y ?? 0.05;
const pz = puck.z;
// Reaction lag: they play the puck where they saw it, not where it is.
if (!seenInit) {
seen.x = px;
seen.z = pz;
seenInit = true;
} else {
const k = clamp(dt / Math.max(1e-3, GOALIE.lag), 0, 1);
seen.x += (px - seen.x) * k;
seen.z += (pz - seen.z) * k;
}
// Velocity from samples — the shootout only hands over a position.
let pvx = 0;
let pvz = 0;
if (hadPuck && dt > 1e-6) {
pvx = (px - lastPuck.x) / dt;
pvz = (pz - lastPuck.z) / dt;
}
lastPuck.x = px;
lastPuck.y = py;
lastPuck.z = pz;
hadPuck = true;
goalieSpot(seen, end, GOALIE.depth, target);
// 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 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;
}
// Square up to the puck.
const yaw = Math.atan2(px - pos.x, pz - pos.z);
mover.position.set(pos.x, 0, pos.z);
mover.rotation.y = yaw;
// Lateral velocity is along world Z in the crease (nets face ±X).
const latVel = dt > 1e-6 ? (pos.z - z0) / dt : 0;
const puckDist = Math.hypot(px - pos.x, pz - pos.z);
const puckSpeed = Math.hypot(pvx, pvz);
const closing = end > 0 ? pvx > 0.5 : pvx < -0.5;
// Proximity alone is enough to load a stance — a deke at the crease
// should draw a butterfly even if the puck is not a rocket. Speed and
// closing just push the same signal harder.
const near = clamp(1 - puckDist / GOALIE.threatDist, 0, 1);
const rush = clamp(puckSpeed / GOALIE.threatSpeed, 0, 1);
const threat = clamp(
near * 0.55
+ near * rush * 0.45
+ (closing ? near * 0.25 : 0),
0,
1,
);
animator.setTransform(mover.position, yaw);
animator.moveSpeed = Math.abs(latVel) + (dist > step ? speed * 0.25 : 0);
animator.lateralVel = latVel;
animator.puckHeight = py;
animator.puckDist = puckDist;
animator.threatened = threat;
animator.update(dt);
return pos;
},
/** Push the pose into the kinematic collider, once per substep. */
syncPhysics(dt) {
if (!body) return;
mover.updateWorldMatrix(true, false);
mover.matrixWorld.decompose(_p, _q, _scale);
// Physics body stays upright on the ice; presentation lean is visual only.
_q.setFromAxisAngle(_up, animator.originYaw);
_p.y = 0;
if (!placed) {
api.b3Body_SetTransform(body, vec3(_p), quat(_q));
placed = true;
return;
}
api.b3Body_SetTargetTransform(body, transform(_p, _q), dt, true);
},
/** True when the puck is inside the goalie's body — a save in progress. */
covers(puck) {
const dx = puck.x - pos.x;
const dz = puck.z - pos.z;
return Math.hypot(dx, dz) < GOALIE.bodyRadius + 0.14;
},
destroy() {
if (body && api) api.b3DestroyBody(body);
gear.destroy();
for (const m of Object.values(gearMats)) m.dispose();
scene.remove(mover);
disposeObject(mover);
},
};
}
export { NET };
+678
View File
@@ -0,0 +1,678 @@
import * as THREE from 'three';
import { clamp, smooth } from '../core/math.js';
import { carvedShell, loft, mergeBars, tint, tube } from './gearMesh.js';
/**
* Goalie equipment, socketed to skeleton bones.
*
* Placement is tuned against `shots/img2mesh/ref/goalie-equipment.png`:
* - pad faces toward the shooter (front of the shin), boot on the ice
* - trapper open on the glove-side hip
* - blocker as a flat board on the stick hand
* - paddle flat in the five-hole, shaft up into the blocker hand
* - mask + cage on the head, chest plate snug on the torso
*
* The pieces that carry the silhouette — pads, mask, gloves, chest — are single
* lofted or shelled meshes rather than stacks of boxes. A pad is one surface
* from the thigh rise through the knee break to the toe; the mask is a shell
* with a hole cut for the face and a cage bent over it.
*
* Bone axes (rest): every rest rotation is identity, so a bone's local axes are
* the mover's. The shin runs almost straight down Y, but the hands and upper
* arms run out *and* down (A-pose), so glove and floater groups are rotated
* onto their bone's real direction instead of being hung off Y.
*/
/**
* Numbers the builders read. Pads, gloves and the chest are described by their
* section tables further down rather than by scalars — a loft's shape lives in
* its keys — so only the mask, whose surface is a formula, needs constants.
*/
export const GEAR = {
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,
},
};
/** 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(),
R: new THREE.Vector3(-0.15, -0.252, 0.01).normalize(),
};
const DOWN = new THREE.Vector3(0, -1, 0);
/** Rest direction the fingers point, from the hand bone. */
const HAND_DIR = {
L: new THREE.Vector3(0.045, -0.095, 0.008).normalize(),
R: new THREE.Vector3(-0.045, -0.095, 0.008).normalize(),
};
/**
* Glove grips, in hand-bone-local space.
*
* Both gloves are modelled facing +Z with the body running down Y, and both
* are put on the hand the same way: Y is aligned to the hand's own axis, so
* the glove carries on out of the wrist the way a hand does, and the *only*
* free variable left is the roll about that axis.
*
* That constraint matters. Solving for a free orientation — "pocket at the
* shooter, fingers up" — squares the glove to the puck but stands it off the
* wrist at an angle no arm makes. Rolling around the hand keeps the join
* honest and still gets the pocket and the board most of the way round.
*
* The two angles below were solved against the ready stance: for each, the
* roll whose pocket normal lands closest to the shooter.
*/
const GRIP_ROLL = { trapper: 1.499, blocker: 5.369 };
function handGrip(side, roll) {
const dir = HAND_DIR[side];
const align = new THREE.Quaternion().setFromUnitVectors(DOWN, dir);
return new THREE.Quaternion().setFromAxisAngle(dir, roll).multiply(align);
}
/**
* @param {{
* kit: THREE.Material, pad: THREE.Material, painted: THREE.Material,
* accent: THREE.Material, leather: THREE.Material, web: THREE.Material,
* cage: THREE.Material, dark: THREE.Material,
* }} mats
*/
export function buildGoalieGear(mats) {
const pieces = [];
const disposables = [];
const PAL = {
base: tint(mats.pad.color),
accent: tint(mats.accent.color),
jersey: tint(mats.kit.color),
trim: tint(mats.dark.color),
};
function mesh(geo, mat, name) {
const m = new THREE.Mesh(geo, mat);
m.name = name;
m.castShadow = true;
m.receiveShadow = true;
disposables.push(geo);
return m;
}
const V = (x, y, z) => new THREE.Vector3(x, y, z);
/** Loft section shorthand. */
const S = (c, rx, rz, e, col) => ({ c, rx, rz, e, col });
/** Point a group's Y down a bone's real limb direction. */
function alignTo(group, dir) {
group.quaternion.setFromUnitVectors(DOWN, dir);
return group;
}
// ---- leg pads ------------------------------------------------------------
// One continuous surface: thigh rise → knee break → shin → boot → toe kick.
// The shin bone runs down Y, so the loft path only has to bend forward at
// the ankle for the toe. Bands are cut by doubling sections at the same
// height — smoothstep between two rings a centimetre apart is a hard edge.
function makePad(side) {
const s = side === 'L' ? 1 : -1;
const g = new THREE.Group();
g.name = `pad${side}`;
const W = 0.152; // half face width
const D = 0.066; // half depth
const z0 = 0.052; // pad centre stands proud of the shin front
// Bands are cut by pairing sections a centimetre apart: smoothstep over
// that gap is an edge, over ten centimetres it is a gradient.
const face = loft([
S(V(0, 0.295, z0 - 0.012), W * 0.74, D * 0.72, 4, PAL.base),
S(V(0, 0.225, z0 + 0.008), W * 0.94, D * 0.86, 5),
// Knee break — the widest point, with a team band across it.
S(V(0, 0.175, z0 + 0.02), W * 1.03, D * 0.97, 6),
S(V(0, 0.165, z0 + 0.022), W * 1.04, D * 0.98, 6, PAL.accent),
S(V(0, 0.10, z0 + 0.026), W * 1.06, D * 1.0, 6),
S(V(0, 0.09, z0 + 0.025), W * 1.03, D * 0.99, 6, PAL.base),
S(V(0, -0.02, z0 + 0.012), W, D * 0.94, 6),
// Mid-shin stripe pair.
S(V(0, -0.135, z0 + 0.007), W, D * 0.92, 6),
S(V(0, -0.145, z0 + 0.006), W, D * 0.92, 6, PAL.accent),
S(V(0, -0.20, z0 + 0.004), W, D * 0.92, 6),
S(V(0, -0.21, z0 + 0.004), W, D * 0.92, 6, PAL.base),
S(V(0, -0.37, z0 + 0.008), W * 1.01, D * 0.96, 6),
// Boot channel: wider, deeper, and dark like the ref's landing gear.
S(V(0, -0.425, z0 + 0.014), W * 1.03, D * 1.02, 6),
S(V(0, -0.44, z0 + 0.018), W * 1.05, D * 1.06, 6, PAL.trim),
S(V(0, -0.485, z0 + 0.045), W * 0.98, D * 0.86, 5),
// Toe kicks forward over the skate, and no further.
S(V(0, -0.505, z0 + 0.09), W * 0.84, D * 0.6, 4),
S(V(0, -0.512, z0 + 0.128), W * 0.58, D * 0.36, 3),
], { radial: 22, sub: 5 });
g.add(mesh(face, mats.painted, `pad${side}Face`));
// Outer roll — the thick rolled edge that gives a pad its profile.
const rail = loft([
S(V(s * W * 0.94, 0.21, z0 + 0.01), 0.022, 0.028, 3, PAL.accent),
S(V(s * W * 1.0, 0.11, z0 + 0.026), 0.028, 0.036, 3),
S(V(s * W * 0.96, -0.05, z0 + 0.014), 0.026, 0.034, 3),
S(V(s * W * 0.96, -0.24, z0 + 0.006), 0.026, 0.034, 3),
S(V(s * W * 1.0, -0.41, z0 + 0.01), 0.028, 0.036, 3),
S(V(s * W * 0.98, -0.48, z0 + 0.038), 0.024, 0.028, 3),
], { radial: 12, sub: 4 });
g.add(mesh(rail, mats.painted, `pad${side}Rail`));
// Knee stack — the block that lands on the ice in a butterfly.
const knee = loft([
S(V(-s * 0.02, 0.165, z0 - 0.03), W * 0.6, 0.042, 4, PAL.base),
S(V(-s * 0.042, 0.105, z0 - 0.048), W * 0.64, 0.05, 4),
S(V(-s * 0.055, 0.045, z0 - 0.052), W * 0.54, 0.044, 4),
], { radial: 14, sub: 4 });
g.add(mesh(knee, mats.painted, `pad${side}Knee`));
// Calf wrap so the back of the leg is not naked from the side.
const calf = loft([
S(V(0, 0.05, -0.028), W * 0.64, 0.048, 4, PAL.trim),
S(V(0, -0.14, -0.032), W * 0.68, 0.052, 4),
S(V(0, -0.31, -0.028), W * 0.66, 0.048, 4),
S(V(0, -0.40, -0.008), W * 0.58, 0.042, 4),
], { radial: 14, sub: 4 });
g.add(mesh(calf, mats.painted, `pad${side}Calf`));
// Toe / boot straps.
const strapPts = [
V(-W * 1.08, -0.465, z0 + 0.015),
V(0, -0.47, z0 + 0.06),
V(W * 1.08, -0.465, z0 + 0.015),
];
g.add(mesh(tube(strapPts, 0.008, { radial: 6 }), mats.leather, `pad${side}Strap`));
// Pads sit slightly toed-out on the leg.
g.rotation.z = -s * 0.05;
pieces.push(g);
return g;
}
const padL = makePad('L');
const padR = makePad('R');
// ---- mask ----------------------------------------------------------------
// A shell, not a helmet-shaped blob: the surface function carries the jaw
// taper, cheekbones, brow ridge and occipital shelf, the face opening is cut
// 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 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);
// 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));
// 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));
let x = rx * sp * sx;
let y = -M.ry * cp;
let z = rz * sp * f;
// 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;
// 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;
return out.set(skull.x + x, skull.y + y, skull.z + z);
}
/** Squared-off ellipse over the eyes — the hole the cage covers. */
function portField(p) {
const dx = Math.abs(p.x) / M.portW;
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) => {
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;
// 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;
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.
rows: 36,
cols: 48,
thickness: M.wall,
center: skull,
surface: maskSurface,
port: inPort,
color: maskColor,
});
mask.add(mesh(shell, mats.painted, 'maskShell'));
// Cage: bars ride a forward-bowed ellipse so they stand off the face.
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));
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));
if (span < 0.022) continue;
const pts = [];
for (let i = 0; i <= 8; i++) {
const x = -span + (2 * span * i) / 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));
if (span < 0.02) continue;
const pts = [];
for (let i = 0; i <= 8; i++) {
const dy = -span + (2 * span * i) / 8;
pts.push(cageAt(x, clamp(dy, -span * 0.995, span * 0.995)));
}
bars.push(tube(pts, M.barR, { radial: 6 }));
}
// Perimeter frame, sunk onto the shell so the cage anchors into it.
{
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);
p.z -= 0.004;
ring.push(p);
}
bars.push(tube(ring, M.barR * 1.3, { radial: 6, closed: true, segments: 72 }));
}
mask.add(mesh(mergeBars(bars), mats.cage, 'maskCage'));
// 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),
], { 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),
], 0.005, { radial: 5 }),
mats.leather,
'maskBibStrap',
));
pieces.push(mask);
// ---- trapper (catch glove) — left hand ----------------------------------
// Built in glove space (fingers down Y, back of the hand +Z) then rotated
// onto the hand bone's real axis. Cuff and pillow are one lofted body; the
// pocket is a rim tube with the web recessed inside it.
const trapper = new THREE.Group();
trapper.name = 'trapper';
{
const body = loft([
S(V(0, 0.045, 0.005), 0.05, 0.048, 3),
S(V(0, -0.03, 0.012), 0.058, 0.055, 3),
S(V(0, -0.09, 0.022), 0.07, 0.062, 3),
S(V(0.008, -0.15, 0.035), 0.076, 0.066, 3),
S(V(0.01, -0.20, 0.042), 0.062, 0.054, 3),
], { radial: 16, sub: 5 });
trapper.add(mesh(body, mats.leather, 'trapperBody'));
// Pocket assembly is canted off the hand axis. A catching face built square
// to the wrist can only ever aim wherever the forearm happens to point;
// real gear is angled across it, which is what lets the pocket face the
// shooter while the glove still runs out of the hand.
const pocket = new THREE.Group();
pocket.name = 'trapperPocket';
pocket.rotation.x = -0.32;
// The catching face is a dish swept forward off the palm: solid leather
// backing, squared off like a real mitt rather than a circle.
const cup = loft([
S(V(0.008, -0.115, 0.01), 0.062, 0.072, 3),
S(V(0.01, -0.12, 0.05), 0.09, 0.108, 4),
S(V(0.012, -0.125, 0.082), 0.098, 0.118, 4.5),
S(V(0.012, -0.125, 0.095), 0.09, 0.108, 4),
], { radial: 20, sub: 5 });
pocket.add(mesh(cup, mats.leather, 'trapperCup'));
// Web pillow proud of the cup mouth — the light face a shooter sees. Sunk
// behind the rim it just reads as a black frying pan.
const web = loft([
S(V(0.012, -0.125, 0.088), 0.078, 0.094, 4),
S(V(0.012, -0.125, 0.112), 0.082, 0.098, 4),
S(V(0.012, -0.125, 0.124), 0.068, 0.082, 3.5),
], { radial: 18, sub: 4 });
pocket.add(mesh(web, mats.web, 'trapperWeb'));
// Rim binding around the pocket mouth.
const rimPts = [];
for (let i = 0; i < 24; i++) {
const a = (i / 24) * Math.PI * 2;
const [cx, cy] = [Math.cos(a), Math.sin(a)];
rimPts.push(V(
0.012 + 0.094 * Math.sign(cx) * Math.abs(cx) ** 0.55,
-0.125 + 0.112 * Math.sign(cy) * Math.abs(cy) ** 0.55,
0.104,
));
}
pocket.add(mesh(
tube(rimPts, 0.012, { radial: 7, closed: true, segments: 72 }),
mats.accent,
'trapperRim',
));
trapper.add(pocket);
// Thumb stall curls off the inside edge.
const thumb = loft([
S(V(0.07, -0.04, 0.03), 0.028, 0.026, 3),
S(V(0.105, -0.075, 0.06), 0.03, 0.028, 3),
S(V(0.115, -0.13, 0.085), 0.026, 0.024, 3),
], { radial: 12, sub: 4 });
trapper.add(mesh(thumb, mats.leather, 'trapperThumb'));
// Cuff.
const cuff = loft([
S(V(0, 0.10, -0.005), 0.055, 0.052, 4, PAL.base),
S(V(0, 0.035, 0.0), 0.062, 0.058, 4),
], { radial: 14, sub: 4 });
trapper.add(mesh(cuff, mats.painted, 'trapperCuff'));
}
trapper.quaternion.copy(handGrip('L', GRIP_ROLL.trapper));
pieces.push(trapper);
// ---- blocker — right hand -----------------------------------------------
// The board is one lofted slab: rounded rectangle in section, swept forward
// off the back of the hand so the face squares to the shooter.
const blocker = new THREE.Group();
blocker.name = 'blocker';
{
// Board is canted off the hand for the same reason the trapper pocket is:
// square to the wrist, it lies flat whenever the arm reaches forward.
const face = new THREE.Group();
face.name = 'blockerFace';
face.rotation.x = 0.66;
const board = loft([
S(V(-0.005, -0.10, 0.018), 0.082, 0.125, 5, PAL.trim),
S(V(-0.005, -0.10, 0.045), 0.098, 0.145, 6, PAL.base),
S(V(-0.005, -0.10, 0.078), 0.098, 0.145, 6),
S(V(-0.005, -0.10, 0.098), 0.084, 0.128, 5, PAL.accent),
], { radial: 20, sub: 5 });
face.add(mesh(board, mats.painted, 'blockerBoard'));
// Sidewall down the outside edge of the board.
const wall = loft([
S(V(-0.09, 0.02, 0.055), 0.016, 0.03, 4, PAL.trim),
S(V(-0.098, -0.10, 0.058), 0.018, 0.034, 4),
S(V(-0.09, -0.215, 0.052), 0.016, 0.03, 4),
], { radial: 10, sub: 4 });
face.add(mesh(wall, mats.painted, 'blockerWall'));
blocker.add(face);
// Glove hand behind the board — the part that holds the stick.
const palm = loft([
S(V(0, 0.05, 0.0), 0.05, 0.048, 3),
S(V(0, -0.035, 0.008), 0.058, 0.055, 3),
S(V(0, -0.13, 0.014), 0.055, 0.052, 3),
S(V(0, -0.19, 0.012), 0.042, 0.04, 3),
], { radial: 14, sub: 4 });
blocker.add(mesh(palm, mats.leather, 'blockerPalm'));
const cuff = loft([
S(V(0, 0.105, -0.006), 0.05, 0.048, 4, PAL.base),
S(V(0, 0.04, 0.0), 0.058, 0.055, 4),
], { radial: 12, sub: 4 });
blocker.add(mesh(cuff, mats.painted, 'blockerCuff'));
}
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.
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 });
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),
], { radial: 14, sub: 4 });
chest.add(mesh(plate, mats.painted, 'chestPlate'));
}
pieces.push(chest);
// 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),
], { 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),
], { radial: 14, sub: 4 });
g.add(mesh(arm, mats.painted, `floater${side}Arm`));
alignTo(g, ARM_DIR[side]);
pieces.push(g);
return g;
}
const floaterL = makeFloater('L');
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.
const stick = new THREE.Group();
stick.name = 'goalieStick';
let paddleMesh = null;
{
const shaft = loft([
S(V(0, 0.02, 0), 0.014, 0.011, 5, PAL.trim),
S(V(0, -0.16, 0.004), 0.014, 0.012, 5),
S(V(0, -0.34, 0.008), 0.016, 0.014, 5),
S(V(0, -0.46, 0.012), 0.019, 0.018, 5),
], { radial: 10, sub: 4 });
stick.add(mesh(shaft, mats.painted, 'stickShaft'));
// Paddle: the wide flat section between shaft and blade. This is the part
// that reads as "goalie stick" from twenty metres away, so it is generous.
const paddleGeo = loft([
S(V(0, -0.46, 0.01), 0.02, 0.03, 5, PAL.trim),
S(V(0.004, -0.50, 0.022), 0.019, 0.055, 6),
S(V(0.005, -0.515, 0.026), 0.019, 0.07, 6, PAL.base),
S(V(0.008, -0.60, 0.055), 0.018, 0.078, 6),
S(V(0.01, -0.65, 0.072), 0.017, 0.072, 6),
S(V(0.011, -0.665, 0.078), 0.017, 0.06, 6, PAL.trim),
], { radial: 14, sub: 5 });
const paddle = mesh(paddleGeo, mats.painted, 'paddle');
stick.add(paddle);
// Blade, running across the crease with a curled toe.
const blade = loft([
S(V(0.012, -0.685, 0.02), 0.016, 0.03, 5, PAL.trim),
S(V(0.012, -0.695, 0.12), 0.015, 0.032, 5),
S(V(0.014, -0.695, 0.22), 0.014, 0.03, 5),
S(V(0.02, -0.688, 0.30), 0.012, 0.024, 4),
], { radial: 12, sub: 5 });
stick.add(mesh(blade, mats.painted, 'stickBlade'));
// Knob at the top of the shaft.
stick.add(mesh(
loft([
S(V(0, 0.055, -0.002), 0.017, 0.015, 4, PAL.base),
S(V(0, 0.02, 0), 0.016, 0.014, 4),
], { radial: 10, sub: 3 }),
mats.painted,
'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);
pieces.push(stick);
paddleMesh = paddle;
}
return {
padL,
padR,
trapper,
blocker,
mask,
chest,
floaterL,
floaterR,
stick,
paddle: paddleMesh,
pieces,
attachTo(bones) {
bones.shinL.add(padL);
bones.shinR.add(padR);
bones.handL.add(trapper);
bones.handR.add(blocker);
bones.handR.add(stick);
bones.head.add(mask);
bones.spine3.add(chest);
bones.upperArmL.add(floaterL);
bones.upperArmR.add(floaterR);
},
destroy() {
for (const p of pieces) p.removeFromParent();
for (const g of disposables) g.dispose();
},
};
}
export function buildGoalieMaterials(teamJersey, teamAccent = 0xf0e6d2) {
return {
kit: new THREE.MeshStandardMaterial({
color: teamJersey,
roughness: 0.72,
metalness: 0.04,
}),
/** Vertex-coloured gear: pads, mask shell, chest, paddle all share it. */
painted: new THREE.MeshStandardMaterial({
color: 0xffffff,
vertexColors: true,
roughness: 0.46,
metalness: 0.04,
}),
pad: new THREE.MeshStandardMaterial({
color: 0xf7f4ec,
roughness: 0.8,
metalness: 0.02,
}),
accent: new THREE.MeshStandardMaterial({
color: teamJersey,
roughness: 0.6,
metalness: 0.03,
}),
trimAccent: new THREE.MeshStandardMaterial({
color: teamAccent,
roughness: 0.7,
metalness: 0.02,
}),
leather: new THREE.MeshStandardMaterial({
color: 0x1a1a20,
roughness: 0.88,
metalness: 0.04,
}),
web: new THREE.MeshStandardMaterial({
color: 0xcfc3a8,
roughness: 0.92,
metalness: 0.0,
}),
cage: new THREE.MeshStandardMaterial({
color: 0x2a2e35,
roughness: 0.32,
metalness: 0.8,
}),
dark: new THREE.MeshStandardMaterial({
color: 0x121218,
roughness: 0.5,
metalness: 0.22,
}),
};
}
+334
View File
@@ -0,0 +1,334 @@
import * as THREE from 'three';
import { makeRng } from '../core/rng.js';
import { disposeObject } from '../core/math.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 { buildSkaterGear, buildSkaterGearMaterials, hideCoveredBody } from './skaterGear.js';
import { buildAnimator } from '../anim/skateAnimator.js';
import { REACTION_ATTACK, createRagdoll } from '../physics/ragdoll.js';
import { createBodyProxy } from '../physics/bodyProxy.js';
import { buildStick } from './stick.js';
import { HIT } from '../game/hits.js';
const clamp01 = (x) => (x < 0 ? 0 : x > 1 ? 1 : x);
/**
* One skater: mesh, skeleton, ragdoll, proxy capsule, animator.
*
* This is Ludus's `createFighter` with the loadout, armor, cloth and weapon
* systems removed — everything that remains is the part the hockey game needs.
* Rebuilding one is a full teardown: geometry and skin weights are derived from
* the seed, so there is no partial-update path worth the complexity.
*
* What it does *not* own: position, velocity, or any decision. Those live in
* the sim state and the brain, and arrive here through `applyState`.
*/
export function createSkater({
seed,
scene,
physics,
index = 0,
team = 0,
position = { x: 0, z: 0 },
facing = 0,
bodyStyle = null,
}) {
const rng = makeRng(seed);
const materials = buildMaterials(rng, team);
const skelData = buildSkeleton();
const mover = new THREE.Group();
mover.name = 'skater:' + index;
mover.position.set(position.x, 0, position.z);
mover.rotation.y = facing;
scene.add(mover);
const bodyGeo = buildBodyGeometry(rng, bodyStyle);
computeSkin(bodyGeo, skelData);
paintUnderLayer(bodyGeo, { skinColor: materials.skinColor });
const bodyMesh = buildBodyMesh(bodyGeo, skelData, materials);
mover.add(bodyMesh);
// Kit over the top: cloth skinned to the same skeleton, hard shells socketed
// to the bones they never bend away from. Sized off the physique the body
// loft was built from, so a heavy build gets a bigger jersey.
const gearMats = buildSkaterGearMaterials(materials.team.jersey, materials.team.accent);
const gear = buildSkaterGear(gearMats, skelData, bodyGeo.userData.physique);
gear.attachTo(skelData.bones, mover);
// Everything the kit encloses stops being drawn — no body poking through a
// seam when a shoulder rolls, and a good chunk of the body's triangles saved.
hideCoveredBody(bodyGeo);
const animator = buildAnimator(skelData, mover);
animator.setTransform(mover.position, facing);
// Socketed to the right hand, not to the mover: the arm pose decides where
// the stick is, which is the correct dependency order and the only way the
// hands can actually be on it.
const stick = buildStick(materials, physics, index);
stick.attachTo(skelData.bones.handR);
stick.setGrip('carry');
animator.stick = stick;
mover.updateMatrixWorld(true);
assertNoNaNBones(skelData);
// The 18-capsule rig, kinematic and chasing the animation. Nothing pushes it
// yet; it is here so that when hits land in a later spike the bodies, joints
// and limits already exist and are already in the right place.
const ragdoll = physics ? createRagdoll(physics, skelData, { skaterIndex: index }) : null;
// The one dynamic body. This is what the boards and other skaters actually
// collide with.
const proxy = physics ? createBodyProxy(physics, { index, position }) : null;
const _look = new THREE.Vector3();
const _moverInv = new THREE.Matrix4();
const _pelvis = new THREE.Vector3();
const _chest = new THREE.Vector3();
const _flat = new THREE.Vector3();
const _scale = new THREE.Vector3();
const _rootWorld = new THREE.Matrix4();
const _correction = new THREE.Matrix4();
/**
* Stagger envelope: how much of the rendered pose physics owns, over time.
* Bites almost instantly, then decays back to the animation — anything
* slower on the attack reads as the skater choosing to flinch rather than
* being moved by the hit.
*/
const reaction = { active: false, t: 0, duration: 0, weight: 0, peak: 0 };
function advanceReaction(dt) {
if (!reaction.active) return;
reaction.t += dt;
if (reaction.t >= reaction.duration) {
reaction.active = false;
reaction.weight = 0;
if (ragdoll && ragdoll.mode === 'reacting') {
ragdoll.setJointStiffness(0);
ragdoll.setMode('driven');
}
return;
}
reaction.weight = reaction.t < REACTION_ATTACK
? reaction.peak * (reaction.t / REACTION_ATTACK)
: reaction.peak
* Math.pow(1 - (reaction.t - REACTION_ATTACK) / Math.max(1e-4, reaction.duration - REACTION_ATTACK), 1.6);
}
const skater = {
index,
seed,
team,
rng,
materials,
skelData,
mover,
bodyGeo,
bodyMesh,
gear,
animator,
ragdoll,
proxy,
stick,
reaction,
/** True while the ragdoll owns the skeleton and the proxy is switched off. */
limp: false,
/** Seconds left before a downed skater starts getting up. Null when up. */
downFor: null,
/** Seconds left of the get-up. Intent is damped while it runs. */
rising: 0,
/** The hit that put them here, for the HUD and for debugging. */
lastHit: null,
/**
* Push one frame of sim state into the presentation layer.
*
* `yawRate` is the turn rate of the *velocity* vector, not of the body:
* the animator banks the skater into the arc they are actually carving,
* which is not the same as the way they are pointing.
*/
applyState(s, yawRate) {
_look.set(s.x, 0, s.z);
animator.setTransform(_look, s.yaw);
animator.moveSpeed = Math.hypot(s.vx, s.vz);
animator.bladeSpeed = s.bladeSpeed;
animator.effort = s.effort;
animator.yawRate = yawRate;
animator.braking = !!s.brake;
},
/** Advance animation, the reaction envelope, and the get-up timer. */
update(dt) {
if (!skater.limp) {
if (skater.rising > 0) skater.rising = Math.max(0, skater.rising - dt);
animator.update(dt);
advanceReaction(dt);
}
// Bone velocities are measured on the frame clock, continuously, even
// though they are only read at the moment a rig goes dynamic — they have
// to already be there when that moment arrives.
if (ragdoll && !skater.limp) ragdoll.sampleVelocities(dt);
},
/** Countdown while down; returns true on the frame they should get up. */
tickDown(dt) {
if (!skater.limp || skater.downFor == null) return false;
skater.downFor -= dt;
return skater.downFor <= 0;
},
/**
* Read the physics pose back onto the skeleton.
*
* Fully while limp; blended against the animated pose during a stagger, so
* a flinch deflects the body without erasing the skating underneath it.
*/
syncFromPhysics() {
if (!ragdoll) return;
if (skater.limp) {
_moverInv.copy(mover.matrixWorld).invert();
ragdoll.syncToSkeleton(_moverInv);
mover.updateMatrixWorld(true);
} else if (reaction.active && reaction.weight > 0) {
_moverInv.copy(mover.matrixWorld).invert();
// Root excluded: displacing it slides the skater across the ice, which
// reads as teleporting rather than as being hit. The proxy owns
// position and has already taken the momentum from the collision.
ragdoll.blendToSkeleton(_moverInv, reaction.weight, { includeRoot: false });
mover.updateMatrixWorld(true);
}
},
/**
* Take a hit without going down: the rig goes dynamic with stiff joints for
* a moment, then is blended back onto the animation.
*/
stagger(hit) {
if (!ragdoll || skater.limp) return;
skater.lastHit = hit;
const s = clamp01((hit.severity - HIT.bump) / (HIT.knockdown - HIT.bump));
reaction.active = true;
reaction.t = 0;
reaction.peak = 0.38 + 0.5 * s;
reaction.duration = 0.3 + 0.5 * s;
ragdoll.setJointStiffness(HIT.staggerStiffness);
ragdoll.setMode('reacting');
},
/**
* Go down.
*
* The handoff: the ragdoll goes dynamic and becomes the body, and the proxy
* capsule is switched off. Leaving the proxy enabled would have two bodies
* claiming the same skater — the sim would keep driving a capsule around
* the rink while the visible ragdoll lay on the ice behind it.
*/
goDown(hit) {
if (!ragdoll || skater.limp) return;
skater.lastHit = hit ?? null;
skater.limp = true;
skater.downFor = HIT.downTime;
skater.rising = 0;
reaction.active = false;
reaction.weight = 0;
ragdoll.setJointStiffness(0);
ragdoll.setMode('limp');
proxy?.disable();
},
/**
* Get back up.
*
* The reverse handoff, and the fiddly half of it. The naive version — read
* the pelvis, move the sim there, crossfade — makes the skater visibly fly
* out and snap back, for a reason worth writing down:
*
* While limp, the ragdoll writes the body's displacement into the *root
* bone*, because the mover has been parked where they fell for the whole
* knockdown. So the world pose is `moverAtFallPosition × bigRootOffset`.
* Teleporting the mover onto the pelvis without touching that offset applies
* the displacement a second time — the body jumps by however far it slid —
* and the crossfade then drags it back as the root offset decays to its
* skating value.
*
* The fix is to re-express the root in the *new* mover frame so the world
* pose across the handoff is bit-for-bit identical. Then the crossfade has
* no position to undo and only has to interpolate lying → skating, which is
* the movement we actually want to see.
*/
getUp(state) {
if (!ragdoll || !skater.limp) return;
mover.updateMatrixWorld(true);
const root = skelData.bones.root;
const pelvisBone = ragdoll.parts.pelvis.bone;
pelvisBone.getWorldPosition(_pelvis);
// Which way is this body pointing? The pelvis' own forward axis is no use
// — on someone lying face-down it points at the ice. The pelvis→chest
// line flattened onto the ice is the body's long axis and survives any
// orientation, so a skater stands up facing the way they were sprawled
// rather than spinning on the spot to recover a stale yaw.
ragdoll.parts.spine3.bone.getWorldPosition(_chest);
_flat.set(_chest.x - _pelvis.x, 0, _chest.z - _pelvis.z);
const yaw = _flat.lengthSq() > 1e-4
? Math.atan2(_flat.x, _flat.z)
: (state?.yaw ?? animator.originYaw);
// Remember the root's exact world transform before anything moves.
root.updateWorldMatrix(true, false);
_rootWorld.copy(root.matrixWorld);
// Move the mover onto the body, now, rather than letting the animator do
// it next frame — the correction below has to be computed against the
// frame the pose will actually be drawn in.
mover.position.set(_pelvis.x, 0, _pelvis.z);
mover.rotation.set(0, yaw, 0);
mover.updateMatrixWorld(true);
animator.setTransform(mover.position, yaw);
// Re-express the root so the skeleton lands in exactly the same world
// pose it was already in.
_moverInv.copy(mover.matrixWorld).invert();
_correction.multiplyMatrices(_moverInv, _rootWorld);
_correction.decompose(root.position, root.quaternion, _scale);
mover.updateMatrixWorld(true);
skater.limp = false;
skater.downFor = null;
// Counted down in update(); the match damps intent while it runs so they
// stand up where they fell instead of skating off mid-rise.
skater.rising = HIT.riseTime;
ragdoll.setJointStiffness(0);
// Snaps the bodies onto the skeleton — which has not moved in world
// space, so this costs nothing and cannot fling anything.
ragdoll.setMode('driven');
if (state) {
state.x = _pelvis.x;
state.z = _pelvis.z;
state.yaw = yaw;
state.vx = 0;
state.vz = 0;
}
proxy?.enable(_pelvis.x, _pelvis.z);
animator.rebase(HIT.riseTime);
},
dispose() {
stick.destroy(physics?.api);
gear.destroy();
for (const m of Object.values(gearMats)) m.dispose();
if (ragdoll) ragdoll.destroy();
if (proxy) proxy.destroy();
scene.remove(mover);
disposeObject(mover);
},
};
return skater;
}
+693
View File
@@ -0,0 +1,693 @@
import * as THREE from 'three';
import { mergeGeoms } from '../core/math.js';
import { PART } from './body.js';
import { computeSkin } from './skinning.js';
import { carvedShell, loft, mergeBars, tint, tube } from './gearMesh.js';
/**
* Skater equipment, in layers.
*
* A hockey player is dressed, not painted, and the order is the order it goes
* on in a dressing room:
*
* 1. shoulder pads and elbow caps — the under layer that gives the torso its
* shape. Mostly hidden, which is the point: the jersey drapes over it.
* 2. jersey — long sleeves, hem past the waist, cut wide enough to clear the
* pads underneath.
* 3. pants — waist-high padded shorts down to just above the knee.
* 4. socks over shin guards, taped at the top and bottom of the wrap.
* 5. skates, gloves, helmet.
*
* ### Skinned vs socketed
*
* Anything that crosses a joint is skinned to the same skeleton the body uses
* (`computeSkin`, then bound as a second SkinnedMesh sharing `skelData`). A
* jersey bolted to the chest bone tears open at the shoulder the first time an
* arm swings; a pant leg bolted to the pelvis passes through the thigh on a
* knee bend. Cloth is authored in rest space, exactly like the body geometry.
*
* Boots, gloves and the helmet are rigid shells that genuinely do not bend, so
* they are socketed to the foot, hand and head bones and cost nothing to skin.
*
* ### Fit
*
* Every radius scales off the physique factors the body loft was built from
* (`bodyGeo.userData.physique`), so a heavy build gets a bigger jersey instead
* of wearing its chest through the front of it.
*/
/** Rest direction the upper arm points, in its own bone space (A-pose). */
const ARM_DIR = {
L: new THREE.Vector3(0.15, -0.252, 0.01).normalize(),
R: new THREE.Vector3(-0.15, -0.252, 0.01).normalize(),
};
/** Rest direction the fingers point, from the hand bone. */
const HAND_DIR = {
L: new THREE.Vector3(0.045, -0.095, 0.008).normalize(),
R: new THREE.Vector3(-0.045, -0.095, 0.008).normalize(),
};
const DOWN = new THREE.Vector3(0, -1, 0);
export const KIT = {
helmet: {
/** Skull centre in head-bone-local space. */
riseY: 0.094,
pushZ: -0.004,
rx: 0.114,
ry: 0.148,
rz: 0.125,
/**
* Polar angle the shell starts at. This is the number that decides whether
* you get a helmet or a beanie: the bottom ring sits at
* riseY ry·cos(phi0), so it has to come out *below* the ear line.
*/
phi0: 0.36,
wall: 0.009,
/** Brow line: everything in front of and below this is open face. */
browY: 0.03,
earY: -0.022,
},
/** Blade bottom, in foot-bone-local metres. Feet plant at y ≈ 0.09. */
bladeY: -0.09,
};
/**
* What the kit covers, as `aT` ranges per body part.
*
* The body underneath a dressed skater is wasted work and a source of
* poke-through: a shoulder rolls, a hip flexes, and a sliver of the layer below
* pushes through a seam. Ludus solved it by dropping the covered body faces
* once the clothing went on, and the same applies here.
*
* Ranges are deliberately short of the seams. A triangle is only dropped when
* *all three* of its vertices are covered, which leaves a one-triangle fringe
* under every edge of the gear — cheap insurance against a gap opening up at
* 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],
// Pants, socks and boots enclose the leg end to end.
[PART.LEG_L]: [0.0, 1.0],
[PART.LEG_R]: [0.0, 1.0],
};
/**
* Drop the body faces the kit covers. Call after `computeSkin` and after the
* body has been painted — it only rewrites the index.
*/
export function hideCoveredBody(geo, coverage = COVERAGE) {
const partAttr = geo.attributes.aPart;
const tAttr = geo.attributes.aT;
if (!partAttr || !tAttr || !geo.index) return geo;
const covered = (v) => {
const range = coverage[partAttr.getX(v)];
if (!range) return false;
const t = tAttr.getX(v);
return t >= range[0] && t <= range[1];
};
const idx = geo.index.array;
const keep = [];
for (let f = 0; f < idx.length; f += 3) {
const a = idx[f];
const b = idx[f + 1];
const c = idx[f + 2];
if (covered(a) && covered(b) && covered(c)) continue;
keep.push(a, b, c);
}
geo.setIndex(keep);
return geo;
}
/**
* @param {*} mats from `buildSkaterGearMaterials`
* @param {*} skelData the skeleton the cloth binds to
* @param {{bulk:number,waistF:number,shoulderF:number,armF:number,legF:number,headF:number}} phys
*/
export function buildSkaterGear(mats, skelData, phys) {
const pieces = [];
const skinned = [];
const disposables = [];
const bulk = phys?.bulk ?? 1;
const shoulder = (phys?.shoulderF ?? 1) * bulk;
const waist = (phys?.waistF ?? 1) * bulk;
const armF = phys?.armF ?? 1;
const legF = phys?.legF ?? 1;
const headF = phys?.headF ?? 1;
const PAL = {
jersey: tint(mats.jersey.color),
accent: tint(mats.accent.color),
trim: tint(mats.trim.color),
pad: tint(mats.pad.color),
tape: tint(mats.tape.color),
};
const V = (x, y, z = 0) => new THREE.Vector3(x, y, z);
const S = (c, rx, rz, e, col) => ({ c, rx, rz, e, col });
function mesh(geo, mat, name) {
const m = new THREE.Mesh(geo, mat);
m.name = name;
m.castShadow = true;
m.receiveShadow = true;
disposables.push(geo);
return m;
}
/** Point a group's Y down a bone's real limb direction. */
function alignTo(group, dir) {
group.quaternion.setFromUnitVectors(DOWN, dir);
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.
*/
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);
skinned.push(m);
pieces.push(m);
return m;
}
// ---- 1. under layer: shoulder pads -------------------------------------
// Sits between skin and jersey. Barely seen, but it is what makes the jersey
// sit square across the shoulders instead of shrink-wrapping the deltoids.
// Kept a clear centimetre inside the jersey at every ring. Two skinned
// meshes never deform identically — their vertices sit in different places,
// so the distance-field solve hands them different weights — and a pad that
// merely *touches* the inside of a sweater will tear through it on a shoulder
// roll. What actually shows is the collar, standing above the neckline.
const padChest = loft([
S(V(0, 1.18, 0.006), 0.156 * bulk, 0.108 * bulk, 4, PAL.pad),
S(V(0, 1.26, 0.008), 0.17 * shoulder, 0.116 * bulk, 4),
S(V(0, 1.335, 0.008), 0.186 * shoulder, 0.12 * bulk, 4),
S(V(0, 1.392, 0.01), 0.16 * shoulder, 0.106 * bulk, 4),
S(V(0, 1.428, 0.012), 0.1 * bulk, 0.09 * bulk, 3),
S(V(0, 1.452, 0.013), 0.094 * bulk, 0.085 * bulk, 3),
], { radial: 16, sub: 3, part: PART.TORSO, t0: 0.5, t1: 0.96 });
skin([padChest], mats.padded, 'shoulderPads');
// Deltoid caps ride the upper arms so they follow the shoulder, not the ribs.
function makeCap(side) {
const g = new THREE.Group();
g.name = `shoulderCap${side}`;
// Kept under the sleeve radius at every ring: the cap is rigid on the bone
// and the sleeve is skinned, so anything close to the same size pushes
// through the cloth the moment the arm swings.
const cap = loft([
S(V(0, 0.04, 0.008), 0.062 * armF, 0.058 * armF, 3, PAL.pad),
S(V(0, -0.025, 0.01), 0.074 * armF, 0.07 * armF, 4),
S(V(0, -0.09, 0.008), 0.068 * armF, 0.064 * armF, 4),
S(V(0, -0.14, 0.006), 0.054 * armF, 0.05 * armF, 3),
], { radial: 14, sub: 3 });
g.add(mesh(cap, mats.padded, `shoulderCap${side}Shell`));
alignTo(g, ARM_DIR[side]);
pieces.push(g);
return g;
}
const capL = makeCap('L');
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');
// ---- 3. pants -----------------------------------------------------------
// Waist-high padded shorts: a hip shell plus two thigh tubes that stop above
// the knee. Stiff, so they are wide and barely taper.
const pantParts = [];
pantParts.push(loft([
S(V(0, 1.115, 0.004), 0.178 * waist, 0.132 * waist, 4, PAL.trim),
S(V(0, 1.09, 0.004), 0.186 * waist, 0.138 * waist, 4),
S(V(0, 1.08, 0.004), 0.19 * waist, 0.142 * waist, 4, PAL.accent),
S(V(0, 1.055, 0.005), 0.196 * waist, 0.146 * waist, 4),
S(V(0, 1.045, 0.005), 0.198 * waist, 0.148 * waist, 4, PAL.trim),
S(V(0, 0.99, 0.005), 0.205 * bulk, 0.152 * bulk, 5),
S(V(0, 0.94, 0.005), 0.207 * bulk, 0.154 * bulk, 5),
S(V(0, 0.90, 0.004), 0.198 * bulk, 0.146 * bulk, 5),
], { radial: 18, sub: 3, part: PART.TORSO, t0: 0.02, t1: 0.34 }));
for (const side of ['L', 'R']) {
const s = side === 'L' ? 1 : -1;
const P = (x, y, z = 0) => V(s * x, y, z);
pantParts.push(loft([
S(P(0.098, 0.97, 0.004), 0.142 * legF, 0.132 * legF, 4, PAL.trim),
S(P(0.112, 0.90, 0.006), 0.138 * legF, 0.13 * legF, 4),
S(P(0.12, 0.80, 0.008), 0.13 * legF, 0.122 * legF, 4),
S(P(0.126, 0.71, 0.008), 0.122 * legF, 0.114 * legF, 4),
S(P(0.127, 0.688, 0.008), 0.119 * legF, 0.111 * legF, 4, PAL.accent),
S(P(0.128, 0.668, 0.008), 0.116 * legF, 0.108 * legF, 4),
S(P(0.1285, 0.658, 0.008), 0.114 * legF, 0.106 * legF, 4, PAL.trim),
S(P(0.129, 0.645, 0.008), 0.112 * legF, 0.104 * legF, 4),
], {
radial: 14,
sub: 3,
part: side === 'L' ? PART.LEG_L : PART.LEG_R,
t0: 0.02,
t1: 0.34,
}));
}
skin(pantParts, mats.padded, 'pants');
// ---- 4. socks over shin guards -----------------------------------------
// The sock is the visible layer; the guard underneath is read as the bulge at
// the knee and the flat down the front of the shin. Tape bands at the top and
// bottom of the wrap, where a player actually tapes.
const sockParts = [];
for (const side of ['L', 'R']) {
const s = side === 'L' ? 1 : -1;
const part = side === 'L' ? PART.LEG_L : PART.LEG_R;
const P = (x, y, z = 0) => V(s * x, y, z);
sockParts.push(loft([
S(P(0.124, 0.735, 0.008), 0.098 * legF, 0.094 * legF, 3, PAL.jersey),
S(P(0.128, 0.66, 0.01), 0.094 * legF, 0.09 * legF, 3),
// Tape at the top of the wrap.
S(P(0.129, 0.638, 0.01), 0.093 * legF, 0.089 * legF, 3, PAL.tape),
S(P(0.13, 0.60, 0.012), 0.092 * legF, 0.088 * legF, 3),
S(P(0.13, 0.578, 0.012), 0.092 * legF, 0.088 * legF, 3, PAL.jersey),
// Knee.
S(P(0.131, 0.53, 0.016), 0.096 * legF, 0.094 * legF, 3),
S(P(0.132, 0.45, 0.014), 0.086 * legF, 0.082 * legF, 3),
S(P(0.133, 0.35, 0.01), 0.079 * legF, 0.074 * legF, 3),
S(P(0.133, 0.26, 0.006), 0.072 * legF, 0.066 * legF, 3),
// Tape at the bottom of the wrap.
S(P(0.133, 0.232, 0.005), 0.07 * legF, 0.064 * legF, 3, PAL.tape),
S(P(0.132, 0.20, 0.004), 0.068 * legF, 0.062 * legF, 3),
S(P(0.132, 0.18, 0.003), 0.066 * legF, 0.06 * legF, 3, PAL.jersey),
S(P(0.131, 0.135, 0.002), 0.06 * legF, 0.056 * legF, 3),
S(P(0.131, 0.105, 0.004), 0.056 * legF, 0.052 * legF, 3, PAL.trim),
], { radial: 14, sub: 3, part, t0: 0.30, t1: 0.87 }));
// Knee cap: a dome off the front of the wrap.
sockParts.push(loft([
S(P(0.131, 0.545, 0.02), 0.062 * legF, 0.058 * legF, 3, PAL.jersey),
S(P(0.131, 0.542, 0.058), 0.07 * legF, 0.066 * legF, 3),
S(P(0.131, 0.538, 0.088), 0.058 * legF, 0.054 * legF, 3),
S(P(0.131, 0.534, 0.104), 0.03 * legF, 0.028 * legF, 3),
], { radial: 14, sub: 3, part, t0: 0.48, t1: 0.54 }));
}
skin(sockParts, mats.cloth, 'socks');
// ---- 5. skates ----------------------------------------------------------
// Foot-bone local: +Z is forward past the toe, the sole sits a little under
// the bone, the blade hangs where the ice is.
function makeSkate(side) {
const g = new THREE.Group();
g.name = `skate${side}`;
const boot = loft([
S(V(0, -0.014, -0.088), 0.036, 0.042, 4, PAL.trim),
S(V(0, -0.02, -0.05), 0.046, 0.05, 4),
S(V(0, -0.026, 0.01), 0.05, 0.048, 4),
S(V(0, -0.03, 0.07), 0.048, 0.042, 4),
S(V(0, -0.034, 0.125), 0.04, 0.032, 4),
S(V(0, -0.038, 0.162), 0.022, 0.018, 3),
], { radial: 16, sub: 4 });
g.add(mesh(boot, mats.hard, `skate${side}Boot`));
// Ankle cuff — the kit stops at the ankle, as asked.
const cuff = loft([
S(V(0, -0.012, -0.05), 0.048, 0.05, 4, PAL.trim),
S(V(0, 0.03, -0.045), 0.05, 0.048, 4),
S(V(0, 0.062, -0.038), 0.047, 0.044, 4, PAL.pad),
S(V(0, 0.078, -0.032), 0.041, 0.038, 3),
], { radial: 14, sub: 3 });
g.add(mesh(cuff, mats.hard, `skate${side}Cuff`));
// Tongue up the front of the ankle.
const tongue = loft([
S(V(0, -0.01, 0.03), 0.03, 0.014, 3, PAL.trim),
S(V(0, 0.03, 0.012), 0.033, 0.015, 3),
S(V(0, 0.07, 0.0), 0.031, 0.014, 3, PAL.accent),
], { radial: 10, sub: 3 });
g.add(mesh(tongue, mats.hard, `skate${side}Tongue`));
// Holder: two posts off the sole down to the runner.
const holder = [];
for (const z of [-0.045, 0.085]) {
holder.push(tube([
V(0, -0.05, z),
V(0, -0.062, z + (z < 0 ? 0.008 : -0.008)),
V(0, -0.072, z + (z < 0 ? 0.012 : -0.012)),
], 0.011, { radial: 6 }));
}
holder.push(tube([
V(0, -0.073, -0.075), V(0, -0.076, 0), V(0, -0.073, 0.13),
], 0.008, { radial: 6 }));
g.add(mesh(mergeBars(holder), mats.holder, `skate${side}Holder`));
// Runner: a thin steel blade with the toe and heel curling up off the ice.
const blade = loft([
S(V(0, KIT.bladeY + 0.028, -0.108), 0.0035, 0.012, 3, PAL.trim),
S(V(0, KIT.bladeY + 0.012, -0.088), 0.0035, 0.013, 3),
S(V(0, KIT.bladeY + 0.012, 0.12), 0.0035, 0.013, 3),
S(V(0, KIT.bladeY + 0.03, 0.145), 0.0035, 0.012, 3),
], { radial: 6, sub: 4 });
g.add(mesh(blade, mats.steel, `skate${side}Blade`));
// Laces.
const laces = [];
for (const y of [0.0, 0.022, 0.044]) {
laces.push(tube([
V(-0.03, y - 0.005, 0.03 - y * 0.4),
V(0, y + 0.004, 0.022 - y * 0.4),
V(0.03, y - 0.005, 0.03 - y * 0.4),
], 0.004, { radial: 5 }));
}
g.add(mesh(mergeBars(laces), mats.lace, `skate${side}Laces`));
pieces.push(g);
return g;
}
const skateL = makeSkate('L');
const skateR = makeSkate('R');
// ---- 6. gloves ----------------------------------------------------------
// Glove space: fingers down Y, back of the hand +Z, then rotated onto the
// hand bone's real axis. The stick is aimed from the same bone, so the glove
// has to stay a shell around the hand and not swallow the shaft.
function makeGlove(side) {
const s = side === 'L' ? 1 : -1;
const g = new THREE.Group();
g.name = `glove${side}`;
const body = loft([
// Flared cuff roll at the wrist.
S(V(0, 0.085, -0.004), 0.056, 0.054, 3, PAL.trim),
S(V(0, 0.062, -0.002), 0.068, 0.064, 3, PAL.accent),
S(V(0, 0.03, 0.002), 0.074, 0.068, 3),
S(V(0, 0.012, 0.004), 0.076, 0.07, 3, PAL.jersey),
S(V(0, -0.04, 0.01), 0.08, 0.068, 4),
S(V(0, -0.105, 0.014), 0.082, 0.066, 4),
S(V(0, -0.16, 0.014), 0.076, 0.06, 4),
S(V(0, -0.19, 0.012), 0.062, 0.05, 4, PAL.trim),
S(V(0, -0.215, 0.008), 0.042, 0.034, 3),
], { radial: 16, sub: 4 });
g.add(mesh(body, mats.hard, `glove${side}Body`));
// Backhand rolls — the padded ridges across the knuckles.
for (const [y, r] of [[-0.06, 0.026], [-0.115, 0.024]]) {
const roll = loft([
S(V(-s * 0.058, y + 0.012, 0.05), r * 0.8, r * 0.7, 3, PAL.accent),
S(V(0, y, 0.062), r, r * 0.9, 3),
S(V(s * 0.058, y + 0.012, 0.05), r * 0.8, r * 0.7, 3),
], { radial: 10, sub: 4 });
g.add(mesh(roll, mats.hard, `glove${side}Roll`));
}
// Thumb, curling toward the shaft.
const thumb = loft([
S(V(s * 0.058, -0.005, 0.03), 0.03, 0.028, 3, PAL.jersey),
S(V(s * 0.09, -0.065, 0.052), 0.028, 0.026, 3),
S(V(s * 0.092, -0.12, 0.066), 0.023, 0.022, 3, PAL.trim),
], { radial: 10, sub: 4 });
g.add(mesh(thumb, mats.hard, `glove${side}Thumb`));
alignTo(g, HAND_DIR[side]);
g.rotateY(s * 0.25);
pieces.push(g);
return g;
}
const gloveL = makeGlove('L');
const gloveR = makeGlove('R');
// ---- 7. helmet ----------------------------------------------------------
// Same carved-shell builder as the goalie mask, cut differently: the whole
// lower front is open face, with ear ports at the sides.
const H = KIT.helmet;
const skull = new THREE.Vector3(0, H.riseY, H.pushZ);
function helmetSurface(theta, v, out) {
const phi = H.phi0 + (Math.PI - H.phi0) * v;
const sp = Math.sin(phi);
const cp = Math.cos(phi);
const f = Math.cos(theta);
const sx = Math.sin(theta);
const front = Math.max(0, f);
const back = Math.max(0, -f);
let rx = H.rx * headF;
let rz = H.rz * headF;
// Occipital shell carries out over the back of the skull.
rz *= 1 + 0.10 * back * v;
// Slight flat across the forehead.
rz *= 1 - 0.06 * front * front * v;
const x = rx * sp * sx;
const y = -H.ry * headF * cp;
let z = rz * sp * f;
// Brow lip juts forward over the eyes.
const lip = Math.exp(-(((v - 0.08) / 0.12) ** 2)) * front ** 2;
z += 0.008 * lip;
return out.set(skull.x + x, skull.y + y, skull.z + z);
}
/** Open face below the brow, plus a port over each ear. */
const helmetPort = (p) => {
const dy = p.y - skull.y;
const dz = p.z - skull.z;
const ax = Math.abs(p.x);
// The face: front-centre below the brow. Narrow, so the shell keeps its
// cheek coverage instead of turning into a cap.
if (dz > 0.028 && dy < H.browY && ax < 0.072) return true;
// Ear ports, covered by the cups.
if (ax > 0.088 && dy < H.earY + 0.026 && dy > H.earY - 0.042 && Math.abs(dz + 0.014) < 0.038) {
return true;
}
return false;
};
const helmetColor = (p, kind) => {
if (kind === 'inner') return PAL.pad;
const dy = p.y - skull.y;
// Dark brim around the bottom edge of the shell.
if (dy < -0.028) return PAL.trim;
// Centre stripe over the crown.
if (Math.abs(p.x) < 0.019 && dy > 0.03) return PAL.accent;
return PAL.jersey;
};
const helmet = new THREE.Group();
helmet.name = 'helmet';
helmet.add(mesh(
carvedShell({
rows: 26,
cols: 36,
thickness: H.wall,
center: skull,
surface: helmetSurface,
port: helmetPort,
color: helmetColor,
}),
mats.hard,
'helmetShell',
));
// Ear cups over the ports, on their own straps.
for (const s of [1, -1]) {
const cup = loft([
S(V(s * 0.09, skull.y + H.earY, skull.z - 0.014), 0.028, 0.026, 3, PAL.trim),
S(V(s * 0.104, skull.y + H.earY, skull.z - 0.014), 0.03, 0.028, 3),
S(V(s * 0.111, skull.y + H.earY, skull.z - 0.014), 0.023, 0.021, 3),
], { radial: 12, sub: 3, ref: new THREE.Vector3(0, 1, 0) });
helmet.add(mesh(cup, mats.hard, 'helmetEar'));
}
// Chin strap under the jaw.
helmet.add(mesh(
tube([
V(-0.105, skull.y + H.earY - 0.012, skull.z - 0.01),
V(-0.07, skull.y - 0.12, skull.z + 0.03),
V(0, skull.y - 0.145, skull.z + 0.05),
V(0.07, skull.y - 0.12, skull.z + 0.03),
V(0.105, skull.y + H.earY - 0.012, skull.z - 0.01),
], 0.006, { radial: 6 }),
mats.strap,
'helmetStrap',
));
// Half visor: eye level only. Run it down over the whole face and the player
// reads as a welder.
{
const arc = [];
for (let i = 0; i <= 10; i++) {
const a = -0.82 + (1.64 * i) / 10;
arc.push(V(
Math.sin(a) * 0.106 * headF,
skull.y + 0.004,
skull.z + Math.cos(a) * 0.116 * headF,
));
}
// The ring axes here are u = up, w = front-to-back, so `rx` is the shield's
// height and `rz` is its thickness. Swap those two and you get a shelf
// sticking out of the face instead of a shield hanging over the eyes.
const visor = loft(
arc.map((c, i) => S(c, i === 0 || i === arc.length - 1 ? 0.026 : 0.038, 0.003, 3)),
{ radial: 8, sub: 2, ref: new THREE.Vector3(0, 1, 0) },
);
helmet.add(mesh(visor, mats.visor, 'helmetVisor'));
}
pieces.push(helmet);
return {
padChest,
capL,
capR,
skateL,
skateR,
gloveL,
gloveR,
helmet,
/** Skinned cloth meshes — these go on the mover, not on a bone. */
skinned,
pieces,
attachTo(bones, mover) {
for (const m of skinned) mover.add(m);
bones.upperArmL.add(capL);
bones.upperArmR.add(capR);
bones.footL.add(skateL);
bones.footR.add(skateR);
bones.handL.add(gloveL);
bones.handR.add(gloveR);
bones.head.add(helmet);
},
destroy() {
for (const p of pieces) p.removeFromParent();
for (const g of disposables) g.dispose();
},
};
}
export function buildSkaterGearMaterials(teamJersey, teamAccent = 0xf0e6d2) {
return {
/** Cloth: jersey, socks. Vertex-coloured, matte. */
cloth: new THREE.MeshStandardMaterial({
color: 0xffffff,
vertexColors: true,
roughness: 0.88,
metalness: 0.0,
}),
/** Padded shells: pants, shoulder pads. */
padded: new THREE.MeshStandardMaterial({
color: 0xffffff,
vertexColors: true,
roughness: 0.72,
metalness: 0.02,
}),
/** Hard shells: helmet, skate boots, gloves. */
hard: new THREE.MeshStandardMaterial({
color: 0xffffff,
vertexColors: true,
roughness: 0.38,
metalness: 0.06,
}),
steel: new THREE.MeshStandardMaterial({
color: 0xc8ccd4,
roughness: 0.22,
metalness: 0.85,
}),
holder: new THREE.MeshStandardMaterial({
color: 0x16181d,
roughness: 0.45,
metalness: 0.1,
}),
lace: new THREE.MeshStandardMaterial({ color: 0xdad6cc, roughness: 0.9 }),
strap: new THREE.MeshStandardMaterial({ color: 0x14141a, roughness: 0.85 }),
visor: new THREE.MeshPhysicalMaterial({
color: 0x9fb8c8,
roughness: 0.08,
metalness: 0.0,
transparent: true,
opacity: 0.32,
side: THREE.DoubleSide,
}),
// Colour sources for the vertex-painted pieces.
jersey: new THREE.MeshStandardMaterial({ color: teamJersey }),
accent: new THREE.MeshStandardMaterial({ color: teamAccent }),
trim: new THREE.MeshStandardMaterial({ color: 0x16181d }),
pad: new THREE.MeshStandardMaterial({ color: 0x3a3f4a }),
tape: new THREE.MeshStandardMaterial({ color: 0xe8e4d8 }),
};
}
+114
View File
@@ -0,0 +1,114 @@
import * as THREE from 'three';
import { V3, assert } from '../core/math.js';
// [name, parent, local offset] — rest local rotations are all identity, so the
// rest pose is an A-pose and every rest world position falls out of the offsets.
export const BONEDEF = [
['root', null, [0, 0, 0]],
['pelvis', 'root', [0, 1.0, 0]],
['spine1', 'pelvis', [0, 0.09, 0.004]],
['spine2', 'spine1', [0, 0.12, 0.005]],
['spine3', 'spine2', [0, 0.13, 0.005]],
['neck', 'spine3', [0, 0.1, 0.012]],
['head', 'neck', [0, 0.075, 0.008]],
['clavicleL', 'spine3', [0.075, 0.048, 0]],
['upperArmL', 'clavicleL', [0.135, -0.022, 0]],
['forearmL', 'upperArmL', [0.15, -0.252, 0.01]],
['handL', 'forearmL', [0.105, -0.227, 0.016]],
['clavicleR', 'spine3', [-0.075, 0.048, 0]],
['upperArmR', 'clavicleR', [-0.135, -0.022, 0]],
['forearmR', 'upperArmR', [-0.15, -0.252, 0.01]],
['handR', 'forearmR', [-0.105, -0.227, 0.016]],
['thighL', 'pelvis', [0.105, -0.05, 0.005]],
['shinL', 'thighL', [0.02, -0.44, 0.006]],
['footL', 'shinL', [0.005, -0.437, -0.012]],
['toeL', 'footL', [-0.004, -0.055, 0.112]],
['thighR', 'pelvis', [-0.105, -0.05, 0.005]],
['shinR', 'thighR', [-0.02, -0.44, 0.006]],
['footR', 'shinR', [-0.005, -0.437, -0.012]],
['toeR', 'footR', [0.004, -0.055, 0.112]],
];
/** Child bone that defines each bone's capsule segment axis. */
export const SEG_CHILD = {
pelvis: 'spine1', spine1: 'spine2', spine2: 'spine3', spine3: 'neck', neck: 'head',
clavicleL: 'upperArmL', upperArmL: 'forearmL', forearmL: 'handL',
clavicleR: 'upperArmR', upperArmR: 'forearmR', forearmR: 'handR',
thighL: 'shinL', shinL: 'footL', footL: 'toeL',
thighR: 'shinR', shinR: 'footR', footR: 'toeR',
root: null, head: null, handL: null, handR: null, toeL: null, toeR: null,
};
/** Per-bone skin influence radius for the capsule falloff. */
export const BONE_RADIUS = {
root: 0.2, pelvis: 0.175, spine1: 0.165, spine2: 0.17, spine3: 0.175, neck: 0.08, head: 0.125,
clavicleL: 0.07, upperArmL: 0.078, forearmL: 0.068, handL: 0.06,
clavicleR: 0.07, upperArmR: 0.078, forearmR: 0.068, handR: 0.06,
thighL: 0.125, shinL: 0.098, footL: 0.075, toeL: 0.055,
thighR: 0.125, shinR: 0.098, footR: 0.075, toeR: 0.055,
};
/**
* Body regions from GDD 5.3. Every bone belongs to exactly one region, and
* damage, armor coverage and ragdoll limb-disable all key off these.
*/
export const REGION = {
HEAD: 'head',
TORSO: 'torso',
UPPER_ARM_L: 'upperArmL', LOWER_ARM_L: 'lowerArmL',
UPPER_ARM_R: 'upperArmR', LOWER_ARM_R: 'lowerArmR',
UPPER_LEG_L: 'upperLegL', LOWER_LEG_L: 'lowerLegL',
UPPER_LEG_R: 'upperLegR', LOWER_LEG_R: 'lowerLegR',
};
export const BONE_REGION = {
head: REGION.HEAD, neck: REGION.HEAD,
pelvis: REGION.TORSO, spine1: REGION.TORSO, spine2: REGION.TORSO, spine3: REGION.TORSO,
clavicleL: REGION.TORSO, clavicleR: REGION.TORSO,
upperArmL: REGION.UPPER_ARM_L, forearmL: REGION.LOWER_ARM_L, handL: REGION.LOWER_ARM_L,
upperArmR: REGION.UPPER_ARM_R, forearmR: REGION.LOWER_ARM_R, handR: REGION.LOWER_ARM_R,
thighL: REGION.UPPER_LEG_L, shinL: REGION.LOWER_LEG_L, footL: REGION.LOWER_LEG_L, toeL: REGION.LOWER_LEG_L,
thighR: REGION.UPPER_LEG_R, shinR: REGION.LOWER_LEG_R, footR: REGION.LOWER_LEG_R, toeR: REGION.LOWER_LEG_R,
};
export function buildSkeleton() {
const bones = {};
const list = [];
for (const [name, parentName, off] of BONEDEF) {
const b = new THREE.Bone();
b.name = name;
b.position.set(off[0], off[1], off[2]);
if (parentName) bones[parentName].add(b);
bones[name] = b;
list.push(b);
}
const root = bones.root;
root.updateMatrixWorld(true);
const restWorld = {};
for (const b of list) restWorld[b.name] = b.getWorldPosition(new THREE.Vector3());
const skeleton = new THREE.Skeleton(list);
const index = {};
list.forEach((b, i) => { index[b.name] = i; });
return { bones, list, index, skeleton, restWorld, rootBone: root };
}
/** The capsule segment a bone deforms, in rest world space. */
export function boneSegment(name, restWorld) {
const a = restWorld[name];
const child = SEG_CHILD[name];
let b;
if (child) b = restWorld[child];
else if (name === 'head') b = a.clone().add(V3(0, 0.15, 0.012));
else if (name.startsWith('hand')) {
const s = name.endsWith('L') ? 1 : -1;
b = a.clone().add(V3(s * 0.045, -0.095, 0.008));
} else b = a.clone().add(V3(0, -0.012, 0.085)); // toes
return { a, b, r: BONE_RADIUS[name] };
}
export function assertNoNaNBones(skelData) {
for (const b of skelData.list) {
const e = b.matrixWorld.elements;
for (let i = 0; i < 16; i++) assert(Number.isFinite(e[i]), 'NaN in bone matrix ' + b.name);
}
}
+213
View File
@@ -0,0 +1,213 @@
import * as THREE from 'three';
import { assert, clamp, segDist } from '../core/math.js';
import { PART } from './body.js';
import { boneSegment } from './skeleton.js';
const TORSO_BONES = new Set([
'pelvis', 'spine1', 'spine2', 'spine3', 'neck', 'clavicleL', 'clavicleR',
]);
const HEAD_BONES = new Set(['spine3', 'neck', 'head']);
const ARM_L_BONES = new Set(['spine3', 'clavicleL', 'upperArmL', 'forearmL', 'handL']);
const ARM_R_BONES = new Set(['spine3', 'clavicleR', 'upperArmR', 'forearmR', 'handR']);
const LEG_L_BONES = new Set(['pelvis', 'thighL', 'shinL', 'footL', 'toeL']);
const LEG_R_BONES = new Set(['pelvis', 'thighR', 'shinR', 'footR', 'toeR']);
const PART_BONES = {
[PART.TORSO]: TORSO_BONES,
[PART.HEAD]: HEAD_BONES,
[PART.ARM_L]: ARM_L_BONES,
[PART.ARM_R]: ARM_R_BONES,
[PART.LEG_L]: LEG_L_BONES,
[PART.LEG_R]: LEG_R_BONES,
};
/**
* Keep the distance field inside the authored body region.
*
* The body lofts overlap at the shoulders and hips. Distance alone therefore
* gives some chest vertices almost entirely to an upper-arm bone and lets one
* thigh influence the other leg. Those weights look plausible in the rest
* pose, but pull the armpit into a spike and shear the legs as the pelvis turns.
*
* The top of each leg is an authored pelvis/thigh blend. Leg IK cancels pelvis
* rotation in the thigh's local transform, so letting the pelvis own that whole
* band would leave the skin behind even after opposite-side bleed is removed.
*/
function constrainPartWeights(wAll, vertex, segs, part, t, point, closestPoint) {
const allowed = PART_BONES[part];
if (!allowed) return;
const base = vertex * segs.length;
let allowedTotal = 0;
for (let s = 0; s < segs.length; s++) {
if (!allowed.has(segs[s].name)) wAll[base + s] = 0;
else allowedTotal += wAll[base + s];
}
// A wide generated silhouette can sit outside every same-region capsule
// even though an overlapping limb capsule reached it. Never let semantic
// filtering turn that valid distance-field result into an unbound vertex.
if (allowedTotal <= 1e-6) {
let nearest = -1;
let nearestDistance = Infinity;
for (let s = 0; s < segs.length; s++) {
if (!allowed.has(segs[s].name)) continue;
const distance = segDist(point, segs[s].a, segs[s].b, closestPoint);
if (distance < nearestDistance) {
nearest = s;
nearestDistance = distance;
}
}
assert(nearest >= 0, `part ${part} has no valid skin bones`);
wAll[base + nearest] = 1;
}
const side = part === PART.LEG_L ? 'L' : part === PART.LEG_R ? 'R' : null;
if (!side || t > 0.2) return;
// Pelvis-led at the groin cap, easing to full thigh ownership below the
// crease. The thigh share is enough to follow IK without opening a hip seam.
const u = clamp(t / 0.2, 0, 1);
const eased = u * u * (3 - 2 * u);
const thighWeight = 0.25 + 0.75 * eased;
for (let s = 0; s < segs.length; s++) wAll[base + s] = 0;
wAll[base + segs.findIndex((seg) => seg.name === 'pelvis')] = 1 - thighWeight;
wAll[base + segs.findIndex((seg) => seg.name === `thigh${side}`)] = thighWeight;
}
/**
* Capsule-segment distance falloff with a Laplacian smoothing pass.
*
* The raw falloff alone produces candy-wrapper collapse at the joints, because
* neighbouring vertices can land on very different influence sets. Smoothing
* over mesh adjacency before the top-4 reduction fixes that without needing
* hand-painted weights.
*/
export function computeSkin(geo, skelData) {
const pos = geo.attributes.position;
const partAttr = geo.attributes.aPart;
const tAttr = geo.attributes.aT;
const n = pos.count;
const bones = skelData.list;
const boneIndex = skelData.index;
const segs = [];
for (const b of bones) {
if (b.name === 'root') continue;
const s = boneSegment(b.name, skelData.restWorld);
segs.push({ name: b.name, idx: boneIndex[b.name], a: s.a, b: s.b, r: s.r });
}
const S = segs.length;
const wAll = new Float32Array(n * S);
const p = new THREE.Vector3();
const cp = new THREE.Vector3();
for (let i = 0; i < n; i++) {
p.fromBufferAttribute(pos, i);
let maxW = 0;
for (let s = 0; s < S; s++) {
const seg = segs[s];
const d = segDist(p, seg.a, seg.b, cp);
const x = clamp(1 - (d / seg.r) * (d / seg.r), 0, 1);
const w = x * x; // smooth compact support inside the influence radius
wAll[i * S + s] = w;
if (w > maxW) maxW = w;
}
if (maxW <= 1e-6) {
// Outside every capsule: hard-bind to the nearest segment.
let bd = 1e9;
let bs = 0;
for (let s = 0; s < S; s++) {
const d = segDist(p, segs[s].a, segs[s].b, cp);
if (d < bd) { bd = d; bs = s; }
}
wAll[i * S + bs] = 1;
}
if (partAttr && tAttr) {
constrainPartWeights(wAll, i, segs, partAttr.getX(i), tAttr.getX(i), p, cp);
}
}
const adj = new Array(n);
for (let i = 0; i < n; i++) adj[i] = [];
const idx = geo.index.array;
for (let f = 0; f < idx.length; f += 3) {
const a = idx[f];
const b = idx[f + 1];
const c = idx[f + 2];
adj[a].push(b, c);
adj[b].push(a, c);
adj[c].push(a, b);
}
const tmp = new Float32Array(S);
for (let iter = 0; iter < 3; iter++) {
const prev = wAll.slice();
for (let i = 0; i < n; i++) {
const nb = adj[i];
if (!nb.length) continue;
tmp.fill(0);
for (const j of nb) {
for (let s = 0; s < S; s++) tmp[s] += prev[j * S + s];
}
const inv = 1 / nb.length;
for (let s = 0; s < S; s++) wAll[i * S + s] = prev[i * S + s] * 0.55 + tmp[s] * inv * 0.45;
}
}
const skinIndex = new Uint16Array(n * 4);
const skinWeight = new Float32Array(n * 4);
for (let i = 0; i < n; i++) {
const tops = [];
for (let s = 0; s < S; s++) {
const w = wAll[i * S + s];
if (w <= 1e-5) continue;
tops.push([w, s]);
}
tops.sort((a, b) => b[0] - a[0]);
let total = 0;
for (let k = 0; k < 4; k++) {
if (k < tops.length) {
skinIndex[i * 4 + k] = segs[tops[k][1]].idx;
skinWeight[i * 4 + k] = tops[k][0];
total += tops[k][0];
}
}
assert(total > 0, 'vertex ' + i + ' has zero total skin weight');
for (let k = 0; k < 4; k++) skinWeight[i * 4 + k] /= total;
}
geo.setAttribute('skinIndex', new THREE.BufferAttribute(skinIndex, 4));
geo.setAttribute('skinWeight', new THREE.BufferAttribute(skinWeight, 4));
// Debug heatmap: dominant bone hue, brightness by weight.
const colors = new Float32Array(n * 3);
const col = new THREE.Color();
for (let i = 0; i < n; i++) {
let bw = 0;
let bi = 0;
for (let k = 0; k < 4; k++) {
if (skinWeight[i * 4 + k] > bw) { bw = skinWeight[i * 4 + k]; bi = skinIndex[i * 4 + k]; }
}
col.setHSL((bi * 0.61803) % 1, 0.85, 0.25 + 0.45 * bw);
colors[i * 3] = col.r;
colors[i * 3 + 1] = col.g;
colors[i * 3 + 2] = col.b;
}
geo.setAttribute('color', new THREE.BufferAttribute(colors, 3));
}
/**
* CPU skinning of one vertex, matching the GPU path exactly:
* out = bindInverse * (sum_k w_k * boneMatrix_k) * bind * v
* Used by the skirt push-out guard and by armor debris baking, both of which
* need posed world positions on the JS side.
*/
export function skinVertex(out, base, i, siAttr, swAttr, boneMats, bind, bindInv, scratchMat) {
const te = scratchMat.elements;
te.fill(0);
for (let k = 0; k < 4; k++) {
const w = swAttr.getComponent(i, k);
if (w === 0) continue;
const ae = boneMats[siAttr.getComponent(i, k)].elements;
for (let e = 0; e < 16; e++) te[e] += ae[e] * w;
}
return out.fromArray(base, i * 3).applyMatrix4(bind).applyMatrix4(scratchMat).applyMatrix4(bindInv);
}
+296
View File
@@ -0,0 +1,296 @@
import * as THREE from 'three';
import { KIND, makeTag, quat, stickFilter, transform, vec3 } from '../physics/bridge.js';
/**
* A hockey stick, socketed to the hand.
*
* ### What changed, and why it matters
*
* The first version hung the stick off the mover and positioned it so the blade
* sat wherever the puck was being carried. That put the blade in the right
* place and the hands nowhere near it — the stick floated.
*
* Now it is parented to a socket on the right hand bone, the way Ludus sockets
* a weapon, and the geometry is authored in *grip space*: the origin is the top
* hand, the shaft runs down Y, the blade is at the far end. The hand carries
* the stick, which is the correct dependency order — a player's hands decide
* where their stick is, not the other way round.
*
* That inverts the puck relationship too. `possession` no longer picks a carry
* point and drags the stick to it; it reads where the blade actually is and
* carries the puck there. Stickhandling is an arm pose, which is what it is in
* real life.
*
* ### Aimed, not bolted
*
* The stick is *aimed* from the hand at a per-stance target rather than bolted
* on at a per-stance rotation. See the note on `GRIP` — a fixed rotation
* composes with whatever the arm is doing and the blade ends up in the air.
*/
export const STICK = {
/** Butt (top hand) to heel of the blade. */
shaftLength: 1.10,
shaftRadius: 0.016,
bladeLength: 0.31,
bladeHeight: 0.075,
bladeThickness: 0.022,
/** How far down the shaft the lower hand grips, 0 = butt, 1 = heel. */
lowerHandAt: 0.28,
};
/**
* Stances, as a blade *target* in the skater's local frame plus a roll about
* the shaft.
*
* The obvious authoring — a fixed rotation in the hand's bone space — does not
* survive contact with an animated arm. That rotation composes with the hand's
* own world rotation, so a socket tuned to put the blade on the ice for one arm
* pose swings it into the air the moment the arm moves, and every stride is a
* different arm pose. Measured: the blade sat between 0.55 m and 0.97 m off the
* ice depending on gait.
*
* Aiming at a target instead makes the constraint the thing we actually care
* about — "the blade is on the ice, this far ahead" — and leaves the wrist
* angle as the free variable, which is what a wrist is for. `roll` is the blade
* face angle about the shaft, which is the part that genuinely is authored.
*
* +X is the skater's left, +Z is forward, so a right-hander carries at X.
*/
export const GRIP = {
/**
* Normal carry: blade on the ice, in front and a little to the forehand
* side — the "puck carry while skating" frame on the reference sheet, not
* parked on the hip. Kept close enough that the off-hand can reach the shaft.
*/
carry: { target: [-0.16, 0.03, 0.70], roll: 0.08 },
/** Hustling: stick dangles out in front on one hand. */
hustle: { target: [-0.14, 0.03, 1.05], roll: 0.14 },
/**
* Wind-up: blade high and back behind the head, not hanging down from the
* hands. y well above the shoulders, z behind the body.
*/
windup: { target: [-0.28, 1.55, -0.48], roll: -0.2 },
/** Follow-through: swept across the body and finishing high. */
follow: { target: [0.34, 0.95, 0.85], roll: 0.55 },
/** Poke: thrust out flat, as far ahead as the arm reaches. */
poke: { target: [-0.18, 0.03, 1.42], roll: 0.05 },
};
/** Small fixed offset of the butt from the hand bone. */
const GRIP_OFFSET = [0.015, -0.02, 0.03];
const _euler = new THREE.Euler();
const clampUnit = (v) => (v < -1 ? -1 : v > 1 ? 1 : v);
export function buildStick(materials, physics, index) {
const group = new THREE.Group();
group.name = 'stick';
const wood = new THREE.MeshStandardMaterial({ color: 0x1a1a1e, roughness: 0.5, metalness: 0.05 });
const tape = new THREE.MeshStandardMaterial({ color: 0x111114, roughness: 0.85 });
// Grip space: origin at the butt, shaft straight down Y, blade at the end.
// Everything that aims the stick is a rotation of this group, which keeps the
// geometry itself trivially correct.
const shaft = new THREE.Mesh(
new THREE.CylinderGeometry(STICK.shaftRadius, STICK.shaftRadius * 1.08, STICK.shaftLength, 8),
wood,
);
shaft.position.y = -STICK.shaftLength / 2;
shaft.castShadow = true;
group.add(shaft);
const blade = new THREE.Mesh(
new THREE.BoxGeometry(STICK.bladeThickness, STICK.bladeHeight, STICK.bladeLength),
tape,
);
// Heel at the bottom of the shaft, toe forward, with a little lie angle so it
// sits flat on the ice rather than on its edge.
blade.position.set(0, -STICK.shaftLength - STICK.bladeHeight * 0.35, STICK.bladeLength * 0.4);
blade.rotation.x = 0.34;
blade.castShadow = true;
group.add(blade);
// ---- blade collider ----------------------------------------------------
// Kinematic, driven to the blade's world transform each substep. It knocks a
// loose puck around; a carried puck is the possession model's business.
let body = null;
let shape = null;
/** False until the collider has been put where the blade actually is. */
let placed = false;
if (physics) {
const { api, world } = physics;
const bd = api.b3DefaultBodyDef();
bd.type = api.b3BodyType.b3_kinematicBody;
bd.enableSleep = false;
body = api.b3CreateBody(world, bd);
const sd = api.b3DefaultShapeDef();
sd.density = 700;
sd.enableContactEvents = true;
sd.baseMaterial.friction = 0.3;
sd.baseMaterial.restitution = 0.25;
sd.baseMaterial.userMaterialId = makeTag(KIND.STICK, index, 0);
const filter = stickFilter();
sd.filter.categoryBits = filter.category;
sd.filter.maskBits = filter.mask;
shape = api.b3CreateBoxShape(
body,
sd,
STICK.bladeThickness / 2,
STICK.bladeHeight / 2,
STICK.bladeLength / 2,
);
}
const _bladeWorld = new THREE.Vector3();
const _bladeQuat = new THREE.Quaternion();
const _scratch = new THREE.Vector3();
const _fromPos = new THREE.Vector3();
const _toPos = new THREE.Vector3();
const _aimDir = new THREE.Vector3();
/** Aim direction brought into the hand's bone space. */
const _aimLocal = new THREE.Vector3();
const _aimQuat = new THREE.Quaternion();
const _rollQuat = new THREE.Quaternion();
// The axis that must end up pointing at the target is the grip-to-*blade*
// direction, not the shaft's Y. The blade sits forward of the shaft end by
// the toe offset, which puts it ~6° off axis — aiming Y instead left the
// blade 10 cm above where the height solve said it would be.
const _bladeAxis = blade.position.clone().normalize();
/** Grip origin to blade centre: the stick's effective reach. */
const reach = blade.position.length();
return {
group,
blade,
shaft,
body,
shape,
/** Parent bone once the skeleton exists. */
attachTo(bone) {
bone.add(group);
return group;
},
/**
* Blade target and roll for a blend between two named stances, in the
* skater's local frame. The animator turns this into an aim.
*/
stanceTarget(from, to = from, t = 0, outTarget) {
const a = GRIP[from] ?? GRIP.carry;
const b = GRIP[to] ?? a;
const k = t < 0 ? 0 : t > 1 ? 1 : t;
_fromPos.fromArray(a.target);
_toPos.fromArray(b.target);
outTarget.lerpVectors(_fromPos, _toPos, k);
return a.roll + (b.roll - a.roll) * k;
},
/**
* Point the stick from the hand at a world-space target.
*
* The group lives in the hand's bone space, so the aim rotation has to be
* solved there — not in world space. `setFromUnitVectors` picks the
* shortest rotation, which leaves a free twist around the shaft; doing that
* in world and then left-multiplying by `handQuatInverse` does *not*
* cancel the parent's yaw. Measured: the stick's local quaternion spun as
* the skater turned, even when the blade target was fixed in the skater's
* frame — the stick rotated with the body instead of staying put in the
* socket. Solving the same aim entirely in hand space keeps the local pose
* stable under body rotation; only a real change of target moves it.
*
* Height is solved exactly, direction is aimed. Pointing straight at the
* target and hoping the length works out puts the blade wherever the stick
* happens to end — short of an on-ice target means *above* it, so the blade
* floats again the moment the arm pose changes the distance. Solving `dy`
* from the height difference makes blade height exact for any arm pose and
* any stick length; the horizontal aim is then whatever is left of the
* unit vector. The blade lands on that ray at one stick length, so targets
* are authored at about that distance — the aim is what has to be right,
* not the reach.
*/
aimAt(worldTarget, handWorldPos, handQuatInverse, roll = 0) {
group.position.fromArray(GRIP_OFFSET);
const dy = clampUnit((worldTarget.y - handWorldPos.y) / reach);
const horiz = Math.sqrt(Math.max(0, 1 - dy * dy));
_aimDir.set(worldTarget.x - handWorldPos.x, 0, worldTarget.z - handWorldPos.z);
if (_aimDir.lengthSq() < 1e-8) _aimDir.set(0, 0, 1);
_aimDir.normalize().multiplyScalar(horiz);
_aimDir.y = dy;
// World aim → hand bone space, then rotate the blade axis onto it.
_aimLocal.copy(_aimDir).applyQuaternion(handQuatInverse);
if (_aimLocal.lengthSq() < 1e-12) _aimLocal.set(0, -1, 0);
else _aimLocal.normalize();
_aimQuat.setFromUnitVectors(_bladeAxis, _aimLocal);
if (roll) {
_rollQuat.setFromAxisAngle(_aimLocal, roll);
_aimQuat.premultiply(_rollQuat);
}
group.quaternion.copy(_aimQuat);
},
/** Static placement, for a rig with no animator driving it. */
setGrip(name = 'carry') {
const g = GRIP[name] ?? GRIP.carry;
group.position.fromArray(GRIP_OFFSET);
group.quaternion.setFromEuler(_euler.set(-0.9, 0, g.roll, 'XYZ'));
},
/**
* A point on the shaft in world space, `t` down from the butt.
*/
shaftPoint(t, out) {
group.updateWorldMatrix(true, false);
out.set(0, -STICK.shaftLength * t, 0).applyMatrix4(group.matrixWorld);
return out;
},
/** The shaft as a world-space segment, butt to heel. */
shaftSegment(outButt, outHeel) {
group.updateWorldMatrix(true, false);
outButt.set(0, 0, 0).applyMatrix4(group.matrixWorld);
outHeel.set(0, -STICK.shaftLength, 0).applyMatrix4(group.matrixWorld);
return outButt;
},
/** Blade position in world space. */
bladeWorld(out) {
blade.updateWorldMatrix(true, false);
return out.setFromMatrixPosition(blade.matrixWorld);
},
/**
* Push the blade's world transform into the kinematic collider.
*
* The first call *teleports*. `SetTargetTransform` derives the velocity
* needed to reach the target over `dt`, so a body still sitting at the
* world origin on frame one derives a velocity of several hundred metres a
* second — and a stick moving at 270 m/s launches the puck off the map. It
* happened; the puck was 1.7 km away inside ten seconds.
*/
syncPhysics(api, dt) {
if (!body) return;
blade.updateWorldMatrix(true, false);
blade.matrixWorld.decompose(_bladeWorld, _bladeQuat, _scratch);
if (!placed) {
api.b3Body_SetTransform(body, vec3(_bladeWorld), quat(_bladeQuat));
placed = true;
return;
}
api.b3Body_SetTargetTransform(body, transform(_bladeWorld, _bladeQuat), dt, true);
},
destroy(api) {
if (body && api) api.b3DestroyBody(body);
group.removeFromParent();
shaft.geometry.dispose();
blade.geometry.dispose();
wood.dispose();
tape.dispose();
},
};
}
+179
View File
@@ -0,0 +1,179 @@
import * as THREE from 'three';
export const V3 = (x = 0, y = 0, z = 0) => new THREE.Vector3(x, y, z);
export const UP = V3(0, 1, 0);
export const FWD = V3(0, 0, 1);
export const clamp = (x, a, b) => (x < a ? a : x > b ? b : x);
export const lerp = (a, b, t) => a + (b - a) * t;
export const smooth = (t) => t * t * (3 - 2 * t);
export function assert(cond, msg) {
if (!cond) throw new Error('ASSERT FAILED: ' + msg);
}
export function lerpAngle(a, b, t) {
let d = b - a;
while (d > Math.PI) d -= Math.PI * 2;
while (d < -Math.PI) d += Math.PI * 2;
return a + d * t;
}
const _sd1 = new THREE.Vector3();
const _sd2 = new THREE.Vector3();
/** Distance from point `p` to segment a-b; writes the closest point into `out`. */
export function segDist(p, a, b, out) {
_sd1.subVectors(b, a);
_sd2.subVectors(p, a);
const t = clamp(_sd2.dot(_sd1) / Math.max(1e-9, _sd1.lengthSq()), 0, 1);
out.copy(a).addScaledVector(_sd1, t);
return p.distanceTo(out);
}
const _u = new THREE.Vector3();
const _v = new THREE.Vector3();
const _w = new THREE.Vector3();
/**
* Closest distance between two segments, writing the closest point on each
* into `outA` / `outB`.
*
* Used to work out which limb hit which limb: both ragdolls are 18 capsules,
* and a capsule is a segment plus a radius, so the nearest pair of segments is
* the nearest pair of body parts. Standard Ericson clamped-parameter solve —
* the degenerate cases (either segment a point, or the two parallel) all fall
* out of the denominator guards rather than needing separate branches.
*/
export function segSegDistance(p1, q1, p2, q2, outA, outB) {
_u.subVectors(q1, p1);
_v.subVectors(q2, p2);
_w.subVectors(p1, p2);
const a = _u.dot(_u);
const b = _u.dot(_v);
const c = _v.dot(_v);
const d = _u.dot(_w);
const e = _v.dot(_w);
const D = a * c - b * b;
let sN;
let sD = D;
let tN;
let tD = D;
if (D < 1e-9) {
// Parallel or degenerate: pin the first parameter and solve the second.
sN = 0;
sD = 1;
tN = e;
tD = c;
} else {
sN = b * e - c * d;
tN = a * e - b * d;
if (sN < 0) {
sN = 0;
tN = e;
tD = c;
} else if (sN > sD) {
sN = sD;
tN = e + b;
tD = c;
}
}
if (tN < 0) {
tN = 0;
if (-d < 0) sN = 0;
else if (-d > a) sN = sD;
else {
sN = -d;
sD = a;
}
} else if (tN > tD) {
tN = tD;
if (-d + b < 0) sN = 0;
else if (-d + b > a) sN = sD;
else {
sN = -d + b;
sD = a;
}
}
const s = Math.abs(sD) < 1e-9 ? 0 : sN / sD;
const t = Math.abs(tD) < 1e-9 ? 0 : tN / tD;
outA.copy(p1).addScaledVector(_u, s);
outB.copy(p2).addScaledVector(_v, t);
return outA.distanceTo(outB);
}
const _euler = new THREE.Euler();
/** Write XYZ euler angles into an existing quaternion without allocating. */
export function E(out, x, y, z, order) {
_euler.set(x, y, z, order || 'XYZ');
return out.setFromEuler(_euler);
}
export { _euler };
/** Merge indexed BufferGeometries that share an attribute set. */
export function mergeGeoms(list) {
let vTotal = 0;
let iTotal = 0;
const attrNames = Object.keys(list[0].attributes);
for (const g of list) {
vTotal += g.attributes.position.count;
iTotal += g.index.count;
}
const out = new THREE.BufferGeometry();
const arrays = {};
for (const name of attrNames) {
const itemSize = list[0].attributes[name].itemSize;
const Ctor = list[0].attributes[name].array.constructor;
arrays[name] = new Ctor(vTotal * itemSize);
}
const index = new (vTotal > 65535 ? Uint32Array : Uint16Array)(iTotal);
let vOff = 0;
let iOff = 0;
for (const g of list) {
const n = g.attributes.position.count;
for (const name of attrNames) {
arrays[name].set(g.attributes[name].array, vOff * g.attributes[name].itemSize);
}
const gi = g.index.array;
for (let i = 0; i < gi.length; i++) index[iOff + i] = gi[i] + vOff;
vOff += n;
iOff += gi.length;
}
for (const name of attrNames) {
out.setAttribute(name, new THREE.BufferAttribute(arrays[name], list[0].attributes[name].itemSize));
}
out.setIndex(new THREE.BufferAttribute(index, 1));
return out;
}
/** Normalize an arbitrary geometry to position/normal/uv + index so it can merge. */
export function stripAttrs(g) {
const out = new THREE.BufferGeometry();
out.setAttribute('position', g.attributes.position);
out.setAttribute('normal', g.attributes.normal);
const n = g.attributes.position.count;
out.setAttribute('uv', g.attributes.uv || new THREE.Float32BufferAttribute(new Float32Array(n * 2), 2));
if (g.index) out.setIndex(g.index);
else {
const idx = [];
for (let i = 0; i < n; i++) idx.push(i);
out.setIndex(idx);
}
return out;
}
export function disposeObject(root) {
root.traverse((o) => {
if (o.geometry) o.geometry.dispose();
if (o.material) {
const mats = Array.isArray(o.material) ? o.material : [o.material];
for (const m of mats) {
for (const k of Object.keys(m)) if (m[k] && m[k].isTexture) m[k].dispose();
m.dispose();
}
}
});
}
+27
View File
@@ -0,0 +1,27 @@
// Seeded PRNG. One integer seed drives every generated detail of a fighter.
//
// The showcase this grew out of used a module-level generator, which is fine
// for one character on screen. A match has at least two, and they have to be
// independently reproducible from their own seeds, so the generator is an
// object that gets threaded through the builders instead.
export function makeRng(seed) {
let a = seed | 0;
const f = () => {
a |= 0;
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
return {
seed,
f,
range: (lo, hi) => lo + (hi - lo) * f(),
int: (lo, hi) => Math.floor(lo + (hi + 0.9999 - lo) * f()),
pick: (arr) => arr[Math.floor(f() * arr.length) % arr.length],
// Independent sub-stream, so adding a generator in one place doesn't shift
// every value drawn after it.
fork: (salt) => makeRng((Math.imul(seed ^ salt, 0x9e3779b1) ^ (seed >>> 3)) | 0),
};
}
+281
View File
@@ -0,0 +1,281 @@
import * as THREE from 'three';
import { segSegDistance } from '../core/math.js';
import { KIND, readTag } from '../physics/bridge.js';
import { REGION } from '../character/skeleton.js';
import { clamp } from '../../shared/scalar.js';
/**
* Body checks.
*
* Two problems have to be solved separately, and conflating them is what makes
* hits feel like one canned event:
*
* *Did* a hit land — a physics question, answered by the proxy capsules,
* which are what actually collide. Closing speed and mass give severity.
*
* *What kind* of hit was it — a pose question, and the proxy cannot answer
* it. A capsule contact point tells you two bodies met at roughly hip height;
* it cannot tell you a shoulder went through a chest. So on the frame a hit
* lands we go back to the two 18-capsule ragdolls, which *are* posed, and
* find the closest pair of limbs. That pair is the hit: `upperArmR → spine2`
* is a shoulder into the chest, `pelvis → thighL` is a hip check, `spine3 →
* head` is the one that should draw a penalty.
*
* 324 segment-segment tests sounds like a lot until you notice it only runs on
* the frame of an actual impact, which is a handful of times a match.
*/
export const HIT = {
/**
* Closing speed thresholds, m/s. Below `bump` nothing happens beyond the
* momentum the solver already exchanged.
*/
bump: 2.6,
stagger: 4.4,
knockdown: 7.0,
/** Impulse per m/s of closing speed, per kg of effective mass. */
impulseScale: 0.55,
/**
* How much of the impulse goes into the struck limb at the contact point,
* versus into the pelvis through its centre.
*
* All of it at the contact point is what launches people: the point is on
* the chest, well above the centre of mass, so a linear impulse there is
* mostly torque and the victim cartwheels over the hitter. Driving most of
* the mass from the middle and using the limb share only to shape the fall
* is what makes a check read as being knocked *down and back*.
*/
limbShare: 0.35,
/**
* Upward fraction. A check lifts a skater slightly off their edges; it does
* not throw them in the air.
*/
liftKnockdown: 0.15,
liftStagger: 0.08,
/** A hit to the head or an unbraced back is worth more than a square one. */
blindsideBonus: 1.5,
headBonus: 1.4,
/** Joint stiffness for a stagger — stiff enough to stay on the feet. */
staggerStiffness: 5,
/** Seconds a downed skater stays down before getting up. */
downTime: 1.5,
/** Seconds of get-up blend from the collapsed pose back to skating. */
riseTime: 0.7,
/** Ignore repeat contacts between the same pair for this long. */
refractory: 0.45,
};
/** Which part of the *attacker* delivered it — this is what varies the hit. */
const DELIVERED_BY = {
upperArmL: 'shoulder', upperArmR: 'shoulder', spine3: 'shoulder',
spine1: 'body', spine2: 'body',
pelvis: 'hip', thighL: 'hip', thighR: 'hip',
forearmL: 'arm', forearmR: 'arm',
shinL: 'leg', shinR: 'leg',
};
/**
* Parts that can deliver a check.
*
* Not a fudge — a rule of the game. A skater at speed is pitched ~30° forward,
* which makes the *head* the geometrically leading part of the body, so an
* unrestricted nearest-pair search credits almost every hit to a headbutt. You
* check with a shoulder, a chest, a hip or a thigh.
*
* The victim side stays unrestricted, deliberately: a shoulder that arrives at
* someone's head is exactly the hit that should register as a head shot.
*/
const CAN_DELIVER = new Set(Object.keys(DELIVERED_BY));
/** Human-readable label, for the HUD and for tests to assert against. */
export function describeHit(hit) {
const where = hit.victimRegion === REGION.HEAD ? 'head'
: hit.victimRegion === REGION.TORSO ? 'body'
: hit.victimRegion.startsWith('upperLeg') || hit.victimRegion.startsWith('lowerLeg') ? 'legs'
: 'arm';
return `${hit.by} to the ${where}`;
}
const _a1 = new THREE.Vector3();
const _b1 = new THREE.Vector3();
const _rel = new THREE.Vector3();
const _dir = new THREE.Vector3();
const _impulse = new THREE.Vector3();
const _point = new THREE.Vector3();
/**
* Closest limb pair between two posed ragdolls.
* Returns `{ attackerPart, victimPart, point, distance }`, or null if the two
* rigs are somehow nowhere near each other.
*/
export function closestLimbs(attacker, victim, { deliveringOnly = true } = {}) {
const A = attacker.worldSegments();
// `worldSegments` reuses its scratch array, so the first result has to be
// copied out before the second call overwrites it.
const aCopy = A
.filter((s) => !deliveringOnly || CAN_DELIVER.has(s.part.name))
.map((s) => ({ part: s.part, a: s.a.clone(), b: s.b.clone(), radius: s.radius }));
const B = victim.worldSegments();
let best = null;
let bestGap = Infinity;
for (const sa of aCopy) {
for (const sb of B) {
const d = segSegDistance(sa.a, sa.b, sb.a, sb.b, _a1, _b1) - sa.radius - sb.radius;
if (d < bestGap) {
bestGap = d;
if (!best) best = { attackerPart: null, victimPart: null, point: new THREE.Vector3(), distance: 0 };
best.attackerPart = sa.part;
best.victimPart = sb.part;
// Midway between the two surfaces is where the impact reads as having
// happened, and is where the impulse should be applied.
best.point.addVectors(_a1, _b1).multiplyScalar(0.5);
best.distance = d;
}
}
}
return best;
}
/**
* Wire up hit detection for a match.
*
* `onHit` is called with a description of every landed check, for the HUD,
* audio and (later) penalties.
*/
export function createHitResolver({ physics, skaters, states, onHit = null }) {
// Last time each unordered pair traded a hit, so one collision does not fire
// every substep it stays in contact.
const lastHit = new Map();
let clock = 0;
const pairKey = (i, j) => (i < j ? `${i}|${j}` : `${j}|${i}`);
function resolve(event) {
const a = readTag(event.userMaterialIdA);
const b = readTag(event.userMaterialIdB);
// Only proxy-on-proxy counts as a check. Limb contacts happen constantly
// once someone is down and are not hits.
if (a.kind !== KIND.PROXY || b.kind !== KIND.PROXY) return;
if (a.skater === b.skater) return;
const speed = event.approachSpeed;
if (speed < HIT.bump) return;
const key = pairKey(a.skater, b.skater);
if (clock - (lastHit.get(key) ?? -Infinity) < HIT.refractory) return;
// Whoever is carrying more speed into the contact is the one throwing it.
const sa = states[a.skater];
const sb = states[b.skater];
_rel.set(sb.x - sa.x, 0, sb.z - sa.z);
const len = _rel.length() || 1;
_rel.multiplyScalar(1 / len);
const closingA = sa.vx * _rel.x + sa.vz * _rel.z;
const closingB = -(sb.vx * _rel.x + sb.vz * _rel.z);
const attackerIndex = closingA >= closingB ? a.skater : b.skater;
const victimIndex = attackerIndex === a.skater ? b.skater : a.skater;
const attacker = skaters[attackerIndex];
const victim = skaters[victimIndex];
// Neither a body already on the ice nor a body being slid into by one is
// throwing a check. Those contacts are real and the solver handles them;
// they are just not hits, and attributing one to a limp skater's flailing
// hand produces nonsense like "arm to the legs" as a headline event.
if (!attacker?.ragdoll || !victim?.ragdoll) return;
if (attacker.limp || victim.limp) return;
const pair = closestLimbs(attacker.ragdoll, victim.ragdoll);
if (!pair) return;
// Direction of the blow: attacker's travel, which is what the victim
// actually has to absorb.
const attackerState = states[attackerIndex];
const victimState = states[victimIndex];
_dir.set(attackerState.vx - victimState.vx, 0, attackerState.vz - victimState.vz);
if (_dir.lengthSq() < 1e-6) _dir.set(_rel.x, 0, _rel.z);
_dir.normalize();
// A hit taken from behind or side-on is worth more than one you can brace
// for: `facing` is +1 square on, -1 straight in the back.
const victimFacing = Math.sin(victimState.yaw) * -_dir.x + Math.cos(victimState.yaw) * -_dir.z;
const blindside = clamp((1 - victimFacing) / 2, 0, 1);
const by = DELIVERED_BY[pair.attackerPart.name] ?? 'body';
const region = pair.victimPart.region;
const headshot = region === REGION.HEAD;
let severity = speed
* (1 + blindside * (HIT.blindsideBonus - 1))
* (headshot ? HIT.headBonus : 1);
// A hit thrown with an arm or a trailing leg is a brush, not a check.
if (by === 'arm' || by === 'leg') severity *= 0.55;
const outcome = severity >= HIT.knockdown ? 'knockdown'
: severity >= HIT.stagger ? 'stagger'
: 'bump';
lastHit.set(key, clock);
const hit = {
attacker: attackerIndex,
victim: victimIndex,
by,
attackerPart: pair.attackerPart.name,
victimPart: pair.victimPart.name,
victimRegion: region,
speed,
severity,
blindside,
headshot,
outcome,
point: pair.point.clone(),
direction: _dir.clone(),
};
apply(hit);
if (onHit) onHit(hit);
}
/** Turn a resolved hit into forces on the victim's skeleton. */
function apply(hit) {
if (hit.outcome === 'bump') return;
const victim = skaters[hit.victim];
const body = victim.ragdoll;
const knockdown = hit.outcome === 'knockdown';
// Impulse scaled by the mass actually being moved, aimed slightly upward —
// a purely horizontal shove on a body standing on near-frictionless ice
// just slides it along without ever putting it on the floor.
const mag = hit.severity * HIT.impulseScale * body.totalMass() * 0.08;
const lift = knockdown ? HIT.liftKnockdown : HIT.liftStagger;
if (knockdown) victim.goDown(hit);
else victim.stagger(hit);
// Most of it through the pelvis centre, which moves the whole body; the
// rest at the contact point, which is what tips them over.
_impulse.copy(hit.direction).multiplyScalar(mag * (1 - HIT.limbShare));
_impulse.y += mag * lift * (1 - HIT.limbShare);
body.applyImpulse('pelvis', _impulse, null);
_impulse.copy(hit.direction).multiplyScalar(mag * HIT.limbShare);
_impulse.y += mag * lift * HIT.limbShare;
_point.copy(hit.point);
body.applyImpulse(hit.victimPart, _impulse, _point);
}
const off = physics.onHit(resolve);
return {
/** Advance the refractory clock. Call once per frame. */
tick(dt) {
clock += dt;
},
get time() { return clock; },
destroy() {
off();
lastHit.clear();
},
};
}
+389
View File
@@ -0,0 +1,389 @@
import { clamp } from '../../shared/scalar.js';
/**
* Player input: Xbox pad first, keyboard as a fallback.
*
* Two things this module is careful about.
*
* **Screen space, not world space.** Sticks come out as `x` right / `y` away
* from the camera. Converting to a world direction needs the camera yaw, which
* belongs to the match. Keeping input ignorant of the camera means the same
* reading works for a follow cam, a broadcast cam or a fixed overhead one.
*
* **Semantics, not button indices.** Everything downstream asks for `pass` or
* `hustle`, never `buttons[7]`. Remapping then happens in one table, and the
* game code does not care whether a shot came from the Skill Stick or a key.
*
* The output object is reused every frame and mutated in place — `match`
* holds a reference to it, so handing it over once is enough.
*/
/**
* W3C "standard" gamepad layout, which is what an Xbox pad reports.
* Named for what they do in this game rather than for the letter on the pad,
* except where the letter *is* the convention players expect.
*/
export const PAD = {
A: 0, B: 1, X: 2, Y: 3,
LB: 4, RB: 5, LT: 6, RT: 7,
BACK: 8, START: 9, LS: 10, RS: 11,
DPAD_UP: 12, DPAD_DOWN: 13, DPAD_LEFT: 14, DPAD_RIGHT: 15,
};
/** Action → pad button. One table, so remapping is a one-line change. */
const BINDING = {
pass: PAD.A,
shoot: PAD.X,
poke: PAD.B,
dump: PAD.Y,
switchPlayer: PAD.LB,
deke: PAD.RB,
start: PAD.START,
camera: PAD.BACK,
};
/** Action → keyboard codes. Arrows drive the Skill Stick, WASD skates. */
const KEYS = {
up: ['KeyW'],
down: ['KeyS'],
left: ['KeyA'],
right: ['KeyD'],
hustle: ['ShiftLeft', 'ShiftRight'],
protect: ['Space'],
skillUp: ['ArrowUp'],
skillDown: ['ArrowDown'],
skillLeft: ['ArrowLeft'],
skillRight: ['ArrowRight'],
pass: ['KeyJ'],
shoot: ['KeyK'],
poke: ['KeyL'],
dump: ['KeyU'],
switchPlayer: ['KeyQ'],
deke: ['KeyE'],
};
/** Sticks rest off-centre when worn; triggers rest slightly pressed. */
const STICK_DEADZONE = 0.18;
const TRIGGER_DEADZONE = 0.06;
/**
* Skill Stick shot gesture, as the NHL games do it: pull the right stick back,
* then push it forward. How long and how far you pulled sets the power, so a
* flick is a wrist shot and a full wind-up is a slapshot.
*/
const SHOT = {
/** Right stick Y below this counts as winding up. */
windAt: -0.5,
/** ...and above this, having wound up, releases. */
releaseAt: 0.35,
/** Wind-up time for full power, seconds. */
fullWind: 0.55,
/** A wind-up abandoned for this long is forgotten rather than fired. */
timeout: 1.6,
/** Floor so a quick snap still does something. */
minPower: 0.25,
};
const rising = () => ({
pass: false, shoot: false, poke: false, dump: false,
switchPlayer: false, deke: false, start: false, camera: false,
});
export function createInput(target = window) {
const held = new Set();
const onDown = (e) => {
if (Object.values(KEYS).some((list) => list.includes(e.code))) e.preventDefault();
held.add(e.code);
};
const onUp = (e) => held.delete(e.code);
// A keyup that lands while the tab is unfocused never arrives, which leaves a
// skater sprinting into the boards forever. Clear everything on blur.
const onBlur = () => held.clear();
target.addEventListener('keydown', onDown);
target.addEventListener('keyup', onUp);
window.addEventListener('blur', onBlur);
let padIndex = null;
const onConnect = (e) => { padIndex = e.gamepad.index; };
const onDisconnect = (e) => { if (padIndex === e.gamepad.index) padIndex = null; };
window.addEventListener('gamepadconnected', onConnect);
window.addEventListener('gamepaddisconnected', onDisconnect);
const any = (codes) => codes.some((c) => held.has(c));
// Previous frame's button state, for edge detection.
const wasDown = rising();
/** Wind-up state for the Skill Stick. */
const wind = { active: false, t: 0, depth: 0, aim: 0, idle: 0 };
const state = {
// ---- the movement contract the match consumes --------------------------
x: 0,
y: 0,
sprint: false,
brake: false,
cameraYaw: 0,
// ---- richer view for everything else -----------------------------------
/** Left stick, screen space. Same numbers as x/y. */
move: { x: 0, y: 0 },
/** Right stick — the Skill Stick. */
skill: { x: 0, y: 0 },
/** Analog triggers, 0..1. */
hustle: 0,
protect: 0,
/** Held this frame. */
held: rising(),
/** True only on the frame the button went down. */
pressed: rising(),
/**
* Set on the frame a Skill Stick wind-up is released, then cleared.
* `{ power: 0..1, aim: -1..1 }` — aim is the stick's lateral position at
* release, which is where the shot is being placed.
*/
shot: null,
/** How wound up the shot is right now, 0..1. Drives the wind-up pose. */
charge: 0,
source: 'none',
padId: null,
};
function readPad() {
const pads = navigator.getGamepads?.() ?? [];
if (padIndex != null && pads[padIndex]) return pads[padIndex];
// The connect event does not fire if the pad was already held when the page
// loaded, so fall back to scanning.
for (const p of pads) if (p?.connected) return p;
return null;
}
/**
* Radial deadzone, rescaled so the first movement past it is slow.
*
* Direction comes from the raw axes and magnitude is rescaled and capped
* separately. Clamping the two components instead would let a pad that
* reports a square range rather than a circular one hand back a diagonal of
* length 1.41 — a stick that is 41% faster on the diagonals.
*/
function stick(rawX, rawY, out) {
const mag = Math.hypot(rawX, rawY);
if (mag <= STICK_DEADZONE) {
out.x = 0;
out.y = 0;
return false;
}
const scaled = clamp((mag - STICK_DEADZONE) / (1 - STICK_DEADZONE), 0, 1);
out.x = (rawX / mag) * scaled;
out.y = (-rawY / mag) * scaled; // pad Y is positive downward
return true;
}
/**
* Advance the shot gesture. Returns a shot on the frame of release.
*
* Kept here rather than in the game because it is a property of the input
* device — the same pull-back-and-push has to mean the same thing whatever
* is holding the puck.
*/
function advanceShot(dt) {
const y = state.skill.y;
if (!wind.active) {
if (y < SHOT.windAt) {
wind.active = true;
wind.t = 0;
wind.depth = Math.abs(y);
wind.aim = state.skill.x;
}
state.charge = 0;
return null;
}
wind.t += dt;
wind.depth = Math.max(wind.depth, Math.abs(Math.min(0, y)));
wind.aim = state.skill.x;
state.charge = clamp(wind.t / SHOT.fullWind, 0, 1) * wind.depth;
if (y > SHOT.releaseAt) {
const power = clamp(
SHOT.minPower + (1 - SHOT.minPower) * clamp(wind.t / SHOT.fullWind, 0, 1) * wind.depth,
0,
1,
);
wind.active = false;
state.charge = 0;
return { power, aim: clamp(state.skill.x, -1, 1) };
}
// Held back forever without releasing: drop it rather than firing later.
if (wind.t > SHOT.timeout) {
wind.active = false;
state.charge = 0;
}
return null;
}
return {
state,
/** Which pad we are reading, or null. */
get padIndex() { return padIndex; },
get connected() { return readPad() != null; },
/**
* Sample this frame's input.
* @param {number} dt seconds, for the shot gesture timing
*/
read(dt = 1 / 60) {
const pad = readPad();
let source = 'none';
// ---- sticks ------------------------------------------------------------
let moved = false;
let skilled = false;
if (pad) {
moved = stick(pad.axes[0] ?? 0, pad.axes[1] ?? 0, state.move);
skilled = stick(pad.axes[2] ?? 0, pad.axes[3] ?? 0, state.skill);
state.padId = pad.id;
} else {
state.move.x = 0;
state.move.y = 0;
state.skill.x = 0;
state.skill.y = 0;
state.padId = null;
}
if (!moved) {
// Keyboard only fills in when the stick is centred, so a pad in hand
// always wins and a stuck key cannot fight it.
let kx = 0;
let ky = 0;
if (any(KEYS.right)) kx += 1;
if (any(KEYS.left)) kx -= 1;
if (any(KEYS.up)) ky += 1;
if (any(KEYS.down)) ky -= 1;
const len = Math.hypot(kx, ky);
if (len > 0) {
state.move.x = kx / Math.max(1, len);
state.move.y = ky / Math.max(1, len);
source = 'keyboard';
}
} else {
source = 'gamepad';
}
if (!skilled) {
let sx = 0;
let sy = 0;
if (any(KEYS.skillRight)) sx += 1;
if (any(KEYS.skillLeft)) sx -= 1;
if (any(KEYS.skillUp)) sy += 1;
if (any(KEYS.skillDown)) sy -= 1;
const len = Math.hypot(sx, sy);
if (len > 0) {
state.skill.x = sx / Math.max(1, len);
state.skill.y = sy / Math.max(1, len);
if (source === 'none') source = 'keyboard';
}
} else if (source === 'none') {
source = 'gamepad';
}
// ---- triggers ----------------------------------------------------------
// Analog, not boolean: hustle is a throttle, and half-pressing it is how
// you keep speed without over-committing.
const trigger = (i) => {
const b = pad?.buttons?.[i];
if (!b) return 0;
const v = typeof b.value === 'number' ? b.value : (b.pressed ? 1 : 0);
return v <= TRIGGER_DEADZONE ? 0 : (v - TRIGGER_DEADZONE) / (1 - TRIGGER_DEADZONE);
};
state.hustle = trigger(PAD.RT);
state.protect = trigger(PAD.LT);
if (state.hustle > 0 || state.protect > 0) source = 'gamepad';
if (any(KEYS.hustle)) state.hustle = 1;
if (any(KEYS.protect)) state.protect = 1;
if ((any(KEYS.hustle) || any(KEYS.protect)) && source === 'none') source = 'keyboard';
// ---- buttons -----------------------------------------------------------
for (const action of Object.keys(BINDING)) {
const padDown = !!pad?.buttons?.[BINDING[action]]?.pressed;
const keyDown = KEYS[action] ? any(KEYS[action]) : false;
const down = padDown || keyDown;
state.pressed[action] = down && !wasDown[action];
state.held[action] = down;
wasDown[action] = down;
if (down) source = padDown ? 'gamepad' : 'keyboard';
}
// ---- derived contract --------------------------------------------------
state.x = state.move.x;
state.y = state.move.y;
// Above half-throttle counts as the sprint stride. The sim takes a
// boolean today; when it takes a throttle this is the line that changes.
state.sprint = state.hustle > 0.5;
state.brake = state.protect > 0.5;
state.source = source;
// ---- Skill Stick -------------------------------------------------------
state.shot = advanceShot(dt);
// Pressing the shoot button is the same event as a stick release, so a
// player who never learns the Skill Stick can still shoot.
if (!state.shot && state.pressed.shoot) {
state.shot = { power: 0.6, aim: clamp(state.skill.x, -1, 1) };
}
return state;
},
/**
* Rumble. Silently does nothing on a pad or browser without haptics, which
* is most of them — never let feedback become a hard dependency.
*/
rumble(strong = 0.5, weak = 0.3, ms = 120) {
const pad = readPad();
const actuator = pad?.vibrationActuator;
if (!actuator?.playEffect) return false;
try {
actuator.playEffect('dual-rumble', {
duration: ms,
strongMagnitude: clamp(strong, 0, 1),
weakMagnitude: clamp(weak, 0, 1),
});
return true;
} catch {
return false;
}
},
destroy() {
target.removeEventListener('keydown', onDown);
target.removeEventListener('keyup', onUp);
window.removeEventListener('blur', onBlur);
window.removeEventListener('gamepadconnected', onConnect);
window.removeEventListener('gamepaddisconnected', onDisconnect);
held.clear();
},
};
}
/**
* Turn a screen-space stick into a world-space intent, given where the camera
* is looking.
*
* The camera orbits at `cameraYaw`, sitting at `+(sin, cos)` from its target,
* so it looks along `-(sin, cos)` and its right is `(cos, -sin)`. Pushing the
* stick away from yourself has to mean "away from the camera" regardless of
* which way the skater currently faces, or steering becomes unusable the moment
* the camera swings round behind them.
*/
export function stickToWorld(stick, cameraYaw, out = { ix: 0, iz: 0 }) {
const s = Math.sin(cameraYaw);
const c = Math.cos(cameraYaw);
out.ix = c * stick.x - s * stick.y;
out.iz = -s * stick.x - c * stick.y;
return out;
}
export { SHOT };
+508
View File
@@ -0,0 +1,508 @@
import * as THREE from 'three';
import { createSkater } from '../character/skater.js';
import { createBrain, spawnLineup, steer } from '../../shared/ai.js';
import { applyIntent, createSkaterState, stepSkater } from '../../shared/skaterSim.js';
import { stickToWorld } from './input.js';
import { createHitResolver } from './hits.js';
import { PUCK, createPuck } from '../physics/puck.js';
import { NET, goalLineX } from '../../shared/net.js';
import { createPossession } from './possession.js';
import { makeRng } from '../core/rng.js';
import { clamp, wrapAngle } from '../../shared/scalar.js';
/**
* The match loop.
*
* Order per frame is the whole design in six lines, so it is worth being
* explicit about why it is this order:
*
* 1. brains produce intent — decisions, once per frame
* 2. physics substeps, and inside each one:
* a. read position/velocity out of the proxy capsules
* b. step the skating sim, which edits that velocity
* c. write it back, then let Box3D solve boards and body contact
* 3. animation runs on the frame clock from the resolved state
* 4. the kinematic ragdolls chase the animated skeleton
*
* The sim living *inside* the substep loop is the part that matters. Skating
* is momentum, and momentum only survives a collision if the thing that
* resolved the collision and the thing that integrates the motion agree about
* the timestep. Running the sim once per frame and Box3D six times would mean
* a board hit gets partly overwritten by a stale velocity.
*/
export function createMatch({ scene, physics, perTeam = 3, teams = 2, seed = 20260802 }) {
const rng = makeRng(seed);
const spawns = spawnLineup(perTeam, teams);
const count = spawns.length;
const states = [];
const brains = [];
const skaters = [];
/** Previous velocity heading per skater, for the animator's bank. */
const prevVelYaw = [];
for (let i = 0; i < count; i++) {
const spawn = spawns[i];
const team = spawn.team;
const s = createSkaterState(i, spawn, {
seed: seed + i * 977,
team,
name: `${team === 0 ? 'Home' : 'Away'} ${(i % perTeam) + 1}`,
});
states.push(s);
brains.push(createBrain(rng.f, {}));
prevVelYaw.push(spawn.yaw);
skaters.push(createSkater({
seed: seed + i * 977,
scene,
physics,
index: i,
team,
position: { x: spawn.x, z: spawn.z },
facing: spawn.yaw,
// A little variety in build so three placeholder bodies are not clones.
bodyStyle: {
mass: rng.range(-0.35, 0.5),
muscle: rng.range(0.1, 0.75),
fat: rng.range(0, 0.25),
},
}));
skaters[i].proxy?.teleport(spawn.x, spawn.z);
}
/**
* Skaters being driven by something other than their brain, by index.
*
* A map rather than a single index because there is no reason for there to be
* only one: local versus is two entries, and a test driving both sides of a
* collision is a third case. The value is a live object that is *read* each
* frame — `input.read()` returns the same object every call, so handing it
* over once is enough.
*/
const controls = new Map();
const _worldIntent = { ix: 0, iz: 0 };
// ---- puck ---------------------------------------------------------------
const puck = createPuck(physics, { position: { x: 0, y: 0.05, z: 0 } });
/** Puck events, newest first, for the HUD. */
const recentPlays = [];
const possession = createPossession({
puck,
skaters,
states,
onEvent(e) {
recentPlays.unshift({ ...e, at: performance.now?.() ?? 0 });
if (recentPlays.length > 8) recentPlays.pop();
},
});
/** Landed checks, newest first, for the HUD. */
const recentHits = [];
const hits = createHitResolver({
physics,
skaters,
states,
onHit(hit) {
recentHits.unshift({ ...hit, at: hits.time });
if (recentHits.length > 8) recentHits.pop();
// Getting hit costs you the puck. A stagger is enough — needing a full
// knockdown to force a turnover made the carrier effectively untouchable.
if (hit.outcome !== 'bump' && possession.carrier === hit.victim) {
possession.jar(hit.severity / 8);
}
},
});
/**
* Turn a controller's shot and pass buttons into puck events.
*
* Aim comes from where the skater is facing plus the Skill Stick's lateral
* position, so you place a shot by holding the stick off to one side as you
* release. A pass looks for the nearest teammate ahead instead.
*/
function handleShooting(i, control) {
if (possession.carrier !== i) return;
const state = states[i];
if (control.shot) {
// Up to ~35° of placement either side of where they are pointing.
// Skill Stick +X is "push right"; positive yaw is a left turn in this
// frame, so aim subtracts — otherwise every placed shot went the wrong way.
const stickAim = control.shot.aim ?? 0;
const aimYaw = state.yaw - stickAim * 0.6;
possession.shoot(control.shot.power, aimYaw);
skaters[i].animator.playAction('shoot', {
power: control.shot.power,
aim: stickAim,
});
return;
}
if (control.pressed?.pass) {
const mate = nearestTeammate(i);
if (mate !== null) {
const dx = states[mate].x - state.x;
const dz = states[mate].z - state.z;
// Lead the target a little; a pass to where someone was is a turnover.
const lead = 0.35;
const aimYaw = Math.atan2(dx + states[mate].vx * lead, dz + states[mate].vz * lead);
const range = Math.hypot(dx, dz);
possession.shoot(clamp(range / 18, 0.3, 1), aimYaw, { pass: true });
skaters[i].animator.playAction('pass', {
aim: clamp(wrapAngle(aimYaw - state.yaw), -1, 1),
});
} else {
// Nobody to hit — dump it forward rather than eating the input.
possession.shoot(0.7, state.yaw, { pass: true });
skaters[i].animator.playAction('pass');
}
}
}
/**
* Bot shooting and passing.
*
* Previously `handleShooting` sat behind `if (control)`, so only a human
* could ever shoot — a bot picked the puck up and carried it until somebody
* poked it away. A minute of play produced zero shots.
*
* The decision is deliberately simple: inside range of the net, shoot; a
* teammate much better placed, pass; otherwise keep skating. Accuracy falls
* off with distance so bots miss, which is the difference between a goalie
* being tested and a goalie being beaten every time.
*/
// Deliberately short. Bots used to fire from 14 m at full spread and miss
// wide; a shootout is about getting in close, not about point shots.
const SHOT_RANGE = 8;
function botShoot(i, dt) {
const b = brains[i];
b.shotCool = (b.shotCool ?? 0) - dt;
if (b.shotCool > 0) return;
const s = states[i];
const goalX = goalLineX(s.team === 0 ? 1 : -1);
// Pick a corner, not the middle. Aiming at the centre of the net means
// aiming at the goalie, who is standing on exactly that line by
// construction — thirty attempts produced thirty saves and no goals.
// Alternating sides also stops a bot grooving the same shot every time.
b.shotSide = b.shotSide === 1 ? -1 : 1;
const targetZ = b.shotSide * (NET.width / 2 - 0.22);
const dx = goalX - s.x;
const dz = targetZ - s.z;
const range = Math.hypot(goalX - s.x, -s.z);
// Only shoot when actually facing the net; a bot firing over its shoulder
// reads as a bug rather than as a highlight.
const toGoal = Math.atan2(dx, dz);
const facing = Math.abs(wrapAngle(toGoal - s.yaw));
if (range > SHOT_RANGE || facing > 0.7) {
// Look for a teammate in a better spot before giving up on the play.
const mate = nearestTeammate(i);
if (mate !== null && b.passCool == null) b.passCool = 0;
return;
}
// Aim, with a spread that grows with range. The scale matters more than it
// looks: 0.22 rad at 8 m is ±1.76 m of scatter against a net that is 1.83 m
// *wide*, so bots were missing the target more often than hitting it. A
// shot has to land inside the posts often enough for the goalie to be the
// thing that stops it.
const spread = clamp(range / SHOT_RANGE, 0, 1) * 0.055;
const aimYaw = toGoal + (rng.f() * 2 - 1) * spread;
const power = clamp(0.45 + range / SHOT_RANGE * 0.55, 0.4, 1);
possession.shoot(power, aimYaw);
skaters[i].animator.playAction('shoot', { power });
b.shotCool = 1.2;
}
function nearestTeammate(i) {
let best = null;
let bestD = Infinity;
for (let j = 0; j < count; j++) {
if (j === i || states[j].team !== states[i].team || skaters[j].limp) continue;
const d = Math.hypot(states[j].x - states[i].x, states[j].z - states[i].z);
if (d < bestD) {
bestD = d;
best = j;
}
}
return best;
}
/**
* Extra work to run inside each physics substep, before the solve.
* Modes register kinematic bodies of their own here — the goalie, today.
*/
const substepSyncs = new Set();
/** What the brains are told about the puck, rebuilt each frame. */
const play = {
puck: { x: 0, z: 0 },
carrier: null,
carrierTeam: null,
/** Index of the one skater per team who is going for the puck. */
chaser: new Array(teams).fill(null),
};
/** @param {number} dt */
function update(dt) {
const pp = puck.position();
play.puck.x = pp.x;
play.puck.z = pp.z;
play.carrier = possession.carrier;
play.carrierTeam = possession.carrier === null ? null : states[possession.carrier].team;
// Nearest upright skater per side goes for the puck; everyone else finds
// space. Recomputed every frame, which means the job passes between
// teammates as the play moves rather than being assigned once.
play.chaser.fill(null);
const bestGap = new Array(teams).fill(Infinity);
for (let i = 0; i < count; i++) {
if (skaters[i].limp) continue;
const t = states[i].team;
const d = Math.hypot(states[i].x - pp.x, states[i].z - pp.z);
if (d < bestGap[t]) {
bestGap[t] = d;
play.chaser[t] = i;
}
}
// ---- 1. decisions ------------------------------------------------------
// `steer` writes intent straight onto the state. The player's skater goes
// through `applyIntent` instead, which clamps and normalises — the same
// path a network message would take, so the sim never has to trust input.
for (let i = 0; i < count; i++) {
// A downed skater makes no decisions. Their state is frozen where they
// fell; the ragdoll is doing the moving.
//
// Someone still getting up makes none either. Letting intent through
// mid-rise means they skate away while the pose is still interpolating
// out of a body on the ice, which reads as the corpse sliding off — the
// whole point of the get-up is that almost nothing moves but the pose.
if (skaters[i].limp || skaters[i].rising > 0) {
states[i].ix = 0;
states[i].iz = 0;
states[i].sprint = false;
states[i].brake = true;
continue;
}
const control = controls.get(i);
if (control) {
// The Skill Stick moves the puck, and only for whoever is carrying it.
if (possession.carrier === i && control.skill) {
possession.handling.x = control.skill.x;
possession.handling.y = control.skill.y;
}
handleShooting(i, control);
stickToWorld(control, control.cameraYaw ?? 0, _worldIntent);
applyIntent(states[i], {
ix: _worldIntent.ix,
iz: _worldIntent.iz,
sprint: control.sprint,
brake: control.brake,
});
// Keep the brain's waypoint fresh so handing control back does not
// send them skating off to somewhere chosen a minute ago.
brains[i].target = null;
if (control.pressed?.poke) {
// The reach always animates, whether or not it connects — a poke
// that only shows when it works gives the player no feedback on the
// ones that miss, which is most of them.
skaters[i].animator.playAction('poke');
possession.poke(i);
}
} else {
steer(brains[i], states[i], states, dt, play);
if (possession.carrier === i) botShoot(i, dt);
// Bots reach in when they get close enough, on a cooldown so they are
// not spamming it every frame they are in range.
if (possession.carrier !== null
&& states[possession.carrier].team !== states[i].team) {
brains[i].pokeCool = (brains[i].pokeCool ?? 0) - dt;
if (brains[i].pokeCool <= 0) {
skaters[i].animator.playAction('poke');
if (possession.poke(i)) brains[i].pokeCool = 0.9;
else brains[i].pokeCool = 0.45;
}
}
}
}
// ---- 2. sim + physics, on the fixed step ------------------------------
physics.step(dt, (fixedDt) => {
for (let i = 0; i < count; i++) {
// While down, the ragdoll is the body and the proxy is switched off.
// Stepping the sim would drive a disabled capsule around the rink and
// then teleport the skater to it on the way up.
if (skaters[i].limp) continue;
const s = states[i];
const proxy = skaters[i].proxy;
if (proxy) proxy.read(s);
// Box3D owns board contact via the proxy, so the sim's own clamp
// would fight it — but keep it on when there is no proxy at all.
stepSkater(s, fixedDt, { clampBoards: !proxy });
if (proxy) proxy.write(s);
}
// The ragdolls chase wherever the animation left the skeleton. This has
// to happen *before* the solve, not after: SetTargetTransform derives the
// velocity that carries a kinematic body to its target over the coming
// step, so setting it afterwards would apply it a step late.
for (const sk of skaters) {
if (sk.ragdoll && !sk.limp && sk.ragdoll.mode === 'driven') {
sk.ragdoll.syncFromSkeleton(fixedDt);
}
// The blade collider follows the stick the same way, and for the same
// reason: SetTargetTransform derives the velocity that carries it over
// the coming step, so it has to be set before the solve or a blade
// sweeping through a loose puck arrives a step late and misses.
sk.stick.syncPhysics(physics.api, fixedDt);
}
for (const fn of substepSyncs) fn(fixedDt);
});
// A collision can hand the puck more speed than any shot ever should —
// `setVelocity` caps what *we* apply, but the solver is not bound by it.
// Cheap insurance against one bad contact putting the puck in orbit.
if (puck.speed() > PUCK.maxSpeed) {
const v = puck.velocity();
const k = PUCK.maxSpeed / puck.speed();
puck.setVelocity(v.x * k, v.y * k, v.z * k);
}
// ---- 3. hits, knockdowns and getting up --------------------------------
hits.tick(dt);
for (let i = 0; i < count; i++) {
if (skaters[i].tickDown(dt)) skaters[i].getUp(states[i]);
}
// ---- 3b. possession ----------------------------------------------------
// Once per frame, not per substep: capture and release are gameplay
// decisions, and running them at 120 Hz only makes the cooldowns fiddly.
// The carrier's stick decays back to neutral so a released Skill Stick
// brings the puck back in front rather than leaving it stranded wide.
if (possession.carrier === null || !controls.has(possession.carrier)) {
possession.handling.x *= Math.max(0, 1 - 6 * dt);
possession.handling.y *= Math.max(0, 1 - 6 * dt);
}
possession.update(dt);
// ---- 4. animation ------------------------------------------------------
for (let i = 0; i < count; i++) {
const s = states[i];
// Turn rate of the velocity vector, not of the body. Only meaningful
// while actually moving; a standing skater has no heading to turn.
const speed = Math.hypot(s.vx, s.vz);
let yawRate = 0;
if (speed > 0.4) {
const velYaw = Math.atan2(s.vx, s.vz);
yawRate = wrapAngle(velYaw - prevVelYaw[i]) / Math.max(1e-4, dt);
prevVelYaw[i] = velYaw;
}
// Stickwork inputs. The animator owns where the stick *is*; this only
// tells it what the skater is trying to do with it.
const anim = skaters[i].animator;
anim.hasPuck = possession.carrier === i;
const ctrl = controls.get(i);
anim.charge = anim.hasPuck ? (ctrl?.charge ?? 0) : 0;
if (anim.hasPuck) {
anim.handling.x = possession.handling.x;
anim.handling.y = possession.handling.y;
} else {
anim.handling.x *= Math.max(0, 1 - 8 * dt);
anim.handling.y *= Math.max(0, 1 - 8 * dt);
}
// Holding the Skill Stick back is a wind-up; letting it go ends one.
if (anim.hasPuck && anim.charge > 0.05 && anim.action === null) {
anim.action = 'windup';
anim.actionTime = 0;
} else if (anim.action === 'windup' && (!anim.hasPuck || anim.charge <= 0.05)) {
anim.action = null;
}
skaters[i].applyState(s, yawRate);
skaters[i].update(dt);
skaters[i].syncFromPhysics();
}
}
return {
states,
brains,
skaters,
perTeam,
teams,
update,
hits,
recentHits,
puck,
/** Register a callback to run inside every physics substep. */
addSubstepSync(fn) {
substepSyncs.add(fn);
return () => substepSyncs.delete(fn);
},
possession,
recentPlays,
controls,
/** The first externally driven skater — what the HUD and camera care about. */
get playerIndex() {
for (const i of controls.keys()) return i;
return null;
},
/**
* Drive a skater from something other than its brain. Pass `null` to hand
* it back. `control` is read every frame, so a live input object works.
*/
setControl(index, control) {
if (index == null || index < 0 || index >= count) return null;
if (!control) {
controls.delete(index);
return null;
}
controls.set(index, control);
// Drop any intent the brain had queued so control starts from neutral
// rather than from whatever the bot was mid-way through doing.
states[index].ix = 0;
states[index].iz = 0;
states[index].sprint = false;
states[index].brake = false;
return index;
},
/** Skater states belonging to one team. */
team(index) {
return states.filter((s) => s.team === index);
},
/** Drop everyone back on their spawn, momentum cleared. */
reset() {
for (let i = 0; i < count; i++) {
const spawn = spawns[i];
// Anyone lying on the ice has to be stood up before being placed, or
// their proxy stays disabled and they spawn as a corpse.
if (skaters[i].limp) skaters[i].getUp(states[i]);
Object.assign(states[i], { x: spawn.x, z: spawn.z, vx: 0, vz: 0, yaw: spawn.yaw });
skaters[i].proxy?.teleport(spawn.x, spawn.z);
brains[i].target = null;
}
recentHits.length = 0;
recentPlays.length = 0;
// Faceoff: puck at centre ice, dead.
possession.reset();
puck.place(0, 0.05, 0);
},
destroy() {
hits.destroy();
puck.destroy();
for (const sk of skaters) sk.dispose();
},
};
}
+313
View File
@@ -0,0 +1,313 @@
import * as THREE from 'three';
import { PUCK } from '../physics/puck.js';
import { clamp, lerp } from '../../shared/scalar.js';
/**
* Who has the puck, and what "having it" means.
*
* This is the one genuinely undecided piece of the game, so it is built as a
* dial rather than as an answer. `magnetism` runs 0..1 between the two models:
*
* 0 Pure physics. The puck is always a free rigid body and the only thing
* that moves it is the blade collider pushing it. Authentic, and skittery
* to the point of being unplayable — you lose it to contacts you never
* intended and can never quite line up a shot.
*
* 1 Hard attach. The puck is placed at the carry point every frame. Totally
* controllable, looks glued, and kills the scrambles that are the reason
* to build a physics-driven hockey game at all.
*
* In between, the puck's velocity is blended toward whatever would carry it to
* the stick, so it *mostly* follows but can be jostled off the blade by a hit,
* a poke or a body in the way. Where that dial should sit is a feel question,
* so it is tunable at runtime (`[` and `]` in the browser) rather than baked.
*
* Everything else here follows from that: capture is a proximity test, release
* is either deliberate (shot, pass) or forced (hit, poke, the puck getting too
* far from the blade).
*/
export const CARRY = {
/** Default dial position. Tuned by hand; see the note above. */
magnetism: 0.72,
/** A loose puck this close to the blade gets picked up. */
captureRadius: 0.55,
/**
* Possession breaks if the puck gets this far from the blade.
*
* Has to be generous relative to how far the blade sits in front of the body
* (~1.35 m). At 1.15 m a shooter accelerating from a standstill outran their
* own puck every time — twelve of nineteen shootout attempts ended with the
* puck sitting on the ice at centre and nobody ever taking a shot.
*/
breakRadius: 2.0,
/** How hard the puck is pulled onto the carry point, 1/s. */
stiffness: 20,
/** Seconds after losing it before the same skater can re-capture. */
reclaimDelay: 0.35,
/** Seconds after a shot or pass before anyone can capture. */
looseDelay: 0.18,
/** How far the Skill Stick can push the puck fore/aft and side to side. */
reachFwd: 0.34,
reachSide: 0.42,
/** Shot speed at full power, m/s. ~45 is a real slapshot. */
shotSpeed: 45,
/** Passes are firm but not shots. */
passSpeed: 18,
/** A shot lifts slightly; a pass stays flat. */
shotLift: 0.1,
/** How far a poke check reaches, blade to puck. */
pokeRadius: 1.25,
/** How hard a poke or a check knocks the puck away, m/s. */
pokeSpeed: 5.5,
/** How far the puck is stepped clear of the blade on release, metres. */
releaseGap: 0.4,
};
const _carryWorld = new THREE.Vector3();
const _toTarget = new THREE.Vector3();
const _desired = new THREE.Vector3();
const _puckPos = new THREE.Vector3();
const _puckVel = new THREE.Vector3();
const _dir = new THREE.Vector3();
/**
* @param {object} opts
* @param {object} opts.puck from createPuck
* @param {object[]} opts.skaters
* @param {object[]} opts.states
*/
export function createPossession({ puck, skaters, states, onEvent = null }) {
/** Index of the carrier, or null. */
let carrier = null;
/** Per-skater cooldown before they may capture again. */
const cooldown = new Array(skaters.length).fill(0);
/** Global cooldown after a deliberate release. */
let looseFor = 0;
const tuning = { ...CARRY };
/** Skill Stick offset applied to the carry point, -1..1 each. */
const handling = { x: 0, y: 0 };
/**
* Where the puck should sit for skater `i`, in world space.
*
* Read off the actual blade rather than computed from a fixed offset. That
* inversion is the point of socketing the stick to the hand: the arms decide
* where the blade is, and the puck goes where the blade is. Stickhandling is
* then an arm pose rather than a number added to a carry point, and the puck
* cannot end up somewhere the stick is not.
*/
function bladePoint(i, out) {
const sk = skaters[i];
if (!sk?.stick) return out.set(0, 0, 0);
sk.stick.bladeWorld(out);
// The puck rides on the ice at the blade's XZ, not at the blade's centre —
// the blade has height and a lie angle, and a puck floating at its middle
// reads as hovering.
out.y = PUCK.thickness / 2;
return out;
}
function emit(type, payload) {
if (onEvent) onEvent({ type, ...payload });
}
/** Hand the puck to nobody, optionally locking capture for a moment. */
function release(reason, delay = tuning.reclaimDelay) {
if (carrier === null) return;
const was = carrier;
cooldown[was] = delay;
carrier = null;
looseFor = Math.max(looseFor, tuning.looseDelay);
emit('lost', { skater: was, reason });
}
function capture(index) {
if (carrier === index) return;
if (carrier !== null) {
const was = carrier;
cooldown[was] = tuning.reclaimDelay;
emit('stolen', { skater: index, from: was });
} else {
emit('gained', { skater: index });
}
carrier = index;
cooldown[index] = 0;
}
/**
* Poke check: reach in and knock the puck off whoever has it.
*
* Range is measured blade-to-puck, so it depends on where the poker's stick
* actually is. Without this — and without contact dislodging the puck — a
* carrier is untouchable, and a minute of play is one skater holding the puck
* for the entire minute while five others follow them around.
*/
function poke(byIndex) {
if (carrier === null || carrier === byIndex) return false;
if (skaters[byIndex]?.limp) return false;
bladePoint(byIndex, _carryWorld);
_puckPos.copy(puck.position());
if (_puckPos.distanceTo(_carryWorld) > tuning.pokeRadius) return false;
// Knock it away from the carrier, roughly along the poke.
_dir.subVectors(_puckPos, _carryWorld).setY(0);
if (_dir.lengthSq() < 1e-6) _dir.set(1, 0, 0);
_dir.normalize().multiplyScalar(tuning.pokeSpeed);
puck.setVelocity(_dir.x, 0, _dir.z);
release('poked', tuning.reclaimDelay);
emit('poke', { skater: byIndex, from: carrier });
return true;
}
/**
* Contact dislodges the puck. Called when a check lands on the carrier —
* a stagger is enough, it does not need a knockdown.
*/
function jar(severity = 1) {
if (carrier === null) return false;
_puckPos.copy(puck.position());
_dir.set(Math.random() - 0.5, 0, Math.random() - 0.5);
if (_dir.lengthSq() < 1e-6) _dir.set(1, 0, 0);
_dir.normalize().multiplyScalar(tuning.pokeSpeed * clamp(severity, 0.4, 1.6));
puck.setVelocity(_dir.x, 0, _dir.z);
release('jarred loose', tuning.reclaimDelay);
return true;
}
/** Fire the puck. `power` 0..1, `aimYaw` world radians. */
function shoot(power, aimYaw, { pass = false } = {}) {
if (carrier === null) return null;
const from = carrier;
const speed = (pass ? tuning.passSpeed : tuning.shotSpeed) * clamp(power, 0.15, 1);
_dir.set(Math.sin(aimYaw), 0, Math.cos(aimYaw));
const state = states[from];
// Step the puck off the blade before releasing it.
//
// It is sitting *exactly* on the blade — that is what carrying it means —
// and the follow-through animation immediately sweeps that kinematic
// collider through the same point at speed. Shots were being smashed
// sideways by the shooter's own stick: measured, they stopped six metres
// short of the net or flew twelve metres wide, and nothing ever scored.
_puckPos.copy(puck.position());
puck.place(
_puckPos.x + _dir.x * tuning.releaseGap,
PUCK.thickness / 2,
_puckPos.z + _dir.z * tuning.releaseGap,
{ keepMotion: true },
);
// A shot inherits the shooter's momentum. Skating into it is worth speed,
// which is the whole reason a one-timer off the rush is dangerous.
puck.setVelocity(
_dir.x * speed + state.vx * 0.4,
pass ? 0 : speed * tuning.shotLift,
_dir.z * speed + state.vz * 0.4,
);
release(pass ? 'pass' : 'shot', tuning.reclaimDelay);
emit(pass ? 'pass' : 'shot', { skater: from, power, speed, aimYaw });
return { from, speed, power };
}
return {
tuning,
handling,
get carrier() { return carrier; },
get loose() { return carrier === null; },
shoot,
poke,
jar,
release,
capture,
bladePoint,
/** Where the puck is being carried, in world space. Null if loose. */
carryPoint(out) {
if (carrier === null) return null;
return bladePoint(carrier, out);
},
/**
* Advance possession by `dt`.
*
* Called once per rendered frame rather than per physics substep: capture
* and release are gameplay decisions, and running them at 120 Hz just makes
* the cooldowns six times as fiddly for no gain in fidelity.
*/
update(dt) {
for (let i = 0; i < cooldown.length; i++) cooldown[i] = Math.max(0, cooldown[i] - dt);
looseFor = Math.max(0, looseFor - dt);
puck.position(); // refresh the cached vector
_puckPos.copy(puck.position());
_puckVel.copy(puck.velocity());
// ---- forced release ---------------------------------------------------
if (carrier !== null) {
const holder = skaters[carrier];
if (holder.limp) {
release('knocked down', 0.8);
} else {
this.carryPoint(_carryWorld);
const gap = _puckPos.distanceTo(_carryWorld);
if (gap > tuning.breakRadius) release('lost the handle');
}
}
// ---- capture ----------------------------------------------------------
if (carrier === null && looseFor <= 0) {
let best = null;
let bestGap = tuning.captureRadius;
for (let i = 0; i < skaters.length; i++) {
if (skaters[i].limp || cooldown[i] > 0) continue;
bladePoint(i, _carryWorld);
const gap = _puckPos.distanceTo(_carryWorld);
if (gap < bestGap) {
bestGap = gap;
best = i;
}
}
if (best !== null) capture(best);
}
// ---- carry ------------------------------------------------------------
if (carrier === null) return;
this.carryPoint(_carryWorld);
_toTarget.subVectors(_carryWorld, _puckPos);
const state = states[carrier];
// The velocity that would put the puck on the carry point, given that the
// carry point is itself moving with the skater.
_desired.set(
state.vx + _toTarget.x * tuning.stiffness,
_toTarget.y * tuning.stiffness,
state.vz + _toTarget.z * tuning.stiffness,
);
const m = clamp(tuning.magnetism, 0, 1);
puck.setVelocity(
lerp(_puckVel.x, _desired.x, m),
lerp(_puckVel.y, _desired.y, m),
lerp(_puckVel.z, _desired.z, m),
);
// There was a second "fumble" test here, a function of stiffness and
// magnetism, meant to catch a puck the magnetism was papering over. It
// was redundant with `breakRadius` and, after stiffness went up, fired
// *tighter* than it — at 1.16 m against a 1.7 m break — so it silently
// stripped the puck off every shooter accelerating out of centre ice.
// Twenty of twenty-four shootout attempts ended with nobody shooting.
// One distance test is enough, and it is the one above.
},
/** Clear everything — faceoffs and resets. */
reset() {
carrier = null;
looseFor = 0;
cooldown.fill(0);
handling.x = 0;
handling.y = 0;
},
};
}
+237
View File
@@ -0,0 +1,237 @@
import * as THREE from 'three';
import { createGoalie } from '../character/goalie.js';
import { buildNetMesh, createNet } from '../physics/net.js';
import { NET, attemptLive, goalLineX, isGoal, shootoutStart } from '../../shared/net.js';
import { RINK } from '../../shared/rink.js';
import { PUCK } from '../physics/puck.js';
/**
* A shootout.
*
* The smallest thing that is actually hockey: one shooter, one goalie, one
* puck, and a result. No lines, no rules, no positional play — all of which
* makes it the right MVP, because everything it does need is the part that has
* to feel good anyway.
*
* Flow is a small state machine over one attempt:
*
* ready → the puck is on the dot, the shooter waits a few metres back
* live → they skate onto the puck and in on the goalie. Losing the handle
* is not the end of it — go and get it back.
* result → goal or save, held long enough to read
* ...then the other team shoots.
*
* Attempts alternate, so "1-on-1" is two players trading chances rather than a
* single endless drill.
*/
export const SHOOTOUT = {
/** Seconds on the clock for one attempt before it is called a miss. */
attemptTime: 15,
/** How long a goal or save is held on screen before the next shooter. */
resultTime: 2.2,
/** Countdown before the shooter is released. */
readyTime: 1.1,
/**
* How far behind the puck the shooter starts, metres.
*
* They skate onto it rather than spawning holding it — picking the puck up is
* part of the attempt, and starting glued to it skipped the only moment where
* the carry model has to prove it can *gain* possession rather than keep it.
*/
startBack: 4.5,
/** Rounds each side gets before it goes to sudden death. */
rounds: 5,
};
export function createShootout({ scene, physics, match }) {
const { puck, possession, states, skaters } = match;
// Nets and goalies at both ends, because the sides alternate.
const nets = [createNet(physics, 1), createNet(physics, -1)];
const netMeshes = [buildNetMesh(scene, 1), buildNetMesh(scene, -1)];
const goalies = {
1: createGoalie(physics, scene, { end: 1, index: 40, team: 1 }),
'-1': createGoalie(physics, scene, { end: -1, index: 41, team: 0 }),
};
const state = {
phase: 'ready',
/** Which team is shooting: 0 shoots at the +X end, 1 at X. */
shootingTeam: 0,
/** Index of the shooter, and which end they are attacking. */
shooter: 0,
end: 1,
round: 1,
score: [0, 0],
attempts: [0, 0],
/** Last result, for the HUD. */
last: null,
clock: 0,
};
const _puckPos = new THREE.Vector3();
/** Everyone who is not shooting gets parked out of the way. */
function parkBystanders() {
let n = 0;
for (let i = 0; i < states.length; i++) {
if (i === state.shooter) continue;
const s = states[i];
const side = n % 2 === 0 ? 1 : -1;
s.x = -state.end * (RINK.halfX * 0.55);
s.z = side * (RINK.halfZ * 0.78) + Math.floor(n / 2) * side * 1.4;
s.vx = 0;
s.vz = 0;
s.yaw = state.end > 0 ? Math.PI / 2 : -Math.PI / 2;
if (skaters[i].limp) skaters[i].getUp(s);
skaters[i].proxy?.teleport(s.x, s.z);
match.setControl(i, { x: 0, y: 0, sprint: false, brake: false, cameraYaw: 0 });
n++;
}
}
/** Set up the next attempt. */
function nextAttempt() {
// Alternate ends so each team shoots at the other's goalie.
state.shootingTeam = state.attempts[0] <= state.attempts[1] ? 0 : 1;
state.end = state.shootingTeam === 0 ? 1 : -1;
// The shooter is the first upright skater on that team.
const perTeam = match.perTeam;
state.shooter = state.shootingTeam * perTeam + (state.round - 1) % perTeam;
const start = shootoutStart(state.end);
const s = states[state.shooter];
if (skaters[state.shooter].limp) skaters[state.shooter].getUp(s);
// Behind the puck, facing the net they are attacking.
s.x = start.x - state.end * SHOOTOUT.startBack;
s.z = start.z;
s.yaw = start.yaw;
s.vx = 0;
s.vz = 0;
skaters[state.shooter].proxy?.teleport(s.x, s.z);
match.setControl(state.shooter, null);
parkBystanders();
possession.reset();
// Puck on the dot at centre ice. Nobody starts holding it.
puck.place(start.x, PUCK.thickness / 2, start.z);
goalies[1].reset();
goalies[-1].reset();
state.phase = 'ready';
state.clock = SHOOTOUT.readyTime;
}
function finish(result, detail = '') {
state.phase = 'result';
state.clock = SHOOTOUT.resultTime;
state.attempts[state.shootingTeam]++;
if (result === 'goal') state.score[state.shootingTeam]++;
state.last = {
result,
detail,
team: state.shootingTeam,
shooter: state.shooter,
round: state.round,
score: [...state.score],
};
// A round is complete once both sides have had the same number of goes.
if (state.attempts[0] === state.attempts[1]) state.round++;
}
/** The goalie defending the end currently being shot at. */
const activeGoalie = () => goalies[state.end];
function update(dt) {
_puckPos.copy(puck.position());
// Both goalies track, so the idle one still looks alive; only the active
// one can be scored on.
goalies[1].update(dt, _puckPos);
goalies[-1].update(dt, _puckPos);
state.clock -= dt;
if (state.phase === 'ready') {
// Hold the shooter still while the countdown runs. The puck sits on the
// dot untouched; picking it up is the first thing they do when released.
const s = states[state.shooter];
s.ix = 0;
s.iz = 0;
s.sprint = false;
if (state.clock <= 0) {
state.phase = 'live';
state.clock = SHOOTOUT.attemptTime;
// Hand control back to whoever is driving, or let the brain take it.
if (pendingControl) match.setControl(state.shooter, pendingControl);
}
return;
}
if (state.phase === 'result') {
if (state.clock <= 0) nextAttempt();
return;
}
// ---- live --------------------------------------------------------------
if (isGoal(_puckPos, state.end, PUCK.radius)) {
finish('goal');
return;
}
if (activeGoalie().covers(_puckPos) && puck.speed() < 3) {
finish('save', 'covered');
return;
}
// Losing the handle does *not* end the attempt. In a one-on-one the puck
// getting away from you is part of the attempt, not the end of it — go and
// get it back. Only the clock, the goalie, or the puck leaving the picture
// finishes an attempt.
if (!attemptLive(_puckPos, state.end)) {
finish('save', 'wide');
return;
}
if (state.clock <= 0) finish('save', 'time');
}
/** Control object handed to whoever is shooting, or null for AI. */
let pendingControl = null;
return {
state,
goalies,
nets,
netMeshes,
update,
nextAttempt,
/** Drive every shooter with this control object. Null hands them to the AI. */
setShooterControl(control) {
pendingControl = control;
if (state.phase === 'live') match.setControl(state.shooter, control);
},
/** Restart the whole shootout. */
reset() {
state.score = [0, 0];
state.attempts = [0, 0];
state.round = 1;
state.last = null;
nextAttempt();
},
destroy() {
for (const n of nets) n.destroy();
for (const m of netMeshes) scene.remove(m);
goalies[1].destroy();
goalies[-1].destroy();
},
};
}
export { NET, goalLineX };
+275
View File
@@ -0,0 +1,275 @@
import * as THREE from 'three';
import { createPhysicsWorld, initPhysics } from './physics/world.js';
import { buildPuckMesh, buildRink } from './render/rink.js';
import { PUCK } from './physics/puck.js';
import { createCamera } from './render/camera.js';
import { createMatch } from './game/match.js';
import { createInput } from './game/input.js';
import { describeHit } from './game/hits.js';
import { createShootout } from './game/shootout.js';
import { RINK } from '../shared/rink.js';
/**
* Spike 1 boot: three AI skaters on a rink.
*
* Everything gameplay-shaped lives in `game/match.js`; this file is the shell —
* renderer, lights, resize, the frame loop and a small debug HUD.
*/
const canvas = document.getElementById('stage');
const boot = document.getElementById('boot');
const hud = document.getElementById('hud');
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, powerPreference: 'high-performance' });
renderer.shadowMap.enabled = true;
// PCFSoft is deprecated as of three r185 and silently falls back to PCF anyway.
renderer.shadowMap.type = THREE.PCFShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.05;
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x0a0e14);
scene.fog = new THREE.Fog(0x0a0e14, 70, 150);
// Arena lighting: a broad soft fill so the ice reads as lit from a roof rather
// than from a single sun, plus one shadow-casting key over centre ice.
scene.add(new THREE.HemisphereLight(0xdce8f5, 0x20242c, 1.5));
const key = new THREE.DirectionalLight(0xffffff, 1.6);
key.position.set(14, 30, 10);
key.castShadow = true;
key.shadow.mapSize.set(2048, 2048);
key.shadow.camera.near = 5;
key.shadow.camera.far = 110;
// The ortho box has to contain the whole rink as seen from the light, or the
// depth texture clamps at its border and everything outside renders fully
// shadowed — a hard black wedge across the far ice, not a subtle artefact.
// Half the rink diagonal is the worst case, whatever angle the light is at.
const shadowSpan = Math.hypot(RINK.halfX, RINK.halfZ) + 6;
key.shadow.camera.left = -shadowSpan;
key.shadow.camera.right = shadowSpan;
key.shadow.camera.top = shadowSpan;
key.shadow.camera.bottom = -shadowSpan;
key.shadow.bias = -0.0006;
scene.add(key);
const rim = new THREE.DirectionalLight(0x9fc4e8, 0.5);
rim.position.set(-20, 14, -18);
scene.add(rim);
const cam = createCamera(canvas, window.innerWidth / window.innerHeight);
/**
* Match the drawing buffer and the CSS box to the window.
*
* `setSize(w, h)` must set the CSS size too — passing `false` for `updateStyle`
* only works if the stylesheet already sizes the canvas, and an absolutely
* positioned canvas with `width: auto` falls back to its *intrinsic* size
* instead. At DPR 2 that made the element twice the window and showed the
* top-left quarter of the render.
*
* The pixel ratio is re-applied here rather than once at startup so that
* dragging the window between a retina and a non-retina display re-resolves it.
*/
function resize() {
const w = window.innerWidth;
const h = window.innerHeight;
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(w, h);
cam.resize(w, h);
}
window.addEventListener('resize', resize);
resize();
const stats = { fps: 0, steps: 0, top: 0 };
const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);
async function boot3() {
await initPhysics();
const physics = createPhysicsWorld();
buildRink(scene);
const match = createMatch({ scene, physics, perTeam: 3, teams: 2 });
const puckView = buildPuckMesh(scene, PUCK);
// The shootout owns the nets and the goalies, and drives its own kinematic
// bodies inside the physics substep.
const shootout = createShootout({ scene, physics, match });
match.addSubstepSync((fixedDt) => {
shootout.goalies[1].syncPhysics(fixedDt);
shootout.goalies[-1].syncPhysics(fixedDt);
});
shootout.reset();
const input = createInput(window);
// One live input object, refreshed each frame and read by the match.
const stick = input.state;
// Rumble on contact the player is part of. Strength tracks the outcome, so
// the pad tells you whether you laid someone out or just brushed them, and
// taking one buzzes harder than giving one.
const RUMBLE = {
knockdown: [1.0, 0.7, 260],
stagger: [0.55, 0.35, 150],
bump: [0.22, 0.12, 70],
};
let lastHitSeen = -1;
/**
* Take control of a skater, or give them back.
*
* Taking control snaps the camera onto whoever you just grabbed — driving a
* skater you cannot see is the kind of thing that reads as a broken build.
*/
let playerShooting = false;
/**
* Take the shooter, or hand them back. In a shootout there is only one
* skater worth driving, and which one it is changes every attempt — so
* control follows the shooter rather than being pinned to an index.
*/
function toggleControl() {
playerShooting = !playerShooting;
shootout.setShooterControl(playerShooting ? stick : null);
if (playerShooting) {
cam.state.mode = 'follow';
cam.state.followIndex = shootout.state.shooter;
cam.state.distance = 9;
cam.state.pitch = 0.3;
}
}
window.addEventListener('keydown', (e) => {
if (e.key === 'c' || e.key === 'C') cam.cycleMode(match.skaters.length);
if (e.key === 'r' || e.key === 'R') shootout.reset();
if (e.key === 'p' || e.key === 'P' || e.code === 'Tab') {
e.preventDefault();
toggleControl();
}
// The possession dial, live. This is the undecided design question, so it
// is adjustable while playing rather than a constant to recompile — the
// answer is a feel judgement and has to be made with hands on the pad.
const t = match.possession.tuning;
if (e.key === '[') t.magnetism = Math.max(0, +(t.magnetism - 0.05).toFixed(2));
if (e.key === ']') t.magnetism = Math.min(1, +(t.magnetism + 0.05).toFixed(2));
});
// Debug handle. The capture tool drives the camera through this to frame
// repeatable shots, and it is the fastest way to poke at a skater from the
// console while tuning.
window.tilt = { match, shootout, cam, physics, scene, renderer, stats, input, toggleControl };
boot.remove();
let last = performance.now();
let fpsAccum = 0;
let fpsFrames = 0;
function frame(now) {
// Clamped so a background tab does not come back and teleport everyone
// across the rink in one step.
const dt = Math.min(0.05, (now - last) / 1000);
last = now;
// The camera yaw rides along with the stick so the match can turn a
// screen-space push into a world direction. Sampled before the update so
// input and simulation are one frame consistent.
input.read(dt);
stick.cameraYaw = cam.state.yaw;
match.update(dt);
shootout.update(dt);
// Follow whoever is shooting, so the camera never has to be told.
if (cam.state.mode === 'follow') cam.state.followIndex = shootout.state.shooter;
// Haptics for anything the player was part of.
const newest = match.recentHits[0];
if (newest && newest.at !== lastHitSeen) {
lastHitSeen = newest.at;
const me = match.playerIndex;
if (me !== null && (newest.attacker === me || newest.victim === me)) {
const [strong, weak, ms] = RUMBLE[newest.outcome] ?? RUMBLE.bump;
// Taking a hit shakes harder than landing one.
const k = newest.victim === me ? 1 : 0.7;
input.rumble(strong * k, weak * k, ms);
}
}
puckView.mesh.position.copy(match.puck.position());
puckView.mesh.quaternion.copy(match.puck.rotation());
puckView.ring.visible = match.possession.loose;
cam.update(dt, match.states);
renderer.render(scene, cam.camera);
fpsAccum += dt;
fpsFrames++;
if (fpsAccum >= 0.5) {
stats.fps = Math.round(fpsFrames / fpsAccum);
stats.steps = physics.stepCount;
stats.top = match.states.reduce((m, s) => Math.max(m, Math.hypot(s.vx, s.vz)), 0);
fpsAccum = 0;
fpsFrames = 0;
}
// Drawn every frame, not on the half-second tick: the hustle and shot
// meters are feedback, and feedback at 2 Hz is worse than none.
drawHud();
requestAnimationFrame(frame);
}
const bar = (v) => '▮'.repeat(Math.round(clamp01(v) * 8)).padEnd(8, '▯');
function drawHud() {
const watching = cam.state.mode === 'follow'
? match.states[cam.state.followIndex]?.name ?? 'broadcast'
: 'broadcast';
const player = match.playerIndex !== null ? match.states[match.playerIndex] : null;
const down = match.skaters.filter((s) => s.limp).length;
const feed = match.recentHits
.filter((h) => h.outcome !== 'bump')
.slice(0, 3)
.map((h) => ` ${match.states[h.attacker].name}${describeHit(h)}`
+ `${h.outcome === 'knockdown' ? ' DOWN' : ''}${h.headshot ? ' (head)' : ''}`)
.join('\n');
const pad = input.connected
? `pad: ${(stick.padId ?? '').slice(0, 30) || 'connected'}`
: 'pad: none — keyboard';
const so = shootout.state;
const teamName = (t) => (t === 0 ? 'HOME' : 'AWAY');
const scoreLine = `${teamName(0)} ${so.score[0]}${so.score[1]} ${teamName(1)}`
+ ` round ${so.round}`;
const phaseLine = so.phase === 'ready'
? `${teamName(so.shootingTeam)} to shoot…`
: so.phase === 'result'
? (so.last?.result === 'goal'
? `GOAL — ${teamName(so.last.team)}`
: `SAVE${so.last?.detail ? ` (${so.last.detail})` : ''}`)
: `${teamName(so.shootingTeam)} shooting · ${Math.max(0, so.clock).toFixed(1)}s`;
const carrier = match.possession.carrier;
const puckLine = carrier === null
? `puck: loose ${match.puck.speed().toFixed(1)} m/s`
: `puck: ${match.states[carrier].name}${carrier === match.playerIndex ? ' ← YOU' : ''}`;
const mag = match.possession.tuning.magnetism;
hud.textContent = `${scoreLine}`
+ `\n${phaseLine}`
+ `\n`
+ `\n${stats.fps} fps · ${puckLine}`
+ `\n${pad}`
+ `\n[P] ${playerShooting ? 'let the AI shoot' : 'take the shooter'} [C] camera [R] restart`
+ `\nmagnetism ${bar(mag)} ${mag.toFixed(2)} [ ] to tune`
+ (player
? (input.connected
? '\nL-stick skate · RT hustle · LT stop · R-stick Skill Stick\nA pass · X shoot · B poke'
: '\nWASD skate · Shift hustle · Space stop · arrows Skill Stick\nJ pass · K shoot · L poke')
+ `\nhustle ${bar(stick.hustle)} wind-up ${bar(stick.charge)}`
: '')
+ (feed ? `\n\nhits:\n${feed}` : '');
}
requestAnimationFrame(frame);
}
boot3().catch((err) => {
console.error(err);
boot.textContent = 'FAILED TO START — ' + (err?.message ?? err);
});
+143
View File
@@ -0,0 +1,143 @@
import { KIND, makeTag, proxyFilter, xyz } from './bridge.js';
import { SKATE } from '../../shared/skaterSim.js';
/**
* One dynamic capsule per skater — the body that Box3D actually solves.
*
* The 18-capsule ragdoll is kinematic while a skater is on their feet, and
* kinematic bodies do not respond to each other: two rigs driven through one
* another would generate contacts and resolve none of them. So physical
* presence lives in a single dynamic capsule instead, and the ragdoll rides
* along on top purely as the visible, hittable skeleton.
*
* The loop is:
*
* read — pull position and velocity out of Box3D into the sim state
* step — the skating sim edits that velocity (stride, carve, drag)
* write — put the edited velocity back on the body, then let Box3D solve
*
* Reading velocity back rather than only writing it is the whole point: a
* board hit or a shoulder from another skater arrives as a change to `vx/vz`
* that the sim then carries forward as momentum, so contact costs speed and
* knocks a skater off their line instead of being overwritten next frame.
*
* Rotation and vertical motion are locked. Upright-ness is an animation
* concern here, not a physics one — and an unlocked capsule on near-frictionless
* ice will happily lie down and roll to the far boards.
*/
/** Capsule spans knee to shoulder; below that is legs, above is head. */
const LOW = 0.5;
const HIGH = 1.28;
/** Skater plus pads, kg. Sets how much of a shove a check transfers. */
const MASS = 88;
const capsuleVolume = (r, len) => Math.PI * r * r * len + (4 / 3) * Math.PI * r * r * r;
export function createBodyProxy(physics, { index = 0, position = { x: 0, z: 0 } } = {}) {
const { api, world } = physics;
const filter = proxyFilter();
const bd = api.b3DefaultBodyDef();
bd.type = api.b3BodyType.b3_dynamicBody;
bd.position = xyz(position.x, 0, position.z);
// Never sleep: a skater standing still still has to be shoved when hit, and
// a sleeping body ignores the velocity we write to it.
bd.enableSleep = false;
bd.motionLocks = {
linearX: false,
linearY: true,
linearZ: false,
angularX: true,
angularY: true,
angularZ: true,
};
const body = api.b3CreateBody(world, bd);
const sd = api.b3DefaultShapeDef();
sd.density = MASS / capsuleVolume(SKATE.radius, HIGH - LOW);
sd.enableContactEvents = true;
sd.enableHitEvents = true;
// Skater-on-skater should shove, not stick. Friction between two bodies on
// ice is what would make a brush past turn into a drag along.
sd.baseMaterial.friction = 0.1;
sd.baseMaterial.restitution = 0.05;
sd.baseMaterial.userMaterialId = makeTag(KIND.PROXY, index, 0);
sd.filter.categoryBits = filter.category;
sd.filter.maskBits = filter.mask;
const shape = api.b3CreateCapsuleShape(body, sd, {
center1: xyz(0, LOW, 0),
center2: xyz(0, HIGH, 0),
radius: SKATE.radius,
});
// Gravity is pointless with linearY locked, and leaving it on means the
// solver spends every step fighting the lock.
api.b3Body_SetGravityScale(body, 0);
// No damping: the skating sim is the only thing allowed to remove speed,
// otherwise top speed and glide length quietly depend on solver settings.
api.b3Body_SetLinearDamping(body, 0);
return {
body,
shape,
index,
mass: api.b3Body_GetMass(body),
/** Box3D → sim. Call before stepping the sim. */
read(state) {
const p = api.b3Body_GetPosition(body);
const v = api.b3Body_GetLinearVelocity(body);
state.x = p.x;
state.z = p.z;
state.vx = v.x;
state.vz = v.z;
},
/** Sim → Box3D. Call after stepping the sim, before the world step. */
write(state) {
api.b3Body_SetLinearVelocity(body, xyz(state.vx, 0, state.vz));
api.b3Body_SetAwake(body, true);
},
/**
* Hard placement, for spawning and respawns. Clears momentum so a skater
* dropped onto the ice does not inherit whatever the last body was doing.
*/
teleport(x, z) {
api.b3Body_SetTransform(body, xyz(x, 0, z), { v: { x: 0, y: 0, z: 0 }, s: 1 });
api.b3Body_SetLinearVelocity(body, xyz(0, 0, 0));
},
/** True while this capsule is taking part in the simulation. */
enabled: true,
/**
* Switch the capsule off while the ragdoll is the body.
*
* Not just "stop writing velocity to it": a body left enabled still
* occupies space, so a downed skater would leave an invisible upright
* bollard on the ice for everyone else to skate into.
*/
disable() {
if (!this.enabled) return;
api.b3Body_Disable(body);
this.enabled = false;
},
/** Put the capsule back, wherever the body actually ended up. */
enable(x, z) {
if (this.enabled) return;
api.b3Body_Enable(body);
api.b3Body_SetTransform(body, xyz(x, 0, z), { v: { x: 0, y: 0, z: 0 }, s: 1 });
api.b3Body_SetLinearVelocity(body, xyz(0, 0, 0));
api.b3Body_SetAwake(body, true);
this.enabled = true;
},
destroy() {
api.b3DestroyBody(body);
},
};
}
+129
View File
@@ -0,0 +1,129 @@
/**
* three.js <-> Box3D type conversion.
*
* The one real trap: Box3D's embind structs use the vector/scalar quaternion
* form `{ v: {x,y,z}, s }`, while three.js uses `{x,y,z,w}`. Passing a three
* quaternion straight into a joint or transform throws `Missing field: "v"`
* from embind, so everything crossing the boundary goes through here.
*/
export const IDENTITY_QUAT = Object.freeze({ v: { x: 0, y: 0, z: 0 }, s: 1 });
export const vec3 = (v) => ({ x: v.x, y: v.y, z: v.z });
export const xyz = (x, y, z) => ({ x, y, z });
/** three.Quaternion -> b3Quat */
export const quat = (q) => ({ v: { x: q.x, y: q.y, z: q.z }, s: q.w });
/** b3Quat -> three.Quaternion (in place) */
export const toThreeQuat = (out, bq) => out.set(bq.v.x, bq.v.y, bq.v.z, bq.s);
/** b3Vec3 -> three.Vector3 (in place) */
export const toThreeVec = (out, bv) => out.set(bv.x, bv.y, bv.z);
/** three position + quaternion -> b3Transform */
export const transform = (p, q) => ({ p: vec3(p), q: quat(q) });
/** Copy a body's pose onto an Object3D that lives in world space. */
export function applyBodyToObject(b3, bodyId, obj) {
const t = b3.b3Body_GetTransform(bodyId);
obj.position.set(t.p.x, t.p.y, t.p.z);
obj.quaternion.set(t.q.v.x, t.q.v.y, t.q.v.z, t.q.s);
}
/**
* Shape tags.
*
* Box3D has no per-body user data, but hit events carry the `userMaterialId`
* of both shapes, so identity is packed into that 64-bit field:
*
* bits 0..7 kind (KIND.*)
* bits 8..15 skater index of the owning skater, 0xff for none
* bits 16..31 slot region or piece index within that skater
*
* A hit event therefore tells us who was struck, where, and by what, without
* any side lookup in the hot path.
*/
export const KIND = {
NONE: 0,
BODY: 1, // ragdoll limb
PROXY: 2, // the skater's single dynamic capsule
STICK: 3, // reserved — spike 2
PUCK: 4, // reserved — spike 2
RINK: 5, // ice / boards
};
export function makeTag(kind, skater, slot) {
return (BigInt(kind & 0xff)) | (BigInt((skater ?? 0xff) & 0xff) << 8n) | (BigInt(slot & 0xffff) << 16n);
}
/**
* Collision layers.
*
* Bit 0 is the rink (ice + boards). Bit 15 is the proxy layer: the one dynamic
* capsule per skater that Box3D actually solves — board contact, and skater
* against skater, both happen there.
*
* Each skater also owns one bit from bit 1 up for their 18 ragdoll capsules.
* Those are kinematic in this spike and exist so the rig is already wired for
* impulses later; they deliberately do *not* collide with any proxy, because a
* kinematic limb driving through the dynamic capsule that carries the same
* body would fight it every frame.
*
* Getting this wrong is silent: a body whose mask excludes bit 0 simply falls
* through the world with no error anywhere.
*/
export const CAT = {
RINK: 1n,
PROXY: 1n << 15n,
PUCK: 1n << 16n,
STICK: 1n << 17n,
skater: (index) => 1n << BigInt(1 + index),
};
const ALL_BITS = 0xffffffffffffffffn;
/**
* The dynamic body capsule: hits the boards, every other skater's proxy, and
* the puck. Not sticks — a stick is a kinematic collider and would shove
* skaters around without ever being pushed back.
*/
export function proxyFilter() {
return { category: CAT.PROXY, mask: CAT.RINK | CAT.PROXY | CAT.PUCK };
}
/**
* The stick blade: touches the puck and nothing else.
*
* Kinematic bodies push dynamic ones without being pushed back, which is
* exactly right for a stick batting a puck and exactly wrong for a stick
* batting a person. Same trap as the ragdoll limbs, resolved the same way —
* by keeping the mask narrow rather than by hoping.
*/
export function stickFilter() {
return { category: CAT.STICK, mask: CAT.PUCK };
}
/**
* Ragdoll limbs: include the self bit so distant parts collide (hand vs
* torso, thigh vs thigh) once the rig goes dynamic. Adjacent pairs are
* rejected by the world custom filter using the userMaterialId slot indices.
* Proxies are masked out — see the note above.
*/
export function ragdollFilter(index) {
const self = CAT.skater(index);
return { category: self, mask: ALL_BITS & ~CAT.PROXY };
}
export function rinkFilter() {
return { category: CAT.RINK, mask: ALL_BITS };
}
export function readTag(tag) {
const t = BigInt(tag);
return {
kind: Number(t & 0xffn),
skater: Number((t >> 8n) & 0xffn),
slot: Number((t >> 16n) & 0xffffn),
};
}
+124
View File
@@ -0,0 +1,124 @@
import * as THREE from 'three';
import { NET, goalLineX } from '../../shared/net.js';
import { CAT, KIND, makeTag, xyz } from './bridge.js';
/**
* The goal frame: posts, crossbar, and a mesh back that stops the puck.
*
* Static bodies, because a net that moves is a rule (it comes off its moorings)
* rather than a feature, and not one worth having before there is a game.
*
* The back and sides are solid boxes rather than a real mesh. A puck that goes
* in should stay in and settle, and modelling twine is a lot of work to make a
* puck stop moving.
*/
export function createNet(physics, end) {
const { api, world } = physics;
const line = goalLineX(end);
const halfW = NET.width / 2;
const r = NET.postRadius;
const sd = api.b3DefaultShapeDef();
sd.baseMaterial.friction = 0.4;
// Posts ring; the back eats everything so the puck settles in the net.
sd.baseMaterial.restitution = 0.35;
sd.baseMaterial.userMaterialId = makeTag(KIND.RINK, 0xff, end > 0 ? 10 : 11);
sd.filter.categoryBits = CAT.RINK;
sd.filter.maskBits = 0xffffffffffffffffn;
const bodies = [];
const box = (x, y, z, hx, hy, hz, restitution = null) => {
const bd = api.b3DefaultBodyDef();
bd.position = xyz(x, y, z);
const b = api.b3CreateBody(world, bd);
if (restitution !== null) sd.baseMaterial.restitution = restitution;
api.b3CreateBoxShape(b, sd, hx, hy, hz);
sd.baseMaterial.restitution = 0.35;
bodies.push(b);
return b;
};
// Posts, on the line.
box(line, NET.height / 2, halfW, r, NET.height / 2, r);
box(line, NET.height / 2, -halfW, r, NET.height / 2, r);
// Crossbar.
box(line, NET.height, 0, r, r, halfW);
// Back and sides, deadened so the puck does not fire back out.
//
// The net extends *away* from centre ice, `line + end * depth`. Getting this
// sign backwards put the back panel a metre in front of the goal line — a
// solid wall across the mouth — and every shot in the game bounced off it
// before it could cross. Nothing ever scored, and the symptom looked like a
// goalie problem.
box(line + end * NET.depth, NET.height / 2, 0, 0.04, NET.height / 2, halfW, 0.02);
box(line + end * NET.depth * 0.5, NET.height / 2, halfW, NET.depth / 2, NET.height / 2, 0.03, 0.05);
box(line + end * NET.depth * 0.5, NET.height / 2, -halfW, NET.depth / 2, NET.height / 2, 0.03, 0.05);
return {
end,
bodies,
destroy() {
for (const b of bodies) api.b3DestroyBody(b);
},
};
}
/** The rendered net: frame tubes plus a translucent mesh bag. */
export function buildNetMesh(scene, end) {
const line = goalLineX(end);
const halfW = NET.width / 2;
const group = new THREE.Group();
group.name = 'net:' + end;
const frame = new THREE.MeshStandardMaterial({ color: 0xc0332c, roughness: 0.45, metalness: 0.25 });
const mesh = new THREE.MeshStandardMaterial({
color: 0xf2f4f8,
roughness: 0.9,
transparent: true,
opacity: 0.28,
side: THREE.DoubleSide,
depthWrite: false,
});
const tube = (len, x, y, z, axis) => {
const g = new THREE.CylinderGeometry(NET.postRadius, NET.postRadius, len, 10);
const m = new THREE.Mesh(g, frame);
if (axis === 'z') m.rotation.x = Math.PI / 2;
if (axis === 'x') m.rotation.z = Math.PI / 2;
m.position.set(x, y, z);
m.castShadow = true;
group.add(m);
};
tube(NET.height, line, NET.height / 2, halfW, 'y');
tube(NET.height, line, NET.height / 2, -halfW, 'y');
tube(NET.width, line, NET.height, 0, 'z');
// Back frame, so the net reads as a box rather than as a doorway.
tube(NET.depth, line + end * NET.depth / 2, 0.06, halfW, 'x');
tube(NET.depth, line + end * NET.depth / 2, 0.06, -halfW, 'x');
const back = new THREE.Mesh(new THREE.PlaneGeometry(NET.width, NET.height), mesh);
back.position.set(line + end * NET.depth, NET.height / 2, 0);
back.rotation.y = Math.PI / 2;
group.add(back);
for (const s of [1, -1]) {
const side = new THREE.Mesh(new THREE.PlaneGeometry(NET.depth, NET.height), mesh);
side.position.set(line + end * NET.depth / 2, NET.height / 2, s * halfW);
group.add(side);
}
const top = new THREE.Mesh(new THREE.PlaneGeometry(NET.depth, NET.width), mesh);
top.rotation.x = -Math.PI / 2;
top.position.set(line + end * NET.depth / 2, NET.height, 0);
group.add(top);
// Crease paint.
const crease = new THREE.Mesh(
new THREE.CircleGeometry(NET.creaseRadius, 24, end > 0 ? -Math.PI / 2 : Math.PI / 2, Math.PI),
new THREE.MeshBasicMaterial({ color: 0x77b3e0, transparent: true, opacity: 0.45, depthWrite: false }),
);
crease.rotation.x = -Math.PI / 2;
crease.position.set(line, 0.004, 0);
group.add(crease);
scene.add(group);
return group;
}
+146
View File
@@ -0,0 +1,146 @@
import * as THREE from 'three';
import { CAT, KIND, makeTag, xyz } from './bridge.js';
/**
* The puck.
*
* Regulation: 76 mm across, 25.4 mm thick, 170 g. Those are not decoration —
* the size is what makes this the one body in the world that genuinely needs
* continuous collision, and the mass is what makes a 45 m/s shot carry about
* the same momentum as a slow-walking person.
*
* ### Why it is a bullet
*
* A hard shot travels ~45 m/s. At the 1/120 s fixed step that is 0.37 m per
* step — nearly ten times the puck's own radius — and even at Box3D's internal
* 1/480 substep it is still 2.4× radius. Without continuous collision it goes
* straight through the boards, the net and anybody standing in the way, and the
* symptom (a puck that vanishes on hard shots only) is miserable to chase.
*
* ### Why it is a cylinder, and why it cannot tip over
*
* A sphere would roll, and a box would catch its corners. Box3D can build a
* cylinder hull directly. Angular X and Z are then locked so the puck stays
* flat on the ice and only ever spins about its own axis — a puck rolling
* around the rink on its edge is technically possible and always reads as a
* bug. Vertical motion stays free, because a shot lifting off the ice is real
* hockey.
*/
export const PUCK = {
radius: 0.0381,
thickness: 0.0254,
mass: 0.170,
/** Ice is slippery; a dumped puck should travel the length of the rink. */
iceFriction: 0.05,
/** Boards are lively for something this light. */
boardRestitution: 0.35,
/** Terminal sanity: nothing in hockey exceeds this. */
maxSpeed: 55,
};
const HULL_SIDES = 16;
export function createPuck(physics, { position = { x: 0, y: 0.02, z: 0 } } = {}) {
const { api, world } = physics;
const bd = api.b3DefaultBodyDef();
bd.type = api.b3BodyType.b3_dynamicBody;
bd.position = xyz(position.x, position.y, position.z);
bd.isBullet = true;
// Never sleep. A puck sitting still in a corner still has to react the
// instant a skate touches it.
bd.enableSleep = false;
bd.motionLocks = {
linearX: false,
linearY: false,
linearZ: false,
angularX: true,
angularY: false,
angularZ: true,
};
const body = api.b3CreateBody(world, bd);
api.b3Body_SetBullet(body, true);
// `b3CreateCylinder` builds *upward from* `yOffset` rather than centring on
// it, so the offset has to be half the thickness or the body origin sits on
// the puck's bottom face — the puck then rests with its origin at y=0 and the
// rendered mesh, which is centred, is drawn half-sunk into the ice.
const hull = api.b3CreateCylinder(PUCK.thickness, PUCK.radius, -PUCK.thickness / 2, HULL_SIDES);
const sd = api.b3DefaultShapeDef();
sd.density = PUCK.mass / (Math.PI * PUCK.radius * PUCK.radius * PUCK.thickness);
sd.enableContactEvents = true;
sd.enableHitEvents = true;
sd.baseMaterial.friction = PUCK.iceFriction;
sd.baseMaterial.restitution = PUCK.boardRestitution;
sd.baseMaterial.userMaterialId = makeTag(KIND.PUCK, 0xff, 0);
sd.filter.categoryBits = CAT.PUCK;
// Everything solid: the rink, skater bodies, downed ragdolls and sticks.
sd.filter.maskBits = 0xffffffffffffffffn;
const shape = api.b3CreateHullShape(body, sd, hull);
// Damping stands in for air resistance and blade scrape; without it a puck
// dumped down the ice never slows at all on a 0.05 friction surface.
api.b3Body_SetLinearDamping(body, 0.22);
api.b3Body_SetAngularDamping(body, 0.4);
const _pos = new THREE.Vector3();
const _vel = new THREE.Vector3();
const _quat = new THREE.Quaternion();
return {
body,
shape,
mass: api.b3Body_GetMass(body),
/** World position, into a reused vector. */
position() {
const p = api.b3Body_GetPosition(body);
return _pos.set(p.x, p.y, p.z);
},
velocity() {
const v = api.b3Body_GetLinearVelocity(body);
return _vel.set(v.x, v.y, v.z);
},
rotation() {
const t = api.b3Body_GetTransform(body);
return _quat.set(t.q.v.x, t.q.v.y, t.q.v.z, t.q.s);
},
speed() {
const v = api.b3Body_GetLinearVelocity(body);
return Math.hypot(v.x, v.y, v.z);
},
setVelocity(x, y, z) {
const speed = Math.hypot(x, y, z);
if (speed > PUCK.maxSpeed) {
const k = PUCK.maxSpeed / speed;
api.b3Body_SetLinearVelocity(body, xyz(x * k, y * k, z * k));
} else {
api.b3Body_SetLinearVelocity(body, xyz(x, y, z));
}
api.b3Body_SetAwake(body, true);
},
applyImpulse(x, y, z) {
api.b3Body_ApplyLinearImpulseToCenter(body, xyz(x, y, z), true);
},
/** Hard placement — faceoffs, resets, and the carry when fully magnetised. */
place(x, y, z, { keepMotion = false } = {}) {
api.b3Body_SetTransform(body, xyz(x, y, z), { v: { x: 0, y: 0, z: 0 }, s: 1 });
if (!keepMotion) {
api.b3Body_SetLinearVelocity(body, xyz(0, 0, 0));
api.b3Body_SetAngularVelocity(body, xyz(0, 0, 0));
}
api.b3Body_SetAwake(body, true);
},
destroy() {
api.b3DestroyBody(body);
api.b3DestroyHull(hull);
},
};
}
+676
View File
@@ -0,0 +1,676 @@
import * as THREE from 'three';
import { BONE_RADIUS, BONE_REGION, SEG_CHILD } from '../character/skeleton.js';
import { CAT, IDENTITY_QUAT, KIND, makeTag, quat, ragdollFilter, transform, vec3 } from './bridge.js';
// Reaction curve: a blow bites almost instantly, then bleeds off over the
// recovery window. Anything slower on the attack reads as the skater choosing
// to flinch rather than being moved by the hit.
// Reach full physics weight fast so the flinch is visible on the first frames
// after the impulse (was 55 ms — most of a light hit was over before peak).
export const REACTION_ATTACK = 0.04;
/**
* Physical body built from the animation skeleton.
*
* Each part's collider is authored in *bone-local* space — capsule from the
* bone origin to its child's local offset — and the rigid body is placed at
* the bone's world transform. That sidesteps any axis-alignment math: the
* capsule matches the bone exactly by construction, whatever direction the
* bone happens to point.
*
* Two modes:
* 'driven' bodies are kinematic and chase the animated skeleton. This is
* everything spike 1 uses — the rig is here so that hits later have
* something to push, not because anything pushes it yet.
* 'limp' bodies go dynamic and the joints take over. Bone velocity at the
* moment of transition is carried across, so a skater taken off
* their feet mid-stride keeps the momentum of that stride.
*
* Carried over from Ludus with the collision filters retargeted (see
* bridge.js) and nothing else changed: it is the same 18 capsules and 17
* joints, and the reaction/limp paths are known-good.
*/
const HINGE_FRAME = { v: { x: 0, y: Math.SQRT1_2, z: 0 }, s: Math.SQRT1_2 }; // local Z -> local X
// Body density by tissue type. Box3D derives mass and inertia from the shapes,
// so these are the only mass numbers we author — but see CALIBRATION below.
const DENSITY = { bone: 1350, limb: 1050, torso: 1010, head: 1090 };
// Adjacent bone capsules deliberately overlap so the rig has no gaps at the
// joints, which means summing their volumes counts the overlaps twice and lands
// around 175 kg of "flesh" for a normal build. Rather than fudge the densities
// (and lose the physical relationship between tissue types), the whole rig is
// scaled once at build time to hit a plausible total. Re-setting the shape
// density and letting Box3D recompute keeps each body's inertia tensor
// consistent with its new mass; scaling the tensor by hand would not.
const TARGET_BODY_MASS = 86; // kg, before pads and stick
/**
* Parts, parent-first. `hinge` marks a joint that should only bend one way
* (elbows, knees); everything else is a cone-limited ball joint.
*/
const PARTS = [
{ name: 'pelvis', bone: 'pelvis', parent: null, density: DENSITY.torso, radiusScale: 1.15 },
{ name: 'spine1', bone: 'spine1', parent: 'pelvis', density: DENSITY.torso, cone: 0.34, twist: 0.5 },
{ name: 'spine2', bone: 'spine2', parent: 'spine1', density: DENSITY.torso, cone: 0.34, twist: 0.5 },
{ name: 'spine3', bone: 'spine3', parent: 'spine2', density: DENSITY.torso, cone: 0.3, twist: 0.4 },
{ name: 'neck', bone: 'neck', parent: 'spine3', density: DENSITY.head, cone: 0.5, twist: 0.7 },
{ name: 'head', bone: 'head', parent: 'neck', density: DENSITY.head, cone: 0.55, twist: 0.8, radiusScale: 1.0 },
{ name: 'upperArmL', bone: 'upperArmL', parent: 'spine3', density: DENSITY.limb, cone: 1.5, twist: 1.1 },
{ name: 'forearmL', bone: 'forearmL', parent: 'upperArmL', density: DENSITY.limb, hinge: [-0.12, 2.5] },
{ name: 'handL', bone: 'handL', parent: 'forearmL', density: DENSITY.limb, cone: 0.7, twist: 0.6 },
{ name: 'upperArmR', bone: 'upperArmR', parent: 'spine3', density: DENSITY.limb, cone: 1.5, twist: 1.1 },
{ name: 'forearmR', bone: 'forearmR', parent: 'upperArmR', density: DENSITY.limb, hinge: [-0.12, 2.5] },
{ name: 'handR', bone: 'handR', parent: 'forearmR', density: DENSITY.limb, cone: 0.7, twist: 0.6 },
// Knee hinge is about bone-local +X (HINGE_FRAME maps joint Z → X). With the
// rest limb along Y, *positive* angle swings the foot back (Z) — flexion.
// Negative angle is hyperextension (foot forward). The old limits were
// inverted ([-2.4, -0.12]), so limp legs only bent the wrong way.
// Residual +0.12 rad of flex stops a perfectly straight column from standing
// forever under gravity, and blocks reverse bend.
{ name: 'thighL', bone: 'thighL', parent: 'pelvis', density: DENSITY.limb, cone: 1.15, twist: 0.5 },
{ name: 'shinL', bone: 'shinL', parent: 'thighL', density: DENSITY.limb, hinge: [0.12, 2.4] },
{ name: 'footL', bone: 'footL', parent: 'shinL', density: DENSITY.bone, cone: 0.5, twist: 0.3 },
{ name: 'thighR', bone: 'thighR', parent: 'pelvis', density: DENSITY.limb, cone: 1.15, twist: 0.5 },
{ name: 'shinR', bone: 'shinR', parent: 'thighR', density: DENSITY.limb, hinge: [0.12, 2.4] },
{ name: 'footR', bone: 'footR', parent: 'shinR', density: DENSITY.bone, cone: 0.5, twist: 0.3 },
];
const _wp = new THREE.Vector3();
const _wq = new THREE.Quaternion();
const _ws = new THREE.Vector3();
const _prevP = new THREE.Vector3();
const _prevQ = new THREE.Quaternion();
const _pq = new THREE.Quaternion();
const _pqi = new THREE.Quaternion();
const _dq = new THREE.Quaternion();
const _axis = new THREE.Vector3();
const _zAxis = new THREE.Vector3(0, 0, 1);
/**
* Adjacency (by part name) for self-collision filtering.
* Adjacent capsules deliberately overlap at joints; they must never generate
* contacts. Parts two links away still often rest inside each other in bind
* pose (spine1↔spine3), so we cull graph distance ≤ 2 as well.
*/
function partDistance(a, b) {
if (a === b) return 0;
// BFS on the undirected tree. PARTS is small (18), so this is free.
const adj = new Map();
for (const p of PARTS) {
if (!adj.has(p.name)) adj.set(p.name, []);
if (p.parent) {
adj.get(p.name).push(p.parent);
if (!adj.has(p.parent)) adj.set(p.parent, []);
adj.get(p.parent).push(p.name);
}
}
const q = [[a, 0]];
const seen = new Set([a]);
while (q.length) {
const [n, d] = q.shift();
if (n === b) return d;
for (const m of adj.get(n) ?? []) {
if (seen.has(m)) continue;
seen.add(m);
q.push([m, d + 1]);
}
}
return 99;
}
/**
* Precomputed "too close to collide" pairs keyed by part name.
* Distance ≤ 1 = joint neighbours (capsules deliberately overlap).
* Distance 2 on the *spine* only — limb forks (thighL↔thighR = 2 via pelvis)
* must still collide so a limp body can tangle.
*/
const NO_COLLIDE = new Set();
{
const names = PARTS.map((p) => p.name);
const spine = new Set(['pelvis', 'spine1', 'spine2', 'spine3', 'neck', 'head']);
for (let i = 0; i < names.length; i++) {
for (let j = i + 1; j < names.length; j++) {
const d = partDistance(names[i], names[j]);
const bothSpine = spine.has(names[i]) && spine.has(names[j]);
if (d <= 1 || (d <= 2 && bothSpine)) {
NO_COLLIDE.add(`${names[i]}|${names[j]}`);
NO_COLLIDE.add(`${names[j]}|${names[i]}`);
}
}
}
}
/** True when two body part *names* on the same rig may generate contacts. */
export function ragdollPartsCollide(nameA, nameB) {
if (nameA === nameB) return false;
return !NO_COLLIDE.has(`${nameA}|${nameB}`);
}
/** Slot indices match the order PARTS is walked when building the ragdoll. */
const SLOT_NAMES = PARTS.map((p) => p.name);
/** True when two body *slots* on the same rig may generate contacts. */
export function slotsShouldCollide(slotA, slotB) {
const a = SLOT_NAMES[slotA];
const b = SLOT_NAMES[slotB];
if (a == null || b == null) return true;
return ragdollPartsCollide(a, b);
}
export function createRagdoll(physics, skelData, { skaterIndex = 0 } = {}) {
const { api, world } = physics;
const bones = skelData.bones;
skelData.rootBone.updateMatrixWorld(true);
const filter = ragdollFilter(skaterIndex);
// Two masks, swapped by setMode. `driven` keeps limbs out of the proxy layer
// so an animated arm cannot shove anybody; `limp` lets a falling body hit
// people. The rig's own proxy is disabled while it is down, so nothing here
// has to special-case self.
const drivenMask = filter.mask & ~CAT.PROXY;
const limpMask = filter.mask | CAT.PROXY;
const _filter = { categoryBits: filter.category, maskBits: drivenMask, groupIndex: 0 };
const parts = {};
const order = [];
for (const def of PARTS) {
const bone = bones[def.bone];
if (!bone) continue;
const childName = SEG_CHILD[def.bone];
const child = childName ? bones[childName] : null;
// Capsule endpoints in bone-local space.
const c1 = new THREE.Vector3(0, 0, 0);
const c2 = child
? child.position.clone()
: def.bone === 'head'
? new THREE.Vector3(0, 0.15, 0.012)
: def.bone.startsWith('hand')
? new THREE.Vector3(def.bone.endsWith('L') ? 0.045 : -0.045, -0.095, 0.008)
: new THREE.Vector3(0, -0.012, 0.085);
const radius = BONE_RADIUS[def.bone] * (def.radiusScale ?? 0.72);
// A degenerate capsule (endpoints closer than the radius) is just a sphere
// and confuses the solver; nudge it out along its own axis instead.
if (c2.length() < radius * 0.5) c2.setLength(radius * 0.5 + 1e-3);
bone.matrixWorld.decompose(_wp, _wq, _ws);
const bd = api.b3DefaultBodyDef();
// Created dynamic so Box3D computes mass and inertia from the shapes, then
// switched to kinematic below. A kinematic body reports zero mass, so this
// is the only moment the real figure is available.
bd.type = api.b3BodyType.b3_dynamicBody;
bd.position = vec3(_wp);
bd.rotation = quat(_wq);
bd.enableSleep = false;
const body = api.b3CreateBody(world, bd);
const sd = api.b3DefaultShapeDef();
sd.density = def.density;
sd.enableHitEvents = true;
sd.enableContactEvents = true;
// Custom filter rejects adjacent limbs of the same skater (see world.js).
sd.enableCustomFiltering = true;
sd.baseMaterial.friction = 0.75;
sd.baseMaterial.restitution = 0.05;
sd.baseMaterial.userMaterialId = makeTag(KIND.BODY, skaterIndex, order.length);
// Self bit is included: distant limbs collide when limp. Adjacent pairs
// are culled by the world custom filter (and joints keep collideConnected off).
sd.filter.categoryBits = filter.category;
sd.filter.maskBits = drivenMask;
sd.filter.groupIndex = 0;
const shape = api.b3CreateCapsuleShape(body, sd, {
center1: vec3(c1),
center2: vec3(c2),
radius,
});
api.b3Body_EnableHitEvents(body, true);
const mass = api.b3Body_GetMass(body);
const part = {
name: def.name,
def,
bone,
body,
shape,
radius,
mass,
// Capsule endpoints in bone-local space, kept so the segment can be
// rebuilt in world space for limb-level hit queries without asking
// Box3D to hand the shape back every frame.
localA: c1.clone(),
localB: c2.clone(),
region: BONE_REGION[def.bone],
index: order.length,
prevPos: _wp.clone(),
prevQuat: _wq.clone(),
linVel: new THREE.Vector3(),
angVel: new THREE.Vector3(),
disabled: false,
};
parts[def.name] = part;
order.push(part);
}
// ---- mass calibration ---------------------------------------------------
// Runs while the bodies are still dynamic: a kinematic body has no mass to
// recompute, so calibrating after the switch would silently do nothing.
{
let raw = 0;
for (const part of order) raw += part.mass;
if (raw > 1e-6) {
const k = TARGET_BODY_MASS / raw;
for (const part of order) {
api.b3Shape_SetDensity(part.shape, part.def.density * k, false);
api.b3Body_ApplyMassFromShapes(part.body);
part.mass = api.b3Body_GetMass(part.body);
}
}
}
for (const part of order) api.b3Body_SetType(part.body, api.b3BodyType.b3_kinematicBody);
// ---- joints -------------------------------------------------------------
const joints = [];
for (const def of PARTS) {
if (!def.parent) continue;
const a = parts[def.parent];
const b = parts[def.name];
if (!a || !b) continue;
// The anchor is the child bone's origin: (0,0,0) in the child's frame, and
// the child's local offset in the parent's frame.
const localA = b.bone.position.clone();
const localB = new THREE.Vector3(0, 0, 0);
let jointId;
if (def.hinge) {
const jd = api.b3DefaultRevoluteJointDef();
jd.base.bodyIdA = a.body;
jd.base.bodyIdB = b.body;
jd.base.localFrameA = { p: vec3(localA), q: HINGE_FRAME };
jd.base.localFrameB = { p: vec3(localB), q: HINGE_FRAME };
// Stiffer limit solver on hinges so a heavy impact cannot soft-blow past
// the hyperextension stop (knees) or the elbow lock.
jd.base.constraintHertz = 90;
jd.base.constraintDampingRatio = 3;
jd.enableLimit = true;
jd.lowerAngle = def.hinge[0];
jd.upperAngle = def.hinge[1];
// Springs start off — see setJointStiffness.
jd.enableSpring = false;
jd.hertz = 0;
jd.dampingRatio = 0.7;
jointId = api.b3CreateRevoluteJoint(world, jd);
} else {
// Cone axis is frame Z, so point Z down the limb.
_axis.copy(localA).normalize();
const frameQ = localA.lengthSq() > 1e-9
? quat(_dq.setFromUnitVectors(_zAxis, _axis))
: IDENTITY_QUAT;
const jd = api.b3DefaultSphericalJointDef();
jd.base.bodyIdA = a.body;
jd.base.bodyIdB = b.body;
jd.base.localFrameA = { p: vec3(localA), q: frameQ };
jd.base.localFrameB = { p: vec3(localB), q: frameQ };
jd.enableConeLimit = true;
jd.coneAngle = def.cone ?? 0.6;
jd.enableTwistLimit = true;
jd.lowerTwistAngle = -(def.twist ?? 0.5);
jd.upperTwistAngle = def.twist ?? 0.5;
// Springs start off. A spring pulls each joint toward its neutral (bind
// pose) rotation, and at any usable stiffness that turns the rig into a
// self-supporting mannequin: it balances on straight legs and never
// collapses. Stiffness is applied deliberately via setJointStiffness for
// the partial "spring-damper blend" reaction, and left at zero for a real
// collapse.
jd.enableSpring = false;
jd.hertz = 0;
jd.dampingRatio = 0.65;
jointId = api.b3CreateSphericalJoint(world, jd);
}
joints.push({ id: jointId, a: a.name, b: b.name, def, hinge: !!def.hinge });
}
let mode = 'driven';
let stiffness = 0;
/**
* Joint stiffness — the spring-damper blend.
*
* `hertz` 0 gives a fully limp rig that collapses under its own weight; the
* useful range for a reaction that recovers its pose is roughly 26 Hz. High
* values make the rig self-supporting, which is right for a stumble and wrong
* for a death.
*/
function setJointStiffness(hertz, dampingRatio = 0.65) {
stiffness = hertz;
const on = hertz > 0.01;
for (const j of joints) {
if (j.severed) continue;
if (j.hinge) {
api.b3RevoluteJoint_EnableSpring(j.id, on);
if (on) {
api.b3RevoluteJoint_SetSpringHertz(j.id, hertz);
api.b3RevoluteJoint_SetSpringDampingRatio(j.id, dampingRatio);
}
} else {
api.b3SphericalJoint_EnableSpring(j.id, on);
if (on) {
api.b3SphericalJoint_SetSpringHertz(j.id, hertz);
api.b3SphericalJoint_SetSpringDampingRatio(j.id, dampingRatio);
}
}
}
}
/** Push the animated skeleton into the physics bodies (driven mode). */
function syncFromSkeleton(dt) {
for (const part of order) {
part.bone.matrixWorld.decompose(_wp, _wq, _ws);
api.b3Body_SetTargetTransform(part.body, transform(_wp, _wq), dt, true);
}
}
// Sanity ceilings for the handoff. A limb tip in a hard stride runs well under
// these; anything above is a sampling artefact, and letting it through
// launches the whole rig into the air the instant it goes limp.
const MAX_LIN = 12; // m/s
const MAX_ANG = 30; // rad/s
/**
* Sample bone velocities, once per rendered frame.
*
* This deliberately does *not* live in syncFromSkeleton. That runs once per
* fixed substep while the skeleton only moves once per rendered frame, so a
* delta measured there gets divided by the substep duration rather than the
* frame duration — inflating velocity by the substep count and leaving the
* stored value dependent on which substep happened to run last.
*/
function sampleVelocities(frameDt) {
const inv = frameDt > 1e-5 ? 1 / frameDt : 0;
for (const part of order) {
part.bone.matrixWorld.decompose(_wp, _wq, _ws);
part.linVel.subVectors(_wp, part.prevPos).multiplyScalar(inv);
if (part.linVel.lengthSq() > MAX_LIN * MAX_LIN) part.linVel.setLength(MAX_LIN);
_prevQ.copy(part.prevQuat).invert();
_dq.copy(_wq).multiply(_prevQ);
if (_dq.w < 0) _dq.set(-_dq.x, -_dq.y, -_dq.z, -_dq.w); // shortest arc
const angle = 2 * Math.acos(Math.min(1, _dq.w));
if (angle > 1e-5) {
const s = Math.sqrt(Math.max(1e-12, 1 - _dq.w * _dq.w));
part.angVel.set(_dq.x / s, _dq.y / s, _dq.z / s).multiplyScalar(angle * inv);
if (part.angVel.lengthSq() > MAX_ANG * MAX_ANG) part.angVel.setLength(MAX_ANG);
} else part.angVel.set(0, 0, 0);
part.prevPos.copy(_wp);
part.prevQuat.copy(_wq);
}
}
// Bone name -> the world quaternion its body currently reports.
const bodyWorldQ = new Map();
// Accumulated world quaternion per bone during the write-back walk.
const accumQ = new Map();
const _mq = new THREE.Quaternion();
/**
* Read the physics bodies back onto the skeleton (limp mode).
*
* Two passes, because a bone's local rotation depends on its parent's *new*
* world rotation. Reading `parent.matrixWorld` mid-walk would use last
* frame's value and skew every limb down the chain.
*
* The walk also has to handle bones with no body of their own (root,
* clavicles, toes): they keep their current local rotation and simply pass
* the accumulated world rotation through. That matters because upperArm's
* *bone* parent is the clavicle while its *joint* parent is spine3.
*/
const _moverQinv = new THREE.Quaternion();
const _physWorld = new THREE.Quaternion();
const _animWorld = new THREE.Quaternion();
const _localTarget = new THREE.Quaternion();
const _rootTarget = new THREE.Vector3();
/**
* Write the physics pose onto the skeleton, blended against the pose the
* animator just produced.
*
* `weight` 1 is a full ragdoll; anything between is the spring-damper
* blend — the body is deflected by the blow but the animation still shows
* through, and as the weight decays the skater recovers their stance.
*
* Blending happens in *world* space per bone and is converted back to a local
* rotation afterwards. Slerping local rotations instead would compound down
* the chain: a half-weight shoulder followed by a half-weight elbow does not
* put the hand halfway between the two poses.
*/
function blendToSkeleton(moverMatrixInverse, weight = 1, { includeRoot = true } = {}) {
if (weight <= 0.0005) return;
const w = Math.min(1, weight);
bodyWorldQ.clear();
accumQ.clear();
for (const part of order) {
const t = api.b3Body_GetTransform(part.body);
bodyWorldQ.set(part.bone.name, _mq.set(t.q.v.x, t.q.v.y, t.q.v.z, t.q.s).clone());
}
// The mover may be rotated, so body world rotations have to be brought into
// the mover's frame before they become bone locals.
_moverQinv.identity();
if (moverMatrixInverse) _moverQinv.setFromRotationMatrix(moverMatrixInverse);
const walk = (bone, parentWorld) => {
const phys = bodyWorldQ.get(bone.name);
// The animated world rotation this bone would have had, given the already
// blended parent above it.
_animWorld.copy(parentWorld).multiply(bone.quaternion);
let world;
if (phys) {
_physWorld.copy(_moverQinv).multiply(phys);
world = _animWorld.clone().slerp(_physWorld, w);
_pqi.copy(parentWorld).invert();
_localTarget.copy(_pqi).multiply(world);
bone.quaternion.copy(_localTarget);
} else {
world = _animWorld.clone();
}
accumQ.set(bone.name, world);
for (const child of bone.children) if (child.isBone) walk(child, world);
};
const rootBone = skelData.bones.root;
const animRootQ = rootBone.quaternion.clone();
rootBone.quaternion.identity();
walk(rootBone, new THREE.Quaternion());
if (w < 1) rootBone.quaternion.slerpQuaternions(animRootQ, rootBone.quaternion, w);
// The pelvis carries the rig's position; every other bone is rotation-only,
// so the hierarchy keeps the limbs attached to it. Partial reactions leave
// the root alone — displacing it slides the skater across the ice, which
// reads as teleporting rather than as being hit.
const pelvis = parts.pelvis;
if (includeRoot && pelvis) {
const t = api.b3Body_GetTransform(pelvis.body);
_wp.set(t.p.x, t.p.y, t.p.z);
if (moverMatrixInverse) _wp.applyMatrix4(moverMatrixInverse);
_rootTarget.copy(_wp).sub(pelvis.bone.position);
rootBone.position.lerp(_rootTarget, w);
}
}
/** Full ragdoll write-back. */
function syncToSkeleton(moverMatrixInverse) {
blendToSkeleton(moverMatrixInverse, 1, { includeRoot: true });
}
/**
* Snap the physics bodies onto the current skeleton pose.
*
* Needed when handing control back to animation: the bodies are wherever the
* simulation left them, and driving a kinematic body toward a distant target
* makes Box3D derive a huge velocity, which would fling anything it touches.
*/
function snapToSkeleton() {
for (const part of order) {
part.bone.matrixWorld.decompose(_wp, _wq, _ws);
api.b3Body_SetTransform(part.body, vec3(_wp), quat(_wq));
api.b3Body_SetLinearVelocity(part.body, { x: 0, y: 0, z: 0 });
api.b3Body_SetAngularVelocity(part.body, { x: 0, y: 0, z: 0 });
part.prevPos.copy(_wp);
part.prevQuat.copy(_wq);
}
}
/**
* Modes:
* 'driven' kinematic, chases the animation exactly
* 'reacting' dynamic with stiff joints — deflects under a blow and is
* expected to be blended back toward the animated pose
* 'limp' dynamic and slack; gravity wins
*/
function setMode(next) {
if (next === mode) return;
const dynamic = next === 'limp' || next === 'reacting';
if (!dynamic) snapToSkeleton();
// Limbs only join the collision world while the rig is dynamic.
//
// A kinematic limb cannot be pushed, but it *can* push: a driven skater's
// arm swinging through its stride would shove other skaters' proxy capsules
// around, so an idle bystander could be checked by someone's elbow. Once
// the rig goes dynamic that is exactly what we want — a falling body should
// take people's legs out — so the mask is widened here rather than being
// fixed once at build time.
const mask = dynamic ? limpMask : drivenMask;
for (const part of order) {
if (part.filterMask !== mask) {
_filter.categoryBits = filter.category;
_filter.maskBits = mask;
_filter.groupIndex = 0;
api.b3Shape_SetFilter(part.shape, _filter, true);
part.filterMask = mask;
}
api.b3Body_SetType(part.body, dynamic ? api.b3BodyType.b3_dynamicBody : api.b3BodyType.b3_kinematicBody);
if (dynamic) {
// Carry the animated motion across so the reaction continues the motion.
api.b3Body_SetLinearVelocity(part.body, vec3(part.linVel));
api.b3Body_SetAngularVelocity(part.body, vec3(part.angVel));
if (next === 'reacting') {
// Damping holds the flinch together without killing the impulse.
// (1.6/2.2 made light hits die in place; recover via blend weight instead.)
api.b3Body_SetLinearDamping(part.body, 0.85);
api.b3Body_SetAngularDamping(part.body, 1.15);
} else {
api.b3Body_SetLinearDamping(part.body, 0.1);
api.b3Body_SetAngularDamping(part.body, 0.25);
}
}
api.b3Body_SetAwake(part.body, true);
}
mode = next;
}
/** Apply a world-space impulse at a world point to one part. */
function applyImpulse(partName, impulse, worldPoint) {
const part = parts[partName];
if (!part) return;
api.b3Body_ApplyLinearImpulse(
part.body,
vec3(impulse),
worldPoint ? vec3(worldPoint) : api.b3Body_GetPosition(part.body),
true,
);
}
function applyTorqueImpulse(partName, torque) {
const part = parts[partName];
if (!part) return;
api.b3Body_ApplyAngularImpulse(part.body, vec3(torque), true);
}
/**
* Total mass of the rig, for stagger thresholds. Uses the figures captured at
* build time rather than querying the bodies, which report zero while kinematic.
*/
function totalMass() {
let m = 0;
for (const part of order) m += part.mass;
return m;
}
/**
* Sever a joint: the limb below it becomes independent debris still made of
* the same bodies, so it keeps colliding and can be sent flying.
*/
function severJoint(childPartName) {
const j = joints.find((x) => x.b === childPartName);
if (!j || j.severed) return false;
api.b3DestroyJoint(j.id, true);
j.severed = true;
const part = parts[childPartName];
if (part) part.disabled = true;
return true;
}
function partForRegion(region) {
return order.filter((p) => p.region === region);
}
/**
* Write each capsule's segment into world space.
*
* Read off the bone matrices rather than off the Box3D bodies, so the answer
* is correct in both modes: while driven the bodies chase the bones a substep
* behind, and a hit resolved against last substep's pose picks the wrong limb
* at speed. Reuses one array of scratch vectors — the caller must not hold on
* to what it gets back.
*/
const _segments = order.map(() => ({
part: null, a: new THREE.Vector3(), b: new THREE.Vector3(), radius: 0,
}));
function worldSegments() {
for (let i = 0; i < order.length; i++) {
const part = order[i];
const seg = _segments[i];
part.bone.updateWorldMatrix(true, false);
seg.part = part;
seg.a.copy(part.localA).applyMatrix4(part.bone.matrixWorld);
seg.b.copy(part.localB).applyMatrix4(part.bone.matrixWorld);
seg.radius = part.radius;
}
return _segments;
}
function destroy() {
for (const j of joints) if (!j.severed) api.b3DestroyJoint(j.id, false);
for (const part of order) api.b3DestroyBody(part.body);
}
return {
parts,
order,
joints,
get mode() { return mode; },
get stiffness() { return stiffness; },
setMode,
setJointStiffness,
sampleVelocities,
syncFromSkeleton,
syncToSkeleton,
blendToSkeleton,
snapToSkeleton,
applyImpulse,
applyTorqueImpulse,
severJoint,
partForRegion,
worldSegments,
totalMass,
destroy,
};
}
+189
View File
@@ -0,0 +1,189 @@
import Box3DFactory from 'box3d.js';
import { KIND, makeTag, readTag, rinkFilter, xyz } from './bridge.js';
import { slotsShouldCollide } from './ragdoll.js';
import { RINK, rinkOutline } from '../../shared/rink.js';
/**
* Box3D world wrapper.
*
* Runs on a fixed timestep with an accumulator so the simulation stays
* reproducible regardless of frame rate. That matters more here than it looks:
* the skating sim reads its velocity back out of Box3D every substep, so a
* variable step would make how hard you can carve depend on your frame rate.
*/
export const FIXED_DT = 1 / 120;
const MAX_SUBSTEPS = 6;
let b3 = null;
/** Load and initialise the wasm module. Safe to call more than once. */
export async function initPhysics() {
if (!b3) b3 = await Box3DFactory();
return b3;
}
export function getB3() {
if (!b3) throw new Error('physics not initialised — await initPhysics() first');
return b3;
}
/**
* Build the rink: an ice slab and a ring of boards, both static.
*
* The boards are a ring of boxes rather than a mesh because a body slammed
* into one should bounce off a flat face the way it would off real dasher
* boards, and because a box ring is cheap enough that we can afford enough
* segments for the corners to read as round.
*/
export function createPhysicsWorld({ gravity = -16 } = {}) {
const api = getB3();
const wd = api.b3DefaultWorldDef();
wd.gravity = xyz(0, gravity, 0);
// Two skaters closing at 14 m/s combined will visibly interpenetrate at the
// default contact stiffness — a fifth of a metre, which on bodies this size
// reads as one skating through the other's shoulder. Stiffer contacts and a
// faster push-out cost nothing at this body count.
wd.contactHertz = 60;
wd.contactDampingRatio = 8;
wd.contactSpeed = 6;
wd.enableContinuous = true;
const world = api.b3CreateWorld(wd);
api.b3World_SetHitEventThreshold(world, 1.2);
// Self-collision: ragdoll limbs enable custom filtering. Adjacent capsules
// (and one skip) would fight the joints if they contacted; distant pairs
// (hand vs torso, crossed legs) must still collide when limp.
// Called only for awake dynamic pairs — exactly the limp case.
api.b3World_SetCustomFilterCallback(world, (shapeA, shapeB) => {
try {
const matA = api.b3Shape_GetSurfaceMaterial(shapeA);
const matB = api.b3Shape_GetSurfaceMaterial(shapeB);
const a = readTag(matA.userMaterialId);
const b = readTag(matB.userMaterialId);
if (
a.kind === KIND.BODY && b.kind === KIND.BODY
&& a.skater === b.skater && a.skater !== 0xff
) {
return slotsShouldCollide(a.slot, b.slot);
}
} catch {
// Embind can throw if a shape was destroyed mid-step; default to collide.
}
return true;
});
const rink = rinkFilter();
// ---- ice ---------------------------------------------------------------
const iceDef = api.b3DefaultBodyDef();
iceDef.position = xyz(0, -0.5, 0);
const ice = api.b3CreateBody(world, iceDef);
const iceShape = api.b3DefaultShapeDef();
// Ice, not sand. The skating sim owns blade friction entirely; anything the
// solver adds here on top of that is a second, invisible drag term.
iceShape.baseMaterial.friction = 0.04;
iceShape.baseMaterial.restitution = 0.0;
iceShape.baseMaterial.userMaterialId = makeTag(KIND.RINK, 0xff, 0);
iceShape.filter.categoryBits = rink.category;
iceShape.filter.maskBits = rink.mask;
api.b3CreateBoxShape(ice, iceShape, RINK.halfX + 4, 0.5, RINK.halfZ + 4);
// ---- boards ------------------------------------------------------------
const boardShape = api.b3DefaultShapeDef();
boardShape.baseMaterial.friction = 0.28;
// Dasher boards flex and eat most of the impact. A lively wall would ping
// skaters back into open ice and read as rubber.
boardShape.baseMaterial.restitution = 0.1;
boardShape.baseMaterial.userMaterialId = makeTag(KIND.RINK, 0xff, 1);
boardShape.filter.categoryBits = rink.category;
boardShape.filter.maskBits = rink.mask;
const outline = rinkOutline(10);
const boardBodies = [];
const halfH = RINK.boardHeight / 2;
for (let i = 0; i < outline.length; i++) {
const a = outline[i];
const b = outline[(i + 1) % outline.length];
const dx = b.x - a.x;
const dz = b.z - a.z;
const len = Math.hypot(dx, dz);
if (len < 1e-4) continue;
// Each segment is a thin box centred on the chord, its local +Z along the
// wall. Overlapping the ends slightly (len/2 + thickness) keeps a skater
// from catching the seam between two corner segments.
const yaw = Math.atan2(dx, dz);
const bd = api.b3DefaultBodyDef();
// Pushed half a thickness outward so the *inner* face sits on the outline.
const nx = dz / len;
const nz = -dx / len;
const thickness = 0.2;
bd.position = xyz(
(a.x + b.x) / 2 - nx * thickness,
halfH,
(a.z + b.z) / 2 - nz * thickness,
);
bd.rotation = { v: { x: 0, y: Math.sin(yaw / 2), z: 0 }, s: Math.cos(yaw / 2) };
const seg = api.b3CreateBody(world, bd);
api.b3CreateBoxShape(seg, boardShape, thickness, halfH, len / 2 + thickness);
boardBodies.push(seg);
}
// ---- event plumbing ----------------------------------------------------
const eventsBuffer = api.createEventsBuffer();
const hitOut = api.createContactHitEvent();
const beginOut = api.createContactTouchEvent();
let accumulator = 0;
let stepCount = 0;
const hitListeners = new Set();
const beginListeners = new Set();
function pumpEvents() {
api.getEvents(eventsBuffer, world);
const nHits = api.getNumContactHitEvents(eventsBuffer);
for (let i = 0; i < nHits; i++) {
api.getContactHitEventAt(hitOut, eventsBuffer, i);
for (const fn of hitListeners) fn(hitOut);
}
const nBegin = api.getNumContactBeginEvents(eventsBuffer);
for (let i = 0; i < nBegin; i++) {
api.getContactBeginEventAt(beginOut, eventsBuffer, i);
for (const fn of beginListeners) fn(beginOut);
}
}
return {
api,
world,
ice,
boardBodies,
get stepCount() { return stepCount; },
/** Advance by real elapsed time, stepping the fixed simulation as needed. */
step(dt, onPreStep) {
accumulator += Math.min(dt, 0.25);
let steps = 0;
while (accumulator >= FIXED_DT && steps < MAX_SUBSTEPS) {
if (onPreStep) onPreStep(FIXED_DT);
api.b3World_Step(world, FIXED_DT, 4);
pumpEvents();
accumulator -= FIXED_DT;
steps++;
stepCount++;
}
// Bail out rather than spiral if we ever fall badly behind.
if (steps === MAX_SUBSTEPS) accumulator = 0;
return steps;
},
onHit(fn) { hitListeners.add(fn); return () => hitListeners.delete(fn); },
onBeginTouch(fn) { beginListeners.add(fn); return () => beginListeners.delete(fn); },
destroy() {
api.destroyEventsBuffer(eventsBuffer);
api.b3DestroyWorld(world);
},
};
}
+140
View File
@@ -0,0 +1,140 @@
import * as THREE from 'three';
import { RINK } from '../../shared/rink.js';
import { clamp, wrapAngle } from '../../shared/scalar.js';
/**
* Broadcast camera.
*
* Two modes, because they answer different questions about the spike:
* 'broadcast' sits off the side boards and pans with the action — the view
* you judge whether the skating reads from.
* 'follow' rides behind one skater, which is the only way to tell whether
* the stride and the carve actually line up with the motion.
*
* Drag orbits, wheel zooms, and the target is smoothed rather than snapped so
* a bot changing direction does not whip the camera.
*/
export function createCamera(canvas, aspect) {
const camera = new THREE.PerspectiveCamera(52, aspect, 0.1, 400);
const state = {
mode: 'broadcast',
/** Orbit angles, radians. */
yaw: 0,
pitch: 0.62,
distance: 34,
target: new THREE.Vector3(),
/** Index of the skater 'follow' rides, or null. */
followIndex: null,
};
const _want = new THREE.Vector3();
const _offset = new THREE.Vector3();
let dragging = false;
let lastX = 0;
let lastY = 0;
canvas.addEventListener('pointerdown', (e) => {
dragging = true;
lastX = e.clientX;
lastY = e.clientY;
canvas.setPointerCapture(e.pointerId);
});
canvas.addEventListener('pointermove', (e) => {
if (!dragging) return;
// Keep yaw on the circle. Unbounded accumulation is what broke the follow
// chase after a few spins: JS `%` on a large negative offset is not a
// positive modulo, so the "shortest turn" picked the long way round and
// the orbit fought the stick until the skater felt stuck.
state.yaw = wrapAngle(state.yaw - (e.clientX - lastX) * 0.005);
state.pitch = clamp(state.pitch - (e.clientY - lastY) * 0.004, 0.08, 1.45);
lastX = e.clientX;
lastY = e.clientY;
});
const endDrag = (e) => {
dragging = false;
if (e.pointerId != null && canvas.hasPointerCapture?.(e.pointerId)) {
canvas.releasePointerCapture(e.pointerId);
}
};
canvas.addEventListener('pointerup', endDrag);
canvas.addEventListener('pointercancel', endDrag);
canvas.addEventListener('wheel', (e) => {
e.preventDefault();
state.distance = clamp(state.distance * (1 + e.deltaY * 0.0012), 6, 90);
}, { passive: false });
return {
camera,
state,
resize(w, h) {
camera.aspect = w / h;
camera.updateProjectionMatrix();
},
/** Cycle broadcast → follow each skater → broadcast. */
cycleMode(count) {
if (state.mode === 'broadcast') {
state.mode = 'follow';
state.followIndex = 0;
} else if (state.followIndex + 1 < count) {
state.followIndex += 1;
} else {
state.mode = 'broadcast';
state.followIndex = null;
}
state.distance = state.mode === 'follow' ? 9 : 34;
state.pitch = state.mode === 'follow' ? 0.3 : 0.62;
},
/**
* @param {number} dt
* @param {{x:number,z:number,yaw:number}[]} skaters
*/
update(dt, skaters) {
// How hard the camera chases its target. Broadcast wants to be lazy;
// follow cannot be, because a skater doing 7 m/s outruns a soft lerp and
// ends up drifting to the edge of frame while the camera trails behind.
let chase = 2.4;
if (state.mode === 'follow' && skaters[state.followIndex]) {
const s = skaters[state.followIndex];
chase = 11;
_want.set(s.x, 1.1, s.z);
// Ease the orbit around behind whoever we are following, but let a
// drag override it — the yaw chases only while the pointer is idle.
if (!dragging) {
const behind = s.yaw + Math.PI;
// wrapAngle, not `%`: see the pointermove note. The old
// `((d + 3π) % 2π) - π` form only works while yaw stays near zero.
state.yaw = wrapAngle(state.yaw + wrapAngle(behind - state.yaw) * Math.min(1, 1.6 * dt));
}
} else {
// Centroid of everyone, clamped so the camera never leaves the barn.
_want.set(0, 0.8, 0);
if (skaters.length) {
let x = 0;
let z = 0;
for (const s of skaters) {
x += s.x;
z += s.z;
}
_want.set(x / skaters.length, 0.8, z / skaters.length);
}
_want.x = clamp(_want.x, -RINK.halfX * 0.6, RINK.halfX * 0.6);
_want.z = clamp(_want.z, -RINK.halfZ * 0.6, RINK.halfZ * 0.6);
}
state.target.lerp(_want, Math.min(1, chase * dt));
const cp = Math.cos(state.pitch);
_offset.set(
Math.sin(state.yaw) * cp,
Math.sin(state.pitch),
Math.cos(state.yaw) * cp,
).multiplyScalar(state.distance);
camera.position.copy(state.target).add(_offset);
camera.lookAt(state.target);
},
};
}
+144
View File
@@ -0,0 +1,144 @@
import * as THREE from 'three';
import { PART } from '../character/body.js';
/**
* Materials for one skater.
*
* Placeholder by design: spike 1 renders the bare procedural body from Ludus,
* team-tinted so three agents can be told apart at a glance. Real gear is a
* later swap onto the same meshes. The only thing that has to hold now is that
* every skater owns its own material instances, so recolouring one never
* touches another.
*/
export const TEAMS = [
{ name: 'home', jersey: 0xb8342c, accent: 0xf0e6d2 },
{ name: 'away', jersey: 0x2b5d8f, accent: 0xf0e6d2 },
{ name: 'third', jersey: 0x3d8c5a, accent: 0xf0e6d2 },
];
const SKIN_TONES = [0xd8a07a, 0xc98d63, 0xa86b45, 0x8a5334, 0xe8bd9a];
const PANTS = 0x1c1f26;
export function buildMaterials(rng, teamIndex = 0) {
const team = TEAMS[teamIndex % TEAMS.length];
const skinColor = rng.pick(SKIN_TONES);
// One material, vertex-coloured. `paintKit` writes the colours; keeping it to
// a single material means the skinned body is still one draw call.
const skin = new THREE.MeshStandardMaterial({
color: 0xffffff,
vertexColors: true,
roughness: 0.68,
metalness: 0.03,
});
skin.userData.skinColor = new THREE.Color(skinColor);
return { skin, team, teamIndex: teamIndex % TEAMS.length, skinColor };
}
/**
* Write the placeholder kit into the geometry's vertex colours.
*
* The loft carries `aPart` (which limb) and `aT` (0..1 along it), so the kit
* can be blocked in without any texture work: sweater over the torso and arms,
* pants over the hips and thighs, socks in the team colour down the shin.
*
* Overwrites the skin-weight heatmap `computeSkin` leaves behind; that array is
* kept on `userData` so the debug view can still be switched back on.
*/
export function paintKit(geo, { jersey, skinColor }) {
const partAttr = geo.attributes.aPart;
const tAttr = geo.attributes.aT;
const existing = geo.attributes.color;
if (existing && !geo.userData.heatColors) geo.userData.heatColors = existing.array.slice();
const n = geo.attributes.position.count;
const colors = new Float32Array(n * 3);
const c = new THREE.Color();
const flesh = new THREE.Color(skinColor);
const sweater = new THREE.Color(jersey);
const pants = new THREE.Color(PANTS);
for (let i = 0; i < n; i++) {
const part = partAttr ? partAttr.getX(i) : PART.TORSO;
const t = tAttr ? tAttr.getX(i) : 0.5;
if (part === PART.HEAD) {
// Helmet from the crown down to the brow; face left bare.
c.copy(t > 0.62 ? sweater : flesh);
} else if (part === PART.TORSO) {
c.copy(t < 0.16 ? pants : sweater);
} else if (part === PART.ARM_L || part === PART.ARM_R) {
// Sleeve, then a dark glove at the cuff.
c.copy(t > 0.88 ? pants : sweater);
} else {
// Leg: pants to mid-thigh, team sock below, black skate at the ankle.
c.copy(t < 0.36 ? pants : t > 0.87 ? pants : sweater);
}
colors[i * 3] = c.r;
colors[i * 3 + 1] = c.g;
colors[i * 3 + 2] = c.b;
}
geo.setAttribute('color', new THREE.BufferAttribute(colors, 3));
}
/**
* Base layer for a skater who is actually wearing gear.
*
* `paintKit` draws the kit *onto* the body, which is the right answer while the
* body is all there is. Once a jersey, pants and socks are real meshes over the
* top, painting a second jersey underneath only shows up as the wrong colour
* peeking out at a collar or a cuff. So: face and neck bare, everything else
* the dark under layer a player has on beneath the pads.
*/
export function paintUnderLayer(geo, { skinColor, under = 0x24262c }) {
const partAttr = geo.attributes.aPart;
const existing = geo.attributes.color;
if (existing && !geo.userData.heatColors) geo.userData.heatColors = existing.array.slice();
const n = geo.attributes.position.count;
const colors = new Float32Array(n * 3);
const flesh = new THREE.Color(skinColor);
const base = new THREE.Color(under);
for (let i = 0; i < n; i++) {
const part = partAttr ? partAttr.getX(i) : PART.TORSO;
const c = part === PART.HEAD ? flesh : base;
colors[i * 3] = c.r;
colors[i * 3 + 1] = c.g;
colors[i * 3 + 2] = c.b;
}
geo.setAttribute('color', new THREE.BufferAttribute(colors, 3));
}
/** Shared rink materials — one set for the whole scene, not per skater. */
export function buildRinkMaterials() {
return {
ice: new THREE.MeshStandardMaterial({
color: 0xeaf2fa,
roughness: 0.16,
metalness: 0.0,
}),
lines: new THREE.MeshBasicMaterial({ color: 0xffffff }),
boards: new THREE.MeshStandardMaterial({
color: 0xf2f2f0,
roughness: 0.5,
metalness: 0.02,
side: THREE.DoubleSide,
}),
kickplate: new THREE.MeshStandardMaterial({
color: 0xd6c33c,
roughness: 0.6,
side: THREE.DoubleSide,
}),
glass: new THREE.MeshStandardMaterial({
color: 0xc4dcea,
roughness: 0.06,
metalness: 0,
transparent: true,
opacity: 0.1,
side: THREE.DoubleSide,
depthWrite: false,
}),
};
}
+216
View File
@@ -0,0 +1,216 @@
import * as THREE from 'three';
import { MARKINGS, RINK, rinkOutline } from '../../shared/rink.js';
import { buildRinkMaterials } from './materials.js';
/**
* The rendered rink.
*
* Geometry comes from the same `rinkOutline` the physics boards are built
* from, so the wall a skater bounces off is the wall they can see — the single
* most annoying class of bug to chase in a game like this, and free to avoid.
*
* Markings are drawn into a canvas texture rather than as meshes. Blue lines,
* circles and dots as geometry means a dozen extra draw calls and z-fighting
* against the ice; one texture is faster and easier to iterate on.
*/
const PIXELS_PER_METRE = 22;
function markingsTexture() {
const w = Math.round(RINK.halfX * 2 * PIXELS_PER_METRE);
const h = Math.round(RINK.halfZ * 2 * PIXELS_PER_METRE);
const canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d');
// Canvas space: +x right is rink +X, +y down is rink +Z.
const tx = (x) => (x + RINK.halfX) * PIXELS_PER_METRE;
const tz = (z) => (z + RINK.halfZ) * PIXELS_PER_METRE;
const m = (v) => v * PIXELS_PER_METRE;
ctx.fillStyle = '#f2f7fc';
ctx.fillRect(0, 0, w, h);
const vline = (x, colour, widthM) => {
ctx.strokeStyle = colour;
ctx.lineWidth = m(widthM);
ctx.beginPath();
ctx.moveTo(tx(x), 0);
ctx.lineTo(tx(x), h);
ctx.stroke();
};
const circle = (x, z, r, colour, widthM, fill = false) => {
ctx.beginPath();
ctx.arc(tx(x), tz(z), m(r), 0, Math.PI * 2);
if (fill) {
ctx.fillStyle = colour;
ctx.fill();
} else {
ctx.strokeStyle = colour;
ctx.lineWidth = m(widthM);
ctx.stroke();
}
};
const RED = '#c8322c';
const BLUE = '#2f5fa8';
vline(0, RED, 0.3);
vline(-MARKINGS.blueLine, BLUE, 0.3);
vline(MARKINGS.blueLine, BLUE, 0.3);
vline(-MARKINGS.goalLine, RED, 0.06);
vline(MARKINGS.goalLine, RED, 0.06);
circle(0, 0, MARKINGS.centreCircleR, BLUE, 0.06);
circle(0, 0, 0.3, BLUE, 0, true);
// Four end-zone faceoff circles plus the two neutral-zone dots.
for (const sx of [-1, 1]) {
for (const sz of [-1, 1]) {
circle(sx * MARKINGS.zoneDotX, sz * MARKINGS.faceoffDotZ, MARKINGS.faceoffCircleR, RED, 0.06);
circle(sx * MARKINGS.zoneDotX, sz * MARKINGS.faceoffDotZ, 0.3, RED, 0, true);
circle(sx * MARKINGS.faceoffDotX, sz * MARKINGS.faceoffDotZ, 0.3, RED, 0, true);
}
}
// Goal creases, as filled arcs facing centre ice.
for (const sx of [-1, 1]) {
ctx.beginPath();
ctx.arc(tx(sx * MARKINGS.goalLine), tz(0), m(1.83), sx > 0 ? Math.PI / 2 : -Math.PI / 2, sx > 0 ? Math.PI * 1.5 : Math.PI / 2);
ctx.closePath();
ctx.fillStyle = 'rgba(120, 175, 225, 0.5)';
ctx.fill();
ctx.strokeStyle = RED;
ctx.lineWidth = m(0.06);
ctx.stroke();
}
const tex = new THREE.CanvasTexture(canvas);
tex.colorSpace = THREE.SRGBColorSpace;
tex.anisotropy = 8;
return tex;
}
/**
* Extrude the board outline into a wall.
*
* Built as one non-indexed strip: the outline is a closed loop, so a wall is
* two triangles per segment and there is no reason to pay for a Shape/Extrude
* pass or for the corner mitring it would do.
*/
function boardBand(outline, y0, y1, inset = 0) {
const pos = [];
const uv = [];
const n = outline.length;
for (let i = 0; i < n; i++) {
const a = outline[i];
const b = outline[(i + 1) % n];
// Inset pushes the band outward along the local normal, so the glass can
// sit flush on top of the boards rather than intersecting them.
const dx = b.x - a.x;
const dz = b.z - a.z;
const len = Math.hypot(dx, dz) || 1;
const nx = (dz / len) * inset;
const nz = (-dx / len) * inset;
const ax = a.x - nx;
const az = a.z - nz;
const bx = b.x - nx;
const bz = b.z - nz;
const u0 = i / n;
const u1 = (i + 1) / n;
pos.push(ax, y0, az, bx, y0, bz, bx, y1, bz);
pos.push(ax, y0, az, bx, y1, bz, ax, y1, az);
uv.push(u0, 0, u1, 0, u1, 1, u0, 0, u1, 1, u0, 1);
}
const g = new THREE.BufferGeometry();
g.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
g.setAttribute('uv', new THREE.Float32BufferAttribute(uv, 2));
g.computeVertexNormals();
return g;
}
/** The puck mesh — a black disc, driven from the Box3D body each frame. */
export function buildPuckMesh(scene, { radius, thickness }) {
const mesh = new THREE.Mesh(
new THREE.CylinderGeometry(radius, radius, thickness, 20),
new THREE.MeshStandardMaterial({ color: 0x0b0b0d, roughness: 0.72, metalness: 0.02 }),
);
mesh.castShadow = true;
mesh.receiveShadow = true;
// A regulation puck is 76 mm across, which is a handful of pixels from the
// broadcast camera. The ring is a readability aid, not decoration — without
// something to catch the eye the puck is genuinely impossible to follow.
const ring = new THREE.Mesh(
new THREE.RingGeometry(radius * 1.6, radius * 2.4, 24),
new THREE.MeshBasicMaterial({
color: 0xffd166, transparent: true, opacity: 0.45, depthWrite: false,
}),
);
ring.rotation.x = -Math.PI / 2;
ring.position.y = -thickness / 2 + 0.002;
ring.renderOrder = 1;
mesh.add(ring);
scene.add(mesh);
return { mesh, ring };
}
export function buildRink(scene) {
const mats = buildRinkMaterials();
const group = new THREE.Group();
group.name = 'rink';
// ---- ice ---------------------------------------------------------------
// A plane clipped to the rounded rectangle, so the surface ends at the
// boards instead of running under them.
const shape = new THREE.Shape();
const outline = rinkOutline(16);
shape.moveTo(outline[0].x, outline[0].z);
for (let i = 1; i < outline.length; i++) shape.lineTo(outline[i].x, outline[i].z);
shape.closePath();
const iceGeo = new THREE.ShapeGeometry(shape, 24);
// ShapeGeometry lives in XY; lay it flat, then rebuild UVs so the markings
// texture maps to rink coordinates rather than to the shape's bounding box.
iceGeo.rotateX(-Math.PI / 2);
const p = iceGeo.attributes.position;
const uv = new Float32Array(p.count * 2);
for (let i = 0; i < p.count; i++) {
uv[i * 2] = (p.getX(i) + RINK.halfX) / (RINK.halfX * 2);
uv[i * 2 + 1] = 1 - (p.getZ(i) + RINK.halfZ) / (RINK.halfZ * 2);
}
iceGeo.setAttribute('uv', new THREE.BufferAttribute(uv, 2));
mats.ice.map = markingsTexture();
const ice = new THREE.Mesh(iceGeo, mats.ice);
ice.receiveShadow = true;
group.add(ice);
// ---- boards, kickplate, glass ------------------------------------------
const boards = new THREE.Mesh(boardBand(outline, 0.22, RINK.boardHeight), mats.boards);
boards.receiveShadow = true;
group.add(boards);
const kick = new THREE.Mesh(boardBand(outline, 0, 0.22), mats.kickplate);
group.add(kick);
const glass = new THREE.Mesh(
boardBand(outline, RINK.boardHeight, RINK.boardHeight + RINK.glassHeight, 0.02),
mats.glass,
);
glass.renderOrder = 2;
group.add(glass);
// ---- surround ----------------------------------------------------------
// A dark apron so the rink does not float in the void when the camera swings
// low. Cheap, and it stops the horizon from reading as a bug.
const apron = new THREE.Mesh(
new THREE.PlaneGeometry(RINK.halfX * 4, RINK.halfZ * 6),
new THREE.MeshStandardMaterial({ color: 0x14181f, roughness: 0.95 }),
);
apron.rotation.x = -Math.PI / 2;
apron.position.y = -0.05;
apron.receiveShadow = true;
group.add(apron);
scene.add(group);
return { group, materials: mats };
}
+760
View File
@@ -0,0 +1,760 @@
import * as THREE from 'three';
import { createSkater } from '../character/skater.js';
import { createGoalie } from '../character/goalie.js';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.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.
*
* Open: http://localhost:5174/character.html
* CLI: npm run img2mesh
*/
const canvas = document.getElementById('stage');
const boot = document.getElementById('boot');
const hud = document.getElementById('hud');
const subjectSel = document.getElementById('subject');
const poseSel = document.getElementById('pose');
const viewSel = document.getElementById('view');
// ---- renderer / scene -----------------------------------------------------
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, powerPreference: 'high-performance' });
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.1;
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x0c1018);
scene.fog = new THREE.Fog(0x0c1018, 18, 40);
scene.add(new THREE.HemisphereLight(0xe8f0fa, 0x1a2030, 1.35));
const key = new THREE.DirectionalLight(0xffffff, 1.7);
key.position.set(4, 10, 6);
key.castShadow = true;
key.shadow.mapSize.set(2048, 2048);
key.shadow.camera.near = 1;
key.shadow.camera.far = 30;
key.shadow.camera.left = -6;
key.shadow.camera.right = 6;
key.shadow.camera.top = 6;
key.shadow.camera.bottom = -6;
key.shadow.bias = -0.0004;
scene.add(key);
const fill = new THREE.DirectionalLight(0xa8c8e8, 0.55);
fill.position.set(-6, 5, -4);
scene.add(fill);
const rim = new THREE.DirectionalLight(0xffe0c0, 0.35);
rim.position.set(2, 3, -8);
scene.add(rim);
// Ground grid — reads scale and foot contact without a full rink.
const ground = new THREE.Mesh(
new THREE.CircleGeometry(8, 48),
new THREE.MeshStandardMaterial({ color: 0x1a2430, roughness: 0.92, metalness: 0.05 }),
);
ground.rotation.x = -Math.PI / 2;
ground.receiveShadow = true;
scene.add(ground);
const grid = new THREE.GridHelper(10, 20, 0x3a5a78, 0x1e3044);
grid.position.y = 0.002;
scene.add(grid);
// Height markers so pad/hand/head heights are obvious.
for (const h of [0.5, 1.0, 1.5, 2.0]) {
const ring = new THREE.Mesh(
new THREE.RingGeometry(0.35, 0.38, 32),
new THREE.MeshBasicMaterial({ color: 0x2a4058, side: THREE.DoubleSide, transparent: true, opacity: 0.5 }),
);
ring.rotation.x = -Math.PI / 2;
ring.position.y = h;
scene.add(ring);
}
const camera = new THREE.PerspectiveCamera(40, 1, 0.05, 80);
const controls = new OrbitControls(camera, canvas);
controls.enableDamping = true;
controls.dampingFactor = 0.08;
controls.target.set(0, 0.9, 0);
controls.minDistance = 1.2;
controls.maxDistance = 14;
controls.maxPolarAngle = Math.PI * 0.49;
function resize() {
const w = window.innerWidth;
const h = window.innerHeight;
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(w, h);
camera.aspect = w / h;
camera.updateProjectionMatrix();
}
window.addEventListener('resize', resize);
resize();
// ---- subjects -------------------------------------------------------------
/** @type {ReturnType<typeof createSkater> | null} */
let player = null;
/** @type {ReturnType<typeof createGoalie> | null} */
let goalie = null;
const state = {
subject: 'player', // player | goalie | both
pose: 'carry',
view: 'threequarter',
showBones: false,
showGear: false,
time: 0,
};
// ---- pose catalogs --------------------------------------------------------
const PLAYER_POSES = {
stand: {
label: 'stand / glide',
apply(sk, t) {
const a = sk.animator;
a.moveSpeed = 0.4;
a.bladeSpeed = 0.4;
a.effort = 0;
a.yawRate = 0;
a.braking = false;
a.hasPuck = false;
a.charge = 0;
a.action = null;
a.handling.x = 0;
a.handling.y = 0;
a.setTransform(sk.mover.position, 0);
a.update(1 / 60);
},
},
stride: {
label: 'full stride',
apply(sk) {
const a = sk.animator;
a.moveSpeed = 7;
a.bladeSpeed = 7;
a.effort = 1;
a.yawRate = 0;
a.braking = false;
a.hasPuck = true;
a.charge = 0;
a.action = null;
a.handling.x = 0;
a.handling.y = 0;
a.setTransform(sk.mover.position, 0);
a.update(1 / 60);
},
},
carve: {
label: 'carve right',
apply(sk) {
const a = sk.animator;
a.moveSpeed = 6.5;
a.bladeSpeed = 6.5;
a.effort = 0.7;
a.yawRate = 1.4;
a.braking = false;
a.hasPuck = true;
a.action = null;
a.handling.x = 0;
a.handling.y = 0;
a.setTransform(sk.mover.position, 0);
a.update(1 / 60);
},
},
carry: {
label: 'puck carry',
apply(sk) {
const a = sk.animator;
a.moveSpeed = 4;
a.bladeSpeed = 4;
a.effort = 0.25;
a.yawRate = 0;
a.braking = false;
a.hasPuck = true;
a.charge = 0;
a.action = null;
a.handling.x = 0;
a.handling.y = 0;
a.setTransform(sk.mover.position, 0);
a.update(1 / 60);
},
},
handleRight: {
label: 'stickhandle right',
apply(sk) {
const a = sk.animator;
a.moveSpeed = 3;
a.bladeSpeed = 3;
a.effort = 0.2;
a.hasPuck = true;
a.action = null;
a.handling.x = 1;
a.handling.y = 0;
a.setTransform(sk.mover.position, 0);
a.update(1 / 60);
},
},
handleLeft: {
label: 'stickhandle left',
apply(sk) {
const a = sk.animator;
a.moveSpeed = 3;
a.bladeSpeed = 3;
a.effort = 0.2;
a.hasPuck = true;
a.action = null;
a.handling.x = -1;
a.handling.y = 0;
a.setTransform(sk.mover.position, 0);
a.update(1 / 60);
},
},
windup: {
label: 'shot wind-up',
apply(sk) {
const a = sk.animator;
a.moveSpeed = 2;
a.bladeSpeed = 2;
a.effort = 0.3;
a.hasPuck = true;
a.charge = 1;
a.action = 'windup';
a.actionTime = 1;
a.handling.x = 0;
a.handling.y = -1;
a.setTransform(sk.mover.position, 0);
a.update(1 / 60);
},
},
shoot: {
label: 'shot follow-through',
apply(sk) {
const a = sk.animator;
a.moveSpeed = 2;
a.bladeSpeed = 2;
a.effort = 0.3;
a.hasPuck = true;
a.charge = 0;
if (a.action !== 'shoot') a.playAction('shoot', { power: 1 });
a.actionTime = 0.18;
a.setTransform(sk.mover.position, 0);
a.update(1 / 60);
},
},
stop: {
label: 'hockey stop',
apply(sk) {
const a = sk.animator;
a.moveSpeed = 5;
a.bladeSpeed = 5;
a.effort = 1;
a.braking = true;
a.hasPuck = true;
a.action = null;
a.setTransform(sk.mover.position, 0);
a.update(1 / 60);
},
},
poke: {
label: 'poke check',
apply(sk) {
const a = sk.animator;
a.moveSpeed = 4;
a.bladeSpeed = 4;
a.effort = 0.5;
a.hasPuck = false;
if (a.action !== 'poke') a.playAction('poke');
a.actionTime = 0.12;
a.setTransform(sk.mover.position, 0);
a.update(1 / 60);
},
},
};
const GOALIE_POSES = {
ready: {
label: 'ready stance',
apply(g) {
// Far puck, mid height — stays in ready.
g.animator.threatened = 0.1;
g.animator.puckHeight = 0.5;
g.animator.puckDist = 12;
g.animator.moveSpeed = 0;
g.animator.lateralVel = 0;
g.animator.setState('ready', 0.05);
g.animator.setTransform(g.mover.position, 0);
g.animator.update(1 / 60);
},
},
shuffle: {
label: 'lateral shuffle',
apply(g) {
g.animator.threatened = 0.2;
g.animator.puckHeight = 0.4;
g.animator.puckDist = 8;
g.animator.moveSpeed = 3.2;
g.animator.lateralVel = 2.4;
g.animator.setState('shuffle', 0.05);
g.animator.setTransform(g.mover.position, 0);
g.animator.update(1 / 60);
},
},
butterfly: {
label: 'butterfly',
apply(g) {
g.animator.threatened = 0.9;
g.animator.puckHeight = 0.1;
g.animator.puckDist = 2;
g.animator.moveSpeed = 0;
g.animator.lateralVel = 0;
g.animator.setState('butterfly', 0.05);
g.animator.setTransform(g.mover.position, 0);
g.animator.update(1 / 60);
},
},
reachGlove: {
label: 'glove reach',
apply(g) {
g.animator.threatened = 0.8;
g.animator.puckHeight = 1.3;
g.animator.puckDist = 2.5;
g.animator.moveSpeed = 0;
g.animator.lateralVel = -0.5;
g.animator.setState('reach', 0.05);
g.animator.setTransform(g.mover.position, 0);
g.animator.update(1 / 60);
},
},
reachBlocker: {
label: 'blocker reach',
apply(g) {
g.animator.threatened = 0.8;
g.animator.puckHeight = 1.25;
g.animator.puckDist = 2.5;
g.animator.moveSpeed = 0;
g.animator.lateralVel = 0.8;
g.animator.setState('reach', 0.05);
g.animator.setTransform(g.mover.position, 0);
g.animator.update(1 / 60);
},
},
};
// ---- views ----------------------------------------------------------------
const VIEWS = {
front: { pos: [0, 1.15, 4.2], target: [0, 0.9, 0] },
threequarter: { pos: [2.6, 1.35, 3.4], target: [0, 0.9, 0] },
side: { pos: [4.4, 1.1, 0.15], target: [0, 0.85, 0] },
back: { pos: [0.2, 1.2, -4.0], target: [0, 0.9, 0] },
top: { pos: [0.1, 6.5, 0.2], target: [0, 0.2, 0] },
closeup: { pos: [1.1, 1.35, 1.7], target: [0, 1.15, 0.15] },
gear: { pos: [1.6, 0.55, 2.0], target: [0, 0.45, 0.1] },
};
function applyView(name) {
const v = VIEWS[name] ?? VIEWS.threequarter;
camera.position.set(...v.pos);
controls.target.set(...v.target);
controls.update();
state.view = name;
viewSel.value = name;
}
// ---- bone / gear debug ----------------------------------------------------
const boneHelpers = new THREE.Group();
boneHelpers.visible = false;
scene.add(boneHelpers);
const gearHelpers = new THREE.Group();
gearHelpers.visible = false;
scene.add(gearHelpers);
function rebuildHelpers() {
while (boneHelpers.children.length) boneHelpers.remove(boneHelpers.children[0]);
while (gearHelpers.children.length) gearHelpers.remove(gearHelpers.children[0]);
const subjects = [];
if (player && (state.subject === 'player' || state.subject === 'both')) subjects.push(player);
if (goalie && (state.subject === 'goalie' || state.subject === 'both')) subjects.push(goalie);
for (const sub of subjects) {
const bones = sub.skelData?.bones;
if (!bones) continue;
for (const b of Object.values(bones)) {
const axes = new THREE.AxesHelper(0.08);
axes.name = `bone:${b.name}`;
b.add(axes);
boneHelpers.userData[b.uuid] = axes;
}
if (sub.gear) {
for (const p of sub.gear.pieces ?? []) {
const box = new THREE.BoxHelper(p, 0x66ccff);
box.name = `gear:${p.name}`;
gearHelpers.add(box);
}
}
if (sub.stick?.group) {
gearHelpers.add(new THREE.BoxHelper(sub.stick.group, 0xffaa44));
}
}
}
function clearBoneAxes() {
// Axes were parented onto bones; remove them.
const strip = (root) => {
if (!root) return;
const kill = [];
root.traverse((o) => {
if (o.isAxesHelper) kill.push(o);
});
for (const o of kill) o.removeFromParent();
};
strip(player?.mover);
strip(goalie?.mover);
}
// ---- build subjects -------------------------------------------------------
function buildPlayer() {
if (player) {
player.dispose();
player = null;
}
player = createSkater({
seed: 42,
scene,
physics: null,
index: 0,
team: 0,
position: { x: state.subject === 'both' ? -0.85 : 0, z: 0 },
facing: 0,
});
// Settle a few frames so blend weights and stick aim land.
for (let i = 0; i < 30; i++) {
player.animator.moveSpeed = 0;
player.animator.effort = 0;
player.animator.hasPuck = true;
player.animator.setTransform(player.mover.position, 0);
player.animator.update(1 / 60);
}
}
function buildGoalie() {
if (goalie) {
goalie.destroy();
goalie = null;
}
goalie = createGoalie(null, scene, {
end: 1,
team: 1,
seed: 77,
index: 40,
});
// Park in studio space facing +Z (camera front), not the net frame.
const x = state.subject === 'both' ? 0.85 : 0;
goalie.mover.position.set(x, 0, 0);
goalie.mover.rotation.y = 0;
goalie.pos.x = x;
goalie.pos.z = 0;
goalie.animator.setTransform(goalie.mover.position, 0);
for (let i = 0; i < 30; i++) {
goalie.animator.threatened = 0.1;
goalie.animator.puckHeight = 0.5;
goalie.animator.puckDist = 12;
goalie.animator.setState('ready', 0.02);
goalie.animator.update(1 / 60);
}
}
function layoutSubjects() {
if (player) {
const x = state.subject === 'both' ? -0.85 : 0;
player.mover.position.set(x, 0, 0);
player.animator.setTransform(player.mover.position, 0);
}
if (goalie) {
const x = state.subject === 'both' ? 0.85 : 0;
goalie.mover.position.set(x, 0, 0);
goalie.pos.x = x;
goalie.pos.z = 0;
goalie.animator.setTransform(goalie.mover.position, 0);
}
if (player) player.mover.visible = state.subject !== 'goalie';
if (goalie) goalie.mover.visible = state.subject !== 'player';
}
// ---- pose application -----------------------------------------------------
function poseList() {
if (state.subject === 'goalie') return Object.keys(GOALIE_POSES);
if (state.subject === 'player') return Object.keys(PLAYER_POSES);
// both: union with player first
return [...Object.keys(PLAYER_POSES), ...Object.keys(GOALIE_POSES).map((k) => `g:${k}`)];
}
function fillPoseSelect() {
const list = poseList();
poseSel.innerHTML = '';
for (const id of list) {
const opt = document.createElement('option');
opt.value = id;
if (id.startsWith('g:')) {
opt.textContent = `G · ${GOALIE_POSES[id.slice(2)].label}`;
} else if (state.subject === 'goalie') {
opt.textContent = GOALIE_POSES[id].label;
} else {
opt.textContent = PLAYER_POSES[id]?.label ?? id;
}
poseSel.appendChild(opt);
}
if (!list.includes(state.pose)) state.pose = list[0];
poseSel.value = state.pose;
}
/** Hold a pose for several frames so blends settle before capture. */
function applyPose(poseId, settleFrames = 45) {
state.pose = poseId;
poseSel.value = poseId;
for (let i = 0; i < settleFrames; i++) {
state.time += 1 / 60;
if (player && player.mover.visible) {
const id = poseId.startsWith('g:') ? 'carry' : poseId;
const def = PLAYER_POSES[id] ?? PLAYER_POSES.carry;
def.apply(player, state.time);
}
if (goalie && goalie.mover.visible) {
const id = poseId.startsWith('g:') ? poseId.slice(2) : (GOALIE_POSES[poseId] ? poseId : 'ready');
const def = GOALIE_POSES[id] ?? GOALIE_POSES.ready;
// Bypass the live tracking loop; drive the animator directly.
def.apply(goalie);
}
}
if (state.showGear) {
for (const c of gearHelpers.children) {
if (c.isBoxHelper) c.update();
}
}
}
function setSubject(sub) {
state.subject = sub;
subjectSel.value = sub;
if ((sub === 'player' || sub === 'both') && !player) buildPlayer();
if ((sub === 'goalie' || sub === 'both') && !goalie) buildGoalie();
layoutSubjects();
fillPoseSelect();
// Default pose per subject.
if (sub === 'goalie' && !GOALIE_POSES[state.pose] && !state.pose.startsWith('g:')) {
state.pose = 'ready';
}
if (sub === 'player' && !PLAYER_POSES[state.pose]) state.pose = 'carry';
applyPose(state.pose);
clearBoneAxes();
if (state.showBones) rebuildHelpers();
}
// ---- measurements HUD -----------------------------------------------------
const _v = new THREE.Vector3();
function measure(sub) {
if (!sub) return null;
const bones = sub.skelData.bones;
const inv = new THREE.Matrix4().copy(sub.mover.matrixWorld).invert();
// Clone each result — a shared scratch vector would make every field the
// last bone written (everything looked like foot height).
const local = (bone) => {
bone.getWorldPosition(_v);
return _v.clone().applyMatrix4(inv);
};
const head = local(bones.head);
const handL = local(bones.handL);
const handR = local(bones.handR);
const footL = local(bones.footL);
const footR = local(bones.footR);
return {
headY: head.y,
handLY: handL.y,
handRY: handR.y,
footLY: footL.y,
footRY: footR.y,
stanceW: Math.abs(footL.x - footR.x),
anim: sub.animator?.state ?? sub.animator?.action ?? '—',
};
}
function refreshHud() {
const lines = [
`img2mesh subject=${state.subject} pose=${state.pose} view=${state.view}`,
];
if (player?.mover.visible) {
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)}`,
);
}
if (goalie?.mover.visible) {
const m = measure(goalie);
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)}`,
);
}
hud.textContent = lines.join('\n');
}
// ---- public API for the CLI harness ---------------------------------------
/**
* Shot sheet the headless tool walks. Keep names filesystem-safe.
* @returns {{ subject: string, pose: string, view: string, file: string }[]}
*/
function shotSheet({ subjects = ['player', 'goalie'], views = null, poses = null } = {}) {
const viewIds = views ?? ['front', 'threequarter', 'side', 'closeup', 'gear'];
const out = [];
for (const sub of subjects) {
const poseIds = poses
?? (sub === 'goalie' ? Object.keys(GOALIE_POSES) : Object.keys(PLAYER_POSES));
for (const pose of poseIds) {
for (const view of viewIds) {
out.push({
subject: sub,
pose,
view,
file: `${sub}_${pose}_${view}.png`,
});
}
}
}
return out;
}
async function captureShot({ subject, pose, view, settleMs = 80 }) {
setSubject(subject);
applyView(view);
applyPose(pose, 50);
// One render so WebGL presents the settled pose.
controls.update();
renderer.render(scene, camera);
await new Promise((r) => setTimeout(r, settleMs));
renderer.render(scene, camera);
return {
subject,
pose,
view,
measures: {
player: player?.mover.visible ? measure(player) : null,
goalie: goalie?.mover.visible ? measure(goalie) : null,
},
};
}
window.img2mesh = {
state,
shotSheet,
captureShot,
setSubject,
applyPose,
applyView,
get player() { return player; },
get goalie() { return goalie; },
/** Data URL of the current canvas (png). */
async screenshotDataURL() {
controls.update();
renderer.render(scene, camera);
return canvas.toDataURL('image/png');
},
/** Pose / view catalogs for external tools. */
catalogs: {
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),
},
};
// ---- UI wiring ------------------------------------------------------------
function cycle(list, cur, dir) {
const i = list.indexOf(cur);
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));
document.getElementById('prevPose').onclick = () => {
applyPose(cycle(poseList(), state.pose, -1));
};
document.getElementById('nextPose').onclick = () => {
applyPose(cycle(poseList(), state.pose, 1));
};
document.getElementById('prevView').onclick = () => {
applyView(cycle(Object.keys(VIEWS), state.view, -1));
};
document.getElementById('nextView').onclick = () => {
applyView(cycle(Object.keys(VIEWS), state.view, 1));
};
document.getElementById('cycle').onclick = async () => {
const sheet = shotSheet({ subjects: [state.subject === 'both' ? 'player' : state.subject] });
for (const s of sheet.slice(0, 12)) {
await captureShot(s);
refreshHud();
await new Promise((r) => setTimeout(r, 120));
}
};
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 === 'b' || e.key === 'B') {
state.showBones = !state.showBones;
if (state.showBones) rebuildHelpers();
else clearBoneAxes();
boneHelpers.visible = state.showBones;
}
if (e.key === 'g' || e.key === 'G') {
state.showGear = !state.showGear;
if (state.showGear) rebuildHelpers();
gearHelpers.visible = state.showGear;
}
});
// ---- boot -----------------------------------------------------------------
buildPlayer();
buildGoalie();
setSubject('player');
applyView('threequarter');
applyPose('carry');
boot.remove();
let last = performance.now();
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 (state.showGear) {
for (const c of gearHelpers.children) {
if (c.isBoxHelper) c.update();
}
}
controls.update();
renderer.render(scene, camera);
refreshHud();
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);