339 lines
13 KiB
JavaScript
339 lines
13 KiB
JavaScript
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);
|