216 lines
8.2 KiB
JavaScript
216 lines
8.2 KiB
JavaScript
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);
|