Initial commit

This commit is contained in:
ryanfitzpatrickio
2026-08-03 10:28:11 -05:00
parent 65bfc3dcb4
commit 6c08153e42
63 changed files with 15790 additions and 1 deletions
+289
View File
@@ -0,0 +1,289 @@
import * as THREE from 'three';
import { NET, goalLineX, goalieSpot } from '../../shared/net.js';
import { CAT, KIND, makeTag, quat, transform, vec3, xyz } from '../physics/bridge.js';
import { clamp } from '../../shared/scalar.js';
import { makeRng } from '../core/rng.js';
import { disposeObject } from '../core/math.js';
import { buildMaterials, paintKit } from '../render/materials.js';
import { assertNoNaNBones, buildSkeleton } from './skeleton.js';
import { buildBodyGeometry, buildBodyMesh } from './body.js';
import { computeSkin } from './skinning.js';
import { buildGoalieAnimator } from '../anim/goalieAnimator.js';
import { buildGoalieGear, buildGoalieMaterials } from './goalieGear.js';
/**
* A goalie.
*
* Deliberately *not* a skater. The skating sim is a carve model — momentum
* dragged onto a blade line — and a goalie almost never carves. They shuffle
* along an arc, square to the puck, and their whole job is to be in the right
* place rather than to travel.
*
* Presentation matches the skaters: same skeleton, skinned body, bone-socketed
* gear (pads, trapper, blocker, mask, paddle). Locomotion and saves stay
* purpose-built — angle tracking with a reaction lag, kinematic pad/body
* colliders the puck bounces off. No save-percentage roll anywhere.
*/
export const GOALIE = {
/** How far out of the net they play. Deeper is safer, shallower cuts angle. */
depth: 0.62,
/** Lateral speed, m/s. Real goalies are quick but not instant. */
speed: 4.4,
/** Seconds of reaction lag on the target. This is the beatable part. */
lag: 0.11,
/** Pad stack: low and wide — the physics shape, not the visual pad. */
padWidth: 0.92,
padHeight: 0.46,
padDepth: 0.22,
/** Upper body plus arms/glove/blocker, as one capsule. */
bodyRadius: 0.30,
bodyLow: 0.46,
bodyHigh: 1.24,
/** How far they lunge at a puck that is already past them. */
desperation: 0.45,
/**
* How close / fast a puck has to be before they commit to butterfly/reach.
* Tuned so idle crease work stays in ready stance.
*/
threatDist: 9,
threatSpeed: 6,
};
export function createGoalie(physics, scene, {
end = 1,
index = 40,
team = 1,
seed = 9000 + Math.abs(end) * 17 + team * 3,
} = {}) {
const line = goalLineX(end);
const rng = makeRng(seed);
const materials = buildMaterials(rng, team);
// Goalies are bulkier in the pads than skaters are in pants.
const bodyStyle = { mass: 0.55, muscle: 0.6, fat: 0.45 };
const skelData = buildSkeleton();
const mover = new THREE.Group();
mover.name = 'goalie:' + end;
scene.add(mover);
const bodyGeo = buildBodyGeometry(rng, bodyStyle);
computeSkin(bodyGeo, skelData);
paintKit(bodyGeo, { jersey: materials.team.jersey, skinColor: materials.skinColor });
const bodyMesh = buildBodyMesh(bodyGeo, skelData, materials);
mover.add(bodyMesh);
const gearMats = buildGoalieMaterials(materials.team.jersey, materials.team.accent);
const gear = buildGoalieGear(gearMats);
gear.attachTo(skelData.bones);
const animator = buildGoalieAnimator(skelData, mover);
animator.stick = gear.stick;
const spawnX = line - end * GOALIE.depth;
const facing = end > 0 ? -Math.PI / 2 : Math.PI / 2;
mover.position.set(spawnX, 0, 0);
mover.rotation.y = facing;
animator.setTransform(mover.position, facing);
mover.updateMatrixWorld(true);
assertNoNaNBones(skelData);
// ---- colliders ----------------------------------------------------------
// Kinematic: the puck bounces off, the goalie does not get pushed around.
// Kept as simple pad+body shapes rather than 18 bone capsules — a goalie's
// job is to be a wall the puck can hit, not a ragdoll that falls over.
let body = null;
const api = physics?.api;
if (physics) {
const bd = api.b3DefaultBodyDef();
bd.type = api.b3BodyType.b3_kinematicBody;
bd.position = xyz(spawnX, 0, 0);
bd.enableSleep = false;
body = api.b3CreateBody(physics.world, bd);
const sd = api.b3DefaultShapeDef();
sd.enableContactEvents = true;
sd.baseMaterial.friction = 0.5;
// Pads absorb. A puck pinging off a goalie like a wall is the single most
// arcade-looking thing a hockey game can do.
sd.baseMaterial.restitution = 0.18;
sd.baseMaterial.userMaterialId = makeTag(KIND.BODY, index, 0);
sd.filter.categoryBits = CAT.skater(index % 12);
// Puck and skaters only — never the rink, which a kinematic body ignores.
sd.filter.maskBits = CAT.PUCK | CAT.PROXY;
api.b3CreateBoxShape(body, sd, GOALIE.padDepth / 2, GOALIE.padHeight / 2, GOALIE.padWidth / 2);
api.b3CreateCapsuleShape(body, sd, {
center1: xyz(0, GOALIE.bodyLow, 0),
center2: xyz(0, GOALIE.bodyHigh, 0),
radius: GOALIE.bodyRadius,
});
}
const target = { x: spawnX, z: 0 };
const pos = { x: spawnX, z: 0 };
/** Lagged puck position, which is what they actually react to. */
const seen = { x: 0, z: 0 };
/** Last raw puck sample, for a cheap velocity estimate. */
const lastPuck = { x: 0, y: 0.05, z: 0 };
let seenInit = false;
let placed = false;
let hadPuck = false;
const _p = new THREE.Vector3();
const _q = new THREE.Quaternion();
const _scale = new THREE.Vector3();
const _up = new THREE.Vector3(0, 1, 0);
return {
end,
index,
team,
/** @deprecated use mover — kept so older callers that read .group still work */
get group() { return mover; },
mover,
body,
pos,
animator,
skelData,
gear,
bodyMesh,
/** Reset to the middle of the crease. */
reset() {
pos.x = line - end * GOALIE.depth;
pos.z = 0;
seenInit = false;
hadPuck = false;
placed = false;
const yaw = end > 0 ? -Math.PI / 2 : Math.PI / 2;
mover.position.set(pos.x, 0, pos.z);
mover.rotation.y = yaw;
animator.setTransform(mover.position, yaw);
animator.moveSpeed = 0;
animator.lateralVel = 0;
animator.threatened = 0;
animator.setState('ready', 0.05);
},
/**
* Track the puck. `dt` on the frame clock.
* `puck` is anything with `{x,y,z}` — the shootout passes a Vector3.
* Returns the current position so callers can watch it.
*/
update(dt, puck) {
const px = puck.x;
const py = puck.y ?? 0.05;
const pz = puck.z;
// Reaction lag: they play the puck where they saw it, not where it is.
if (!seenInit) {
seen.x = px;
seen.z = pz;
seenInit = true;
} else {
const k = clamp(dt / Math.max(1e-3, GOALIE.lag), 0, 1);
seen.x += (px - seen.x) * k;
seen.z += (pz - seen.z) * k;
}
// Velocity from samples — the shootout only hands over a position.
let pvx = 0;
let pvz = 0;
if (hadPuck && dt > 1e-6) {
pvx = (px - lastPuck.x) / dt;
pvz = (pz - lastPuck.z) / dt;
}
lastPuck.x = px;
lastPuck.y = py;
lastPuck.z = pz;
hadPuck = true;
goalieSpot(seen, end, GOALIE.depth, target);
// A puck already behind them gets a desperation push across, which is
// why a slow deke beats them and a fast one sometimes does not.
const beaten = end > 0 ? px > pos.x : px < pos.x;
const speed = GOALIE.speed * (beaten ? 1 + GOALIE.desperation : 1);
const dx = target.x - pos.x;
const dz = target.z - pos.z;
const dist = Math.hypot(dx, dz);
const step = speed * dt;
const z0 = pos.z;
if (dist <= step || dist < 1e-6) {
pos.x = target.x;
pos.z = target.z;
} else {
pos.x += (dx / dist) * step;
pos.z += (dz / dist) * step;
}
// Square up to the puck.
const yaw = Math.atan2(px - pos.x, pz - pos.z);
mover.position.set(pos.x, 0, pos.z);
mover.rotation.y = yaw;
// Lateral velocity is along world Z in the crease (nets face ±X).
const latVel = dt > 1e-6 ? (pos.z - z0) / dt : 0;
const puckDist = Math.hypot(px - pos.x, pz - pos.z);
const puckSpeed = Math.hypot(pvx, pvz);
const closing = end > 0 ? pvx > 0.5 : pvx < -0.5;
// Proximity alone is enough to load a stance — a deke at the crease
// should draw a butterfly even if the puck is not a rocket. Speed and
// closing just push the same signal harder.
const near = clamp(1 - puckDist / GOALIE.threatDist, 0, 1);
const rush = clamp(puckSpeed / GOALIE.threatSpeed, 0, 1);
const threat = clamp(
near * 0.55
+ near * rush * 0.45
+ (closing ? near * 0.25 : 0),
0,
1,
);
animator.setTransform(mover.position, yaw);
animator.moveSpeed = Math.abs(latVel) + (dist > step ? speed * 0.25 : 0);
animator.lateralVel = latVel;
animator.puckHeight = py;
animator.puckDist = puckDist;
animator.threatened = threat;
animator.update(dt);
return pos;
},
/** Push the pose into the kinematic collider, once per substep. */
syncPhysics(dt) {
if (!body) return;
mover.updateWorldMatrix(true, false);
mover.matrixWorld.decompose(_p, _q, _scale);
// Physics body stays upright on the ice; presentation lean is visual only.
_q.setFromAxisAngle(_up, animator.originYaw);
_p.y = 0;
if (!placed) {
api.b3Body_SetTransform(body, vec3(_p), quat(_q));
placed = true;
return;
}
api.b3Body_SetTargetTransform(body, transform(_p, _q), dt, true);
},
/** True when the puck is inside the goalie's body — a save in progress. */
covers(puck) {
const dx = puck.x - pos.x;
const dz = puck.z - pos.z;
return Math.hypot(dx, dz) < GOALIE.bodyRadius + 0.14;
},
destroy() {
if (body && api) api.b3DestroyBody(body);
gear.destroy();
for (const m of Object.values(gearMats)) m.dispose();
scene.remove(mover);
disposeObject(mover);
},
};
}
export { NET };