Initial commit
This commit is contained in:
+199
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user