Initial commit

This commit is contained in:
ryanfitzpatrickio
2026-08-03 06:43:21 -05:00
commit 7ee3e9d02f
63 changed files with 15792 additions and 0 deletions
+215
View File
@@ -0,0 +1,215 @@
import { clamp, lerpAngle, wrapAngle } from './scalar.js';
import { clampToRink } from './rink.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.
*/
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,
/** 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,
/** 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 = {}) {
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,
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,
// ---- 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;
}
/** 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);
// ---- 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) {
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;
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°.
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 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 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;
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.
const align = intentLen > 0.05 ? (s.ix * fx + s.iz * fz) / intentLen : 0;
let effort = 0;
if (s.brake || (intentLen > 0.05 && align < -0.35 && vf > 0.4)) {
// 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 (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.
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;
}
// 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);
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);