animations
This commit is contained in:
+132
-17
@@ -24,6 +24,13 @@ import { normalizePlayer } from './player.js';
|
||||
* 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({
|
||||
@@ -35,6 +42,27 @@ export const SKATE = Object.freeze({
|
||||
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)². */
|
||||
@@ -49,6 +77,11 @@ export const SKATE = Object.freeze({
|
||||
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. */
|
||||
@@ -83,6 +116,11 @@ export function createSkaterState(id, spawn = {}, opts = {}) {
|
||||
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. */
|
||||
@@ -108,6 +146,7 @@ export function applyIntent(s, msg) {
|
||||
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). */
|
||||
@@ -123,15 +162,17 @@ export const forwardZ = (yaw) => Math.cos(yaw);
|
||||
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 --------------------------------------------------------
|
||||
// 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) {
|
||||
// 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);
|
||||
const rate = SKATE.turnRate + (SKATE.turnRateFast - SKATE.turnRate) * t;
|
||||
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);
|
||||
@@ -140,17 +181,22 @@ export function stepSkater(s, dt, { clampBoards = true } = {}) {
|
||||
// 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 grip = 1 - Math.exp(-SKATE.edgeGrip * dt);
|
||||
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 kept = 1 - clamp(SKATE.carveScrub * Math.abs(turnBy), 0, 0.6);
|
||||
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;
|
||||
@@ -165,21 +211,86 @@ export function stepSkater(s, dt, { clampBoards = true } = {}) {
|
||||
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.
|
||||
// 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;
|
||||
if (s.brake || (intentLen > 0.05 && align < -0.35 && vf > 0.4)) {
|
||||
// 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) {
|
||||
// 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.
|
||||
// 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;
|
||||
@@ -195,9 +306,13 @@ export function stepSkater(s, dt, { clampBoards = true } = {}) {
|
||||
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);
|
||||
// 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;
|
||||
|
||||
Reference in New Issue
Block a user