Files
2026-08-03 06:43:21 -05:00

103 lines
3.8 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}