Initial commit
This commit is contained in:
@@ -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.02–0.05) without spearing through.
|
||||
const STICK_READY_E = new THREE.Euler(1.55, 0.3, 0.05, 'XYZ');
|
||||
const STICK_FLY_E = new THREE.Euler(1.65, 0.2, 0.0, 'XYZ');
|
||||
const STICK_READY_POS = new THREE.Vector3(0.04, -0.02, 0.04);
|
||||
const STICK_FLY_POS = new THREE.Vector3(0.05, 0.02, 0.05);
|
||||
const _stickEuler = new THREE.Euler();
|
||||
const _stickPos = new THREE.Vector3();
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 }),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 2–6 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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user