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; }