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