190 lines
6.8 KiB
JavaScript
190 lines
6.8 KiB
JavaScript
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);
|
|
},
|
|
};
|
|
}
|