Files
tilt/shared/rink.js
T
ryanfitzpatrickio dd819e991d Add main menu, 3v3 with goalies, and dead-puck faceoffs.
Players pick 1-on-1 shootout or 3-on-3 scrimmage; 3v3 gets nets,
goalies, scoring, OOB whistles to the nearest faceoff circle, and
one-way board re-entry for skaters who leave the ice.
2026-08-03 10:28:11 -05:00

219 lines
7.6 KiB
JavaScript

/**
* Rink geometry.
*
* NHL dimensions in metres, kept as plain numbers with no three.js import so
* the sim, the tests and (later) a server can all agree on where the boards
* are without pulling in a renderer.
*
* The surface is a rounded rectangle: a `halfX` by `halfZ` box with the four
* corners replaced by quarter circles of radius `cornerR`. Every containment
* query in the game reduces to "how far outside that shape are you", so it
* lives here once as `rinkPenetration`.
*/
/** 200ft x 85ft, 28ft corner radius, 42in boards. */
export const RINK = Object.freeze({
halfX: 30.48, // length/2 — the long axis runs along X
halfZ: 12.95, // width/2
cornerR: 8.53,
boardHeight: 1.07,
/** Glass above the boards is visual only in this spike. */
glassHeight: 1.8,
});
/** Blue lines / centre line, as distances from centre ice along X. */
export const MARKINGS = Object.freeze({
blueLine: 7.77,
goalLine: 25.6,
faceoffCircleR: 4.57,
centreCircleR: 4.57,
faceoffDotX: 6.7,
faceoffDotZ: 6.7,
zoneDotX: 20.2,
});
/**
* All nine faceoff dots: centre, four neutral-zone, four end-zone.
* Order is stable so tests and HUD labels can index if they want.
*/
export const FACEOFF_DOTS = Object.freeze([
Object.freeze({ id: 'centre', x: 0, z: 0 }),
Object.freeze({ id: 'nz-pp', x: MARKINGS.faceoffDotX, z: MARKINGS.faceoffDotZ }),
Object.freeze({ id: 'nz-pm', x: MARKINGS.faceoffDotX, z: -MARKINGS.faceoffDotZ }),
Object.freeze({ id: 'nz-mp', x: -MARKINGS.faceoffDotX, z: MARKINGS.faceoffDotZ }),
Object.freeze({ id: 'nz-mm', x: -MARKINGS.faceoffDotX, z: -MARKINGS.faceoffDotZ }),
Object.freeze({ id: 'ez-pp', x: MARKINGS.zoneDotX, z: MARKINGS.faceoffDotZ }),
Object.freeze({ id: 'ez-pm', x: MARKINGS.zoneDotX, z: -MARKINGS.faceoffDotZ }),
Object.freeze({ id: 'ez-mp', x: -MARKINGS.zoneDotX, z: MARKINGS.faceoffDotZ }),
Object.freeze({ id: 'ez-mm', x: -MARKINGS.zoneDotX, z: -MARKINGS.faceoffDotZ }),
]);
/** Nearest faceoff dot to a world point — where a whistle drops the next draw. */
export function nearestFaceoffDot(x, z) {
let best = FACEOFF_DOTS[0];
let bestD = Infinity;
for (const d of FACEOFF_DOTS) {
const dd = (d.x - x) * (d.x - x) + (d.z - z) * (d.z - z);
if (dd < bestD) {
bestD = dd;
best = d;
}
}
return best;
}
/**
* Is the puck still in play?
*
* Horizontal: must be on the ice surface (small inset so "on the boards" is
* still playable, but over the glass / past the outline is dead).
* Vertical: above the glass, under the slab, or impossibly high is unplayable.
*/
export function puckPlayable(x, y, z, radius = 0.0381) {
if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) {
return { ok: false, reason: 'nan' };
}
// Far outside the barn entirely (escaped continuous collision).
if (Math.abs(x) > RINK.halfX + 4 || Math.abs(z) > RINK.halfZ + 4) {
return { ok: false, reason: 'escaped' };
}
// Under the ice or stuck in the slab.
if (y < -0.15) return { ok: false, reason: 'under' };
// Over the glass. Boards are ~1.07 m; glass is visual only above that.
if (y > RINK.boardHeight + RINK.glassHeight * 0.55) {
return { ok: false, reason: 'over' };
}
// Centre past the board line — the puck has left the playing surface.
// Tiny slack so a rattle against the boards does not whistle every contact.
if (rinkPenetration(x, z, 0).dist > radius * 0.75) {
return { ok: false, reason: 'out' };
}
return { ok: true, reason: '' };
}
/**
* Centre of the corner arc nearest (x, z), and the sign of the quadrant.
* Points outside the straight sections belong to exactly one corner.
*/
function cornerCentre(x, z, out) {
const sx = x >= 0 ? 1 : -1;
const sz = z >= 0 ? 1 : -1;
out.x = sx * (RINK.halfX - RINK.cornerR);
out.z = sz * (RINK.halfZ - RINK.cornerR);
return out;
}
const _c = { x: 0, z: 0 };
/**
* Signed distance from the rink's inner surface, plus the inward normal.
*
* Positive `dist` means the point is outside the playing surface by that much;
* `nx`/`nz` point back toward the ice. Returns the same object every call, so
* copy anything you need to keep.
*/
const _pen = { dist: 0, nx: 0, nz: 0 };
export function rinkPenetration(x, z, radius = 0) {
const ax = Math.abs(x);
const az = Math.abs(z);
const straightX = RINK.halfX - RINK.cornerR;
const straightZ = RINK.halfZ - RINK.cornerR;
if (ax <= straightX || az <= straightZ) {
// Straight section: whichever wall is closer wins. A point can only be
// outside one of them here, since the corners are handled below.
const overX = ax + radius - RINK.halfX;
const overZ = az + radius - RINK.halfZ;
if (overX >= overZ) {
_pen.dist = overX;
_pen.nx = x >= 0 ? -1 : 1;
_pen.nz = 0;
} else {
_pen.dist = overZ;
_pen.nx = 0;
_pen.nz = z >= 0 ? -1 : 1;
}
return _pen;
}
cornerCentre(x, z, _c);
const dx = x - _c.x;
const dz = z - _c.z;
const d = Math.hypot(dx, dz) || 1e-6;
_pen.dist = d + radius - RINK.cornerR;
_pen.nx = -dx / d;
_pen.nz = -dz / d;
return _pen;
}
/** True when a circle of `radius` at (x, z) is fully on the ice. */
export function insideRink(x, z, radius = 0) {
return rinkPenetration(x, z, radius).dist <= 0;
}
/**
* Push a body back inside the boards and kill the velocity going into them.
*
* Box3D owns board contact for anything with a proxy capsule; this is the
* headless fallback (tests, and any future server tick without a physics
* world) and a cheap safety net against a body escaping the world.
*
* `restitution` 0 is a dead thud, 1 a perfect bounce. Boards eat most of it.
*/
export function clampToRink(state, radius = 0.36, restitution = 0.18) {
const pen = rinkPenetration(state.x, state.z, radius);
if (pen.dist <= 0) return false;
state.x += pen.nx * pen.dist;
state.z += pen.nz * pen.dist;
const into = state.vx * pen.nx + state.vz * pen.nz;
if (into < 0) {
// Remove the inward-normal component, then add back a fraction reversed.
state.vx -= into * pen.nx * (1 + restitution);
state.vz -= into * pen.nz * (1 + restitution);
}
return true;
}
/**
* The board line as a closed polyline, counter-clockwise from the +X end.
*
* The physics boards and the rendered boards are both built from this, so the
* wall a skater bounces off is the wall they can see. `cornerSteps` is the
* number of segments each of the four corner arcs is cut into.
*/
export function rinkOutline(cornerSteps = 8) {
const sx = RINK.halfX - RINK.cornerR;
const sz = RINK.halfZ - RINK.cornerR;
const pts = [];
// Four corners, each an arc swept from its own quadrant, with the straight
// sections falling out as the gaps between consecutive arcs.
const corners = [
{ cx: sx, cz: sz, a0: 0 }, // +X +Z
{ cx: -sx, cz: sz, a0: Math.PI / 2 }, // -X +Z
{ cx: -sx, cz: -sz, a0: Math.PI }, // -X -Z
{ cx: sx, cz: -sz, a0: -Math.PI / 2 }, // +X -Z
];
for (const c of corners) {
for (let i = 0; i <= cornerSteps; i++) {
const a = c.a0 + (i / cornerSteps) * (Math.PI / 2);
pts.push({ x: c.cx + Math.cos(a) * RINK.cornerR, z: c.cz + Math.sin(a) * RINK.cornerR });
}
}
return pts;
}
/**
* A random point on the ice, inset from the boards.
* `rand` is any () => [0,1) so callers can keep it seeded.
*/
export function randomIcePoint(rand, inset = 3) {
for (let i = 0; i < 24; i++) {
const x = (rand() * 2 - 1) * (RINK.halfX - inset);
const z = (rand() * 2 - 1) * (RINK.halfZ - inset);
if (insideRink(x, z, inset)) return { x, z };
}
// Rejection sampling in a rounded rect basically never fails, but never
// hand back an off-ice waypoint if it does.
return { x: 0, z: 0 };
}