Initial commit
This commit is contained in:
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 2–6 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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user