diff --git a/src/platform/input/game-pads.js b/src/platform/input/game-pads.js index 3d4ce1c7b2e..2bf3767a9dd 100644 --- a/src/platform/input/game-pads.js +++ b/src/platform/input/game-pads.js @@ -6,19 +6,19 @@ import { platform } from '../../core/platform.js'; const dummyArray = Object.freeze([]); /** - * Get Gamepads from API. + * Get Gamepads from API. The API is looked up on every call so that an implementation that + * appears (or is replaced, e.g. under test) after this module has loaded is still used. * - * @type {Function} - * @returns {Gamepad[]} Retrieved gamepads from the device. + * @returns {(Gamepad|null)[]} Retrieved gamepads from the device. */ -let getGamepads = function () { - return dummyArray; +const getGamepads = () => { + if (typeof navigator === 'undefined') { + return dummyArray; + } + const api = navigator.getGamepads || navigator.webkitGetGamepads; + return api ? api.call(navigator) : dummyArray; }; -if (typeof navigator !== 'undefined') { - getGamepads = (navigator.getGamepads || navigator.webkitGetGamepads || getGamepads).bind(navigator); -} - const MAPS_INDEXES = { buttons: { PAD_FACE_1, @@ -314,7 +314,8 @@ class GamePadButton { this.touched = current.touched ?? current.value > 0; } - if (previous) { + // A numeric previous value of 0 is a valid resting state, so only skip when omitted. + if (previous !== undefined) { if (typeof previous === 'number') { this.wasPressed = previous !== 1 && this.pressed; this.wasReleased = previous === 1 && !this.pressed; @@ -532,13 +533,21 @@ class GamePad { const previousAxes = this._previousAxes; const axes = this._axes; - // Store previous values for axes for dual buttons. - previousAxes.length = 0; - previousAxes.push(...axes); + // Store previous values for axes for dual buttons. Copy by index rather than spread so + // that no iterator or temporary array is created every frame. + const numAxes = axes.length; + for (let i = 0; i < numAxes; i++) { + previousAxes[i] = axes[i]; + } + previousAxes.length = numAxes; // Update axes - axes.length = 0; - axes.push(...gamepad.axes); + const newAxes = gamepad.axes; + const numNewAxes = newAxes.length; + for (let i = 0; i < numNewAxes; i++) { + axes[i] = newAxes[i]; + } + axes.length = numNewAxes; // Update buttons const buttons = this._buttons; @@ -911,7 +920,7 @@ class GamePads extends EventHandler { * @ignore */ update() { - this.poll(); + this._poll(null); } /** @@ -926,27 +935,36 @@ class GamePads extends EventHandler { * const pads = gamepads.poll(); */ poll(pads = []) { - if (pads.length > 0) { - pads.length = 0; - } + pads.length = 0; + this._poll(pads); + return pads; + } + /** + * Read the latest state of every connected device into its {@link GamePad}, creating a + * GamePad for any device seen for the first time. + * + * @param {GamePad[]|null} pads - An optional array that receives the polled gamepads. + * @private + */ + _poll(pads) { const padDevices = getGamepads(); for (let i = 0, len = padDevices.length; i < len; i++) { - if (padDevices[i]) { - const pad = this.findByIndex(padDevices[i].index); + const device = padDevices[i]; + if (device) { + let pad = this.findByIndex(device.index); if (pad) { - pads.push(pad.update(padDevices[i])); + pad.update(device); } else { - const nPad = new GamePad(padDevices[i], this.getMap(padDevices[i])); - this.current.push(nPad); - pads.push(nPad); + pad = new GamePad(device, this.getMap(device)); + this.current.push(pad); } + + pads?.push(pad); } } - - return pads; } /** @@ -1064,7 +1082,14 @@ class GamePads extends EventHandler { * @returns {GamePad|null} The {@link GamePad} with the matching device index or null if no gamepad is found or the gamepad is not connected. */ findByIndex(index) { - return this.current.find(gp => gp && gp.index === index) || null; + const current = this.current; + for (let i = 0, l = current.length; i < l; i++) { + const pad = current[i]; + if (pad && pad.index === index) { + return pad; + } + } + return null; } } diff --git a/test/platform/input/game-pads.test.mjs b/test/platform/input/game-pads.test.mjs new file mode 100644 index 00000000000..8505152611f --- /dev/null +++ b/test/platform/input/game-pads.test.mjs @@ -0,0 +1,223 @@ +import { expect } from 'chai'; + +import { PAD_1, PAD_FACE_1, PAD_RIGHT } from '../../../src/platform/input/constants.js'; +import { GamePad, GamePadButton, GamePads } from '../../../src/platform/input/game-pads.js'; +import { jsdomSetup, jsdomTeardown } from '../../jsdom.mjs'; + +// Minimal stand-in for a Gamepad API device object. +const createDevice = (index = 0, axes = [0, 0, 0, 0]) => ({ + id: 'Test Gamepad (STANDARD GAMEPAD)', + index, + mapping: 'standard', + connected: true, + buttons: Array.from({ length: 17 }, () => ({ value: 0, pressed: false, touched: false })), + axes +}); + +const press = (device, button) => { + device.buttons[button] = { value: 1, pressed: true, touched: true }; +}; + +const release = (device, button) => { + device.buttons[button] = { value: 0, pressed: false, touched: false }; +}; + +// Map a single synthesized button onto one axis so the previous-axes bookkeeping is +// observable through the public API. +const synthesizedMap = axis => ({ + buttons: [], + axes: [], + synthesizedButtons: { + PAD_RIGHT: { axis, min: 0, max: 1 } + } +}); + +describe('GamePads', function () { + + /** @type {GamePads} */ + let gamepads; + + /** @type {(object|null)[]} */ + let devices; + + /** @type {boolean} */ + let hadNavigator; + + beforeEach(function () { + jsdomSetup(); + devices = []; + + // Older Node runtimes have no global navigator, so borrow JSDOM's before installing + // the fake Gamepad API on it. + hadNavigator = 'navigator' in globalThis; + if (!hadNavigator) { + globalThis.navigator = window.navigator; + } + globalThis.navigator.getGamepads = () => devices; + + gamepads = new GamePads(); + }); + + afterEach(function () { + gamepads.destroy(); + delete globalThis.navigator.getGamepads; + if (!hadNavigator) { + delete globalThis.navigator; + } + jsdomTeardown(); + }); + + describe('#poll', function () { + + it('returns one GamePad per connected device and skips empty slots', function () { + devices = [createDevice(0), null, createDevice(2)]; + + const pads = gamepads.poll(); + + expect(pads).to.have.lengthOf(2); + expect(pads[0]).to.be.an.instanceOf(GamePad); + expect(pads.map(pad => pad.index)).to.deep.equal([0, 2]); + expect(gamepads.current).to.have.members(pads); + }); + + it('fills and returns the array passed in', function () { + devices = [createDevice(0)]; + const target = ['stale']; + + const pads = gamepads.poll(target); + + expect(pads).to.equal(target); + expect(target).to.have.lengthOf(1); + expect(target[0].index).to.equal(0); + }); + + it('reuses the GamePad instance for a device seen before', function () { + devices = [createDevice(0)]; + + const first = gamepads.poll()[0]; + const second = gamepads.poll()[0]; + + expect(second).to.equal(first); + expect(gamepads.current).to.have.lengthOf(1); + }); + + }); + + describe('#update', function () { + + it('adds a device the first time it is reported', function () { + gamepads.update(); + expect(gamepads.current).to.have.lengthOf(0); + + devices.push(createDevice(0)); + gamepads.update(); + + expect(gamepads.current).to.have.lengthOf(1); + expect(gamepads.current[0].index).to.equal(0); + }); + + it('updates existing pads in place and tracks button edges across frames', function () { + const device = createDevice(0); + devices = [device]; + gamepads.update(); + const pad = gamepads.current[0]; + + press(device, PAD_FACE_1); + gamepads.update(); + + expect(gamepads.current[0]).to.equal(pad); + expect(gamepads.isPressed(PAD_1, PAD_FACE_1)).to.be.true; + expect(gamepads.wasPressed(PAD_1, PAD_FACE_1)).to.be.true; + + gamepads.update(); + + expect(gamepads.isPressed(PAD_1, PAD_FACE_1)).to.be.true; + expect(gamepads.wasPressed(PAD_1, PAD_FACE_1)).to.be.false; + + release(device, PAD_FACE_1); + gamepads.update(); + + expect(gamepads.isPressed(PAD_1, PAD_FACE_1)).to.be.false; + expect(gamepads.wasReleased(PAD_1, PAD_FACE_1)).to.be.true; + }); + + it('keeps the previous axes so synthesized buttons report edges', function () { + const device = createDevice(0, [0, 0, 0, 0]); + devices = [device]; + gamepads.update(); + const pad = gamepads.current[0]; + pad.updateMap(synthesizedMap(0)); + + device.axes = [1, 0, 0, 0]; + gamepads.update(); + + expect(pad.isPressed(PAD_RIGHT)).to.be.true; + expect(pad.wasPressed(PAD_RIGHT)).to.be.true; + + gamepads.update(); + + expect(pad.isPressed(PAD_RIGHT)).to.be.true; + expect(pad.wasPressed(PAD_RIGHT)).to.be.false; + }); + + }); + +}); + +describe('GamePadButton', function () { + + describe('#constructor', function () { + + it('derives edge state from a numeric previous value of 0', function () { + const button = new GamePadButton(1, 0); + + expect(button.pressed).to.be.true; + expect(button.wasPressed).to.be.true; + expect(button.wasTouched).to.be.true; + expect(button.wasReleased).to.be.false; + }); + + it('reports a release from a numeric previous value of 1', function () { + const button = new GamePadButton(0, 1); + + expect(button.pressed).to.be.false; + expect(button.wasPressed).to.be.false; + expect(button.wasReleased).to.be.true; + }); + + it('reports no edges when no previous value is given', function () { + const button = new GamePadButton(1); + + expect(button.wasPressed).to.be.false; + expect(button.wasReleased).to.be.false; + }); + + }); + +}); + +describe('GamePad', function () { + + describe('#update', function () { + + it('snapshots the previous axes before adopting a shorter axes array', function () { + const pad = new GamePad(createDevice(0, [0, 0, 1]), synthesizedMap(2)); + + pad.update(createDevice(0, [0, 0])); + + expect(pad.isPressed(PAD_RIGHT)).to.be.false; + expect(pad.wasReleased(PAD_RIGHT)).to.be.true; + }); + + it('snapshots the previous axes before adopting a longer axes array', function () { + const pad = new GamePad(createDevice(0, [0, 0]), synthesizedMap(2)); + + pad.update(createDevice(0, [0, 0, 1])); + + expect(pad.isPressed(PAD_RIGHT)).to.be.true; + expect(pad.wasPressed(PAD_RIGHT)).to.be.true; + }); + + }); + +});