From da1fea3be84c3ec202e099e554cb749a24ae4550 Mon Sep 17 00:00:00 2001 From: eduardo aleixo Date: Thu, 11 Jan 2024 22:14:14 +0000 Subject: [PATCH] feat: navigate with Shoulder buttons (#34) Previously I've mapped for my specific case, with an Arcade Stick, now shoulder buttons should work for most cases. --- README.md | 4 +- src/gamepad.ts | 117 --------------------- src/gamepad/buttons.ts | 96 +++++++++++++++++ src/gamepad/events.ts | 26 +++++ src/gamepad/index.ts | 68 ++++++++++++ src/gamepad/layouts/8bitdo_arcade_stick.ts | 13 +++ src/gamepad/layouts/generic.ts | 13 +++ src/gamepad/layouts/identify.ts | 13 +++ src/gamepad/layouts/index.ts | 3 + src/gamepad/layouts/layout.d.ts | 5 + src/inject.ts | 2 +- 11 files changed, 240 insertions(+), 120 deletions(-) delete mode 100644 src/gamepad.ts create mode 100644 src/gamepad/buttons.ts create mode 100644 src/gamepad/events.ts create mode 100644 src/gamepad/index.ts create mode 100644 src/gamepad/layouts/8bitdo_arcade_stick.ts create mode 100644 src/gamepad/layouts/generic.ts create mode 100644 src/gamepad/layouts/identify.ts create mode 100644 src/gamepad/layouts/index.ts create mode 100644 src/gamepad/layouts/layout.d.ts diff --git a/README.md b/README.md index 479afc9..cc7ef75 100644 --- a/README.md +++ b/README.md @@ -21,12 +21,12 @@ Open Fightcade, then press any button for the plugin to recognize the controller a notification message should tell the controller has been recognized. Then navigate with the D-pad (or equivalent). -By default, BUTTON_1 is translated to "Enter", and BUTTON_3 and BUTTON_4 as +By default, BUTTON_1 is translated to "Enter", and SHOULDER_LEFT and SHOULDER_RIGHT to Shift+Tab and Tab, respectively. # Caveats * It only handles the frontend, ie not the emulators part. So initial setup for the inputs still requires a mouse/keyboard. -* It relies on the DOM structure, so if it ever changes, it will break this plugin and will require updates. PRs welcome :) +* It relies on the DOM structure, so if it ever changes, it will break and require updates. PRs welcome :) # Strategy The approach is to make everything more keyboard accessible, and then translate gamepad diff --git a/src/gamepad.ts b/src/gamepad.ts deleted file mode 100644 index 27b5118..0000000 --- a/src/gamepad.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { tabNext, tabPrev } from "./dom"; -import { Controller } from "@controllerjs"; -import { notify } from "./notify"; - -// Source: https://github.com/microsoft/TypeScript/issues/28357#issuecomment-748550734 -declare global { - interface GlobalEventHandlersEventMap { - "gc.controller.found": ControllerConnectEvent; - "gc.controller.lost": ControllerDisconnectEvent; - "gc.button.press": ControllerButtonPressed; - } -} - -type ControllerConnectEvent = CustomEvent<{ - index: number; - controller: { - index: number; - name: string; - }; -}>; -type ControllerDisconnectEvent = CustomEvent<{ index: number }>; -type ControllerButtonPressed = CustomEvent<{ - controllerIndex: number; - // https://github.com/samiare/Controller.js/blob/347fc1416b7d5889983933a916cd7d1eeb3ee166/source/lib/GC_Layouts.js#L62-L81 - // name: string; - name: - | "FACE_1" - | "FACE_2" - | "FACE_3" - | "FACE_4" - | "LEFT_SHOULDER" - | "RIGHT_SHOULDER" - | "LEFT_SHOULDER_BOTTOM" - | "RIGHT_SHOULDER_BOTTOM" - | "SELECT" - | "START" - | "LEFT_ANALOG_BUTTON" - | "RIGHT_ANALOG_BUTTON" - | "DPAD_UP" - | "DPAD_DOWN" - | "DPAD_LEFT" - | "DPAD_RIGHT" - | "HOME"; - - value: number; - pressed: true; - time: number; -}>; - -export function initGamepad() { - Controller.globalSettings.useAnalogAsDpad = "left"; - Controller.search(); - - window.addEventListener( - "gc.controller.found", - function (event) { - var controller = event.detail.controller; - notify( - `[CONNECTED]: Controller ${controller.name} recognized at index ${controller.index}` - ); - }, - false - ); - window.addEventListener( - "gc.controller.lost", - function (event) { - notify(`[DISCONNECTED]: Controller at index ${event.detail.index}`); - }, - false - ); - - window.addEventListener("gc.button.press", function (event) { - const focusedElement = document.activeElement; - // TODO: ideally these mappings should be able to be set by the user - switch (event.detail.name) { - case "FACE_1": { - focusedElement?.dispatchEvent( - new KeyboardEvent("keydown", { key: "Enter", bubbles: true }) - ); - break; - } - - case "DPAD_UP": { - focusedElement?.dispatchEvent( - new KeyboardEvent("keydown", { key: "ArrowUp", bubbles: true }) - ); - break; - } - case "DPAD_RIGHT": { - focusedElement?.dispatchEvent( - new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true }) - ); - break; - } - case "DPAD_DOWN": { - focusedElement?.dispatchEvent( - new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }) - ); - break; - } - case "DPAD_LEFT": { - focusedElement?.dispatchEvent( - new KeyboardEvent("keydown", { key: "ArrowLeft", bubbles: true }) - ); - break; - } - case "FACE_3": { - tabPrev(); - break; - } - case "FACE_4": { - tabNext(); - break; - } - } - }); -} diff --git a/src/gamepad/buttons.ts b/src/gamepad/buttons.ts new file mode 100644 index 0000000..881032b --- /dev/null +++ b/src/gamepad/buttons.ts @@ -0,0 +1,96 @@ +import { tabNext, tabPrev } from "@app/dom"; + +/** + * Buttons as defined by Controller.js + * https://github.com/samiare/Controller.js/blob/347fc1416b7d5889983933a916cd7d1eeb3ee166/source/lib/GC_Layouts.js#L62-L81 + */ +export type Button = + | "FACE_1" + | "FACE_2" + | "FACE_3" + | "FACE_4" + | "LEFT_SHOULDER" + | "RIGHT_SHOULDER" + | "LEFT_SHOULDER_BOTTOM" + | "RIGHT_SHOULDER_BOTTOM" + | "SELECT" + | "START" + | "LEFT_ANALOG_BUTTON" + | "RIGHT_ANALOG_BUTTON" + | "DPAD_UP" + | "DPAD_DOWN" + | "DPAD_LEFT" + | "DPAD_RIGHT" + | "HOME"; + +export type NavigationControls = + | "Up" + | "Down" + | "Right" + | "Left" + | "Confirm" + | "Return" + | "TabPrev" + | "TabNext"; + +/* + * given a layout and a Controllerjs button + * return the mapping + * The reason is that the mapping is defined as a record where keys + * are NavigationControls so that the domain is smaller + * but when figuring out the mapping, we need the reverse operation + */ +export function convertToNavigationControl( + mappings: Layout["Mappings"], + button: Button +): NavigationControls | undefined { + const reverse = Object.fromEntries( + Object.entries(mappings).map(([key, value]) => [value, key]) + ) as Record; + return reverse[button]; +} + +export function toKeyboardEvent( + mappings: Layout["Mappings"], + button: Button, + activeElement: Element | null +) { + const nc = convertToNavigationControl(mappings, button); + switch (nc) { + case "TabPrev": { + return tabPrev; + } + case "TabNext": { + return tabNext; + } + + case undefined: { + return () => {}; + } + + default: { + return () => { + activeElement?.dispatchEvent( + new KeyboardEvent("keydown", { key: toKey(nc), bubbles: true }) + ); + }; + } + } +} + +function toKey(nc: Exclude) { + switch (nc) { + case "Up": + case "Right": + case "Down": + case "Left": { + return `Arrow${nc}`; + } + case "Confirm": { + return "Enter"; + } + case "Return": { + return "Escape"; + } + } +} diff --git a/src/gamepad/events.ts b/src/gamepad/events.ts new file mode 100644 index 0000000..77ea812 --- /dev/null +++ b/src/gamepad/events.ts @@ -0,0 +1,26 @@ +import { Button } from "./buttons"; + +// Source: https://github.com/microsoft/TypeScript/issues/28357#issuecomment-748550734 +declare global { + interface GlobalEventHandlersEventMap { + "gc.controller.found": ControllerConnectEvent; + "gc.controller.lost": ControllerDisconnectEvent; + "gc.button.press": ControllerButtonPressed; + } +} + +type ControllerConnectEvent = CustomEvent<{ + index: number; + controller: { + index: number; + name: string; + }; +}>; +type ControllerDisconnectEvent = CustomEvent<{ index: number }>; +type ControllerButtonPressed = CustomEvent<{ + controllerIndex: number; + name: Button; + value: number; + pressed: true; + time: number; +}>; diff --git a/src/gamepad/index.ts b/src/gamepad/index.ts new file mode 100644 index 0000000..2a985a3 --- /dev/null +++ b/src/gamepad/index.ts @@ -0,0 +1,68 @@ +import { Controller } from "@controllerjs"; +import { notify } from "@app/notify"; +import { toKeyboardEvent } from "@app/gamepad/buttons"; +import { identifyLayout } from "@app/gamepad/layouts/identify"; +import { findFirstFocusableChild } from "@app/dom"; + +type ControllerInfo = { + name: string; + layout: Layout; +}; +type ControllerIndex = number; +const controllers: Record = []; + +export function initGamepad() { + Controller.globalSettings.useAnalogAsDpad = "left"; + Controller.search(); + + window.addEventListener( + "gc.controller.found", + function (event) { + const ci: ControllerInfo = { + layout: identifyLayout(event.detail.controller.name), + name: event.detail.controller.name, + }; + + controllers[event.detail.controller.index] = ci; + + const controller = event.detail.controller; + notify( + `[CONNECTED]: Controller ${controller.name} recognized at index ${controller.index} with layout ${ci.layout.Name}` + ); + }, + false + ); + window.addEventListener( + "gc.controller.lost", + function (event) { + notify(`[DISCONNECTED]: Controller at index ${event.detail.index}`); + }, + false + ); + + window.addEventListener("gc.button.press", function (event) { + const activeElement = document.activeElement; + + // If nothing is focused, navigation won't work + if (!activeElement || activeElement === document.body) { + const firstFocusable = findFirstFocusableChild(document.body); + firstFocusable?.focus(); + } + + // It may be possible that this event is dispatched before the controller initialization + const controller = controllers[event.detail.controllerIndex]; + if (!controller) { + return; + } + + const ev = toKeyboardEvent( + controller.layout.Mappings, + event.detail.name, + document.activeElement + ); + + if (ev) { + ev(); + } + }); +} diff --git a/src/gamepad/layouts/8bitdo_arcade_stick.ts b/src/gamepad/layouts/8bitdo_arcade_stick.ts new file mode 100644 index 0000000..2d1edee --- /dev/null +++ b/src/gamepad/layouts/8bitdo_arcade_stick.ts @@ -0,0 +1,13 @@ +export const EightBitDoArcadeStick: Layout = { + Name: "8BitDo Arcade Stick", + Mappings: { + Up: "DPAD_UP", + Down: "DPAD_DOWN", + Left: "DPAD_LEFT", + Right: "DPAD_RIGHT", + Confirm: "FACE_1", + Return: "FACE_2", + TabPrev: "RIGHT_SHOULDER_BOTTOM", + TabNext: "LEFT_SHOULDER_BOTTOM", + }, +}; diff --git a/src/gamepad/layouts/generic.ts b/src/gamepad/layouts/generic.ts new file mode 100644 index 0000000..6259a82 --- /dev/null +++ b/src/gamepad/layouts/generic.ts @@ -0,0 +1,13 @@ +export const GenericLayout: Layout = { + Name: "Generic", + Mappings: { + Up: "DPAD_UP", + Down: "DPAD_DOWN", + Left: "DPAD_LEFT", + Right: "DPAD_RIGHT", + Confirm: "FACE_1", + Return: "FACE_2", + TabPrev: "LEFT_SHOULDER", + TabNext: "RIGHT_SHOULDER", + }, +}; diff --git a/src/gamepad/layouts/identify.ts b/src/gamepad/layouts/identify.ts new file mode 100644 index 0000000..718336d --- /dev/null +++ b/src/gamepad/layouts/identify.ts @@ -0,0 +1,13 @@ +import { GenericLayout } from "@app/gamepad/layouts"; +import { EightBitDoArcadeStick } from "./8bitdo_arcade_stick"; + +export function identifyLayout(name: string): Layout { + switch (name) { + case EightBitDoArcadeStick.Name: { + return EightBitDoArcadeStick; + } + + default: + return GenericLayout; + } +} diff --git a/src/gamepad/layouts/index.ts b/src/gamepad/layouts/index.ts new file mode 100644 index 0000000..54b4c70 --- /dev/null +++ b/src/gamepad/layouts/index.ts @@ -0,0 +1,3 @@ +export * from "./8bitdo_arcade_stick"; +export * from "./generic"; +export * from "./identify"; diff --git a/src/gamepad/layouts/layout.d.ts b/src/gamepad/layouts/layout.d.ts new file mode 100644 index 0000000..2c96720 --- /dev/null +++ b/src/gamepad/layouts/layout.d.ts @@ -0,0 +1,5 @@ +type Layout = { + /** name of the controller as provided by the driver. */ + Name: string; + Mappings: Record; +}; diff --git a/src/inject.ts b/src/inject.ts index 535faf9..6e6da45 100644 --- a/src/inject.ts +++ b/src/inject.ts @@ -1,4 +1,4 @@ -import { initGamepad } from "./gamepad"; +import { initGamepad } from "@app/gamepad"; import { initSidebar, updateSidebar } from "./sections/sidebar"; import * as welcomePage from "./pages/welcome"; import * as searchPage from "./pages/search";