animations

This commit is contained in:
ryanfitzpatrickio
2026-08-03 19:20:00 -05:00
parent fb1ebeed05
commit afd764e5e7
22 changed files with 2049 additions and 212 deletions
+132 -17
View File
@@ -24,6 +24,13 @@ import { normalizePlayer } from './player.js';
* lateral friction is what keeps momentum: turning redirects speed instead of
* destroying it, so a hard change of direction costs a little (`carveScrub`)
* and coasts out wide, which is the whole feel we are after.
*
* **LT / L2 contain & backskate.** EA NHL style: holding LT does *not* spin
* you around. Facing stays put (eyes on the play) and the stick drives
* movement relative to that facing — push back to retreat heels-first, push
* forward for a careful crawl, or leave the stick centred to bleed speed and
* stay neutral instead of flying up-ice. Reverse-stick without LT is still a
* hockey stop; LT is the controlled/defensive gear.
*/
export const SKATE = Object.freeze({
@@ -35,6 +42,27 @@ export const SKATE = Object.freeze({
accel: 7.2,
/** Extra push while sprinting. */
sprintAccel: 9.0,
/**
* LT contain speeds (m/s). Forward stays a crawl so you don't fly the zone;
* reverse and lateral are quick so you can open up and gap-control.
*/
containForwardSpeed: 3.2,
containForwardSprint: 4.0,
containBackSpeed: 5.8,
containBackSprint: 6.8,
containLateralSpeed: 5.4,
containLateralSprint: 6.4,
/** Accel under LT (m/s²). Reverse / lateral / direction flips are snappy. */
containForwardAccel: 5.5,
containBackAccel: 16.0,
containLateralAccel: 15.0,
/** Extra accel when flipping fore↔aft or side under LT (offense ↔ defense). */
containPivotAccel: 20.0,
/**
* Soft decel when LT is held and the stick is centred (or nearly). Bleeds a
* rush without a full snowplow so you can sit neutral in the gap.
*/
containIdleDecel: 3.2,
/** Constant scrub from blade friction, m/s². Small — this is ice. */
glideDrag: 0.42,
/** Quadratic term so top speed is reached asymptotically, per (m/s)². */
@@ -49,6 +77,11 @@ export const SKATE = Object.freeze({
turnRate: 5.2,
/** Body yaw rate at top speed — you cannot pivot on a rail. */
turnRateFast: 1.9,
/**
* Facing turn rate scale while LT is held. Zero: stick steers *travel* only,
* chest holds so you do not spin when retreating.
*/
containTurnScale: 0,
/** Proxy capsule radius, also used for board clamping. */
radius: 0.36,
/** Never let a collision fling anyone faster than this. */
@@ -83,6 +116,11 @@ export function createSkaterState(id, spawn = {}, opts = {}) {
sprint: false,
/** Held brake — a hockey stop, independent of which way the stick points. */
brake: false,
/**
* LT / L2 contain & backskate. Facing holds; stick drives travel relative
* to facing (including reverse) at a moderated speed.
*/
backskate: false,
// ---- read-only outputs the animator and the AI read -------------------
/** Signed speed along the blade. Negative means gliding backwards. */
@@ -108,6 +146,7 @@ export function applyIntent(s, msg) {
s.iz = iz;
s.sprint = !!msg.sprint;
s.brake = !!msg.brake;
s.backskate = !!msg.backskate;
}
/** Unit forward for a yaw, in three.js's convention (+Z is forward at yaw 0). */
@@ -123,15 +162,17 @@ export const forwardZ = (yaw) => Math.cos(yaw);
export function stepSkater(s, dt, { clampBoards = true } = {}) {
const intentLen = Math.min(1, Math.hypot(s.ix, s.iz));
const speed0 = Math.hypot(s.vx, s.vz);
const backskating = !!s.backskate;
// ---- 1. body yaw --------------------------------------------------------
// The skater turns their body toward the stick. How fast falls off with
// speed: at a standstill you can spin on the spot, at full flight you have
// to carve the turn.
if (intentLen > 0.05) {
// Normal skating: body turns toward the stick.
// LT contain: facing *holds* — stick steers travel relative to the chest so
// a reverse push is heels-first without spinning around.
if (intentLen > 0.05 && !(backskating && SKATE.containTurnScale <= 0)) {
const intentYaw = Math.atan2(s.ix, s.iz);
const t = clamp(speed0 / SKATE.sprintSpeed, 0, 1);
const rate = SKATE.turnRate + (SKATE.turnRateFast - SKATE.turnRate) * t;
let rate = SKATE.turnRate + (SKATE.turnRateFast - SKATE.turnRate) * t;
if (backskating) rate *= SKATE.containTurnScale;
s.yaw = lerpAngle(s.yaw, intentYaw, Math.min(1, rate * dt));
}
s.yaw = wrapAngle(s.yaw);
@@ -140,17 +181,22 @@ export function stepSkater(s, dt, { clampBoards = true } = {}) {
// Drag the momentum vector onto the blade line. The blade is a line, not a
// ray, so a skater gliding backwards keeps gliding backwards instead of
// being snapped through 180°.
//
// Under LT we ease the grip a little so reverse and lateral shuffles are not
// immediately crushed onto a pure blade line.
s.carve = 0;
if (speed0 > 1e-4) {
const velYaw = Math.atan2(s.vx, s.vz);
const off = wrapAngle(s.yaw - velYaw);
const bladeOff = Math.abs(off) > Math.PI / 2 ? wrapAngle(off - Math.sign(off) * Math.PI) : off;
const grip = 1 - Math.exp(-SKATE.edgeGrip * dt);
const edge = backskating ? SKATE.edgeGrip * 0.55 : SKATE.edgeGrip;
const grip = 1 - Math.exp(-edge * dt);
const turnBy = bladeOff * grip;
const newVelYaw = velYaw + turnBy;
// Redirect preserves magnitude; the scrub below is the only speed cost, so
// a wide turn is nearly free and a hard one bleeds.
const kept = 1 - clamp(SKATE.carveScrub * Math.abs(turnBy), 0, 0.6);
const scrub = backskating ? SKATE.carveScrub * 0.45 : SKATE.carveScrub;
const kept = 1 - clamp(scrub * Math.abs(turnBy), 0, 0.6);
const speed = speed0 * kept;
s.vx = Math.sin(newVelYaw) * speed;
s.vz = Math.cos(newVelYaw) * speed;
@@ -165,21 +211,86 @@ export function stepSkater(s, dt, { clampBoards = true } = {}) {
const rz = -Math.sin(s.yaw);
let vr = s.vx * rx + s.vz * rz;
const maxSpeed = s.sprint ? SKATE.sprintSpeed : SKATE.cruiseSpeed;
// How much of the stick points where the skater is facing. A stick pulled
// behind them is a request to turn (handled above) and, until the body comes
// round, a request to stop.
// Stick relative to facing: +1 = stick ahead, 1 = stick behind the heels.
const align = intentLen > 0.05 ? (s.ix * fx + s.iz * fz) / intentLen : 0;
const side = intentLen > 0.05 ? (s.ix * rx + s.iz * rz) / intentLen : 0;
let effort = 0;
if (s.brake || (intentLen > 0.05 && align < -0.35 && vf > 0.4)) {
// Explicit brake, or reverse-stick hockey stop *while not* holding LT.
// With LT down, reverse stick means *skate* reverse — eyes stay on the play.
const reverseStop = !backskating
&& intentLen > 0.05
&& align < -0.35
&& vf > 0.4;
if (s.brake || reverseStop) {
// Hockey stop: both blades across the momentum.
const decel = SKATE.brakeDecel * dt;
vf = Math.abs(vf) <= decel ? 0 : vf - Math.sign(vf) * decel;
effort = 1;
} else if (backskating) {
// Contain gear: facing holds; stick drives travel relative to the chest.
// Forward is a crawl (don't fly the zone). Reverse and left/right are
// snappy so a D-man can open up, gap, and jump back into the play.
const fwdMax = s.sprint ? SKATE.containForwardSprint : SKATE.containForwardSpeed;
const backMax = s.sprint ? SKATE.containBackSprint : SKATE.containBackSpeed;
const latMax = s.sprint ? SKATE.containLateralSprint : SKATE.containLateralSpeed;
if (intentLen > 0.08) {
// Desired blade speed: stick fore → crawl, stick aft → reverse.
const alongMax = align >= 0 ? fwdMax : backMax;
const desiredVf = align * intentLen * alongMax;
const gap = desiredVf - vf;
// Near target ease off; when flipping direction, stay full-send.
const flipping = Math.abs(vf) > 0.4
&& Math.abs(desiredVf) > 0.2
&& Math.sign(desiredVf) !== Math.sign(vf);
const headroom = flipping
? 1
: (Math.abs(gap) > 1e-4
? clamp(Math.abs(gap) / Math.max(0.3, alongMax), 0, 1)
: 0);
let base;
if (flipping) base = SKATE.containPivotAccel;
else if (align < -0.08 || desiredVf < vf && desiredVf < 0) base = SKATE.containBackAccel;
else base = SKATE.containForwardAccel;
vf += Math.sign(gap || 1) * base * headroom * dt;
if (vf > fwdMax) vf = fwdMax;
if (vf < -backMax) vf = -backMax;
// Lateral shuffle: full side speed, high accel, snappy side-to-side cuts.
const desiredVr = side * intentLen * latMax;
const latGap = desiredVr - vr;
const latFlip = Math.abs(vr) > 0.35
&& Math.abs(desiredVr) > 0.15
&& Math.sign(desiredVr) !== Math.sign(vr);
const latHead = latFlip
? 1
: (Math.abs(latGap) > 1e-4
? clamp(Math.abs(latGap) / Math.max(0.25, latMax), 0, 1)
: 0);
const latBase = latFlip ? SKATE.containPivotAccel : SKATE.containLateralAccel;
vr += Math.sign(latGap || 1) * latBase * latHead * dt;
if (vr > latMax) vr = latMax;
if (vr < -latMax) vr = -latMax;
// Effort reads higher on reverse / cut so the legs keep moving.
const cut = Math.max(Math.abs(align), Math.abs(side));
effort = clamp(intentLen * (0.45 + 0.55 * cut), 0, 1);
} else {
// Stick centred under LT: sit in the gap. Bleed speed without snowplowing.
const decel = SKATE.containIdleDecel * dt;
if (Math.abs(vf) <= decel) vf = 0;
else vf -= Math.sign(vf) * decel;
if (Math.abs(vr) <= decel) vr = 0;
else vr -= Math.sign(vr) * decel;
effort = speed0 > 0.8 ? 0.35 : 0.08;
}
} else if (intentLen > 0.05 && align > 0.15) {
// Stride. The push weakens as the blade approaches the speed the legs can
// deliver, so top speed is a property of the stride rather than a clamp.
// Forward stride. The push weakens as the blade approaches the speed the
// legs can deliver, so top speed is a property of the stride rather than
// a clamp.
const maxSpeed = s.sprint ? SKATE.sprintSpeed : SKATE.cruiseSpeed;
const base = s.sprint ? SKATE.sprintAccel : SKATE.accel;
const headroom = clamp(1 - vf / maxSpeed, 0, 1);
vf += base * align * intentLen * headroom * dt;
@@ -195,9 +306,13 @@ export function stepSkater(s, dt, { clampBoards = true } = {}) {
const drag = (SKATE.glideDrag + SKATE.dragQuad * vf * vf) * dt;
vf = Math.abs(vf) <= drag ? 0 : vf - Math.sign(vf) * drag;
}
// Whatever lateral slip survived the carve dies here — it is only ever a
// rounding remnant, but leaving it in lets a skater drift sideways forever.
vr *= Math.exp(-SKATE.edgeGrip * 2 * dt);
// Lateral: normally killed hard. Under LT a shuffle is intentional — barely
// scrub so left/right accel actually sticks between steps.
if (backskating) {
vr *= Math.exp(-SKATE.edgeGrip * 0.12 * dt);
} else {
vr *= Math.exp(-SKATE.edgeGrip * 2 * dt);
}
s.vx = fx * vf + rx * vr;
s.vz = fz * vf + rz * vr;