This commit is contained in:
1 parent
2127f5f459
commit
8b80d05ca2
56 files changed
+6910
-1
No files matched your search
@@ -0,0 +1,165 @@
|
||||
import { type Button } from "./types";
|
||||
import { InputController } from "./controller";
|
||||
|
||||
const KEYS: Record<string, Button> = {
|
||||
ArrowUp: "up",
|
||||
ArrowDown: "down",
|
||||
ArrowLeft: "left",
|
||||
ArrowRight: "right",
|
||||
KeyX: "a",
|
||||
KeyZ: "b",
|
||||
Enter: "start",
|
||||
ShiftLeft: "select",
|
||||
ShiftRight: "select",
|
||||
};
|
||||
|
||||
export function bindControls(
|
||||
root: HTMLElement,
|
||||
input: InputController,
|
||||
active: () => boolean,
|
||||
unlock: () => void,
|
||||
) {
|
||||
const abort = new AbortController();
|
||||
const options = { signal: abort.signal };
|
||||
const buttons = [
|
||||
...root.querySelectorAll<HTMLButtonElement>("[data-game-button]"),
|
||||
];
|
||||
const refresh = () =>
|
||||
buttons.forEach((el) => {
|
||||
const pressed = input.isPressed(el.dataset.gameButton as Button);
|
||||
el.classList.toggle("pressed", pressed);
|
||||
el.setAttribute("aria-pressed", String(pressed));
|
||||
});
|
||||
const release = () => {
|
||||
input.releaseAll();
|
||||
refresh();
|
||||
};
|
||||
for (const el of buttons) {
|
||||
const focusedSource = `focused:${el.dataset.gameButton}`;
|
||||
el.addEventListener(
|
||||
"keydown",
|
||||
(event) => {
|
||||
if (!active() || !["Space", "Enter"].includes(event.code)) return;
|
||||
event.preventDefault();
|
||||
unlock();
|
||||
input.press(focusedSource, el.dataset.gameButton as Button);
|
||||
refresh();
|
||||
},
|
||||
options,
|
||||
);
|
||||
el.addEventListener(
|
||||
"keyup",
|
||||
(event) => {
|
||||
if (!["Space", "Enter"].includes(event.code)) return;
|
||||
event.preventDefault();
|
||||
input.release(focusedSource);
|
||||
refresh();
|
||||
},
|
||||
options,
|
||||
);
|
||||
el.addEventListener(
|
||||
"blur",
|
||||
() => {
|
||||
input.release(focusedSource);
|
||||
refresh();
|
||||
},
|
||||
options,
|
||||
);
|
||||
el.addEventListener(
|
||||
"pointerdown",
|
||||
(event) => {
|
||||
if (!active() || (event.pointerType === "mouse" && event.button !== 0))
|
||||
return;
|
||||
event.preventDefault();
|
||||
unlock();
|
||||
el.setPointerCapture(event.pointerId);
|
||||
input.press(
|
||||
`pointer:${event.pointerId}`,
|
||||
el.dataset.gameButton as Button,
|
||||
);
|
||||
refresh();
|
||||
},
|
||||
options,
|
||||
);
|
||||
// Slide between D-pad directions without lifting the finger.
|
||||
el.addEventListener(
|
||||
"pointermove",
|
||||
(event) => {
|
||||
if (!el.hasPointerCapture(event.pointerId) || !el.closest(".dpad"))
|
||||
return;
|
||||
const target = document
|
||||
.elementFromPoint(event.clientX, event.clientY)
|
||||
?.closest<HTMLElement>("[data-game-button]");
|
||||
if (target?.closest(".dpad"))
|
||||
input.press(
|
||||
`pointer:${event.pointerId}`,
|
||||
target.dataset.gameButton as Button,
|
||||
);
|
||||
else input.release(`pointer:${event.pointerId}`);
|
||||
refresh();
|
||||
},
|
||||
options,
|
||||
);
|
||||
for (const name of [
|
||||
"pointerup",
|
||||
"pointercancel",
|
||||
"lostpointercapture",
|
||||
] as const) {
|
||||
el.addEventListener(
|
||||
name,
|
||||
(event) => {
|
||||
input.release(`pointer:${event.pointerId}`);
|
||||
refresh();
|
||||
},
|
||||
options,
|
||||
);
|
||||
}
|
||||
el.addEventListener(
|
||||
"contextmenu",
|
||||
(event) => event.preventDefault(),
|
||||
options,
|
||||
);
|
||||
}
|
||||
window.addEventListener(
|
||||
"keydown",
|
||||
(event) => {
|
||||
if (!active() || event.ctrlKey || event.metaKey || event.altKey) return;
|
||||
if (
|
||||
(event.target as HTMLElement)?.closest(
|
||||
"input, textarea, select, button, a, [contenteditable]",
|
||||
)
|
||||
)
|
||||
return;
|
||||
const button = KEYS[event.code];
|
||||
if (!button) return;
|
||||
event.preventDefault();
|
||||
unlock();
|
||||
input.press(`key:${event.code}`, button);
|
||||
refresh();
|
||||
},
|
||||
options,
|
||||
);
|
||||
window.addEventListener(
|
||||
"keyup",
|
||||
(event) => {
|
||||
input.release(`key:${event.code}`);
|
||||
refresh();
|
||||
},
|
||||
options,
|
||||
);
|
||||
window.addEventListener("blur", release, options);
|
||||
document.addEventListener(
|
||||
"visibilitychange",
|
||||
() => {
|
||||
if (document.hidden) release();
|
||||
},
|
||||
options,
|
||||
);
|
||||
return {
|
||||
release,
|
||||
dispose: () => {
|
||||
release();
|
||||
abort.abort();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { BUTTONS, type Button } from "./types";
|
||||
|
||||
/** Multiple fingers/keys can hold a button independently. */
|
||||
export class InputController {
|
||||
private sources = new Map<string, Button>();
|
||||
constructor(private send: (button: Button, pressed: boolean) => void) {}
|
||||
|
||||
press(source: string, button: Button) {
|
||||
if (this.sources.get(source) === button) return;
|
||||
this.release(source);
|
||||
const alreadyPressed = this.isPressed(button);
|
||||
this.sources.set(source, button);
|
||||
if (!alreadyPressed) this.send(button, true);
|
||||
}
|
||||
|
||||
release(source: string) {
|
||||
const button = this.sources.get(source);
|
||||
this.sources.delete(source);
|
||||
if (button && !this.isPressed(button)) this.send(button, false);
|
||||
}
|
||||
|
||||
isPressed(button: Button) {
|
||||
return [...this.sources.values()].includes(button);
|
||||
}
|
||||
|
||||
releaseAll() {
|
||||
this.sources.clear();
|
||||
for (const button of BUTTONS) this.send(button, false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export const BUTTONS = [
|
||||
"up",
|
||||
"down",
|
||||
"left",
|
||||
"right",
|
||||
"a",
|
||||
"b",
|
||||
"start",
|
||||
"select",
|
||||
] as const;
|
||||
export type Button = (typeof BUTTONS)[number];
|
||||
Reference in new issue
Block a user