Skip to content

Commit

Permalink
feat: navigate with Shoulder buttons (#34)
Browse files Browse the repository at this point in the history
Previously I've mapped for my specific case, with an Arcade Stick, now
shoulder buttons should work for most cases.
  • Loading branch information
eh-am authored Jan 11, 2024
1 parent 1440ca2 commit da1fea3
Show file tree
Hide file tree
Showing 11 changed files with 240 additions and 120 deletions.
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
117 changes: 0 additions & 117 deletions src/gamepad.ts

This file was deleted.

96 changes: 96 additions & 0 deletions src/gamepad/buttons.ts
Original file line number Diff line number Diff line change
@@ -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<Button, NavigationControls>;
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<NavigationControls, "TabPrev" | "TabNext">) {
switch (nc) {
case "Up":
case "Right":
case "Down":
case "Left": {
return `Arrow${nc}`;
}
case "Confirm": {
return "Enter";
}
case "Return": {
return "Escape";
}
}
}
26 changes: 26 additions & 0 deletions src/gamepad/events.ts
Original file line number Diff line number Diff line change
@@ -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;
}>;
68 changes: 68 additions & 0 deletions src/gamepad/index.ts
Original file line number Diff line number Diff line change
@@ -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<ControllerIndex, ControllerInfo> = [];

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();
}
});
}
13 changes: 13 additions & 0 deletions src/gamepad/layouts/8bitdo_arcade_stick.ts
Original file line number Diff line number Diff line change
@@ -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",
},
};
13 changes: 13 additions & 0 deletions src/gamepad/layouts/generic.ts
Original file line number Diff line number Diff line change
@@ -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",
},
};
13 changes: 13 additions & 0 deletions src/gamepad/layouts/identify.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
3 changes: 3 additions & 0 deletions src/gamepad/layouts/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from "./8bitdo_arcade_stick";
export * from "./generic";
export * from "./identify";
5 changes: 5 additions & 0 deletions src/gamepad/layouts/layout.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
type Layout = {
/** name of the controller as provided by the driver. */
Name: string;
Mappings: Record<NavigationControls, Button>;
};
2 changes: 1 addition & 1 deletion src/inject.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down

0 comments on commit da1fea3

Please sign in to comment.