soiz1's picture
Upload 225 files
7aec436 verified
import {EventTarget, CustomEvent} from '../../common/event-target';
let console = window.console;
/*
Mapping types:
type: "key" maps a button to a keyboard key
All key names will be interpreted as a KeyboardEvent.key value (https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key/Key_Values)
high: "KeyName" is the name of the key to dispatch when a button reads a HIGH value
low: "KeyName" is the name of the key to dispatch when a button reads a LOW value
deadZone: 0.5 controls the minimum value necessary to be read in either + or - to trigger either high or low
The high/low distinction is necessary for axes. Buttons will only use high
type: "mousedown" maps a button to control whether the mouse is down or not
deadZone: 0.5 controls the minimum value to trigger a mousedown
button: 0, 1, 2, etc. controls which button to press
type: "virtual_cursor" maps a button to control the "virtual cursor"
deadZone: 0.5 again controls the minimum value to trigger a movement
sensitivity: 10 controls the speed
high: "+y"/"-y"/"+x"/"-x" defines what happens when an axis reads high
low: "+y"/"-y"/"+x"/"-x" defines what happens when an axis reads low
+y increases y, -y decreases y, +x increases x, -x decreases x.
*/
const defaultAxesMappings = {
arrows: [
{
type: "key",
high: "ArrowRight",
low: "ArrowLeft",
deadZone: 0.5,
},
{
type: "key",
high: "ArrowDown",
low: "ArrowUp",
deadZone: 0.5,
},
],
wasd: [
{
type: "key",
high: "d",
low: "a",
deadZone: 0.5,
},
{
type: "key",
high: "s",
low: "w",
deadZone: 0.5,
},
],
cursor: [
{
type: "virtual_cursor",
high: "+x",
low: "-x",
sensitivity: 0.6,
deadZone: 0.2,
},
{
type: "virtual_cursor",
high: "-y",
low: "+y",
sensitivity: 0.6,
deadZone: 0.2,
},
],
};
const emptyMapping = () => ({
type: "key",
high: null,
low: null,
});
const transformAndCopyMapping = (mapping) => {
if (typeof mapping !== "object" || !mapping) {
console.warn("invalid mapping", mapping);
return emptyMapping();
}
const copy = Object.assign({}, mapping);
if (copy.type === "key") {
if (typeof copy.deadZone === "undefined") {
copy.deadZone = 0.5;
}
if (typeof copy.high === "undefined") {
copy.high = "";
}
if (typeof copy.low === "undefined") {
copy.low = "";
}
} else if (copy.type === "mousedown") {
if (typeof copy.deadZone === "undefined") {
copy.deadZone = 0.5;
}
if (typeof copy.button === "undefined") {
copy.button = 0;
}
} else if (copy.type === "virtual_cursor") {
if (typeof copy.high === "undefined") {
copy.high = "";
}
if (typeof copy.low === "undefined") {
copy.low = "";
}
if (typeof copy.sensitivity === "undefined") {
copy.sensitivity = 10;
}
if (typeof copy.deadZone === "undefined") {
copy.deadZone = 0.5;
}
} else {
console.warn("unknown mapping type", copy.type);
return emptyMapping();
}
return copy;
};
const prepareMappingForExport = (mapping) => Object.assign({}, mapping);
const prepareAxisMappingForExport = prepareMappingForExport;
const prepareButtonMappingForExport = (mapping) => {
const copy = prepareMappingForExport(mapping);
delete copy.deadZone;
delete copy.low;
return copy;
};
const padWithEmptyMappings = (array, length) => {
// Keep adding empty mappings until the list is full
while (array.length < length) {
array.push(emptyMapping());
}
// In case the input array is longer than the desired length
array.length = length;
return array;
};
const createEmptyMappingList = (length) => padWithEmptyMappings([], length);
const getMovementConfiguration = (usedKeys) => ({
usesArrows:
usedKeys.has("ArrowUp") || usedKeys.has("ArrowDown") || usedKeys.has("ArrowRight") || usedKeys.has("ArrowLeft"),
usesWASD: (usedKeys.has("w") && usedKeys.has("s")) || (usedKeys.has("a") && usedKeys.has("d")),
});
const getGamepadId = (gamepad) => `${gamepad.id} (${gamepad.index})`;
class GamepadData {
/**
* @param {Gamepad} gamepad Source Gamepad
* @param {GamepadLib} gamepadLib Parent GamepadLib
*/
constructor(gamepad, gamepadLib) {
this.gamepad = gamepad;
this.gamepadLib = gamepadLib;
this.resetMappings();
}
resetMappings() {
this.hints = this.gamepadLib.getHints();
this.buttonMappings = this.getDefaultButtonMappings().map(transformAndCopyMapping);
this.axesMappings = this.getDefaultAxisMappings().map(transformAndCopyMapping);
}
clearMappings() {
this.buttonMappings = createEmptyMappingList(this.gamepad.buttons.length);
this.axesMappings = createEmptyMappingList(this.gamepad.axes.length);
}
getDefaultButtonMappings() {
let buttons;
if (this.hints.importedSettings) {
buttons = this.hints.importedSettings.buttons;
} else {
const usedKeys = this.hints.usedKeys;
const alreadyUsedKeys = new Set();
const { usesArrows, usesWASD } = getMovementConfiguration(usedKeys);
if (usesWASD) {
alreadyUsedKeys.add("w");
alreadyUsedKeys.add("a");
alreadyUsedKeys.add("s");
alreadyUsedKeys.add("d");
}
const possiblePauseKeys = [
// Restart keys, pause keys, other potentially dangerous keys
"p",
"q",
"r",
];
const possibleActionKeys = [
" ",
"Enter",
"e",
"f",
"z",
"x",
"c",
...Array.from(usedKeys).filter((i) => i.length === 1 && !possiblePauseKeys.includes(i)),
];
const findKey = (keys) => {
for (const key of keys) {
if (usedKeys.has(key) && !alreadyUsedKeys.has(key)) {
alreadyUsedKeys.add(key);
return key;
}
}
return null;
};
const getPrimaryAction = () => {
if (usesArrows && usedKeys.has("ArrowUp")) {
return "ArrowUp";
}
if (usesWASD && usedKeys.has("w")) {
return "w";
}
return findKey(possibleActionKeys);
};
const getSecondaryAction = () => findKey(possibleActionKeys);
const getPauseKey = () => findKey(possiblePauseKeys);
const getUp = () => {
if (usesArrows || !usesWASD) return "ArrowUp";
return "w";
};
const getDown = () => {
if (usesArrows || !usesWASD) return "ArrowDown";
return "s";
};
const getRight = () => {
if (usesArrows || !usesWASD) return "ArrowRight";
return "d";
};
const getLeft = () => {
if (usesArrows || !usesWASD) return "ArrowLeft";
return "a";
};
const action1 = getPrimaryAction();
let action2 = getSecondaryAction();
let action3 = getSecondaryAction();
let action4 = getSecondaryAction();
// When only 1 or 2 action keys are detected, bind the other buttons to the same things.
if (action1 && !action2 && !action3 && !action4) {
action2 = action1;
action3 = action1;
action4 = action1;
}
if (action1 && action2 && !action3 && !action4) {
action3 = action1;
action4 = action2;
}
// Set indices "manually" because we don't evaluate them in order.
buttons = [];
buttons[0] = {
/*
Xbox: A
SNES-like: B
*/
type: "key",
high: action1,
};
buttons[1] = {
/*
Xbox: B
SNES-like: A
*/
type: "key",
high: action2,
};
buttons[2] = {
/*
Xbox: X
SNES-like: Y
*/
type: "key",
high: action3,
};
buttons[3] = {
/*
Xbox: Y
SNES-like: X
*/
type: "key",
high: action4,
};
buttons[4] = {
/*
Xbox: LB
SNES-like: Left trigger
*/
type: "mousedown",
};
buttons[5] = {
/*
Xbox: RB
*/
type: "mousedown",
};
buttons[6] = {
/*
Xbox: LT
*/
type: "mousedown",
};
buttons[7] = {
/*
Xbox: RT
SNES-like: Right trigger
*/
type: "mousedown",
};
buttons[9] = {
/*
Xbox: Menu
SNES-like: Start
*/
type: "key",
high: getPauseKey(),
};
buttons[8] = {
/*
Xbox: Change view
SNES-like: Select
*/
type: "key",
high: getPauseKey(),
};
// Xbox: Left analog press
buttons[10] = emptyMapping();
// Xbox: Right analog press
buttons[11] = emptyMapping();
buttons[12] = {
/*
Xbox: D-pad up
*/
type: "key",
high: getUp(),
};
buttons[13] = {
/*
Xbox: D-pad down
*/
type: "key",
high: getDown(),
};
buttons[14] = {
/*
Xbox: D-pad left
*/
type: "key",
high: getLeft(),
};
buttons[15] = {
/*
Xbox: D-pad right
*/
type: "key",
high: getRight(),
};
}
return padWithEmptyMappings(buttons, this.gamepad.buttons.length);
}
getDefaultAxisMappings() {
let axes = [];
if (this.hints.importedSettings) {
axes = this.hints.importedSettings.axes;
} else {
// Only return default axis mappings when there are 4 axes, like an xbox controller
// If there isn't exactly 4, we can't really predict what the axes mean
// Some controllers map the dpad to *both* buttons and axes at the same time, which would cause conflicts.
if (this.gamepad.axes.length === 4) {
const usedKeys = this.hints.usedKeys;
const { usesArrows, usesWASD } = getMovementConfiguration(usedKeys);
if (usesWASD) {
axes.push(defaultAxesMappings.wasd[0]);
axes.push(defaultAxesMappings.wasd[1]);
} else if (usesArrows) {
axes.push(defaultAxesMappings.arrows[0]);
axes.push(defaultAxesMappings.arrows[1]);
} else {
axes.push(defaultAxesMappings.cursor[0]);
axes.push(defaultAxesMappings.cursor[1]);
}
axes.push(defaultAxesMappings.cursor[0]);
axes.push(defaultAxesMappings.cursor[1]);
}
}
return padWithEmptyMappings(axes, this.gamepad.axes.length);
}
}
const defaultHints = () => ({
usedKeys: new Set(),
importedSettings: null,
generated: false,
});
class GamepadLib extends EventTarget {
constructor() {
super();
/** @type {Map<string, GamepadData>} */
this.gamepads = new Map();
this.handleConnect = this.handleConnect.bind(this);
this.handleDisconnect = this.handleDisconnect.bind(this);
this.update = this.update.bind(this);
this.animationFrame = null;
this.currentTime = null;
this.deltaTime = 0;
this.virtualCursor = {
x: 0,
y: 0,
maxX: Infinity,
minX: -Infinity,
maxY: Infinity,
minY: -Infinity,
modified: false,
};
this._editor = null;
this.connectCallbacks = [];
this.keysPressedThisFrame = new Set();
this.oldKeysPressed = new Set();
this.mouseButtonsPressedThisFrame = new Set();
this.oldMouseDown = new Set();
this.addEventHandlers();
}
addEventHandlers() {
window.addEventListener("gamepadconnected", this.handleConnect);
window.addEventListener("gamepaddisconnected", this.handleDisconnect);
}
removeEventHandlers() {
window.removeEventListener("gamepadconnected", this.handleConnect);
window.removeEventListener("gamepaddisconnected", this.handleDisconnect);
}
gamepadConnected() {
if (this.gamepads.size > 0) {
return Promise.resolve();
}
return new Promise((resolve) => {
this.connectCallbacks.push(resolve);
});
}
getHints() {
return Object.assign(defaultHints(), this.getUserHints());
}
getUserHints() {
// to be overridden by users
return {};
}
resetControls() {
for (const gamepad of this.gamepads.values()) {
gamepad.resetMappings();
}
}
clearControls() {
for (const gamepad of this.gamepads.values()) {
gamepad.clearMappings();
}
}
handleConnect(e) {
for (const callback of this.connectCallbacks) {
callback();
}
this.connectCallbacks = [];
const gamepad = e.gamepad;
const id = getGamepadId(gamepad);
console.log("connected", gamepad);
const gamepadData = new GamepadData(gamepad, this);
this.gamepads.set(id, gamepadData);
if (this.animationFrame === null) {
this.animationFrame = requestAnimationFrame(this.update);
}
this.dispatchEvent(new CustomEvent("gamepadconnected", { detail: gamepadData }));
}
handleDisconnect(e) {
const gamepad = e.gamepad;
const id = getGamepadId(gamepad);
console.log("disconnected", gamepad);
const gamepadData = this.gamepads.get(id);
this.gamepads.delete(id);
this.dispatchEvent(new CustomEvent("gamepaddisconnected", { detail: gamepadData }));
if (this.gamepads.size === 0) {
cancelAnimationFrame(this.animationFrame);
this.animationFrame = null;
this.currentTime = null;
}
}
dispatchKey(key, pressed) {
if (pressed) {
this.dispatchEvent(new CustomEvent("keydown", { detail: key }));
} else {
this.dispatchEvent(new CustomEvent("keyup", { detail: key }));
}
}
dispatchMouse(button, down) {
if (down) {
this.dispatchEvent(new CustomEvent("mousedown", { detail: button }));
} else {
this.dispatchEvent(new CustomEvent("mouseup", { detail: button }));
}
}
dispatchMouseMove(x, y) {
this.dispatchEvent(new CustomEvent("mousemove", { detail: { x, y } }));
}
updateButton(value, mapping) {
if (mapping.type === "key") {
if (value >= mapping.deadZone) {
if (mapping.high) {
this.keysPressedThisFrame.add(mapping.high);
}
} else if (value <= -mapping.deadZone) {
if (mapping.low) {
this.keysPressedThisFrame.add(mapping.low);
}
}
} else if (mapping.type === "mousedown") {
const isDown = Math.abs(value) >= mapping.deadZone;
if (isDown) {
this.mouseButtonsPressedThisFrame.add(mapping.button);
}
} else if (mapping.type === "virtual_cursor") {
const deadZone = mapping.deadZone;
let action;
if (value >= deadZone) action = mapping.high;
if (value <= -deadZone) action = mapping.low;
if (action) {
// an axis value just beyond the deadzone should have a multiplier near 0, a high value should have a multiplier of 1
const multiplier = (Math.abs(value) - deadZone) / (1 - deadZone);
const speed = multiplier * multiplier * mapping.sensitivity * this.deltaTime;
if (action === "+x") {
this.virtualCursor.x += speed;
} else if (action === "-x") {
this.virtualCursor.x -= speed;
} else if (action === "+y") {
this.virtualCursor.y += speed;
} else if (action === "-y") {
this.virtualCursor.y -= speed;
}
this.virtualCursor.modified = true;
}
}
}
update(time) {
this.oldKeysPressed = this.keysPressedThisFrame;
this.oldMouseButtonsPressed = this.mouseButtonsPressedThisFrame;
this.keysPressedThisFrame = new Set();
this.mouseButtonsPressedThisFrame = new Set();
if (this.currentTime === null) {
this.deltaTime = 0; // doesn't matter what this is, it's just the first frame
} else {
this.deltaTime = time - this.currentTime;
}
this.deltaTime = Math.max(Math.min(this.deltaTime, 1000), 0);
this.currentTime = time;
this.animationFrame = requestAnimationFrame(this.update);
const gamepads = navigator.getGamepads();
for (const gamepad of gamepads) {
if (gamepad === null) {
continue;
}
const id = getGamepadId(gamepad);
const data = this.gamepads.get(id);
for (let i = 0; i < gamepad.buttons.length; i++) {
const button = gamepad.buttons[i];
const value = button.value;
const mapping = data.buttonMappings[i];
this.updateButton(value, mapping);
}
for (let i = 0; i < gamepad.axes.length; i++) {
const axis = gamepad.axes[i];
const mapping = data.axesMappings[i];
this.updateButton(axis, mapping);
}
}
if (this._editor) {
this._editor.update(gamepads);
}
for (const key of this.keysPressedThisFrame) {
if (!this.oldKeysPressed.has(key)) {
this.dispatchKey(key, true);
}
}
for (const key of this.oldKeysPressed) {
if (!this.keysPressedThisFrame.has(key)) {
this.dispatchKey(key, false);
}
}
for (const button of this.mouseButtonsPressedThisFrame) {
if (!this.oldMouseButtonsPressed.has(button)) {
this.dispatchMouse(button, true);
}
}
for (const button of this.oldMouseButtonsPressed) {
if (!this.mouseButtonsPressedThisFrame.has(button)) {
this.dispatchMouse(button, false);
}
}
if (this.virtualCursor.modified) {
this.virtualCursor.modified = false;
if (this.virtualCursor.x > this.virtualCursor.maxX) {
this.virtualCursor.x = this.virtualCursor.maxX;
}
if (this.virtualCursor.x < this.virtualCursor.minX) {
this.virtualCursor.x = this.virtualCursor.minX;
}
if (this.virtualCursor.y > this.virtualCursor.maxY) {
this.virtualCursor.y = this.virtualCursor.maxY;
}
if (this.virtualCursor.y < this.virtualCursor.minY) {
this.virtualCursor.y = this.virtualCursor.minY;
}
this.dispatchMouseMove(this.virtualCursor.x, this.virtualCursor.y);
}
}
}
GamepadLib.browserHasBrokenGamepadAPI = () => {
// Check that the gamepad API is supported at all
if (!navigator.getGamepads) {
return true;
}
// Firefox on Linux has a broken gamepad API implementation that results in strange and sometimes unusable mappings
// https://bugzilla.mozilla.org/show_bug.cgi?id=1643358
// https://bugzilla.mozilla.org/show_bug.cgi?id=1643835
if (navigator.userAgent.includes("Firefox") && navigator.userAgent.includes("Linux")) {
return true;
}
// Firefox on macOS has other bugs that result in strange and unusable mappings
// eg. https://bugzilla.mozilla.org/show_bug.cgi?id=1434408
if (navigator.userAgent.includes("Firefox") && navigator.userAgent.includes("Mac OS")) {
return true;
}
return false;
};
GamepadLib.setConsole = (n) => (console = n);
export default GamepadLib;