Files
tilt/shared/rink.js
T
2026-08-03 06:43:21 -05:00

160 lines
5.2 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,
});
/**
* 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 };
}