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');