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