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.
219 lines
7.6 KiB
JavaScript
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 };
|
|
}
|