Initial commit

This commit is contained in:
ryanfitzpatrickio
2026-08-03 06:43:21 -05:00
commit 7ee3e9d02f
63 changed files with 15792 additions and 0 deletions
+334
View File
@@ -0,0 +1,334 @@
import * as THREE from 'three';
import { makeRng } from '../core/rng.js';
import { disposeObject } from '../core/math.js';
import { buildMaterials, paintUnderLayer } from '../render/materials.js';
import { assertNoNaNBones, buildSkeleton } from './skeleton.js';
import { buildBodyGeometry, buildBodyMesh } from './body.js';
import { computeSkin } from './skinning.js';
import { buildSkaterGear, buildSkaterGearMaterials, hideCoveredBody } from './skaterGear.js';
import { buildAnimator } from '../anim/skateAnimator.js';
import { REACTION_ATTACK, createRagdoll } from '../physics/ragdoll.js';
import { createBodyProxy } from '../physics/bodyProxy.js';
import { buildStick } from './stick.js';
import { HIT } from '../game/hits.js';
const clamp01 = (x) => (x < 0 ? 0 : x > 1 ? 1 : x);
/**
* One skater: mesh, skeleton, ragdoll, proxy capsule, animator.
*
* This is Ludus's `createFighter` with the loadout, armor, cloth and weapon
* systems removed — everything that remains is the part the hockey game needs.
* Rebuilding one is a full teardown: geometry and skin weights are derived from
* the seed, so there is no partial-update path worth the complexity.
*
* What it does *not* own: position, velocity, or any decision. Those live in
* the sim state and the brain, and arrive here through `applyState`.
*/
export function createSkater({
seed,
scene,
physics,
index = 0,
team = 0,
position = { x: 0, z: 0 },
facing = 0,
bodyStyle = null,
}) {
const rng = makeRng(seed);
const materials = buildMaterials(rng, team);
const skelData = buildSkeleton();
const mover = new THREE.Group();
mover.name = 'skater:' + index;
mover.position.set(position.x, 0, position.z);
mover.rotation.y = facing;
scene.add(mover);
const bodyGeo = buildBodyGeometry(rng, bodyStyle);
computeSkin(bodyGeo, skelData);
paintUnderLayer(bodyGeo, { skinColor: materials.skinColor });
const bodyMesh = buildBodyMesh(bodyGeo, skelData, materials);
mover.add(bodyMesh);
// Kit over the top: cloth skinned to the same skeleton, hard shells socketed
// to the bones they never bend away from. Sized off the physique the body
// loft was built from, so a heavy build gets a bigger jersey.
const gearMats = buildSkaterGearMaterials(materials.team.jersey, materials.team.accent);
const gear = buildSkaterGear(gearMats, skelData, bodyGeo.userData.physique);
gear.attachTo(skelData.bones, mover);
// Everything the kit encloses stops being drawn — no body poking through a
// seam when a shoulder rolls, and a good chunk of the body's triangles saved.
hideCoveredBody(bodyGeo);
const animator = buildAnimator(skelData, mover);
animator.setTransform(mover.position, facing);
// Socketed to the right hand, not to the mover: the arm pose decides where
// the stick is, which is the correct dependency order and the only way the
// hands can actually be on it.
const stick = buildStick(materials, physics, index);
stick.attachTo(skelData.bones.handR);
stick.setGrip('carry');
animator.stick = stick;
mover.updateMatrixWorld(true);
assertNoNaNBones(skelData);
// The 18-capsule rig, kinematic and chasing the animation. Nothing pushes it
// yet; it is here so that when hits land in a later spike the bodies, joints
// and limits already exist and are already in the right place.
const ragdoll = physics ? createRagdoll(physics, skelData, { skaterIndex: index }) : null;
// The one dynamic body. This is what the boards and other skaters actually
// collide with.
const proxy = physics ? createBodyProxy(physics, { index, position }) : null;
const _look = new THREE.Vector3();
const _moverInv = new THREE.Matrix4();
const _pelvis = new THREE.Vector3();
const _chest = new THREE.Vector3();
const _flat = new THREE.Vector3();
const _scale = new THREE.Vector3();
const _rootWorld = new THREE.Matrix4();
const _correction = new THREE.Matrix4();
/**
* Stagger envelope: how much of the rendered pose physics owns, over time.
* Bites almost instantly, then decays back to the animation — anything
* slower on the attack reads as the skater choosing to flinch rather than
* being moved by the hit.
*/
const reaction = { active: false, t: 0, duration: 0, weight: 0, peak: 0 };
function advanceReaction(dt) {
if (!reaction.active) return;
reaction.t += dt;
if (reaction.t >= reaction.duration) {
reaction.active = false;
reaction.weight = 0;
if (ragdoll && ragdoll.mode === 'reacting') {
ragdoll.setJointStiffness(0);
ragdoll.setMode('driven');
}
return;
}
reaction.weight = reaction.t < REACTION_ATTACK
? reaction.peak * (reaction.t / REACTION_ATTACK)
: reaction.peak
* Math.pow(1 - (reaction.t - REACTION_ATTACK) / Math.max(1e-4, reaction.duration - REACTION_ATTACK), 1.6);
}
const skater = {
index,
seed,
team,
rng,
materials,
skelData,
mover,
bodyGeo,
bodyMesh,
gear,
animator,
ragdoll,
proxy,
stick,
reaction,
/** True while the ragdoll owns the skeleton and the proxy is switched off. */
limp: false,
/** Seconds left before a downed skater starts getting up. Null when up. */
downFor: null,
/** Seconds left of the get-up. Intent is damped while it runs. */
rising: 0,
/** The hit that put them here, for the HUD and for debugging. */
lastHit: null,
/**
* Push one frame of sim state into the presentation layer.
*
* `yawRate` is the turn rate of the *velocity* vector, not of the body:
* the animator banks the skater into the arc they are actually carving,
* which is not the same as the way they are pointing.
*/
applyState(s, yawRate) {
_look.set(s.x, 0, s.z);
animator.setTransform(_look, s.yaw);
animator.moveSpeed = Math.hypot(s.vx, s.vz);
animator.bladeSpeed = s.bladeSpeed;
animator.effort = s.effort;
animator.yawRate = yawRate;
animator.braking = !!s.brake;
},
/** Advance animation, the reaction envelope, and the get-up timer. */
update(dt) {
if (!skater.limp) {
if (skater.rising > 0) skater.rising = Math.max(0, skater.rising - dt);
animator.update(dt);
advanceReaction(dt);
}
// Bone velocities are measured on the frame clock, continuously, even
// though they are only read at the moment a rig goes dynamic — they have
// to already be there when that moment arrives.
if (ragdoll && !skater.limp) ragdoll.sampleVelocities(dt);
},
/** Countdown while down; returns true on the frame they should get up. */
tickDown(dt) {
if (!skater.limp || skater.downFor == null) return false;
skater.downFor -= dt;
return skater.downFor <= 0;
},
/**
* Read the physics pose back onto the skeleton.
*
* Fully while limp; blended against the animated pose during a stagger, so
* a flinch deflects the body without erasing the skating underneath it.
*/
syncFromPhysics() {
if (!ragdoll) return;
if (skater.limp) {
_moverInv.copy(mover.matrixWorld).invert();
ragdoll.syncToSkeleton(_moverInv);
mover.updateMatrixWorld(true);
} else if (reaction.active && reaction.weight > 0) {
_moverInv.copy(mover.matrixWorld).invert();
// Root excluded: displacing it slides the skater across the ice, which
// reads as teleporting rather than as being hit. The proxy owns
// position and has already taken the momentum from the collision.
ragdoll.blendToSkeleton(_moverInv, reaction.weight, { includeRoot: false });
mover.updateMatrixWorld(true);
}
},
/**
* Take a hit without going down: the rig goes dynamic with stiff joints for
* a moment, then is blended back onto the animation.
*/
stagger(hit) {
if (!ragdoll || skater.limp) return;
skater.lastHit = hit;
const s = clamp01((hit.severity - HIT.bump) / (HIT.knockdown - HIT.bump));
reaction.active = true;
reaction.t = 0;
reaction.peak = 0.38 + 0.5 * s;
reaction.duration = 0.3 + 0.5 * s;
ragdoll.setJointStiffness(HIT.staggerStiffness);
ragdoll.setMode('reacting');
},
/**
* Go down.
*
* The handoff: the ragdoll goes dynamic and becomes the body, and the proxy
* capsule is switched off. Leaving the proxy enabled would have two bodies
* claiming the same skater — the sim would keep driving a capsule around
* the rink while the visible ragdoll lay on the ice behind it.
*/
goDown(hit) {
if (!ragdoll || skater.limp) return;
skater.lastHit = hit ?? null;
skater.limp = true;
skater.downFor = HIT.downTime;
skater.rising = 0;
reaction.active = false;
reaction.weight = 0;
ragdoll.setJointStiffness(0);
ragdoll.setMode('limp');
proxy?.disable();
},
/**
* Get back up.
*
* The reverse handoff, and the fiddly half of it. The naive version — read
* the pelvis, move the sim there, crossfade — makes the skater visibly fly
* out and snap back, for a reason worth writing down:
*
* While limp, the ragdoll writes the body's displacement into the *root
* bone*, because the mover has been parked where they fell for the whole
* knockdown. So the world pose is `moverAtFallPosition × bigRootOffset`.
* Teleporting the mover onto the pelvis without touching that offset applies
* the displacement a second time — the body jumps by however far it slid —
* and the crossfade then drags it back as the root offset decays to its
* skating value.
*
* The fix is to re-express the root in the *new* mover frame so the world
* pose across the handoff is bit-for-bit identical. Then the crossfade has
* no position to undo and only has to interpolate lying → skating, which is
* the movement we actually want to see.
*/
getUp(state) {
if (!ragdoll || !skater.limp) return;
mover.updateMatrixWorld(true);
const root = skelData.bones.root;
const pelvisBone = ragdoll.parts.pelvis.bone;
pelvisBone.getWorldPosition(_pelvis);
// Which way is this body pointing? The pelvis' own forward axis is no use
// — on someone lying face-down it points at the ice. The pelvis→chest
// line flattened onto the ice is the body's long axis and survives any
// orientation, so a skater stands up facing the way they were sprawled
// rather than spinning on the spot to recover a stale yaw.
ragdoll.parts.spine3.bone.getWorldPosition(_chest);
_flat.set(_chest.x - _pelvis.x, 0, _chest.z - _pelvis.z);
const yaw = _flat.lengthSq() > 1e-4
? Math.atan2(_flat.x, _flat.z)
: (state?.yaw ?? animator.originYaw);
// Remember the root's exact world transform before anything moves.
root.updateWorldMatrix(true, false);
_rootWorld.copy(root.matrixWorld);
// Move the mover onto the body, now, rather than letting the animator do
// it next frame — the correction below has to be computed against the
// frame the pose will actually be drawn in.
mover.position.set(_pelvis.x, 0, _pelvis.z);
mover.rotation.set(0, yaw, 0);
mover.updateMatrixWorld(true);
animator.setTransform(mover.position, yaw);
// Re-express the root so the skeleton lands in exactly the same world
// pose it was already in.
_moverInv.copy(mover.matrixWorld).invert();
_correction.multiplyMatrices(_moverInv, _rootWorld);
_correction.decompose(root.position, root.quaternion, _scale);
mover.updateMatrixWorld(true);
skater.limp = false;
skater.downFor = null;
// Counted down in update(); the match damps intent while it runs so they
// stand up where they fell instead of skating off mid-rise.
skater.rising = HIT.riseTime;
ragdoll.setJointStiffness(0);
// Snaps the bodies onto the skeleton — which has not moved in world
// space, so this costs nothing and cannot fling anything.
ragdoll.setMode('driven');
if (state) {
state.x = _pelvis.x;
state.z = _pelvis.z;
state.yaw = yaw;
state.vx = 0;
state.vz = 0;
}
proxy?.enable(_pelvis.x, _pelvis.z);
animator.rebase(HIT.riseTime);
},
dispose() {
stick.destroy(physics?.api);
gear.destroy();
for (const m of Object.values(gearMats)) m.dispose();
if (ragdoll) ragdoll.destroy();
if (proxy) proxy.destroy();
scene.remove(mover);
disposeObject(mover);
},
};
return skater;
}