335 lines
12 KiB
JavaScript
335 lines
12 KiB
JavaScript
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;
|
||
}
|