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
+189
View File
@@ -0,0 +1,189 @@
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);
},
};
}