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