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