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);