Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 53 additions & 28 deletions src/platform/input/game-pads.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -911,7 +920,7 @@ class GamePads extends EventHandler {
* @ignore
*/
update() {
this.poll();
this._poll(null);
}

/**
Expand All @@ -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;
}

/**
Expand Down Expand Up @@ -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;
}
}

Expand Down
223 changes: 223 additions & 0 deletions test/platform/input/game-pads.test.mjs
Original file line number Diff line number Diff line change
@@ -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;
});

});

});
Loading