Files
2026-08-03 19:20:00 -05:00

339 lines
13 KiB
JavaScript
Raw Permalink 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 { clamp, lerpAngle, wrapAngle } from './scalar.js';
import { clampToRink } from './rink.js';
import { normalizePlayer } from './player.js';
/**
* Skating locomotion.
*
* This is Ludus's `fighterSim` pattern — intent in, velocity out, integrated on
* a fixed step so client and (eventual) server cannot drift — with the walking
* model swapped for a blade.
*
* The difference that matters: a runner's velocity points where they are
* pushing, so friction is isotropic and stopping is instant-ish. A skate glides
* almost freely along its own length and bites hard across it. Movement is
* therefore modelled as two separate things:
*
* 1. the *blade line*, which the momentum vector is continuously dragged onto
* (`edgeGrip`) — this is the carve, and it is why a hockey player leans
* into a turn and arrives somewhere they were not pointing a moment ago;
* 2. speed *along* that line, which a stride adds to and a very small drag
* removes.
*
* Modelling the carve as a rotation of the velocity vector rather than as
* 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({
/** Flat-out forward speed, m/s. ~8 m/s is a fast NHL skater. */
sprintSpeed: 8.4,
/** Speed the stride settles at without pushing hard. */
cruiseSpeed: 5.6,
/** Stride acceleration from a standstill, m/s². */
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)². */
dragQuad: 0.011,
/** Rate the momentum vector swings onto the blade line, 1/s. */
edgeGrip: 6.2,
/** Speed lost per radian of redirect. A hard carve costs; a lazy one doesn't. */
carveScrub: 0.55,
/** Snowplow / hockey stop deceleration, m/s². */
brakeDecel: 12.5,
/** Body yaw rate at a standstill, rad/s. */
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. */
speedCeiling: 11,
});
export function createSkaterState(id, spawn = {}, opts = {}) {
const player = normalizePlayer(opts.player ?? opts);
return {
id,
name: opts.name ?? `Skater ${id}`,
/** Seed for appearance; the sim itself is deterministic without it. */
seed: opts.seed ?? 1337,
team: opts.team ?? 0,
/**
* Stable identity traits — shot side, and anything else that picks a pose
* set rather than a frame of motion. See `shared/player.js`.
*/
shotSide: player.shotSide,
x: spawn.x ?? 0,
y: 0,
z: spawn.z ?? 0,
vx: 0,
vz: 0,
yaw: spawn.yaw ?? 0,
/** Intent: world-space XZ direction, length 0..1 (a left stick). */
ix: 0,
iz: 0,
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. */
bladeSpeed: 0,
/** Last frame's redirect, rad. Sign tells the animator which edge is loaded. */
carve: 0,
/** How hard the skater is pushing, 0..1. Drives stride amplitude. */
effort: 0,
};
}
/** Clamp an input bag into something the sim can trust. */
export function applyIntent(s, msg) {
const n = (v) => (Number.isFinite(v) ? v : 0);
let ix = clamp(n(msg.ix), -1, 1);
let iz = clamp(n(msg.iz), -1, 1);
const len = Math.hypot(ix, iz);
if (len > 1) {
ix /= len;
iz /= len;
}
s.ix = ix;
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). */
export const forwardX = (yaw) => Math.sin(yaw);
export const forwardZ = (yaw) => Math.cos(yaw);
/**
* Advance one skater by `dt`.
*
* `clampBoards` is on for the headless path. In the browser the Box3D proxy
* capsule owns board contact — running both would double the push-out.
*/
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 --------------------------------------------------------
// 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);
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);
// ---- 2. the carve -------------------------------------------------------
// 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 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 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;
s.carve = turnBy;
}
// ---- 3. speed along the blade -------------------------------------------
const fx = forwardX(s.yaw);
const fz = forwardZ(s.yaw);
let vf = s.vx * fx + s.vz * fz;
const rx = Math.cos(s.yaw);
const rz = -Math.sin(s.yaw);
let vr = s.vx * rx + s.vz * rz;
// 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;
// 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) {
// 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;
// Effort is leg work, not acceleration. A skater holding top speed is
// still throwing full strides — they just stop gaining from them — so
// folding `headroom` in here would quietly freeze the legs of anyone at
// cruise, which is most of the time.
effort = clamp(align * intentLen, 0, 1);
}
// Glide drag: tiny linear term plus a quadratic one that sets the ceiling.
if (Math.abs(vf) > 1e-4) {
const drag = (SKATE.glideDrag + SKATE.dragQuad * vf * vf) * dt;
vf = Math.abs(vf) <= drag ? 0 : vf - Math.sign(vf) * drag;
}
// 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;
s.bladeSpeed = vf;
s.effort = effort;
// A board hit or a body check can hand back more speed than a skater can
// generate; cap it so nothing launches.
const speed = Math.hypot(s.vx, s.vz);
if (speed > SKATE.speedCeiling) {
const k = SKATE.speedCeiling / speed;
s.vx *= k;
s.vz *= k;
}
// ---- 4. integrate -------------------------------------------------------
s.x += s.vx * dt;
s.z += s.vz * dt;
if (clampBoards) clampToRink(s, SKATE.radius);
}
/** Planar speed, m/s. */
export const speedOf = (s) => Math.hypot(s.vx, s.vz);