Initial commit
This commit is contained in:
@@ -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);
|
||||
Reference in New Issue
Block a user