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
+216
View File
@@ -0,0 +1,216 @@
import * as THREE from 'three';
import { MARKINGS, RINK, rinkOutline } from '../../shared/rink.js';
import { buildRinkMaterials } from './materials.js';
/**
* The rendered rink.
*
* Geometry comes from the same `rinkOutline` the physics boards are built
* from, so the wall a skater bounces off is the wall they can see — the single
* most annoying class of bug to chase in a game like this, and free to avoid.
*
* Markings are drawn into a canvas texture rather than as meshes. Blue lines,
* circles and dots as geometry means a dozen extra draw calls and z-fighting
* against the ice; one texture is faster and easier to iterate on.
*/
const PIXELS_PER_METRE = 22;
function markingsTexture() {
const w = Math.round(RINK.halfX * 2 * PIXELS_PER_METRE);
const h = Math.round(RINK.halfZ * 2 * PIXELS_PER_METRE);
const canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d');
// Canvas space: +x right is rink +X, +y down is rink +Z.
const tx = (x) => (x + RINK.halfX) * PIXELS_PER_METRE;
const tz = (z) => (z + RINK.halfZ) * PIXELS_PER_METRE;
const m = (v) => v * PIXELS_PER_METRE;
ctx.fillStyle = '#f2f7fc';
ctx.fillRect(0, 0, w, h);
const vline = (x, colour, widthM) => {
ctx.strokeStyle = colour;
ctx.lineWidth = m(widthM);
ctx.beginPath();
ctx.moveTo(tx(x), 0);
ctx.lineTo(tx(x), h);
ctx.stroke();
};
const circle = (x, z, r, colour, widthM, fill = false) => {
ctx.beginPath();
ctx.arc(tx(x), tz(z), m(r), 0, Math.PI * 2);
if (fill) {
ctx.fillStyle = colour;
ctx.fill();
} else {
ctx.strokeStyle = colour;
ctx.lineWidth = m(widthM);
ctx.stroke();
}
};
const RED = '#c8322c';
const BLUE = '#2f5fa8';
vline(0, RED, 0.3);
vline(-MARKINGS.blueLine, BLUE, 0.3);
vline(MARKINGS.blueLine, BLUE, 0.3);
vline(-MARKINGS.goalLine, RED, 0.06);
vline(MARKINGS.goalLine, RED, 0.06);
circle(0, 0, MARKINGS.centreCircleR, BLUE, 0.06);
circle(0, 0, 0.3, BLUE, 0, true);
// Four end-zone faceoff circles plus the two neutral-zone dots.
for (const sx of [-1, 1]) {
for (const sz of [-1, 1]) {
circle(sx * MARKINGS.zoneDotX, sz * MARKINGS.faceoffDotZ, MARKINGS.faceoffCircleR, RED, 0.06);
circle(sx * MARKINGS.zoneDotX, sz * MARKINGS.faceoffDotZ, 0.3, RED, 0, true);
circle(sx * MARKINGS.faceoffDotX, sz * MARKINGS.faceoffDotZ, 0.3, RED, 0, true);
}
}
// Goal creases, as filled arcs facing centre ice.
for (const sx of [-1, 1]) {
ctx.beginPath();
ctx.arc(tx(sx * MARKINGS.goalLine), tz(0), m(1.83), sx > 0 ? Math.PI / 2 : -Math.PI / 2, sx > 0 ? Math.PI * 1.5 : Math.PI / 2);
ctx.closePath();
ctx.fillStyle = 'rgba(120, 175, 225, 0.5)';
ctx.fill();
ctx.strokeStyle = RED;
ctx.lineWidth = m(0.06);
ctx.stroke();
}
const tex = new THREE.CanvasTexture(canvas);
tex.colorSpace = THREE.SRGBColorSpace;
tex.anisotropy = 8;
return tex;
}
/**
* Extrude the board outline into a wall.
*
* Built as one non-indexed strip: the outline is a closed loop, so a wall is
* two triangles per segment and there is no reason to pay for a Shape/Extrude
* pass or for the corner mitring it would do.
*/
function boardBand(outline, y0, y1, inset = 0) {
const pos = [];
const uv = [];
const n = outline.length;
for (let i = 0; i < n; i++) {
const a = outline[i];
const b = outline[(i + 1) % n];
// Inset pushes the band outward along the local normal, so the glass can
// sit flush on top of the boards rather than intersecting them.
const dx = b.x - a.x;
const dz = b.z - a.z;
const len = Math.hypot(dx, dz) || 1;
const nx = (dz / len) * inset;
const nz = (-dx / len) * inset;
const ax = a.x - nx;
const az = a.z - nz;
const bx = b.x - nx;
const bz = b.z - nz;
const u0 = i / n;
const u1 = (i + 1) / n;
pos.push(ax, y0, az, bx, y0, bz, bx, y1, bz);
pos.push(ax, y0, az, bx, y1, bz, ax, y1, az);
uv.push(u0, 0, u1, 0, u1, 1, u0, 0, u1, 1, u0, 1);
}
const g = new THREE.BufferGeometry();
g.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
g.setAttribute('uv', new THREE.Float32BufferAttribute(uv, 2));
g.computeVertexNormals();
return g;
}
/** The puck mesh — a black disc, driven from the Box3D body each frame. */
export function buildPuckMesh(scene, { radius, thickness }) {
const mesh = new THREE.Mesh(
new THREE.CylinderGeometry(radius, radius, thickness, 20),
new THREE.MeshStandardMaterial({ color: 0x0b0b0d, roughness: 0.72, metalness: 0.02 }),
);
mesh.castShadow = true;
mesh.receiveShadow = true;
// A regulation puck is 76 mm across, which is a handful of pixels from the
// broadcast camera. The ring is a readability aid, not decoration — without
// something to catch the eye the puck is genuinely impossible to follow.
const ring = new THREE.Mesh(
new THREE.RingGeometry(radius * 1.6, radius * 2.4, 24),
new THREE.MeshBasicMaterial({
color: 0xffd166, transparent: true, opacity: 0.45, depthWrite: false,
}),
);
ring.rotation.x = -Math.PI / 2;
ring.position.y = -thickness / 2 + 0.002;
ring.renderOrder = 1;
mesh.add(ring);
scene.add(mesh);
return { mesh, ring };
}
export function buildRink(scene) {
const mats = buildRinkMaterials();
const group = new THREE.Group();
group.name = 'rink';
// ---- ice ---------------------------------------------------------------
// A plane clipped to the rounded rectangle, so the surface ends at the
// boards instead of running under them.
const shape = new THREE.Shape();
const outline = rinkOutline(16);
shape.moveTo(outline[0].x, outline[0].z);
for (let i = 1; i < outline.length; i++) shape.lineTo(outline[i].x, outline[i].z);
shape.closePath();
const iceGeo = new THREE.ShapeGeometry(shape, 24);
// ShapeGeometry lives in XY; lay it flat, then rebuild UVs so the markings
// texture maps to rink coordinates rather than to the shape's bounding box.
iceGeo.rotateX(-Math.PI / 2);
const p = iceGeo.attributes.position;
const uv = new Float32Array(p.count * 2);
for (let i = 0; i < p.count; i++) {
uv[i * 2] = (p.getX(i) + RINK.halfX) / (RINK.halfX * 2);
uv[i * 2 + 1] = 1 - (p.getZ(i) + RINK.halfZ) / (RINK.halfZ * 2);
}
iceGeo.setAttribute('uv', new THREE.BufferAttribute(uv, 2));
mats.ice.map = markingsTexture();
const ice = new THREE.Mesh(iceGeo, mats.ice);
ice.receiveShadow = true;
group.add(ice);
// ---- boards, kickplate, glass ------------------------------------------
const boards = new THREE.Mesh(boardBand(outline, 0.22, RINK.boardHeight), mats.boards);
boards.receiveShadow = true;
group.add(boards);
const kick = new THREE.Mesh(boardBand(outline, 0, 0.22), mats.kickplate);
group.add(kick);
const glass = new THREE.Mesh(
boardBand(outline, RINK.boardHeight, RINK.boardHeight + RINK.glassHeight, 0.02),
mats.glass,
);
glass.renderOrder = 2;
group.add(glass);
// ---- surround ----------------------------------------------------------
// A dark apron so the rink does not float in the void when the camera swings
// low. Cheap, and it stops the horizon from reading as a bug.
const apron = new THREE.Mesh(
new THREE.PlaneGeometry(RINK.halfX * 4, RINK.halfZ * 6),
new THREE.MeshStandardMaterial({ color: 0x14181f, roughness: 0.95 }),
);
apron.rotation.x = -Math.PI / 2;
apron.position.y = -0.05;
apron.receiveShadow = true;
group.add(apron);
scene.add(group);
return { group, materials: mats };
}