import { MARKINGS, RINK } from './rink.js'; /** * The net, and what counts as a goal. * * Pure numbers and pure predicates, so the shootout logic and the tests can * agree about scoring without a physics world in the room. */ /** Regulation: 6ft wide, 4ft tall, 44in deep. */ export const NET = Object.freeze({ width: 1.83, height: 1.22, depth: 1.12, postRadius: 0.048, /** Crease: 6ft radius arc off the goal line. */ creaseRadius: 1.83, }); /** * Goal line X for an end. `end` is +1 for the +X end, −1 for −X. * The net's mouth sits *on* this line, opening back toward centre ice. */ export const goalLineX = (end) => end * MARKINGS.goalLine; /** * Has the puck fully crossed the line, between the posts and under the bar? * * "Fully" is the rule and it matters: a puck resting on the line is not a goal. * The whole puck has to be past, so the test is against the puck's leading edge * — its centre plus its radius. */ export function isGoal(puck, end, puckRadius = 0.0381) { const line = goalLineX(end); // Leading edge past the line, travelling into the net. const past = end > 0 ? puck.x - puckRadius > line : puck.x + puckRadius < line; if (!past) return false; // ...but not out the back of it. if (Math.abs(puck.x - line) > NET.depth) return false; if (Math.abs(puck.z) > NET.width / 2 - puckRadius) return false; return (puck.y ?? 0) < NET.height; } /** True once the puck is behind the goal line but wide or high — a miss. */ export function isWide(puck, end, puckRadius = 0.0381) { const line = goalLineX(end); const past = end > 0 ? puck.x - puckRadius > line : puck.x + puckRadius < line; return past && !isGoal(puck, end, puckRadius); } /** * Where a goalie should stand, given where the puck is. * * Angle play, which is the whole of goaltending positioning: stand on the line * between the puck and the middle of the net, `depth` metres out from the goal * line. Cover the angle and the shooter has nothing to shoot at; the rest is * reflexes. * * Returns a point, clamped so the goalie never wanders past the posts by more * than a pad's width — a goalie who chases the puck to the corner has left an * open net, which reads as broken rather than as aggressive. */ export function goalieSpot(puck, end, depth = 0.55, out = { x: 0, z: 0 }) { const line = goalLineX(end); // Aim from the middle of the goal mouth toward the puck. const dx = puck.x - line; const dz = puck.z - 0; const len = Math.hypot(dx, dz); if (len < 1e-4) { out.x = line - end * 0.1; out.z = 0; return out; } out.x = line + (dx / len) * depth; out.z = (dz / len) * depth; // Two clamps, and *both* were originally the wrong way round — between them // they teleported the goalie onto the puck and then pinned them to the goal // line, which threw away every bit of angle the depth was there to buy. // // "Out" means toward centre ice, which is decreasing x at the +X end. So // coming out past the puck is `out.x < puck.x` there, and going behind the // line is `out.x > line`. if (end > 0 ? out.x < puck.x : out.x > puck.x) out.x = puck.x; if (end > 0 ? out.x > line : out.x < line) out.x = line; // Stay within the posts, plus a little for a pad sticking out. const limit = NET.width / 2 + 0.22; out.z = Math.max(-limit, Math.min(limit, out.z)); return out; } /** Centre ice, facing the end being shot at — where a shootout attempt starts. */ export function shootoutStart(end) { return { x: 0, z: 0, yaw: end > 0 ? Math.PI / 2 : -Math.PI / 2 }; } /** Is the puck still in a sensible place for a live attempt? */ export function attemptLive(puck, end) { if (Math.abs(puck.z) > RINK.halfZ) return false; // Past the goal line at that end, one way or another, ends it. return end > 0 ? puck.x < goalLineX(end) + NET.depth : puck.x > goalLineX(end) - NET.depth; }