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
+143
View File
@@ -0,0 +1,143 @@
import { KIND, makeTag, proxyFilter, xyz } from './bridge.js';
import { SKATE } from '../../shared/skaterSim.js';
/**
* One dynamic capsule per skater — the body that Box3D actually solves.
*
* The 18-capsule ragdoll is kinematic while a skater is on their feet, and
* kinematic bodies do not respond to each other: two rigs driven through one
* another would generate contacts and resolve none of them. So physical
* presence lives in a single dynamic capsule instead, and the ragdoll rides
* along on top purely as the visible, hittable skeleton.
*
* The loop is:
*
* read — pull position and velocity out of Box3D into the sim state
* step — the skating sim edits that velocity (stride, carve, drag)
* write — put the edited velocity back on the body, then let Box3D solve
*
* Reading velocity back rather than only writing it is the whole point: a
* board hit or a shoulder from another skater arrives as a change to `vx/vz`
* that the sim then carries forward as momentum, so contact costs speed and
* knocks a skater off their line instead of being overwritten next frame.
*
* Rotation and vertical motion are locked. Upright-ness is an animation
* concern here, not a physics one — and an unlocked capsule on near-frictionless
* ice will happily lie down and roll to the far boards.
*/
/** Capsule spans knee to shoulder; below that is legs, above is head. */
const LOW = 0.5;
const HIGH = 1.28;
/** Skater plus pads, kg. Sets how much of a shove a check transfers. */
const MASS = 88;
const capsuleVolume = (r, len) => Math.PI * r * r * len + (4 / 3) * Math.PI * r * r * r;
export function createBodyProxy(physics, { index = 0, position = { x: 0, z: 0 } } = {}) {
const { api, world } = physics;
const filter = proxyFilter();
const bd = api.b3DefaultBodyDef();
bd.type = api.b3BodyType.b3_dynamicBody;
bd.position = xyz(position.x, 0, position.z);
// Never sleep: a skater standing still still has to be shoved when hit, and
// a sleeping body ignores the velocity we write to it.
bd.enableSleep = false;
bd.motionLocks = {
linearX: false,
linearY: true,
linearZ: false,
angularX: true,
angularY: true,
angularZ: true,
};
const body = api.b3CreateBody(world, bd);
const sd = api.b3DefaultShapeDef();
sd.density = MASS / capsuleVolume(SKATE.radius, HIGH - LOW);
sd.enableContactEvents = true;
sd.enableHitEvents = true;
// Skater-on-skater should shove, not stick. Friction between two bodies on
// ice is what would make a brush past turn into a drag along.
sd.baseMaterial.friction = 0.1;
sd.baseMaterial.restitution = 0.05;
sd.baseMaterial.userMaterialId = makeTag(KIND.PROXY, index, 0);
sd.filter.categoryBits = filter.category;
sd.filter.maskBits = filter.mask;
const shape = api.b3CreateCapsuleShape(body, sd, {
center1: xyz(0, LOW, 0),
center2: xyz(0, HIGH, 0),
radius: SKATE.radius,
});
// Gravity is pointless with linearY locked, and leaving it on means the
// solver spends every step fighting the lock.
api.b3Body_SetGravityScale(body, 0);
// No damping: the skating sim is the only thing allowed to remove speed,
// otherwise top speed and glide length quietly depend on solver settings.
api.b3Body_SetLinearDamping(body, 0);
return {
body,
shape,
index,
mass: api.b3Body_GetMass(body),
/** Box3D → sim. Call before stepping the sim. */
read(state) {
const p = api.b3Body_GetPosition(body);
const v = api.b3Body_GetLinearVelocity(body);
state.x = p.x;
state.z = p.z;
state.vx = v.x;
state.vz = v.z;
},
/** Sim → Box3D. Call after stepping the sim, before the world step. */
write(state) {
api.b3Body_SetLinearVelocity(body, xyz(state.vx, 0, state.vz));
api.b3Body_SetAwake(body, true);
},
/**
* Hard placement, for spawning and respawns. Clears momentum so a skater
* dropped onto the ice does not inherit whatever the last body was doing.
*/
teleport(x, z) {
api.b3Body_SetTransform(body, xyz(x, 0, z), { v: { x: 0, y: 0, z: 0 }, s: 1 });
api.b3Body_SetLinearVelocity(body, xyz(0, 0, 0));
},
/** True while this capsule is taking part in the simulation. */
enabled: true,
/**
* Switch the capsule off while the ragdoll is the body.
*
* Not just "stop writing velocity to it": a body left enabled still
* occupies space, so a downed skater would leave an invisible upright
* bollard on the ice for everyone else to skate into.
*/
disable() {
if (!this.enabled) return;
api.b3Body_Disable(body);
this.enabled = false;
},
/** Put the capsule back, wherever the body actually ended up. */
enable(x, z) {
if (this.enabled) return;
api.b3Body_Enable(body);
api.b3Body_SetTransform(body, xyz(x, 0, z), { v: { x: 0, y: 0, z: 0 }, s: 1 });
api.b3Body_SetLinearVelocity(body, xyz(0, 0, 0));
api.b3Body_SetAwake(body, true);
this.enabled = true;
},
destroy() {
api.b3DestroyBody(body);
},
};
}
+129
View File
@@ -0,0 +1,129 @@
/**
* three.js <-> Box3D type conversion.
*
* The one real trap: Box3D's embind structs use the vector/scalar quaternion
* form `{ v: {x,y,z}, s }`, while three.js uses `{x,y,z,w}`. Passing a three
* quaternion straight into a joint or transform throws `Missing field: "v"`
* from embind, so everything crossing the boundary goes through here.
*/
export const IDENTITY_QUAT = Object.freeze({ v: { x: 0, y: 0, z: 0 }, s: 1 });
export const vec3 = (v) => ({ x: v.x, y: v.y, z: v.z });
export const xyz = (x, y, z) => ({ x, y, z });
/** three.Quaternion -> b3Quat */
export const quat = (q) => ({ v: { x: q.x, y: q.y, z: q.z }, s: q.w });
/** b3Quat -> three.Quaternion (in place) */
export const toThreeQuat = (out, bq) => out.set(bq.v.x, bq.v.y, bq.v.z, bq.s);
/** b3Vec3 -> three.Vector3 (in place) */
export const toThreeVec = (out, bv) => out.set(bv.x, bv.y, bv.z);
/** three position + quaternion -> b3Transform */
export const transform = (p, q) => ({ p: vec3(p), q: quat(q) });
/** Copy a body's pose onto an Object3D that lives in world space. */
export function applyBodyToObject(b3, bodyId, obj) {
const t = b3.b3Body_GetTransform(bodyId);
obj.position.set(t.p.x, t.p.y, t.p.z);
obj.quaternion.set(t.q.v.x, t.q.v.y, t.q.v.z, t.q.s);
}
/**
* Shape tags.
*
* Box3D has no per-body user data, but hit events carry the `userMaterialId`
* of both shapes, so identity is packed into that 64-bit field:
*
* bits 0..7 kind (KIND.*)
* bits 8..15 skater index of the owning skater, 0xff for none
* bits 16..31 slot region or piece index within that skater
*
* A hit event therefore tells us who was struck, where, and by what, without
* any side lookup in the hot path.
*/
export const KIND = {
NONE: 0,
BODY: 1, // ragdoll limb
PROXY: 2, // the skater's single dynamic capsule
STICK: 3, // reserved — spike 2
PUCK: 4, // reserved — spike 2
RINK: 5, // ice / boards
};
export function makeTag(kind, skater, slot) {
return (BigInt(kind & 0xff)) | (BigInt((skater ?? 0xff) & 0xff) << 8n) | (BigInt(slot & 0xffff) << 16n);
}
/**
* Collision layers.
*
* Bit 0 is the rink (ice + boards). Bit 15 is the proxy layer: the one dynamic
* capsule per skater that Box3D actually solves — board contact, and skater
* against skater, both happen there.
*
* Each skater also owns one bit from bit 1 up for their 18 ragdoll capsules.
* Those are kinematic in this spike and exist so the rig is already wired for
* impulses later; they deliberately do *not* collide with any proxy, because a
* kinematic limb driving through the dynamic capsule that carries the same
* body would fight it every frame.
*
* Getting this wrong is silent: a body whose mask excludes bit 0 simply falls
* through the world with no error anywhere.
*/
export const CAT = {
RINK: 1n,
PROXY: 1n << 15n,
PUCK: 1n << 16n,
STICK: 1n << 17n,
skater: (index) => 1n << BigInt(1 + index),
};
const ALL_BITS = 0xffffffffffffffffn;
/**
* The dynamic body capsule: hits the boards, every other skater's proxy, and
* the puck. Not sticks — a stick is a kinematic collider and would shove
* skaters around without ever being pushed back.
*/
export function proxyFilter() {
return { category: CAT.PROXY, mask: CAT.RINK | CAT.PROXY | CAT.PUCK };
}
/**
* The stick blade: touches the puck and nothing else.
*
* Kinematic bodies push dynamic ones without being pushed back, which is
* exactly right for a stick batting a puck and exactly wrong for a stick
* batting a person. Same trap as the ragdoll limbs, resolved the same way —
* by keeping the mask narrow rather than by hoping.
*/
export function stickFilter() {
return { category: CAT.STICK, mask: CAT.PUCK };
}
/**
* Ragdoll limbs: include the self bit so distant parts collide (hand vs
* torso, thigh vs thigh) once the rig goes dynamic. Adjacent pairs are
* rejected by the world custom filter using the userMaterialId slot indices.
* Proxies are masked out — see the note above.
*/
export function ragdollFilter(index) {
const self = CAT.skater(index);
return { category: self, mask: ALL_BITS & ~CAT.PROXY };
}
export function rinkFilter() {
return { category: CAT.RINK, mask: ALL_BITS };
}
export function readTag(tag) {
const t = BigInt(tag);
return {
kind: Number(t & 0xffn),
skater: Number((t >> 8n) & 0xffn),
slot: Number((t >> 16n) & 0xffffn),
};
}
+124
View File
@@ -0,0 +1,124 @@
import * as THREE from 'three';
import { NET, goalLineX } from '../../shared/net.js';
import { CAT, KIND, makeTag, xyz } from './bridge.js';
/**
* The goal frame: posts, crossbar, and a mesh back that stops the puck.
*
* Static bodies, because a net that moves is a rule (it comes off its moorings)
* rather than a feature, and not one worth having before there is a game.
*
* The back and sides are solid boxes rather than a real mesh. A puck that goes
* in should stay in and settle, and modelling twine is a lot of work to make a
* puck stop moving.
*/
export function createNet(physics, end) {
const { api, world } = physics;
const line = goalLineX(end);
const halfW = NET.width / 2;
const r = NET.postRadius;
const sd = api.b3DefaultShapeDef();
sd.baseMaterial.friction = 0.4;
// Posts ring; the back eats everything so the puck settles in the net.
sd.baseMaterial.restitution = 0.35;
sd.baseMaterial.userMaterialId = makeTag(KIND.RINK, 0xff, end > 0 ? 10 : 11);
sd.filter.categoryBits = CAT.RINK;
sd.filter.maskBits = 0xffffffffffffffffn;
const bodies = [];
const box = (x, y, z, hx, hy, hz, restitution = null) => {
const bd = api.b3DefaultBodyDef();
bd.position = xyz(x, y, z);
const b = api.b3CreateBody(world, bd);
if (restitution !== null) sd.baseMaterial.restitution = restitution;
api.b3CreateBoxShape(b, sd, hx, hy, hz);
sd.baseMaterial.restitution = 0.35;
bodies.push(b);
return b;
};
// Posts, on the line.
box(line, NET.height / 2, halfW, r, NET.height / 2, r);
box(line, NET.height / 2, -halfW, r, NET.height / 2, r);
// Crossbar.
box(line, NET.height, 0, r, r, halfW);
// Back and sides, deadened so the puck does not fire back out.
//
// The net extends *away* from centre ice, `line + end * depth`. Getting this
// sign backwards put the back panel a metre in front of the goal line — a
// solid wall across the mouth — and every shot in the game bounced off it
// before it could cross. Nothing ever scored, and the symptom looked like a
// goalie problem.
box(line + end * NET.depth, NET.height / 2, 0, 0.04, NET.height / 2, halfW, 0.02);
box(line + end * NET.depth * 0.5, NET.height / 2, halfW, NET.depth / 2, NET.height / 2, 0.03, 0.05);
box(line + end * NET.depth * 0.5, NET.height / 2, -halfW, NET.depth / 2, NET.height / 2, 0.03, 0.05);
return {
end,
bodies,
destroy() {
for (const b of bodies) api.b3DestroyBody(b);
},
};
}
/** The rendered net: frame tubes plus a translucent mesh bag. */
export function buildNetMesh(scene, end) {
const line = goalLineX(end);
const halfW = NET.width / 2;
const group = new THREE.Group();
group.name = 'net:' + end;
const frame = new THREE.MeshStandardMaterial({ color: 0xc0332c, roughness: 0.45, metalness: 0.25 });
const mesh = new THREE.MeshStandardMaterial({
color: 0xf2f4f8,
roughness: 0.9,
transparent: true,
opacity: 0.28,
side: THREE.DoubleSide,
depthWrite: false,
});
const tube = (len, x, y, z, axis) => {
const g = new THREE.CylinderGeometry(NET.postRadius, NET.postRadius, len, 10);
const m = new THREE.Mesh(g, frame);
if (axis === 'z') m.rotation.x = Math.PI / 2;
if (axis === 'x') m.rotation.z = Math.PI / 2;
m.position.set(x, y, z);
m.castShadow = true;
group.add(m);
};
tube(NET.height, line, NET.height / 2, halfW, 'y');
tube(NET.height, line, NET.height / 2, -halfW, 'y');
tube(NET.width, line, NET.height, 0, 'z');
// Back frame, so the net reads as a box rather than as a doorway.
tube(NET.depth, line + end * NET.depth / 2, 0.06, halfW, 'x');
tube(NET.depth, line + end * NET.depth / 2, 0.06, -halfW, 'x');
const back = new THREE.Mesh(new THREE.PlaneGeometry(NET.width, NET.height), mesh);
back.position.set(line + end * NET.depth, NET.height / 2, 0);
back.rotation.y = Math.PI / 2;
group.add(back);
for (const s of [1, -1]) {
const side = new THREE.Mesh(new THREE.PlaneGeometry(NET.depth, NET.height), mesh);
side.position.set(line + end * NET.depth / 2, NET.height / 2, s * halfW);
group.add(side);
}
const top = new THREE.Mesh(new THREE.PlaneGeometry(NET.depth, NET.width), mesh);
top.rotation.x = -Math.PI / 2;
top.position.set(line + end * NET.depth / 2, NET.height, 0);
group.add(top);
// Crease paint.
const crease = new THREE.Mesh(
new THREE.CircleGeometry(NET.creaseRadius, 24, end > 0 ? -Math.PI / 2 : Math.PI / 2, Math.PI),
new THREE.MeshBasicMaterial({ color: 0x77b3e0, transparent: true, opacity: 0.45, depthWrite: false }),
);
crease.rotation.x = -Math.PI / 2;
crease.position.set(line, 0.004, 0);
group.add(crease);
scene.add(group);
return group;
}
+146
View File
@@ -0,0 +1,146 @@
import * as THREE from 'three';
import { CAT, KIND, makeTag, xyz } from './bridge.js';
/**
* The puck.
*
* Regulation: 76 mm across, 25.4 mm thick, 170 g. Those are not decoration —
* the size is what makes this the one body in the world that genuinely needs
* continuous collision, and the mass is what makes a 45 m/s shot carry about
* the same momentum as a slow-walking person.
*
* ### Why it is a bullet
*
* A hard shot travels ~45 m/s. At the 1/120 s fixed step that is 0.37 m per
* step — nearly ten times the puck's own radius — and even at Box3D's internal
* 1/480 substep it is still 2.4× radius. Without continuous collision it goes
* straight through the boards, the net and anybody standing in the way, and the
* symptom (a puck that vanishes on hard shots only) is miserable to chase.
*
* ### Why it is a cylinder, and why it cannot tip over
*
* A sphere would roll, and a box would catch its corners. Box3D can build a
* cylinder hull directly. Angular X and Z are then locked so the puck stays
* flat on the ice and only ever spins about its own axis — a puck rolling
* around the rink on its edge is technically possible and always reads as a
* bug. Vertical motion stays free, because a shot lifting off the ice is real
* hockey.
*/
export const PUCK = {
radius: 0.0381,
thickness: 0.0254,
mass: 0.170,
/** Ice is slippery; a dumped puck should travel the length of the rink. */
iceFriction: 0.05,
/** Boards are lively for something this light. */
boardRestitution: 0.35,
/** Terminal sanity: nothing in hockey exceeds this. */
maxSpeed: 55,
};
const HULL_SIDES = 16;
export function createPuck(physics, { position = { x: 0, y: 0.02, z: 0 } } = {}) {
const { api, world } = physics;
const bd = api.b3DefaultBodyDef();
bd.type = api.b3BodyType.b3_dynamicBody;
bd.position = xyz(position.x, position.y, position.z);
bd.isBullet = true;
// Never sleep. A puck sitting still in a corner still has to react the
// instant a skate touches it.
bd.enableSleep = false;
bd.motionLocks = {
linearX: false,
linearY: false,
linearZ: false,
angularX: true,
angularY: false,
angularZ: true,
};
const body = api.b3CreateBody(world, bd);
api.b3Body_SetBullet(body, true);
// `b3CreateCylinder` builds *upward from* `yOffset` rather than centring on
// it, so the offset has to be half the thickness or the body origin sits on
// the puck's bottom face — the puck then rests with its origin at y=0 and the
// rendered mesh, which is centred, is drawn half-sunk into the ice.
const hull = api.b3CreateCylinder(PUCK.thickness, PUCK.radius, -PUCK.thickness / 2, HULL_SIDES);
const sd = api.b3DefaultShapeDef();
sd.density = PUCK.mass / (Math.PI * PUCK.radius * PUCK.radius * PUCK.thickness);
sd.enableContactEvents = true;
sd.enableHitEvents = true;
sd.baseMaterial.friction = PUCK.iceFriction;
sd.baseMaterial.restitution = PUCK.boardRestitution;
sd.baseMaterial.userMaterialId = makeTag(KIND.PUCK, 0xff, 0);
sd.filter.categoryBits = CAT.PUCK;
// Everything solid: the rink, skater bodies, downed ragdolls and sticks.
sd.filter.maskBits = 0xffffffffffffffffn;
const shape = api.b3CreateHullShape(body, sd, hull);
// Damping stands in for air resistance and blade scrape; without it a puck
// dumped down the ice never slows at all on a 0.05 friction surface.
api.b3Body_SetLinearDamping(body, 0.22);
api.b3Body_SetAngularDamping(body, 0.4);
const _pos = new THREE.Vector3();
const _vel = new THREE.Vector3();
const _quat = new THREE.Quaternion();
return {
body,
shape,
mass: api.b3Body_GetMass(body),
/** World position, into a reused vector. */
position() {
const p = api.b3Body_GetPosition(body);
return _pos.set(p.x, p.y, p.z);
},
velocity() {
const v = api.b3Body_GetLinearVelocity(body);
return _vel.set(v.x, v.y, v.z);
},
rotation() {
const t = api.b3Body_GetTransform(body);
return _quat.set(t.q.v.x, t.q.v.y, t.q.v.z, t.q.s);
},
speed() {
const v = api.b3Body_GetLinearVelocity(body);
return Math.hypot(v.x, v.y, v.z);
},
setVelocity(x, y, z) {
const speed = Math.hypot(x, y, z);
if (speed > PUCK.maxSpeed) {
const k = PUCK.maxSpeed / speed;
api.b3Body_SetLinearVelocity(body, xyz(x * k, y * k, z * k));
} else {
api.b3Body_SetLinearVelocity(body, xyz(x, y, z));
}
api.b3Body_SetAwake(body, true);
},
applyImpulse(x, y, z) {
api.b3Body_ApplyLinearImpulseToCenter(body, xyz(x, y, z), true);
},
/** Hard placement — faceoffs, resets, and the carry when fully magnetised. */
place(x, y, z, { keepMotion = false } = {}) {
api.b3Body_SetTransform(body, xyz(x, y, z), { v: { x: 0, y: 0, z: 0 }, s: 1 });
if (!keepMotion) {
api.b3Body_SetLinearVelocity(body, xyz(0, 0, 0));
api.b3Body_SetAngularVelocity(body, xyz(0, 0, 0));
}
api.b3Body_SetAwake(body, true);
},
destroy() {
api.b3DestroyBody(body);
api.b3DestroyHull(hull);
},
};
}
+676
View File
@@ -0,0 +1,676 @@
import * as THREE from 'three';
import { BONE_RADIUS, BONE_REGION, SEG_CHILD } from '../character/skeleton.js';
import { CAT, IDENTITY_QUAT, KIND, makeTag, quat, ragdollFilter, transform, vec3 } from './bridge.js';
// Reaction curve: a blow bites almost instantly, then bleeds off over the
// recovery window. Anything slower on the attack reads as the skater choosing
// to flinch rather than being moved by the hit.
// Reach full physics weight fast so the flinch is visible on the first frames
// after the impulse (was 55 ms — most of a light hit was over before peak).
export const REACTION_ATTACK = 0.04;
/**
* Physical body built from the animation skeleton.
*
* Each part's collider is authored in *bone-local* space — capsule from the
* bone origin to its child's local offset — and the rigid body is placed at
* the bone's world transform. That sidesteps any axis-alignment math: the
* capsule matches the bone exactly by construction, whatever direction the
* bone happens to point.
*
* Two modes:
* 'driven' bodies are kinematic and chase the animated skeleton. This is
* everything spike 1 uses — the rig is here so that hits later have
* something to push, not because anything pushes it yet.
* 'limp' bodies go dynamic and the joints take over. Bone velocity at the
* moment of transition is carried across, so a skater taken off
* their feet mid-stride keeps the momentum of that stride.
*
* Carried over from Ludus with the collision filters retargeted (see
* bridge.js) and nothing else changed: it is the same 18 capsules and 17
* joints, and the reaction/limp paths are known-good.
*/
const HINGE_FRAME = { v: { x: 0, y: Math.SQRT1_2, z: 0 }, s: Math.SQRT1_2 }; // local Z -> local X
// Body density by tissue type. Box3D derives mass and inertia from the shapes,
// so these are the only mass numbers we author — but see CALIBRATION below.
const DENSITY = { bone: 1350, limb: 1050, torso: 1010, head: 1090 };
// Adjacent bone capsules deliberately overlap so the rig has no gaps at the
// joints, which means summing their volumes counts the overlaps twice and lands
// around 175 kg of "flesh" for a normal build. Rather than fudge the densities
// (and lose the physical relationship between tissue types), the whole rig is
// scaled once at build time to hit a plausible total. Re-setting the shape
// density and letting Box3D recompute keeps each body's inertia tensor
// consistent with its new mass; scaling the tensor by hand would not.
const TARGET_BODY_MASS = 86; // kg, before pads and stick
/**
* Parts, parent-first. `hinge` marks a joint that should only bend one way
* (elbows, knees); everything else is a cone-limited ball joint.
*/
const PARTS = [
{ name: 'pelvis', bone: 'pelvis', parent: null, density: DENSITY.torso, radiusScale: 1.15 },
{ name: 'spine1', bone: 'spine1', parent: 'pelvis', density: DENSITY.torso, cone: 0.34, twist: 0.5 },
{ name: 'spine2', bone: 'spine2', parent: 'spine1', density: DENSITY.torso, cone: 0.34, twist: 0.5 },
{ name: 'spine3', bone: 'spine3', parent: 'spine2', density: DENSITY.torso, cone: 0.3, twist: 0.4 },
{ name: 'neck', bone: 'neck', parent: 'spine3', density: DENSITY.head, cone: 0.5, twist: 0.7 },
{ name: 'head', bone: 'head', parent: 'neck', density: DENSITY.head, cone: 0.55, twist: 0.8, radiusScale: 1.0 },
{ name: 'upperArmL', bone: 'upperArmL', parent: 'spine3', density: DENSITY.limb, cone: 1.5, twist: 1.1 },
{ name: 'forearmL', bone: 'forearmL', parent: 'upperArmL', density: DENSITY.limb, hinge: [-0.12, 2.5] },
{ name: 'handL', bone: 'handL', parent: 'forearmL', density: DENSITY.limb, cone: 0.7, twist: 0.6 },
{ name: 'upperArmR', bone: 'upperArmR', parent: 'spine3', density: DENSITY.limb, cone: 1.5, twist: 1.1 },
{ name: 'forearmR', bone: 'forearmR', parent: 'upperArmR', density: DENSITY.limb, hinge: [-0.12, 2.5] },
{ name: 'handR', bone: 'handR', parent: 'forearmR', density: DENSITY.limb, cone: 0.7, twist: 0.6 },
// Knee hinge is about bone-local +X (HINGE_FRAME maps joint Z → X). With the
// rest limb along Y, *positive* angle swings the foot back (Z) — flexion.
// Negative angle is hyperextension (foot forward). The old limits were
// inverted ([-2.4, -0.12]), so limp legs only bent the wrong way.
// Residual +0.12 rad of flex stops a perfectly straight column from standing
// forever under gravity, and blocks reverse bend.
{ name: 'thighL', bone: 'thighL', parent: 'pelvis', density: DENSITY.limb, cone: 1.15, twist: 0.5 },
{ name: 'shinL', bone: 'shinL', parent: 'thighL', density: DENSITY.limb, hinge: [0.12, 2.4] },
{ name: 'footL', bone: 'footL', parent: 'shinL', density: DENSITY.bone, cone: 0.5, twist: 0.3 },
{ name: 'thighR', bone: 'thighR', parent: 'pelvis', density: DENSITY.limb, cone: 1.15, twist: 0.5 },
{ name: 'shinR', bone: 'shinR', parent: 'thighR', density: DENSITY.limb, hinge: [0.12, 2.4] },
{ name: 'footR', bone: 'footR', parent: 'shinR', density: DENSITY.bone, cone: 0.5, twist: 0.3 },
];
const _wp = new THREE.Vector3();
const _wq = new THREE.Quaternion();
const _ws = new THREE.Vector3();
const _prevP = new THREE.Vector3();
const _prevQ = new THREE.Quaternion();
const _pq = new THREE.Quaternion();
const _pqi = new THREE.Quaternion();
const _dq = new THREE.Quaternion();
const _axis = new THREE.Vector3();
const _zAxis = new THREE.Vector3(0, 0, 1);
/**
* Adjacency (by part name) for self-collision filtering.
* Adjacent capsules deliberately overlap at joints; they must never generate
* contacts. Parts two links away still often rest inside each other in bind
* pose (spine1↔spine3), so we cull graph distance ≤ 2 as well.
*/
function partDistance(a, b) {
if (a === b) return 0;
// BFS on the undirected tree. PARTS is small (18), so this is free.
const adj = new Map();
for (const p of PARTS) {
if (!adj.has(p.name)) adj.set(p.name, []);
if (p.parent) {
adj.get(p.name).push(p.parent);
if (!adj.has(p.parent)) adj.set(p.parent, []);
adj.get(p.parent).push(p.name);
}
}
const q = [[a, 0]];
const seen = new Set([a]);
while (q.length) {
const [n, d] = q.shift();
if (n === b) return d;
for (const m of adj.get(n) ?? []) {
if (seen.has(m)) continue;
seen.add(m);
q.push([m, d + 1]);
}
}
return 99;
}
/**
* Precomputed "too close to collide" pairs keyed by part name.
* Distance ≤ 1 = joint neighbours (capsules deliberately overlap).
* Distance 2 on the *spine* only — limb forks (thighL↔thighR = 2 via pelvis)
* must still collide so a limp body can tangle.
*/
const NO_COLLIDE = new Set();
{
const names = PARTS.map((p) => p.name);
const spine = new Set(['pelvis', 'spine1', 'spine2', 'spine3', 'neck', 'head']);
for (let i = 0; i < names.length; i++) {
for (let j = i + 1; j < names.length; j++) {
const d = partDistance(names[i], names[j]);
const bothSpine = spine.has(names[i]) && spine.has(names[j]);
if (d <= 1 || (d <= 2 && bothSpine)) {
NO_COLLIDE.add(`${names[i]}|${names[j]}`);
NO_COLLIDE.add(`${names[j]}|${names[i]}`);
}
}
}
}
/** True when two body part *names* on the same rig may generate contacts. */
export function ragdollPartsCollide(nameA, nameB) {
if (nameA === nameB) return false;
return !NO_COLLIDE.has(`${nameA}|${nameB}`);
}
/** Slot indices match the order PARTS is walked when building the ragdoll. */
const SLOT_NAMES = PARTS.map((p) => p.name);
/** True when two body *slots* on the same rig may generate contacts. */
export function slotsShouldCollide(slotA, slotB) {
const a = SLOT_NAMES[slotA];
const b = SLOT_NAMES[slotB];
if (a == null || b == null) return true;
return ragdollPartsCollide(a, b);
}
export function createRagdoll(physics, skelData, { skaterIndex = 0 } = {}) {
const { api, world } = physics;
const bones = skelData.bones;
skelData.rootBone.updateMatrixWorld(true);
const filter = ragdollFilter(skaterIndex);
// Two masks, swapped by setMode. `driven` keeps limbs out of the proxy layer
// so an animated arm cannot shove anybody; `limp` lets a falling body hit
// people. The rig's own proxy is disabled while it is down, so nothing here
// has to special-case self.
const drivenMask = filter.mask & ~CAT.PROXY;
const limpMask = filter.mask | CAT.PROXY;
const _filter = { categoryBits: filter.category, maskBits: drivenMask, groupIndex: 0 };
const parts = {};
const order = [];
for (const def of PARTS) {
const bone = bones[def.bone];
if (!bone) continue;
const childName = SEG_CHILD[def.bone];
const child = childName ? bones[childName] : null;
// Capsule endpoints in bone-local space.
const c1 = new THREE.Vector3(0, 0, 0);
const c2 = child
? child.position.clone()
: def.bone === 'head'
? new THREE.Vector3(0, 0.15, 0.012)
: def.bone.startsWith('hand')
? new THREE.Vector3(def.bone.endsWith('L') ? 0.045 : -0.045, -0.095, 0.008)
: new THREE.Vector3(0, -0.012, 0.085);
const radius = BONE_RADIUS[def.bone] * (def.radiusScale ?? 0.72);
// A degenerate capsule (endpoints closer than the radius) is just a sphere
// and confuses the solver; nudge it out along its own axis instead.
if (c2.length() < radius * 0.5) c2.setLength(radius * 0.5 + 1e-3);
bone.matrixWorld.decompose(_wp, _wq, _ws);
const bd = api.b3DefaultBodyDef();
// Created dynamic so Box3D computes mass and inertia from the shapes, then
// switched to kinematic below. A kinematic body reports zero mass, so this
// is the only moment the real figure is available.
bd.type = api.b3BodyType.b3_dynamicBody;
bd.position = vec3(_wp);
bd.rotation = quat(_wq);
bd.enableSleep = false;
const body = api.b3CreateBody(world, bd);
const sd = api.b3DefaultShapeDef();
sd.density = def.density;
sd.enableHitEvents = true;
sd.enableContactEvents = true;
// Custom filter rejects adjacent limbs of the same skater (see world.js).
sd.enableCustomFiltering = true;
sd.baseMaterial.friction = 0.75;
sd.baseMaterial.restitution = 0.05;
sd.baseMaterial.userMaterialId = makeTag(KIND.BODY, skaterIndex, order.length);
// Self bit is included: distant limbs collide when limp. Adjacent pairs
// are culled by the world custom filter (and joints keep collideConnected off).
sd.filter.categoryBits = filter.category;
sd.filter.maskBits = drivenMask;
sd.filter.groupIndex = 0;
const shape = api.b3CreateCapsuleShape(body, sd, {
center1: vec3(c1),
center2: vec3(c2),
radius,
});
api.b3Body_EnableHitEvents(body, true);
const mass = api.b3Body_GetMass(body);
const part = {
name: def.name,
def,
bone,
body,
shape,
radius,
mass,
// Capsule endpoints in bone-local space, kept so the segment can be
// rebuilt in world space for limb-level hit queries without asking
// Box3D to hand the shape back every frame.
localA: c1.clone(),
localB: c2.clone(),
region: BONE_REGION[def.bone],
index: order.length,
prevPos: _wp.clone(),
prevQuat: _wq.clone(),
linVel: new THREE.Vector3(),
angVel: new THREE.Vector3(),
disabled: false,
};
parts[def.name] = part;
order.push(part);
}
// ---- mass calibration ---------------------------------------------------
// Runs while the bodies are still dynamic: a kinematic body has no mass to
// recompute, so calibrating after the switch would silently do nothing.
{
let raw = 0;
for (const part of order) raw += part.mass;
if (raw > 1e-6) {
const k = TARGET_BODY_MASS / raw;
for (const part of order) {
api.b3Shape_SetDensity(part.shape, part.def.density * k, false);
api.b3Body_ApplyMassFromShapes(part.body);
part.mass = api.b3Body_GetMass(part.body);
}
}
}
for (const part of order) api.b3Body_SetType(part.body, api.b3BodyType.b3_kinematicBody);
// ---- joints -------------------------------------------------------------
const joints = [];
for (const def of PARTS) {
if (!def.parent) continue;
const a = parts[def.parent];
const b = parts[def.name];
if (!a || !b) continue;
// The anchor is the child bone's origin: (0,0,0) in the child's frame, and
// the child's local offset in the parent's frame.
const localA = b.bone.position.clone();
const localB = new THREE.Vector3(0, 0, 0);
let jointId;
if (def.hinge) {
const jd = api.b3DefaultRevoluteJointDef();
jd.base.bodyIdA = a.body;
jd.base.bodyIdB = b.body;
jd.base.localFrameA = { p: vec3(localA), q: HINGE_FRAME };
jd.base.localFrameB = { p: vec3(localB), q: HINGE_FRAME };
// Stiffer limit solver on hinges so a heavy impact cannot soft-blow past
// the hyperextension stop (knees) or the elbow lock.
jd.base.constraintHertz = 90;
jd.base.constraintDampingRatio = 3;
jd.enableLimit = true;
jd.lowerAngle = def.hinge[0];
jd.upperAngle = def.hinge[1];
// Springs start off — see setJointStiffness.
jd.enableSpring = false;
jd.hertz = 0;
jd.dampingRatio = 0.7;
jointId = api.b3CreateRevoluteJoint(world, jd);
} else {
// Cone axis is frame Z, so point Z down the limb.
_axis.copy(localA).normalize();
const frameQ = localA.lengthSq() > 1e-9
? quat(_dq.setFromUnitVectors(_zAxis, _axis))
: IDENTITY_QUAT;
const jd = api.b3DefaultSphericalJointDef();
jd.base.bodyIdA = a.body;
jd.base.bodyIdB = b.body;
jd.base.localFrameA = { p: vec3(localA), q: frameQ };
jd.base.localFrameB = { p: vec3(localB), q: frameQ };
jd.enableConeLimit = true;
jd.coneAngle = def.cone ?? 0.6;
jd.enableTwistLimit = true;
jd.lowerTwistAngle = -(def.twist ?? 0.5);
jd.upperTwistAngle = def.twist ?? 0.5;
// Springs start off. A spring pulls each joint toward its neutral (bind
// pose) rotation, and at any usable stiffness that turns the rig into a
// self-supporting mannequin: it balances on straight legs and never
// collapses. Stiffness is applied deliberately via setJointStiffness for
// the partial "spring-damper blend" reaction, and left at zero for a real
// collapse.
jd.enableSpring = false;
jd.hertz = 0;
jd.dampingRatio = 0.65;
jointId = api.b3CreateSphericalJoint(world, jd);
}
joints.push({ id: jointId, a: a.name, b: b.name, def, hinge: !!def.hinge });
}
let mode = 'driven';
let stiffness = 0;
/**
* Joint stiffness — the spring-damper blend.
*
* `hertz` 0 gives a fully limp rig that collapses under its own weight; the
* useful range for a reaction that recovers its pose is roughly 26 Hz. High
* values make the rig self-supporting, which is right for a stumble and wrong
* for a death.
*/
function setJointStiffness(hertz, dampingRatio = 0.65) {
stiffness = hertz;
const on = hertz > 0.01;
for (const j of joints) {
if (j.severed) continue;
if (j.hinge) {
api.b3RevoluteJoint_EnableSpring(j.id, on);
if (on) {
api.b3RevoluteJoint_SetSpringHertz(j.id, hertz);
api.b3RevoluteJoint_SetSpringDampingRatio(j.id, dampingRatio);
}
} else {
api.b3SphericalJoint_EnableSpring(j.id, on);
if (on) {
api.b3SphericalJoint_SetSpringHertz(j.id, hertz);
api.b3SphericalJoint_SetSpringDampingRatio(j.id, dampingRatio);
}
}
}
}
/** Push the animated skeleton into the physics bodies (driven mode). */
function syncFromSkeleton(dt) {
for (const part of order) {
part.bone.matrixWorld.decompose(_wp, _wq, _ws);
api.b3Body_SetTargetTransform(part.body, transform(_wp, _wq), dt, true);
}
}
// Sanity ceilings for the handoff. A limb tip in a hard stride runs well under
// these; anything above is a sampling artefact, and letting it through
// launches the whole rig into the air the instant it goes limp.
const MAX_LIN = 12; // m/s
const MAX_ANG = 30; // rad/s
/**
* Sample bone velocities, once per rendered frame.
*
* This deliberately does *not* live in syncFromSkeleton. That runs once per
* fixed substep while the skeleton only moves once per rendered frame, so a
* delta measured there gets divided by the substep duration rather than the
* frame duration — inflating velocity by the substep count and leaving the
* stored value dependent on which substep happened to run last.
*/
function sampleVelocities(frameDt) {
const inv = frameDt > 1e-5 ? 1 / frameDt : 0;
for (const part of order) {
part.bone.matrixWorld.decompose(_wp, _wq, _ws);
part.linVel.subVectors(_wp, part.prevPos).multiplyScalar(inv);
if (part.linVel.lengthSq() > MAX_LIN * MAX_LIN) part.linVel.setLength(MAX_LIN);
_prevQ.copy(part.prevQuat).invert();
_dq.copy(_wq).multiply(_prevQ);
if (_dq.w < 0) _dq.set(-_dq.x, -_dq.y, -_dq.z, -_dq.w); // shortest arc
const angle = 2 * Math.acos(Math.min(1, _dq.w));
if (angle > 1e-5) {
const s = Math.sqrt(Math.max(1e-12, 1 - _dq.w * _dq.w));
part.angVel.set(_dq.x / s, _dq.y / s, _dq.z / s).multiplyScalar(angle * inv);
if (part.angVel.lengthSq() > MAX_ANG * MAX_ANG) part.angVel.setLength(MAX_ANG);
} else part.angVel.set(0, 0, 0);
part.prevPos.copy(_wp);
part.prevQuat.copy(_wq);
}
}
// Bone name -> the world quaternion its body currently reports.
const bodyWorldQ = new Map();
// Accumulated world quaternion per bone during the write-back walk.
const accumQ = new Map();
const _mq = new THREE.Quaternion();
/**
* Read the physics bodies back onto the skeleton (limp mode).
*
* Two passes, because a bone's local rotation depends on its parent's *new*
* world rotation. Reading `parent.matrixWorld` mid-walk would use last
* frame's value and skew every limb down the chain.
*
* The walk also has to handle bones with no body of their own (root,
* clavicles, toes): they keep their current local rotation and simply pass
* the accumulated world rotation through. That matters because upperArm's
* *bone* parent is the clavicle while its *joint* parent is spine3.
*/
const _moverQinv = new THREE.Quaternion();
const _physWorld = new THREE.Quaternion();
const _animWorld = new THREE.Quaternion();
const _localTarget = new THREE.Quaternion();
const _rootTarget = new THREE.Vector3();
/**
* Write the physics pose onto the skeleton, blended against the pose the
* animator just produced.
*
* `weight` 1 is a full ragdoll; anything between is the spring-damper
* blend — the body is deflected by the blow but the animation still shows
* through, and as the weight decays the skater recovers their stance.
*
* Blending happens in *world* space per bone and is converted back to a local
* rotation afterwards. Slerping local rotations instead would compound down
* the chain: a half-weight shoulder followed by a half-weight elbow does not
* put the hand halfway between the two poses.
*/
function blendToSkeleton(moverMatrixInverse, weight = 1, { includeRoot = true } = {}) {
if (weight <= 0.0005) return;
const w = Math.min(1, weight);
bodyWorldQ.clear();
accumQ.clear();
for (const part of order) {
const t = api.b3Body_GetTransform(part.body);
bodyWorldQ.set(part.bone.name, _mq.set(t.q.v.x, t.q.v.y, t.q.v.z, t.q.s).clone());
}
// The mover may be rotated, so body world rotations have to be brought into
// the mover's frame before they become bone locals.
_moverQinv.identity();
if (moverMatrixInverse) _moverQinv.setFromRotationMatrix(moverMatrixInverse);
const walk = (bone, parentWorld) => {
const phys = bodyWorldQ.get(bone.name);
// The animated world rotation this bone would have had, given the already
// blended parent above it.
_animWorld.copy(parentWorld).multiply(bone.quaternion);
let world;
if (phys) {
_physWorld.copy(_moverQinv).multiply(phys);
world = _animWorld.clone().slerp(_physWorld, w);
_pqi.copy(parentWorld).invert();
_localTarget.copy(_pqi).multiply(world);
bone.quaternion.copy(_localTarget);
} else {
world = _animWorld.clone();
}
accumQ.set(bone.name, world);
for (const child of bone.children) if (child.isBone) walk(child, world);
};
const rootBone = skelData.bones.root;
const animRootQ = rootBone.quaternion.clone();
rootBone.quaternion.identity();
walk(rootBone, new THREE.Quaternion());
if (w < 1) rootBone.quaternion.slerpQuaternions(animRootQ, rootBone.quaternion, w);
// The pelvis carries the rig's position; every other bone is rotation-only,
// so the hierarchy keeps the limbs attached to it. Partial reactions leave
// the root alone — displacing it slides the skater across the ice, which
// reads as teleporting rather than as being hit.
const pelvis = parts.pelvis;
if (includeRoot && pelvis) {
const t = api.b3Body_GetTransform(pelvis.body);
_wp.set(t.p.x, t.p.y, t.p.z);
if (moverMatrixInverse) _wp.applyMatrix4(moverMatrixInverse);
_rootTarget.copy(_wp).sub(pelvis.bone.position);
rootBone.position.lerp(_rootTarget, w);
}
}
/** Full ragdoll write-back. */
function syncToSkeleton(moverMatrixInverse) {
blendToSkeleton(moverMatrixInverse, 1, { includeRoot: true });
}
/**
* Snap the physics bodies onto the current skeleton pose.
*
* Needed when handing control back to animation: the bodies are wherever the
* simulation left them, and driving a kinematic body toward a distant target
* makes Box3D derive a huge velocity, which would fling anything it touches.
*/
function snapToSkeleton() {
for (const part of order) {
part.bone.matrixWorld.decompose(_wp, _wq, _ws);
api.b3Body_SetTransform(part.body, vec3(_wp), quat(_wq));
api.b3Body_SetLinearVelocity(part.body, { x: 0, y: 0, z: 0 });
api.b3Body_SetAngularVelocity(part.body, { x: 0, y: 0, z: 0 });
part.prevPos.copy(_wp);
part.prevQuat.copy(_wq);
}
}
/**
* Modes:
* 'driven' kinematic, chases the animation exactly
* 'reacting' dynamic with stiff joints — deflects under a blow and is
* expected to be blended back toward the animated pose
* 'limp' dynamic and slack; gravity wins
*/
function setMode(next) {
if (next === mode) return;
const dynamic = next === 'limp' || next === 'reacting';
if (!dynamic) snapToSkeleton();
// Limbs only join the collision world while the rig is dynamic.
//
// A kinematic limb cannot be pushed, but it *can* push: a driven skater's
// arm swinging through its stride would shove other skaters' proxy capsules
// around, so an idle bystander could be checked by someone's elbow. Once
// the rig goes dynamic that is exactly what we want — a falling body should
// take people's legs out — so the mask is widened here rather than being
// fixed once at build time.
const mask = dynamic ? limpMask : drivenMask;
for (const part of order) {
if (part.filterMask !== mask) {
_filter.categoryBits = filter.category;
_filter.maskBits = mask;
_filter.groupIndex = 0;
api.b3Shape_SetFilter(part.shape, _filter, true);
part.filterMask = mask;
}
api.b3Body_SetType(part.body, dynamic ? api.b3BodyType.b3_dynamicBody : api.b3BodyType.b3_kinematicBody);
if (dynamic) {
// Carry the animated motion across so the reaction continues the motion.
api.b3Body_SetLinearVelocity(part.body, vec3(part.linVel));
api.b3Body_SetAngularVelocity(part.body, vec3(part.angVel));
if (next === 'reacting') {
// Damping holds the flinch together without killing the impulse.
// (1.6/2.2 made light hits die in place; recover via blend weight instead.)
api.b3Body_SetLinearDamping(part.body, 0.85);
api.b3Body_SetAngularDamping(part.body, 1.15);
} else {
api.b3Body_SetLinearDamping(part.body, 0.1);
api.b3Body_SetAngularDamping(part.body, 0.25);
}
}
api.b3Body_SetAwake(part.body, true);
}
mode = next;
}
/** Apply a world-space impulse at a world point to one part. */
function applyImpulse(partName, impulse, worldPoint) {
const part = parts[partName];
if (!part) return;
api.b3Body_ApplyLinearImpulse(
part.body,
vec3(impulse),
worldPoint ? vec3(worldPoint) : api.b3Body_GetPosition(part.body),
true,
);
}
function applyTorqueImpulse(partName, torque) {
const part = parts[partName];
if (!part) return;
api.b3Body_ApplyAngularImpulse(part.body, vec3(torque), true);
}
/**
* Total mass of the rig, for stagger thresholds. Uses the figures captured at
* build time rather than querying the bodies, which report zero while kinematic.
*/
function totalMass() {
let m = 0;
for (const part of order) m += part.mass;
return m;
}
/**
* Sever a joint: the limb below it becomes independent debris still made of
* the same bodies, so it keeps colliding and can be sent flying.
*/
function severJoint(childPartName) {
const j = joints.find((x) => x.b === childPartName);
if (!j || j.severed) return false;
api.b3DestroyJoint(j.id, true);
j.severed = true;
const part = parts[childPartName];
if (part) part.disabled = true;
return true;
}
function partForRegion(region) {
return order.filter((p) => p.region === region);
}
/**
* Write each capsule's segment into world space.
*
* Read off the bone matrices rather than off the Box3D bodies, so the answer
* is correct in both modes: while driven the bodies chase the bones a substep
* behind, and a hit resolved against last substep's pose picks the wrong limb
* at speed. Reuses one array of scratch vectors — the caller must not hold on
* to what it gets back.
*/
const _segments = order.map(() => ({
part: null, a: new THREE.Vector3(), b: new THREE.Vector3(), radius: 0,
}));
function worldSegments() {
for (let i = 0; i < order.length; i++) {
const part = order[i];
const seg = _segments[i];
part.bone.updateWorldMatrix(true, false);
seg.part = part;
seg.a.copy(part.localA).applyMatrix4(part.bone.matrixWorld);
seg.b.copy(part.localB).applyMatrix4(part.bone.matrixWorld);
seg.radius = part.radius;
}
return _segments;
}
function destroy() {
for (const j of joints) if (!j.severed) api.b3DestroyJoint(j.id, false);
for (const part of order) api.b3DestroyBody(part.body);
}
return {
parts,
order,
joints,
get mode() { return mode; },
get stiffness() { return stiffness; },
setMode,
setJointStiffness,
sampleVelocities,
syncFromSkeleton,
syncToSkeleton,
blendToSkeleton,
snapToSkeleton,
applyImpulse,
applyTorqueImpulse,
severJoint,
partForRegion,
worldSegments,
totalMass,
destroy,
};
}
+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);
},
};
}