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 };