Initial commit
This commit is contained in:
@@ -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 };
|
||||
Reference in New Issue
Block a user