362 lines
12 KiB
JavaScript
362 lines
12 KiB
JavaScript
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');
|