/** * 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 }; }