Files
tilt/src/character/skater.js
T
2026-08-03 06:43:21 -05:00

335 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}