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
+361
View File
@@ -0,0 +1,361 @@
import { PAD, createInput, stickToWorld } from '../src/game/input.js';
import { createSkaterState, stepSkater } from '../shared/skaterSim.js';
import { done, near, ok, section } from './harness.mjs';
/**
* A fake window and a fake gamepad, so the pad layer can be tested without a
* browser or a pad. The Gamepad API is polled, not evented, which makes it
* unusually easy to stand in for.
*/
function fakePad(overrides = {}) {
const buttons = Array.from({ length: 17 }, () => ({ pressed: false, value: 0 }));
return {
index: 0,
id: 'Xbox Wireless Controller (STANDARD GAMEPAD)',
connected: true,
mapping: 'standard',
axes: [0, 0, 0, 0],
buttons,
...overrides,
};
}
/**
* Node exposes `navigator` as a getter-only global, so it has to be replaced
* with defineProperty rather than assigned. Both globals are restored after
* each case so one test cannot leak a fake pad into the next.
*/
function stubGlobal(name, value) {
const had = Object.getOwnPropertyDescriptor(globalThis, name);
Object.defineProperty(globalThis, name, { value, configurable: true, writable: true });
return () => {
if (had) Object.defineProperty(globalThis, name, had);
else delete globalThis[name];
};
}
function harness() {
const listeners = new Map();
const fakeWindow = {
addEventListener: (t, fn) => listeners.set(t, fn),
removeEventListener: () => {},
};
const pad = fakePad();
const restoreNav = stubGlobal('navigator', { getGamepads: () => [pad] });
const restoreWin = stubGlobal('window', fakeWindow);
const input = createInput(fakeWindow);
return {
input,
pad,
listeners,
press: (i, value = 1) => { pad.buttons[i] = { pressed: value > 0.5, value }; },
release: (i) => { pad.buttons[i] = { pressed: false, value: 0 }; },
restore: () => {
restoreWin();
restoreNav();
},
};
}
/**
* Camera-relative steering.
*
* Worth its own file because the failure mode is silent and infuriating:
* a sign flip here means pushing the stick forward sends the skater backwards
* only when the camera happens to be on a particular side, which is very easy
* to mistake for a physics bug.
*
* The convention under test: the camera orbits at `cameraYaw`, sitting at
* +(sin, cos) from its target, so "away from the camera" is -(sin, cos).
*/
const DT = 1 / 120;
/** Angle between two XZ directions, radians. */
function angleBetween(ax, az, bx, bz) {
const dot = (ax * bx + az * bz) / (Math.hypot(ax, az) * Math.hypot(bx, bz));
return Math.acos(Math.max(-1, Math.min(1, dot)));
}
section('pushing forward always means away from the camera');
{
for (const yaw of [0, 0.7, Math.PI / 2, 2.5, Math.PI, -1.2, -Math.PI / 2]) {
const w = stickToWorld({ x: 0, y: 1 }, yaw);
// The camera sits at +(sin, cos) * distance from its target, so away from
// it is the negative of that.
near(w.ix, -Math.sin(yaw), 1e-12, `yaw ${yaw.toFixed(2)}: forward is away from the camera (x)`);
near(w.iz, -Math.cos(yaw), 1e-12, `yaw ${yaw.toFixed(2)}: forward is away from the camera (z)`);
}
}
section('the four directions are square to each other');
{
for (const yaw of [0, 1.1, -2.2, Math.PI]) {
const f = stickToWorld({ x: 0, y: 1 }, yaw);
const b = stickToWorld({ x: 0, y: -1 }, yaw);
const r = stickToWorld({ x: 1, y: 0 }, yaw);
const l = stickToWorld({ x: -1, y: 0 }, yaw);
near(angleBetween(f.ix, f.iz, r.ix, r.iz), Math.PI / 2, 1e-9, `yaw ${yaw.toFixed(1)}: right is 90° from forward`);
near(angleBetween(f.ix, f.iz, b.ix, b.iz), Math.PI, 1e-9, `yaw ${yaw.toFixed(1)}: back is opposite forward`);
near(angleBetween(r.ix, r.iz, l.ix, l.iz), Math.PI, 1e-9, `yaw ${yaw.toFixed(1)}: left is opposite right`);
// Right must be to the camera's right, not its left. Cross product of
// forward x right about +Y is negative for a correct right-handed frame.
const cross = f.ix * r.iz - f.iz * r.ix;
ok(cross > 0, `yaw ${yaw.toFixed(1)}: "right" is on the camera's right, not its left`);
}
}
section('magnitude survives the transform');
{
for (const yaw of [0, 0.9, -1.7]) {
for (const stick of [{ x: 1, y: 0 }, { x: 0, y: 1 }, { x: 0.6, y: 0.8 }, { x: 0.3, y: -0.2 }]) {
const w = stickToWorld(stick, yaw);
near(
Math.hypot(w.ix, w.iz),
Math.hypot(stick.x, stick.y),
1e-12,
`yaw ${yaw.toFixed(1)}: a rotation does not change stick magnitude`,
);
}
}
}
section('a centred stick produces no intent');
{
for (const yaw of [0, 1.4, -2.9]) {
const w = stickToWorld({ x: 0, y: 0 }, yaw);
near(w.ix, 0, 1e-12, 'centred stick, no x');
near(w.iz, 0, 1e-12, 'centred stick, no z');
}
}
section('holding forward drives the skater away from the camera');
{
// The end-to-end claim: stick + sim together move the body where the player
// expects, from any camera angle and any starting facing.
for (const cameraYaw of [0, 1.0, -2.0, Math.PI]) {
const s = createSkaterState(0, { x: 0, z: 0, yaw: 2.3 }); // facing anywhere
const w = stickToWorld({ x: 0, y: 1 }, cameraYaw);
for (let n = 0; n < 3 / DT; n++) {
s.ix = w.ix;
s.iz = w.iz;
s.sprint = true;
stepSkater(s, DT, { clampBoards: false });
}
const travelled = angleBetween(s.x, s.z, w.ix, w.iz);
ok(
travelled < 0.2,
`camera ${cameraYaw.toFixed(1)}: skater ended up where the stick pointed (${travelled.toFixed(3)} rad off)`,
);
ok(Math.hypot(s.x, s.z) > 8, 'and actually covered ground');
}
}
section('the skater turns to face the stick regardless of where they started');
{
for (const startYaw of [0, 2.0, -2.0, Math.PI]) {
const s = createSkaterState(0, { x: 0, z: 0, yaw: startYaw });
const w = stickToWorld({ x: 0, y: 1 }, 0); // away from a camera at yaw 0
for (let n = 0; n < 2 / DT; n++) {
s.ix = w.ix;
s.iz = w.iz;
stepSkater(s, DT, { clampBoards: false });
}
const want = Math.atan2(w.ix, w.iz);
const off = Math.abs(Math.atan2(Math.sin(s.yaw - want), Math.cos(s.yaw - want)));
ok(off < 0.25, `from yaw ${startYaw.toFixed(1)}: came round to face the stick (${off.toFixed(3)} rad off)`);
}
}
section('the pad reads as an Xbox controller');
{
const h = harness();
const s = h.input.read(1 / 60);
ok(h.input.connected, 'a connected pad is found even without a connect event');
ok(s.padId.includes('Xbox'), `and identifies itself (${s.padId})`);
near(s.x, 0, 1e-9, 'a resting stick is centred (x)');
near(s.y, 0, 1e-9, 'a resting stick is centred (y)');
ok(!s.sprint && !s.brake, 'and nothing is pressed');
h.restore();
}
section('sticks have a radial deadzone and correct signs');
{
const h = harness();
h.pad.axes = [0.1, -0.1, 0, 0];
let s = h.input.read(1 / 60);
near(s.x, 0, 1e-9, 'a small drift is inside the deadzone');
near(s.y, 0, 1e-9, 'on both axes');
// Pad Y is positive *downward*, so pushing up must come out positive.
h.pad.axes = [0, -1, 0, 0];
s = h.input.read(1 / 60);
ok(s.y > 0.9, `pushing the stick up is positive y (${s.y.toFixed(2)})`);
near(s.x, 0, 1e-9, 'and no x');
h.pad.axes = [1, 0, 0, 0];
s = h.input.read(1 / 60);
ok(s.x > 0.9, `pushing right is positive x (${s.x.toFixed(2)})`);
// Full diagonal must not exceed unit length, or diagonals are faster.
h.pad.axes = [1, -1, 0, 0];
s = h.input.read(1 / 60);
ok(Math.hypot(s.x, s.y) <= 1.0001, `a full diagonal stays on the unit circle (${Math.hypot(s.x, s.y).toFixed(3)})`);
// The right stick is axes 2/3 and must not be confused with the left.
h.pad.axes = [0, 0, 0, -1];
s = h.input.read(1 / 60);
near(s.x, 0, 1e-9, 'the right stick does not move the skater');
ok(s.skill.y > 0.9, `and lands on the Skill Stick (${s.skill.y.toFixed(2)})`);
h.restore();
}
section('triggers are analog, not boolean');
{
const h = harness();
h.press(PAD.RT, 0.3);
let s = h.input.read(1 / 60);
ok(s.hustle > 0.2 && s.hustle < 0.4, `a light pull is a light hustle (${s.hustle.toFixed(2)})`);
ok(!s.sprint, 'and does not trip the sprint stride');
h.press(PAD.RT, 1);
s = h.input.read(1 / 60);
near(s.hustle, 1, 1e-9, 'a full pull is full hustle');
ok(s.sprint, 'and does trip the sprint stride');
h.press(PAD.LT, 1);
s = h.input.read(1 / 60);
ok(s.brake, 'the left trigger stops');
ok(s.protect > 0.9, `and reports analog (${s.protect.toFixed(2)})`);
h.restore();
}
section('buttons report as actions, and only on the edge');
{
const h = harness();
h.input.read(1 / 60);
h.press(PAD.A);
let s = h.input.read(1 / 60);
ok(s.pressed.pass, 'A is a pass');
ok(s.held.pass, 'and is held');
s = h.input.read(1 / 60);
ok(!s.pressed.pass, 'holding it does not re-fire the press');
ok(s.held.pass, 'but it is still held');
h.release(PAD.A);
h.press(PAD.B);
s = h.input.read(1 / 60);
ok(!s.held.pass, 'releasing clears held');
ok(s.pressed.poke, 'B is a poke check');
h.release(PAD.B);
h.press(PAD.LB);
s = h.input.read(1 / 60);
ok(s.pressed.switchPlayer, 'LB switches player');
h.restore();
}
section('the Skill Stick fires a shot on pull-back-and-push');
{
const h = harness();
const dt = 1 / 60;
h.input.read(dt);
// Pull back and hold, which should charge but not fire.
h.pad.axes = [0, 0, 0, 1]; // pad Y down = stick pulled back
let s;
for (let i = 0; i < 20; i++) s = h.input.read(dt);
ok(s.shot === null, 'holding the stick back does not fire');
ok(s.charge > 0.4, `it winds up instead (${s.charge.toFixed(2)})`);
// Push forward: release.
h.pad.axes = [0, 0, 0, -1];
s = h.input.read(dt);
ok(s.shot, 'pushing forward releases the shot');
ok(s.shot.power > 0.5, `with real power after a long wind-up (${s.shot.power.toFixed(2)})`);
near(s.charge, 0, 1e-9, 'and the wind-up is spent');
s = h.input.read(dt);
ok(s.shot === null, 'the shot fires once, not every frame after');
h.restore();
}
section('a quick flick is a weaker shot than a full wind-up');
{
function fire(windFrames) {
const h = harness();
const dt = 1 / 60;
h.input.read(dt);
h.pad.axes = [0, 0, 0, 1];
for (let i = 0; i < windFrames; i++) h.input.read(dt);
h.pad.axes = [0, 0, 0, -1];
const s = h.input.read(dt);
h.restore();
return s.shot;
}
const flick = fire(2);
const loaded = fire(40);
ok(flick, 'a flick still fires');
ok(loaded, 'and so does a full wind-up');
ok(loaded.power > flick.power, `holding longer hits harder (${loaded.power.toFixed(2)} vs ${flick.power.toFixed(2)})`);
ok(flick.power >= 0.25, `but a snap shot is never nothing (${flick.power.toFixed(2)})`);
}
section('an abandoned wind-up is forgotten, not banked');
{
const h = harness();
const dt = 1 / 60;
h.input.read(dt);
h.pad.axes = [0, 0, 0, 1];
for (let i = 0; i < 8; i++) h.input.read(dt);
// Let go back to centre and wait it out.
h.pad.axes = [0, 0, 0, 0];
let s;
for (let i = 0; i < 150; i++) s = h.input.read(dt);
ok(s.shot === null, 'nothing fired from a wind-up left to rot');
near(s.charge, 0, 1e-9, 'and the charge decayed away');
h.restore();
}
section('the shot carries aim from the stick');
{
const h = harness();
const dt = 1 / 60;
h.input.read(dt);
h.pad.axes = [0, 0, 0.8, 1]; // wound back, stick held to the right
for (let i = 0; i < 20; i++) h.input.read(dt);
h.pad.axes = [0, 0, 0.8, -1];
const s = h.input.read(dt);
ok(s.shot, 'the shot fired');
ok(s.shot.aim > 0.5, `and remembers it was aimed right (${s.shot.aim.toFixed(2)})`);
h.restore();
}
section('the shoot button works for anyone who never learns the Skill Stick');
{
const h = harness();
h.input.read(1 / 60);
h.press(PAD.X);
const s = h.input.read(1 / 60);
ok(s.shot, 'X shoots');
ok(s.shot.power > 0 && s.shot.power <= 1, `at a sensible power (${s.shot.power.toFixed(2)})`);
h.restore();
}
section('rumble never throws, whatever the pad supports');
{
const h = harness();
ok(h.input.rumble(1, 1, 100) === false, 'a pad without haptics reports no rumble rather than crashing');
h.pad.vibrationActuator = { playEffect: () => Promise.resolve('complete') };
ok(h.input.rumble(1, 1, 100) === true, 'and a pad with them reports success');
h.pad.vibrationActuator = { playEffect: () => { throw new Error('nope'); } };
ok(h.input.rumble(1, 1, 100) === false, 'a throwing actuator is swallowed');
h.restore();
}
done('input');