Initial commit

This commit is contained in:
ryanfitzpatrickio
2026-08-03 06:43:21 -05:00
commit 7ee3e9d02f
63 changed files with 15792 additions and 0 deletions
+199
View File
@@ -0,0 +1,199 @@
import { RINK, randomIcePoint, rinkPenetration } from './rink.js';
import { SKATE, speedOf } from './skaterSim.js';
import { clamp } from './scalar.js';
/**
* Waypoint AI.
*
* Deliberately dumb: pick a spot, steer at it, get out of everyone's way.
* It exists to exercise the locomotion — the interesting question for this
* spike is whether the *skating* reads, and a bot that just holds a direction
* answers that better than one making tactical decisions.
*
* Everything is produced as a steering *intent*, never as a position fix, so
* when a real controller or a puck-chasing brain replaces this the movement
* layer underneath does not change at all.
*/
const AI = {
/** Considered arrived inside this radius. */
arriveR: 2.6,
/** New waypoints are at least this far away, so bots commit to a line. */
minTravel: 9,
/** Repath anyway after this long, in case a waypoint became unreachable. */
patience: 9,
/** Start avoiding another skater inside this range. */
personalSpace: 3.4,
/** Weight of the avoidance push relative to the waypoint pull. */
avoidGain: 1.5,
/** Much weaker while racing for a puck — you contest it, you don't yield. */
avoidChasing: 0.25,
/** Seconds of velocity looked ahead when checking for boards. */
boardLookahead: 0.9,
/** Steer away once the lookahead point is within this of the boards. */
boardMargin: 2.2,
boardGain: 2.2,
/** Slow down for the last stretch so they don't overshoot every waypoint. */
easeR: 6,
};
export function createBrain(rand, opts = {}) {
return {
rand,
target: null,
age: 0,
/** Personality: some bots cruise, some chase every waypoint flat out. */
eagerness: opts.eagerness ?? (0.35 + rand() * 0.6),
/** Small heading wobble so three bots on the same errand don't lockstep. */
wobblePhase: rand() * Math.PI * 2,
wobbleRate: 0.5 + rand() * 0.7,
};
}
function pickWaypoint(brain, from) {
for (let i = 0; i < 12; i++) {
const p = randomIcePoint(brain.rand, 3.5);
if (Math.hypot(p.x - from.x, p.z - from.z) >= AI.minTravel) return p;
}
return randomIcePoint(brain.rand, 3.5);
}
/**
* Produce this frame's intent for one skater.
*
* @param {object} brain from createBrain
* @param {object} s skater state (mutated: ix, iz, sprint)
* @param {object[]} others other skater states to keep clear of
* @param {number} dt
*/
export function steer(brain, s, others, dt, play = null) {
brain.age += dt;
// ---- what am I doing --------------------------------------------------
// With a puck on the ice there is something to want. Without one this falls
// back to wandering, which is what spike 1 did and is still what happens
// between whistles.
if (play?.puck) {
const mine = play.carrier === s.id;
const teammateHas = play.carrier != null && play.carrierTeam === s.team && !mine;
// Only the closest skater on each side actually goes for it. Letting all
// six chase does produce contact, but it also produces a single moving
// scrum with nobody anywhere else on the ice, which reads as a bug rather
// than as hockey.
const chaser = play.chaser?.[s.team] === s.id;
if (mine) {
// Carrying: head for open ice up the attacking end.
const attackX = s.team === 0 ? RINK.halfX * 0.7 : -RINK.halfX * 0.7;
brain.target = { x: attackX, z: clamp(play.puck.z * 0.6, -RINK.halfZ * 0.6, RINK.halfZ * 0.6) };
brain.age = 0;
} else if (chaser) {
// Go and get it. This is what makes contact happen on its own.
brain.target = { x: play.puck.x, z: play.puck.z };
brain.age = 0;
} else {
// Everyone else finds space: ahead of the puck when their side has it,
// between the puck and their own end when it does not.
const dir = s.team === 0 ? 1 : -1;
const depth = teammateHas ? RINK.halfX * 0.42 : -RINK.halfX * 0.18;
const side = s.id % 2 === 0 ? 1 : -1;
brain.target = {
x: clamp(play.puck.x + dir * depth, -RINK.halfX * 0.85, RINK.halfX * 0.85),
z: clamp(play.puck.z + side * RINK.halfZ * 0.55, -RINK.halfZ * 0.8, RINK.halfZ * 0.8),
};
brain.age = 0;
}
}
const reached = brain.target
&& Math.hypot(brain.target.x - s.x, brain.target.z - s.z) < AI.arriveR;
if (!brain.target || (reached && !play?.puck) || brain.age > AI.patience) {
brain.target = pickWaypoint(brain, s);
brain.age = 0;
}
// ---- pull toward the waypoint ------------------------------------------
let dx = brain.target.x - s.x;
let dz = brain.target.z - s.z;
const dist = Math.hypot(dx, dz) || 1e-6;
dx /= dist;
dz /= dist;
// ---- push away from other skaters --------------------------------------
// Box3D resolves the actual bump; this only stops the bots from queueing up
// to walk through each other, which looks like a bug even when it isn't.
//
// Chasing a loose puck is the exception, and an important one: at full
// avoidance six skaters converging on the same puck politely peel off before
// they ever touch, and the entire hit system never fires in normal play.
// Competing for a puck means being willing to skate into somebody.
const avoid = play?.puck && play.chaser?.[s.team] === s.id ? AI.avoidChasing : AI.avoidGain;
for (const o of others) {
if (o === s) continue;
const ox = s.x - o.x;
const oz = s.z - o.z;
const d = Math.hypot(ox, oz);
if (d >= AI.personalSpace || d < 1e-4) continue;
const w = (1 - d / AI.personalSpace) * avoid;
dx += (ox / d) * w;
dz += (oz / d) * w;
}
// ---- push away from the boards -----------------------------------------
// Checked against where they will be, not where they are: on ice, noticing
// the boards at arm's length is already too late.
const ahead = AI.boardLookahead;
const pen = rinkPenetration(s.x + s.vx * ahead, s.z + s.vz * ahead, SKATE.radius);
if (pen.dist > -AI.boardMargin) {
const w = clamp((pen.dist + AI.boardMargin) / AI.boardMargin, 0, 1) * AI.boardGain;
dx += pen.nx * w;
dz += pen.nz * w;
}
// ---- wobble + normalise -------------------------------------------------
brain.wobblePhase += brain.wobbleRate * dt;
const wob = Math.sin(brain.wobblePhase) * 0.12;
const len = Math.hypot(dx, dz) || 1e-6;
const yaw = Math.atan2(dx / len, dz / len) + wob;
s.ix = Math.sin(yaw);
s.iz = Math.cos(yaw);
// Ease off approaching the waypoint, and only sprint on the long straights.
// Chasing a puck is the exception: you do not coast in on a loose puck, you
// get there first, so the ease and the arrival brake are both dropped.
const chasing = !!play?.puck && play.chaser?.[s.team] === s.id;
const ease = chasing ? 1 : clamp(dist / AI.easeR, 0.25, 1);
s.ix *= ease;
s.iz *= ease;
s.sprint = chasing ? dist > 2 : (dist > AI.easeR * 1.5 && brain.eagerness > 0.5);
// Hard stop rather than a lazy drift when arriving hot.
s.brake = !chasing && dist < AI.arriveR * 1.4 && speedOf(s) > 4.5;
}
/**
* Starting lineup: each team in its own half, everyone facing centre ice.
*
* Laid out like a faceoff rather than scattered at random, because that is the
* arrangement every later spike starts from — drop a puck at centre and this is
* already the right picture. Ordered team by team, so index `i` belongs to
* team `Math.floor(i / perTeam)` and the returned `team` field agrees.
*
* The depth pattern is one skater up, the rest spread behind: with three a side
* that reads as a forward and two defenders without encoding any positional
* rules the game does not have yet.
*/
export function spawnLineup(perTeam = 3, teams = 2) {
const out = [];
for (let t = 0; t < teams; t++) {
// Team 0 defends the -X end, team 1 the +X end.
const sign = t === 0 ? -1 : 1;
for (let i = 0; i < perTeam; i++) {
const lead = i === 0;
const x = sign * (lead ? RINK.halfX * 0.18 : RINK.halfX * 0.42);
// Fan the back line across the width; a single one sits on the centre.
const back = perTeam > 1 ? (i - 1) / Math.max(1, perTeam - 2) : 0.5;
const z = lead ? 0 : (back * 2 - 1) * RINK.halfZ * 0.55;
out.push({ x, z, yaw: Math.atan2(-x, -z), team: t });
}
}
return out;
}
+165
View File
@@ -0,0 +1,165 @@
/**
* Body style — three appearance sliders inspired by dreamfall simhuman
* global morphs (mass / muscle / fat).
*
* Plain numbers only: Party, C path, and client mesh build all share this.
* Ranges match vibe-human MODELING_CONTROLS for body.global.*:
* mass 1 lean … +1 heavy
* muscle 1 soft … +1 muscular
* fat 0 base … +1 fat (no negative target in the reference)
*/
export const BODY_STYLE_DEFAULTS = Object.freeze({
mass: 0,
muscle: 0,
fat: 0,
});
/** Slider metadata for the kit chest UI. */
export const BODY_STYLE_SLIDERS = Object.freeze([
{
id: 'mass',
label: 'Mass',
min: -1,
max: 1,
step: 0.01,
left: 'Lean',
right: 'Heavy',
},
{
id: 'muscle',
label: 'Muscle',
min: -1,
max: 1,
step: 0.01,
left: 'Soft',
right: 'Muscular',
},
{
id: 'fat',
label: 'Fat',
min: 0,
max: 1,
step: 0.01,
left: 'Base',
right: 'Heavy',
},
]);
function clamp(v, lo, hi) {
const n = Number(v);
if (!Number.isFinite(n)) return lo;
return Math.max(lo, Math.min(hi, n));
}
/**
* Normalize a partial body bag. Missing keys become 0 (average build).
* @param {unknown} raw
* @returns {{ mass: number, muscle: number, fat: number }}
*/
export function normalizeBodyStyle(raw) {
if (!raw || typeof raw !== 'object') {
return { ...BODY_STYLE_DEFAULTS };
}
return {
mass: clamp(raw.mass, -1, 1),
muscle: clamp(raw.muscle, -1, 1),
fat: clamp(raw.fat, 0, 1),
};
}
/** True when every channel is at the neutral default. */
export function isDefaultBodyStyle(style) {
const s = normalizeBodyStyle(style);
return s.mass === 0 && s.muscle === 0 && s.fat === 0;
}
/**
* Compact wire form for full-roster snapshots (two decimals).
* @returns {{ m: number, u: number, f: number }}
*/
export function packBodyStyle(style) {
const s = normalizeBodyStyle(style);
return {
m: Math.round(s.mass * 100) / 100,
u: Math.round(s.muscle * 100) / 100,
f: Math.round(s.fat * 100) / 100,
};
}
/** Inverse of packBodyStyle — also accepts full { mass, muscle, fat }. */
export function unpackBodyStyle(raw) {
if (!raw || typeof raw !== 'object') return normalizeBodyStyle(null);
if ('mass' in raw || 'muscle' in raw || 'fat' in raw) {
return normalizeBodyStyle(raw);
}
return normalizeBodyStyle({
mass: raw.m,
muscle: raw.u,
fat: raw.f,
});
}
/**
* Ease unit strength so mid-slider stays mild and the last third punches
* harder into caricature (leaner / bulkier / more cut / fatter).
* @param {number} t 0..1
* @returns {number} 0..1
*/
function easeExtreme(t) {
const a = clamp(t, 0, 1);
// ~0.35 at half travel, 1.0 at the end — more of the gain sits near the stop.
return a * 0.35 + a * a * 0.25 + a * a * a * 0.4;
}
/** Signed ease: preserves direction, applies easeExtreme on |v|. */
function shapedSigned(v) {
if (v === 0) return 0;
return Math.sign(v) * easeExtreme(Math.abs(v));
}
/**
* Map mass/muscle/fat onto the loft physique scalars used by buildBodyGeometry.
*
* Ends of each slider push hard (ease-in + large peak gains). Mid values stay
* readable so average builds do not jump. Seeded rng still adds a small natural
* variation so two fighters with the same sliders are not voxel-identical.
*
* @param {{ mass: number, muscle: number, fat: number }} style
* @param {{ range: (a: number, b: number) => number }} rng
*/
export function physiqueFromBodyStyle(style, rng) {
const s = normalizeBodyStyle(style);
// Shaped channels: mild near 0, extreme at ±1 / 1.
const mass = shapedSigned(s.mass);
const muscle = shapedSigned(s.muscle);
const fat = easeExtreme(s.fat);
// Peak gains (at shaped = ±1 / 1) — roughly 2× the first-pass response so
// full lean / tank / soft / cut / fat reads clearly under armor.
// Overall girth: mass + fat dominate; muscle adds a little.
const bulkBase = 1 + mass * 0.32 + fat * 0.26 + muscle * 0.1;
// Waist: fat fills hard, muscle nips, mass thickens.
const waistBase = 1 + fat * 0.48 + mass * 0.2 - muscle * 0.18;
// Shoulders / chest: muscle primary.
const shoulderBase = 1 + muscle * 0.42 + mass * 0.16 + fat * 0.1;
// Arms: muscle, with a little mass/fat.
const armBase = 1 + muscle * 0.48 + mass * 0.14 + fat * 0.12;
// Legs: fat + mass, muscle secondary.
const legBase = 1 + fat * 0.36 + mass * 0.2 + muscle * 0.16;
// Head: slight only — keeps helm fit.
const headBase = 1 + mass * 0.06 + fat * 0.05;
const jitter = (base, lo = 0.97, hi = 1.03) => base * (rng?.range?.(lo, hi) ?? 1);
return {
bulk: jitter(bulkBase),
// Lower floors so full lean/soft can actually go thin.
waistF: Math.max(0.52, jitter(waistBase, 0.98, 1.02)),
shoulderF: Math.max(0.62, jitter(shoulderBase, 0.98, 1.02)),
armF: Math.max(0.58, jitter(armBase, 0.97, 1.03)),
legF: Math.max(0.6, jitter(legBase, 0.97, 1.03)),
headF: Math.max(0.85, jitter(headBase, 0.98, 1.02)),
style: s,
};
}
+102
View File
@@ -0,0 +1,102 @@
import { MARKINGS, RINK } from './rink.js';
/**
* The net, and what counts as a goal.
*
* Pure numbers and pure predicates, so the shootout logic and the tests can
* agree about scoring without a physics world in the room.
*/
/** Regulation: 6ft wide, 4ft tall, 44in deep. */
export const NET = Object.freeze({
width: 1.83,
height: 1.22,
depth: 1.12,
postRadius: 0.048,
/** Crease: 6ft radius arc off the goal line. */
creaseRadius: 1.83,
});
/**
* Goal line X for an end. `end` is +1 for the +X end, 1 for X.
* The net's mouth sits *on* this line, opening back toward centre ice.
*/
export const goalLineX = (end) => end * MARKINGS.goalLine;
/**
* Has the puck fully crossed the line, between the posts and under the bar?
*
* "Fully" is the rule and it matters: a puck resting on the line is not a goal.
* The whole puck has to be past, so the test is against the puck's leading edge
* — its centre plus its radius.
*/
export function isGoal(puck, end, puckRadius = 0.0381) {
const line = goalLineX(end);
// Leading edge past the line, travelling into the net.
const past = end > 0 ? puck.x - puckRadius > line : puck.x + puckRadius < line;
if (!past) return false;
// ...but not out the back of it.
if (Math.abs(puck.x - line) > NET.depth) return false;
if (Math.abs(puck.z) > NET.width / 2 - puckRadius) return false;
return (puck.y ?? 0) < NET.height;
}
/** True once the puck is behind the goal line but wide or high — a miss. */
export function isWide(puck, end, puckRadius = 0.0381) {
const line = goalLineX(end);
const past = end > 0 ? puck.x - puckRadius > line : puck.x + puckRadius < line;
return past && !isGoal(puck, end, puckRadius);
}
/**
* Where a goalie should stand, given where the puck is.
*
* Angle play, which is the whole of goaltending positioning: stand on the line
* between the puck and the middle of the net, `depth` metres out from the goal
* line. Cover the angle and the shooter has nothing to shoot at; the rest is
* reflexes.
*
* Returns a point, clamped so the goalie never wanders past the posts by more
* than a pad's width — a goalie who chases the puck to the corner has left an
* open net, which reads as broken rather than as aggressive.
*/
export function goalieSpot(puck, end, depth = 0.55, out = { x: 0, z: 0 }) {
const line = goalLineX(end);
// Aim from the middle of the goal mouth toward the puck.
const dx = puck.x - line;
const dz = puck.z - 0;
const len = Math.hypot(dx, dz);
if (len < 1e-4) {
out.x = line - end * 0.1;
out.z = 0;
return out;
}
out.x = line + (dx / len) * depth;
out.z = (dz / len) * depth;
// Two clamps, and *both* were originally the wrong way round — between them
// they teleported the goalie onto the puck and then pinned them to the goal
// line, which threw away every bit of angle the depth was there to buy.
//
// "Out" means toward centre ice, which is decreasing x at the +X end. So
// coming out past the puck is `out.x < puck.x` there, and going behind the
// line is `out.x > line`.
if (end > 0 ? out.x < puck.x : out.x > puck.x) out.x = puck.x;
if (end > 0 ? out.x > line : out.x < line) out.x = line;
// Stay within the posts, plus a little for a pad sticking out.
const limit = NET.width / 2 + 0.22;
out.z = Math.max(-limit, Math.min(limit, out.z));
return out;
}
/** Centre ice, facing the end being shot at — where a shootout attempt starts. */
export function shootoutStart(end) {
return { x: 0, z: 0, yaw: end > 0 ? Math.PI / 2 : -Math.PI / 2 };
}
/** Is the puck still in a sensible place for a live attempt? */
export function attemptLive(puck, end) {
if (Math.abs(puck.z) > RINK.halfZ) return false;
// Past the goal line at that end, one way or another, ends it.
return end > 0 ? puck.x < goalLineX(end) + NET.depth : puck.x > goalLineX(end) - NET.depth;
}
+159
View File
@@ -0,0 +1,159 @@
/**
* Rink geometry.
*
* NHL dimensions in metres, kept as plain numbers with no three.js import so
* the sim, the tests and (later) a server can all agree on where the boards
* are without pulling in a renderer.
*
* The surface is a rounded rectangle: a `halfX` by `halfZ` box with the four
* corners replaced by quarter circles of radius `cornerR`. Every containment
* query in the game reduces to "how far outside that shape are you", so it
* lives here once as `rinkPenetration`.
*/
/** 200ft x 85ft, 28ft corner radius, 42in boards. */
export const RINK = Object.freeze({
halfX: 30.48, // length/2 — the long axis runs along X
halfZ: 12.95, // width/2
cornerR: 8.53,
boardHeight: 1.07,
/** Glass above the boards is visual only in this spike. */
glassHeight: 1.8,
});
/** Blue lines / centre line, as distances from centre ice along X. */
export const MARKINGS = Object.freeze({
blueLine: 7.77,
goalLine: 25.6,
faceoffCircleR: 4.57,
centreCircleR: 4.57,
faceoffDotX: 6.7,
faceoffDotZ: 6.7,
zoneDotX: 20.2,
});
/**
* Centre of the corner arc nearest (x, z), and the sign of the quadrant.
* Points outside the straight sections belong to exactly one corner.
*/
function cornerCentre(x, z, out) {
const sx = x >= 0 ? 1 : -1;
const sz = z >= 0 ? 1 : -1;
out.x = sx * (RINK.halfX - RINK.cornerR);
out.z = sz * (RINK.halfZ - RINK.cornerR);
return out;
}
const _c = { x: 0, z: 0 };
/**
* Signed distance from the rink's inner surface, plus the inward normal.
*
* Positive `dist` means the point is outside the playing surface by that much;
* `nx`/`nz` point back toward the ice. Returns the same object every call, so
* copy anything you need to keep.
*/
const _pen = { dist: 0, nx: 0, nz: 0 };
export function rinkPenetration(x, z, radius = 0) {
const ax = Math.abs(x);
const az = Math.abs(z);
const straightX = RINK.halfX - RINK.cornerR;
const straightZ = RINK.halfZ - RINK.cornerR;
if (ax <= straightX || az <= straightZ) {
// Straight section: whichever wall is closer wins. A point can only be
// outside one of them here, since the corners are handled below.
const overX = ax + radius - RINK.halfX;
const overZ = az + radius - RINK.halfZ;
if (overX >= overZ) {
_pen.dist = overX;
_pen.nx = x >= 0 ? -1 : 1;
_pen.nz = 0;
} else {
_pen.dist = overZ;
_pen.nx = 0;
_pen.nz = z >= 0 ? -1 : 1;
}
return _pen;
}
cornerCentre(x, z, _c);
const dx = x - _c.x;
const dz = z - _c.z;
const d = Math.hypot(dx, dz) || 1e-6;
_pen.dist = d + radius - RINK.cornerR;
_pen.nx = -dx / d;
_pen.nz = -dz / d;
return _pen;
}
/** True when a circle of `radius` at (x, z) is fully on the ice. */
export function insideRink(x, z, radius = 0) {
return rinkPenetration(x, z, radius).dist <= 0;
}
/**
* Push a body back inside the boards and kill the velocity going into them.
*
* Box3D owns board contact for anything with a proxy capsule; this is the
* headless fallback (tests, and any future server tick without a physics
* world) and a cheap safety net against a body escaping the world.
*
* `restitution` 0 is a dead thud, 1 a perfect bounce. Boards eat most of it.
*/
export function clampToRink(state, radius = 0.36, restitution = 0.18) {
const pen = rinkPenetration(state.x, state.z, radius);
if (pen.dist <= 0) return false;
state.x += pen.nx * pen.dist;
state.z += pen.nz * pen.dist;
const into = state.vx * pen.nx + state.vz * pen.nz;
if (into < 0) {
// Remove the inward-normal component, then add back a fraction reversed.
state.vx -= into * pen.nx * (1 + restitution);
state.vz -= into * pen.nz * (1 + restitution);
}
return true;
}
/**
* The board line as a closed polyline, counter-clockwise from the +X end.
*
* The physics boards and the rendered boards are both built from this, so the
* wall a skater bounces off is the wall they can see. `cornerSteps` is the
* number of segments each of the four corner arcs is cut into.
*/
export function rinkOutline(cornerSteps = 8) {
const sx = RINK.halfX - RINK.cornerR;
const sz = RINK.halfZ - RINK.cornerR;
const pts = [];
// Four corners, each an arc swept from its own quadrant, with the straight
// sections falling out as the gaps between consecutive arcs.
const corners = [
{ cx: sx, cz: sz, a0: 0 }, // +X +Z
{ cx: -sx, cz: sz, a0: Math.PI / 2 }, // -X +Z
{ cx: -sx, cz: -sz, a0: Math.PI }, // -X -Z
{ cx: sx, cz: -sz, a0: -Math.PI / 2 }, // +X -Z
];
for (const c of corners) {
for (let i = 0; i <= cornerSteps; i++) {
const a = c.a0 + (i / cornerSteps) * (Math.PI / 2);
pts.push({ x: c.cx + Math.cos(a) * RINK.cornerR, z: c.cz + Math.sin(a) * RINK.cornerR });
}
}
return pts;
}
/**
* A random point on the ice, inset from the boards.
* `rand` is any () => [0,1) so callers can keep it seeded.
*/
export function randomIcePoint(rand, inset = 3) {
for (let i = 0; i < 24; i++) {
const x = (rand() * 2 - 1) * (RINK.halfX - inset);
const z = (rand() * 2 - 1) * (RINK.halfZ - inset);
if (insideRink(x, z, inset)) return { x, z };
}
// Rejection sampling in a rounded rect basically never fails, but never
// hand back an off-ice waypoint if it does.
return { x: 0, z: 0 };
}
+29
View File
@@ -0,0 +1,29 @@
/** Scalar helpers shared by the sim and the renderer. No three.js here. */
export const clamp = (x, a, b) => (x < a ? a : x > b ? b : x);
export const lerp = (a, b, t) => a + (b - a) * t;
export const smooth = (t) => t * t * (3 - 2 * t);
/** Wrap to (-PI, PI]. */
export function wrapAngle(a) {
let x = a;
while (x > Math.PI) x -= Math.PI * 2;
while (x <= -Math.PI) x += Math.PI * 2;
return x;
}
/** Shortest-arc interpolation between two headings. */
export function lerpAngle(a, b, t) {
return a + wrapAngle(b - a) * t;
}
/** Move `from` toward `to` by at most `step`, without overshooting. */
export function approach(from, to, step) {
return Math.abs(to - from) <= step ? to : from + Math.sign(to - from) * step;
}
/** Same, on the circle. */
export function approachAngle(from, to, step) {
const d = wrapAngle(to - from);
return Math.abs(d) <= step ? wrapAngle(to) : wrapAngle(from + Math.sign(d) * step);
}
+215
View File
@@ -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);