import Box3DFactory from 'box3d.js'; import { KIND, makeTag, readTag, rinkFilter, xyz } from './bridge.js'; import { slotsShouldCollide } from './ragdoll.js'; import { RINK, rinkOutline } from '../../shared/rink.js'; /** * Box3D world wrapper. * * Runs on a fixed timestep with an accumulator so the simulation stays * reproducible regardless of frame rate. That matters more here than it looks: * the skating sim reads its velocity back out of Box3D every substep, so a * variable step would make how hard you can carve depend on your frame rate. */ export const FIXED_DT = 1 / 120; const MAX_SUBSTEPS = 6; let b3 = null; /** Load and initialise the wasm module. Safe to call more than once. */ export async function initPhysics() { if (!b3) b3 = await Box3DFactory(); return b3; } export function getB3() { if (!b3) throw new Error('physics not initialised — await initPhysics() first'); return b3; } /** * Build the rink: an ice slab and a ring of boards, both static. * * The boards are a ring of boxes rather than a mesh because a body slammed * into one should bounce off a flat face the way it would off real dasher * boards, and because a box ring is cheap enough that we can afford enough * segments for the corners to read as round. */ export function createPhysicsWorld({ gravity = -16 } = {}) { const api = getB3(); const wd = api.b3DefaultWorldDef(); wd.gravity = xyz(0, gravity, 0); // Two skaters closing at 14 m/s combined will visibly interpenetrate at the // default contact stiffness — a fifth of a metre, which on bodies this size // reads as one skating through the other's shoulder. Stiffer contacts and a // faster push-out cost nothing at this body count. wd.contactHertz = 60; wd.contactDampingRatio = 8; wd.contactSpeed = 6; wd.enableContinuous = true; const world = api.b3CreateWorld(wd); api.b3World_SetHitEventThreshold(world, 1.2); // Self-collision: ragdoll limbs enable custom filtering. Adjacent capsules // (and one skip) would fight the joints if they contacted; distant pairs // (hand vs torso, crossed legs) must still collide when limp. // Called only for awake dynamic pairs — exactly the limp case. api.b3World_SetCustomFilterCallback(world, (shapeA, shapeB) => { try { const matA = api.b3Shape_GetSurfaceMaterial(shapeA); const matB = api.b3Shape_GetSurfaceMaterial(shapeB); const a = readTag(matA.userMaterialId); const b = readTag(matB.userMaterialId); if ( a.kind === KIND.BODY && b.kind === KIND.BODY && a.skater === b.skater && a.skater !== 0xff ) { return slotsShouldCollide(a.slot, b.slot); } } catch { // Embind can throw if a shape was destroyed mid-step; default to collide. } return true; }); const rink = rinkFilter(); // ---- ice --------------------------------------------------------------- const iceDef = api.b3DefaultBodyDef(); iceDef.position = xyz(0, -0.5, 0); const ice = api.b3CreateBody(world, iceDef); const iceShape = api.b3DefaultShapeDef(); // Ice, not sand. The skating sim owns blade friction entirely; anything the // solver adds here on top of that is a second, invisible drag term. iceShape.baseMaterial.friction = 0.04; iceShape.baseMaterial.restitution = 0.0; iceShape.baseMaterial.userMaterialId = makeTag(KIND.RINK, 0xff, 0); iceShape.filter.categoryBits = rink.category; iceShape.filter.maskBits = rink.mask; api.b3CreateBoxShape(ice, iceShape, RINK.halfX + 4, 0.5, RINK.halfZ + 4); // ---- boards ------------------------------------------------------------ const boardShape = api.b3DefaultShapeDef(); boardShape.baseMaterial.friction = 0.28; // Dasher boards flex and eat most of the impact. A lively wall would ping // skaters back into open ice and read as rubber. boardShape.baseMaterial.restitution = 0.1; boardShape.baseMaterial.userMaterialId = makeTag(KIND.RINK, 0xff, 1); boardShape.filter.categoryBits = rink.category; boardShape.filter.maskBits = rink.mask; const outline = rinkOutline(10); const boardBodies = []; const halfH = RINK.boardHeight / 2; for (let i = 0; i < outline.length; i++) { const a = outline[i]; const b = outline[(i + 1) % outline.length]; const dx = b.x - a.x; const dz = b.z - a.z; const len = Math.hypot(dx, dz); if (len < 1e-4) continue; // Each segment is a thin box centred on the chord, its local +Z along the // wall. Overlapping the ends slightly (len/2 + thickness) keeps a skater // from catching the seam between two corner segments. const yaw = Math.atan2(dx, dz); const bd = api.b3DefaultBodyDef(); // Pushed half a thickness outward so the *inner* face sits on the outline. const nx = dz / len; const nz = -dx / len; const thickness = 0.2; bd.position = xyz( (a.x + b.x) / 2 - nx * thickness, halfH, (a.z + b.z) / 2 - nz * thickness, ); bd.rotation = { v: { x: 0, y: Math.sin(yaw / 2), z: 0 }, s: Math.cos(yaw / 2) }; const seg = api.b3CreateBody(world, bd); api.b3CreateBoxShape(seg, boardShape, thickness, halfH, len / 2 + thickness); boardBodies.push(seg); } // ---- event plumbing ---------------------------------------------------- const eventsBuffer = api.createEventsBuffer(); const hitOut = api.createContactHitEvent(); const beginOut = api.createContactTouchEvent(); let accumulator = 0; let stepCount = 0; const hitListeners = new Set(); const beginListeners = new Set(); function pumpEvents() { api.getEvents(eventsBuffer, world); const nHits = api.getNumContactHitEvents(eventsBuffer); for (let i = 0; i < nHits; i++) { api.getContactHitEventAt(hitOut, eventsBuffer, i); for (const fn of hitListeners) fn(hitOut); } const nBegin = api.getNumContactBeginEvents(eventsBuffer); for (let i = 0; i < nBegin; i++) { api.getContactBeginEventAt(beginOut, eventsBuffer, i); for (const fn of beginListeners) fn(beginOut); } } return { api, world, ice, boardBodies, get stepCount() { return stepCount; }, /** Advance by real elapsed time, stepping the fixed simulation as needed. */ step(dt, onPreStep) { accumulator += Math.min(dt, 0.25); let steps = 0; while (accumulator >= FIXED_DT && steps < MAX_SUBSTEPS) { if (onPreStep) onPreStep(FIXED_DT); api.b3World_Step(world, FIXED_DT, 4); pumpEvents(); accumulator -= FIXED_DT; steps++; stepCount++; } // Bail out rather than spiral if we ever fall badly behind. if (steps === MAX_SUBSTEPS) accumulator = 0; return steps; }, onHit(fn) { hitListeners.add(fn); return () => hitListeners.delete(fn); }, onBeginTouch(fn) { beginListeners.add(fn); return () => beginListeners.delete(fn); }, destroy() { api.destroyEventsBuffer(eventsBuffer); api.b3DestroyWorld(world); }, }; }