275 lines
9.3 KiB
JavaScript
275 lines
9.3 KiB
JavaScript
import { SKATE, applyIntent, createSkaterState, speedOf, stepSkater } from '../shared/skaterSim.js';
|
|
import { RINK, insideRink } from '../shared/rink.js';
|
|
import {
|
|
normalizePlayer, normalizeShotSide, packPlayer, rollShotSide, shotSign, unpackPlayer,
|
|
} from '../shared/player.js';
|
|
import { done, near, ok, section } from './harness.mjs';
|
|
|
|
const DT = 1 / 120;
|
|
|
|
/**
|
|
* Run the sim for `seconds`, optionally editing the state each step.
|
|
*
|
|
* Board clamping is off by default so a test about acceleration is not
|
|
* secretly a test about the end boards. The containment section turns it back
|
|
* on, which is the only place it is the subject.
|
|
*/
|
|
function run(s, seconds, edit = null, opts = { clampBoards: false }) {
|
|
const steps = Math.round(seconds / DT);
|
|
for (let i = 0; i < steps; i++) {
|
|
if (edit) edit(s, i * DT);
|
|
stepSkater(s, DT, opts);
|
|
}
|
|
return s;
|
|
}
|
|
|
|
/** Down the ice: the rink's long axis is +X, which is yaw = PI/2. */
|
|
const START = { x: 0, z: 0, yaw: Math.PI / 2 };
|
|
const forward = (s) => {
|
|
s.ix = 1;
|
|
s.iz = 0;
|
|
};
|
|
const coast = (s) => {
|
|
s.ix = 0;
|
|
s.iz = 0;
|
|
};
|
|
|
|
section('the stride reaches a speed and holds it');
|
|
{
|
|
const s = createSkaterState(0, START);
|
|
run(s, 8, forward);
|
|
const cruise = speedOf(s);
|
|
ok(cruise > 4.5, `cruise settles above 4.5 m/s (got ${cruise.toFixed(2)})`);
|
|
ok(cruise <= SKATE.cruiseSpeed, `and never exceeds the cruise ceiling (${cruise.toFixed(2)})`);
|
|
|
|
// Another four seconds must not keep adding speed.
|
|
const before = speedOf(s);
|
|
run(s, 4, forward);
|
|
near(speedOf(s), before, 0.05, 'top speed is stable, not creeping');
|
|
}
|
|
|
|
section('acceleration takes time — you cannot jump to top speed');
|
|
{
|
|
const s = createSkaterState(0, START);
|
|
run(s, 0.5, forward);
|
|
const half = speedOf(s);
|
|
ok(half > 0.8, `half a second of pushing gets you moving (${half.toFixed(2)} m/s)`);
|
|
ok(half < 4, 'but nowhere near cruise');
|
|
}
|
|
|
|
section('sprinting is meaningfully faster');
|
|
{
|
|
const cruiser = createSkaterState(0, START);
|
|
run(cruiser, 8, forward);
|
|
const sprinter = createSkaterState(1, START);
|
|
run(sprinter, 8, (s) => {
|
|
forward(s);
|
|
s.sprint = true;
|
|
});
|
|
ok(
|
|
speedOf(sprinter) > speedOf(cruiser) + 1.5,
|
|
`sprint beats cruise by more than 1.5 m/s (${speedOf(sprinter).toFixed(2)} vs ${speedOf(cruiser).toFixed(2)})`,
|
|
);
|
|
ok(speedOf(sprinter) <= SKATE.sprintSpeed, 'and stays under the sprint ceiling');
|
|
}
|
|
|
|
section('a glide keeps its momentum');
|
|
{
|
|
const s = createSkaterState(0, START);
|
|
run(s, 8, forward);
|
|
const entry = speedOf(s);
|
|
run(s, 3, coast);
|
|
const after = speedOf(s);
|
|
ok(after > entry * 0.6, `three seconds of glide keeps most of the speed (${after.toFixed(2)} of ${entry.toFixed(2)})`);
|
|
ok(after < entry, 'but not all of it');
|
|
}
|
|
|
|
section('braking is much faster than gliding');
|
|
{
|
|
const glide = createSkaterState(0, START);
|
|
run(glide, 8, forward);
|
|
const brake = createSkaterState(1, START);
|
|
run(brake, 8, forward);
|
|
near(speedOf(glide), speedOf(brake), 0.01, 'both start from the same speed');
|
|
|
|
run(glide, 1, coast);
|
|
run(brake, 1, (s) => {
|
|
coast(s);
|
|
s.brake = true;
|
|
});
|
|
ok(speedOf(brake) < 0.6, `a hockey stop is done inside a second (${speedOf(brake).toFixed(2)} m/s left)`);
|
|
ok(speedOf(glide) > speedOf(brake) * 4, 'a glide over the same second is nowhere near stopped');
|
|
}
|
|
|
|
section('the blade kills sideways drift');
|
|
{
|
|
const s = createSkaterState(0, START);
|
|
// Thrown across the blade at 4 m/s: body pointing +X, momentum along +Z.
|
|
s.vx = 0;
|
|
s.vz = 4;
|
|
run(s, 1.5, coast);
|
|
const velYaw = Math.atan2(s.vx, s.vz);
|
|
const offBlade = Math.abs(Math.abs(velYaw) - Math.PI / 2);
|
|
ok(offBlade < 0.25, `momentum ends up along the blade, not across it (${offBlade.toFixed(3)} rad off)`);
|
|
}
|
|
|
|
section('a carve redirects momentum instead of destroying it');
|
|
{
|
|
const s = createSkaterState(0, START);
|
|
run(s, 6, forward);
|
|
const entry = speedOf(s);
|
|
ok(Math.abs(Math.atan2(s.vx, s.vz) - Math.PI / 2) < 0.05, 'travelling straight down the ice first');
|
|
|
|
// Ninety degrees of turn: the stick swings from +X to -Z.
|
|
run(s, 1.2, (st) => {
|
|
st.ix = 0;
|
|
st.iz = -1;
|
|
});
|
|
|
|
const velYaw = Math.atan2(s.vx, s.vz);
|
|
ok(velYaw > 2.2, `the velocity vector followed the turn round (${velYaw.toFixed(2)} rad, want ~PI)`);
|
|
ok(speedOf(s) > entry * 0.4, `and kept real speed through it (${speedOf(s).toFixed(2)} of ${entry.toFixed(2)})`);
|
|
ok(speedOf(s) < entry, 'a hard carve is not free');
|
|
}
|
|
|
|
section('momentum resists an instant reversal');
|
|
{
|
|
const s = createSkaterState(0, START);
|
|
run(s, 6, forward);
|
|
const entryX = s.vx;
|
|
ok(entryX > 3, 'moving down the ice to begin with');
|
|
|
|
// A tenth of a second of "go back the other way" must not flip the velocity.
|
|
run(s, 0.1, (st) => {
|
|
st.ix = -1;
|
|
st.iz = 0;
|
|
});
|
|
ok(s.vx > 0, 'still travelling the original way a tenth of a second later');
|
|
ok(s.vx < entryX, 'but already losing speed to the edges');
|
|
}
|
|
|
|
section('a turn on the spot costs nothing');
|
|
{
|
|
const s = createSkaterState(0, { x: 0, z: 0, yaw: 0 });
|
|
run(s, 0.6, (st) => {
|
|
st.ix = 1;
|
|
st.iz = 0;
|
|
});
|
|
ok(Math.abs(s.yaw - Math.PI / 2) < 0.35, `a standing skater can pivot (yaw ${s.yaw.toFixed(2)})`);
|
|
}
|
|
|
|
section('turning is harder at speed than at rest');
|
|
{
|
|
const slow = createSkaterState(0, { x: 0, z: 0, yaw: 0 });
|
|
run(slow, 0.3, (st) => {
|
|
st.ix = 1;
|
|
st.iz = 0;
|
|
});
|
|
const fast = createSkaterState(1, { x: 0, z: 0, yaw: 0 });
|
|
run(fast, 6, (st) => {
|
|
st.ix = 0;
|
|
st.iz = 1;
|
|
st.sprint = true;
|
|
});
|
|
const before = fast.yaw;
|
|
run(fast, 0.3, (st) => {
|
|
st.ix = 1;
|
|
st.iz = 0;
|
|
st.sprint = true;
|
|
});
|
|
ok(
|
|
Math.abs(fast.yaw - before) < Math.abs(slow.yaw),
|
|
`a flying skater turns slower than a standing one (${(fast.yaw - before).toFixed(3)} vs ${slow.yaw.toFixed(3)} rad)`,
|
|
);
|
|
}
|
|
|
|
section('nothing leaves the rink');
|
|
{
|
|
// Point skaters at the boards from centre ice and hold it for ten seconds.
|
|
for (let i = 0; i < 16; i++) {
|
|
const a = (i / 16) * Math.PI * 2;
|
|
const s = createSkaterState(i, { x: 0, z: 0, yaw: a });
|
|
run(s, 10, (st) => {
|
|
st.ix = Math.sin(a);
|
|
st.iz = Math.cos(a);
|
|
st.sprint = true;
|
|
}, { clampBoards: true });
|
|
ok(insideRink(s.x, s.z, SKATE.radius), `skater driving at heading ${a.toFixed(2)} stayed on the ice`);
|
|
ok(Number.isFinite(s.x) && Number.isFinite(s.z), 'and its position stayed finite');
|
|
}
|
|
}
|
|
|
|
section('the sim is deterministic');
|
|
{
|
|
const drive = (st, t) => {
|
|
st.ix = Math.sin(t * 1.3);
|
|
st.iz = Math.cos(t * 0.7);
|
|
st.sprint = t > 3;
|
|
};
|
|
const a = createSkaterState(0, { x: 4, z: -6, yaw: 1 });
|
|
const b = createSkaterState(0, { x: 4, z: -6, yaw: 1 });
|
|
run(a, 12, drive, { clampBoards: true });
|
|
run(b, 12, drive, { clampBoards: true });
|
|
near(a.x, b.x, 0, 'same inputs, same x');
|
|
near(a.z, b.z, 0, 'same inputs, same z');
|
|
near(a.yaw, b.yaw, 0, 'same inputs, same yaw');
|
|
}
|
|
|
|
section('intent from a controller is clamped before the sim sees it');
|
|
{
|
|
// This is the seam a gamepad or a network message will come in through, so
|
|
// it has to survive garbage without the sim ever seeing it.
|
|
const s = createSkaterState(0, START);
|
|
|
|
applyIntent(s, { ix: 1, iz: 1 });
|
|
near(Math.hypot(s.ix, s.iz), 1, 1e-9, 'a diagonal stick is normalised, not sqrt(2) fast');
|
|
|
|
applyIntent(s, { ix: 0.3, iz: -0.4 });
|
|
near(s.ix, 0.3, 1e-9, 'a stick inside the deadzone circle is left alone (x)');
|
|
near(s.iz, -0.4, 1e-9, 'a stick inside the deadzone circle is left alone (z)');
|
|
|
|
applyIntent(s, { ix: 40, iz: -40 });
|
|
ok(Math.hypot(s.ix, s.iz) <= 1 + 1e-9, 'an out-of-range stick is clamped');
|
|
|
|
applyIntent(s, { ix: NaN, iz: undefined, sprint: 'yes', brake: 0 });
|
|
ok(s.ix === 0 && s.iz === 0, 'NaN and undefined become a centred stick');
|
|
ok(s.sprint === true && s.brake === false, 'and the flags come through as booleans');
|
|
|
|
// The clamped state must still step without producing garbage.
|
|
stepSkater(s, DT);
|
|
|
|
ok(Number.isFinite(s.x) && Number.isFinite(s.vx), 'and the sim steps cleanly afterwards');
|
|
}
|
|
|
|
section('rink dimensions are the ones we think they are');
|
|
{
|
|
near(RINK.halfX * 2, 60.96, 0.01, 'the rink is 200 feet long');
|
|
near(RINK.halfZ * 2, 25.9, 0.02, 'and 85 feet wide');
|
|
}
|
|
|
|
section('player definition carries shot side');
|
|
{
|
|
ok(normalizeShotSide('left') === 'left', 'left stays left');
|
|
ok(normalizeShotSide('L') === 'left', 'L is left');
|
|
ok(normalizeShotSide(-1) === 'left', '-1 is left');
|
|
ok(normalizeShotSide('right') === 'right', 'right stays right');
|
|
ok(normalizeShotSide('R') === 'right', 'R is right');
|
|
ok(normalizeShotSide(undefined) === 'right', 'missing defaults to right (authored side)');
|
|
ok(shotSign('left') === -1 && shotSign('right') === 1, 'shotSign is ±1');
|
|
ok(rollShotSide(0.1) === 'left' && rollShotSide(0.9) === 'right', 'roll respects the NHL-ish split');
|
|
|
|
const packed = packPlayer({ shotSide: 'left' });
|
|
ok(packed.ss === 'L', 'pack uses a short wire form');
|
|
ok(unpackPlayer(packed).shotSide === 'left', 'unpack restores shot side');
|
|
ok(normalizePlayer({ shotSide: 'left' }).shotSide === 'left', 'normalizePlayer keeps shot side');
|
|
|
|
const lefty = createSkaterState(0, START, { shotSide: 'left', name: 'Lefty' });
|
|
const righty = createSkaterState(1, START, { shotSide: 'right' });
|
|
const plain = createSkaterState(2, START);
|
|
ok(lefty.shotSide === 'left', 'state stores left shot');
|
|
ok(righty.shotSide === 'right', 'state stores right shot');
|
|
ok(plain.shotSide === 'right', 'state defaults to right shot');
|
|
}
|
|
|
|
done('skaterSim');
|