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
+102
View File
@@ -0,0 +1,102 @@
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;
}