217 lines
7.4 KiB
JavaScript
217 lines
7.4 KiB
JavaScript
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 };
|
|
}
|