311 lines
11 KiB
TypeScript
311 lines
11 KiB
TypeScript
import { Capacitor } from "@capacitor/core";
|
|
|
|
export interface ControllerTokenEvent {
|
|
token: string;
|
|
repeat: boolean;
|
|
/** False is emitted when a physical button is released. Older bridges omit it and mean pressed. */
|
|
pressed?: boolean;
|
|
}
|
|
|
|
export interface ControllerMovement {
|
|
moveX: number;
|
|
moveY: number;
|
|
lookX: number;
|
|
lookY: number;
|
|
}
|
|
|
|
type TokenListener = (event: ControllerTokenEvent) => void;
|
|
type MovementListener = (movement: Readonly<ControllerMovement>) => void;
|
|
|
|
const NATIVE_TOKEN_EVENT = "hm-native-controller";
|
|
const NATIVE_MOTION_EVENT = "hm-native-controller-motion";
|
|
const NATIVE_RESET_EVENT = "hm-native-controller-reset";
|
|
export const CONTROLLER_SYSTEM_BACK_TOKEN = "SystemBack";
|
|
const INITIAL_REPEAT_MS = 280;
|
|
const REPEAT_MS = 90;
|
|
const AXIS_THRESHOLD = 0.55;
|
|
const AXIS_STEPS = 128;
|
|
const GAMEPAD_DISCOVERY_INTERVAL_MS = 500;
|
|
const REPEATABLE_BUTTON_TOKENS = new Set(["Button12", "Button13", "Button14", "Button15"]);
|
|
|
|
const listeners = new Set<TokenListener>();
|
|
const movementListeners = new Set<MovementListener>();
|
|
const repeatAt = new Map<string, number>();
|
|
const lastNativeTokenAt = new Map<string, number>();
|
|
const heldTokens = new Set<string>();
|
|
let previousTokens = new Set<string>();
|
|
let currentTokens = new Set<string>();
|
|
let movement: ControllerMovement = { moveX: 0, moveY: 0, lookX: 0, lookY: 0 };
|
|
let stopService: (() => void) | null = null;
|
|
|
|
function connectedGamepad(): Gamepad | null {
|
|
const gamepads = navigator.getGamepads?.();
|
|
if (!gamepads) return null;
|
|
for (let index = 0; index < gamepads.length; index += 1) {
|
|
const gamepad = gamepads[index];
|
|
if (gamepad) return gamepad;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function tokensFor(gamepad: Gamepad, tokens: Set<string>): void {
|
|
tokens.clear();
|
|
for (let index = 0; index < gamepad.buttons.length; index += 1) {
|
|
const button = gamepad.buttons[index];
|
|
if (button.pressed || button.value > 0.65) tokens.add(`Button${index}`);
|
|
}
|
|
for (let index = 0; index < gamepad.axes.length; index += 1) {
|
|
const value = gamepad.axes[index];
|
|
if (value <= -AXIS_THRESHOLD) tokens.add(`Axis${index}-`);
|
|
if (value >= AXIS_THRESHOLD) tokens.add(`Axis${index}+`);
|
|
}
|
|
}
|
|
|
|
function canRepeat(token: string): boolean {
|
|
return REPEATABLE_BUTTON_TOKENS.has(token)
|
|
|| token.startsWith("Axis0")
|
|
|| token.startsWith("Axis1");
|
|
}
|
|
|
|
function tokenSetsMatch(left: ReadonlySet<string>, right: ReadonlySet<string>): boolean {
|
|
if (left.size !== right.size) return false;
|
|
for (const token of left) if (!right.has(token)) return false;
|
|
return true;
|
|
}
|
|
|
|
function isLayerModifier(token: string): boolean {
|
|
return token === "Button4" || token === "Button6";
|
|
}
|
|
|
|
/**
|
|
* Orders a sampled gamepad transition so the modifier state is already exact
|
|
* when an ability-button down is published. In particular, releases must run
|
|
* before action downs: a quick L2-up/Cross-down transition must cast Base,
|
|
* never the previously held L2 layer.
|
|
*/
|
|
export function orderedControllerTokenTransitions(
|
|
previous: ReadonlySet<string>,
|
|
current: ReadonlySet<string>,
|
|
): ControllerTokenEvent[] {
|
|
const released = [...previous].filter((token) => !current.has(token));
|
|
const pressed = [...current].filter((token) => !previous.has(token));
|
|
return [
|
|
...released.filter(isLayerModifier).map((token) => ({ token, repeat: false, pressed: false })),
|
|
...pressed.filter(isLayerModifier).map((token) => ({ token, repeat: false, pressed: true })),
|
|
...pressed.filter((token) => !isLayerModifier(token)).map((token) => ({ token, repeat: false, pressed: true })),
|
|
...released.filter((token) => !isLayerModifier(token)).map((token) => ({ token, repeat: false, pressed: false })),
|
|
];
|
|
}
|
|
|
|
export function emitControllerToken(event: ControllerTokenEvent): void {
|
|
const pressed = event.pressed !== false;
|
|
const normalized = {
|
|
...event,
|
|
pressed,
|
|
repeat: pressed ? event.repeat || heldTokens.has(event.token) : false,
|
|
};
|
|
if (normalized.pressed) heldTokens.add(normalized.token);
|
|
else heldTokens.delete(normalized.token);
|
|
for (const listener of listeners) listener(normalized);
|
|
}
|
|
|
|
export function subscribeControllerToken(listener: TokenListener): () => void {
|
|
listeners.add(listener);
|
|
return () => listeners.delete(listener);
|
|
}
|
|
|
|
export function subscribeControllerMovement(listener: MovementListener): () => void {
|
|
movementListeners.add(listener);
|
|
return () => movementListeners.delete(listener);
|
|
}
|
|
|
|
export function getControllerMovement(): Readonly<ControllerMovement> {
|
|
return movement;
|
|
}
|
|
|
|
function normalizedAxis(value: number): number {
|
|
return Math.abs(value) >= 0.12 ? Math.round(value * AXIS_STEPS) / AXIS_STEPS : 0;
|
|
}
|
|
|
|
export function setExternalControllerMovement(next: ControllerMovement): void {
|
|
const moveX = normalizedAxis(next.moveX);
|
|
const moveY = normalizedAxis(next.moveY);
|
|
const lookX = normalizedAxis(next.lookX);
|
|
const lookY = normalizedAxis(next.lookY);
|
|
if (
|
|
moveX === movement.moveX
|
|
&& moveY === movement.moveY
|
|
&& lookX === movement.lookX
|
|
&& lookY === movement.lookY
|
|
) return;
|
|
movement = { moveX, moveY, lookX, lookY };
|
|
for (const listener of movementListeners) listener(movement);
|
|
}
|
|
|
|
export function resetControllerState(): void {
|
|
const released = [...heldTokens];
|
|
heldTokens.clear();
|
|
for (const token of released) {
|
|
for (const listener of listeners) listener({ token, repeat: false, pressed: false });
|
|
}
|
|
previousTokens.clear();
|
|
currentTokens.clear();
|
|
repeatAt.clear();
|
|
lastNativeTokenAt.clear();
|
|
setExternalControllerMovement({ moveX: 0, moveY: 0, lookX: 0, lookY: 0 });
|
|
}
|
|
|
|
export function startControllerInput(): () => void {
|
|
if (stopService) return stopService;
|
|
|
|
const webGamepadPolling = !Capacitor.isNativePlatform();
|
|
|
|
const onNativeToken = (event: Event) => {
|
|
const detail = (event as CustomEvent<ControllerTokenEvent>).detail;
|
|
if (!detail?.token) return;
|
|
const pressed = detail.pressed !== false;
|
|
const now = performance.now();
|
|
const dispatchKey = `${detail.token}:${pressed ? "down" : "up"}`;
|
|
const lastAt = lastNativeTokenAt.get(dispatchKey) ?? Number.NEGATIVE_INFINITY;
|
|
if (pressed && now - lastAt < 24) return;
|
|
lastNativeTokenAt.set(dispatchKey, now);
|
|
emitControllerToken({ token: detail.token, repeat: pressed && Boolean(detail.repeat), pressed });
|
|
};
|
|
const onNativeMotion = (event: Event) => {
|
|
const detail = (event as CustomEvent<ControllerMovement>).detail;
|
|
if (detail) setExternalControllerMovement(detail);
|
|
};
|
|
const onReset = () => resetControllerState();
|
|
window.addEventListener(NATIVE_TOKEN_EVENT, onNativeToken);
|
|
window.addEventListener(NATIVE_MOTION_EVENT, onNativeMotion);
|
|
window.addEventListener(NATIVE_RESET_EVENT, onReset);
|
|
|
|
let frame = 0;
|
|
let discoveryTimer: ReturnType<typeof setTimeout> | null = null;
|
|
let hadGamepad = false;
|
|
let activeGamepadIndex: number | null = null;
|
|
|
|
const canPollGamepads = () => document.visibilityState === "visible"
|
|
&& (typeof document.hasFocus !== "function" || document.hasFocus());
|
|
|
|
const cancelScheduledPoll = () => {
|
|
if (frame) cancelAnimationFrame(frame);
|
|
frame = 0;
|
|
if (discoveryTimer !== null) clearTimeout(discoveryTimer);
|
|
discoveryTimer = null;
|
|
};
|
|
|
|
const suspendPolling = () => {
|
|
cancelScheduledPoll();
|
|
if (hadGamepad) resetControllerState();
|
|
hadGamepad = false;
|
|
activeGamepadIndex = null;
|
|
};
|
|
|
|
const scheduleFrame = () => {
|
|
if (!webGamepadPolling || frame || !canPollGamepads()) return;
|
|
if (discoveryTimer !== null) clearTimeout(discoveryTimer);
|
|
discoveryTimer = null;
|
|
frame = requestAnimationFrame(poll);
|
|
};
|
|
|
|
const scheduleDiscovery = () => {
|
|
if (!webGamepadPolling || frame || discoveryTimer !== null || !canPollGamepads()) return;
|
|
discoveryTimer = setTimeout(() => {
|
|
discoveryTimer = null;
|
|
scheduleFrame();
|
|
}, GAMEPAD_DISCOVERY_INTERVAL_MS);
|
|
};
|
|
|
|
const poll = (now: number) => {
|
|
frame = 0;
|
|
if (!canPollGamepads()) {
|
|
suspendPolling();
|
|
return;
|
|
}
|
|
|
|
const gamepad = connectedGamepad();
|
|
if (!gamepad) {
|
|
if (hadGamepad) resetControllerState();
|
|
hadGamepad = false;
|
|
activeGamepadIndex = null;
|
|
scheduleDiscovery();
|
|
} else {
|
|
hadGamepad = true;
|
|
activeGamepadIndex = gamepad.index;
|
|
setExternalControllerMovement({
|
|
moveX: gamepad.axes[0] ?? 0,
|
|
moveY: gamepad.axes[1] ?? 0,
|
|
lookX: gamepad.axes[2] ?? 0,
|
|
lookY: gamepad.axes[3] ?? 0,
|
|
});
|
|
tokensFor(gamepad, currentTokens);
|
|
if (!tokenSetsMatch(previousTokens, currentTokens)) {
|
|
for (const transition of orderedControllerTokenTransitions(previousTokens, currentTokens)) {
|
|
emitControllerToken(transition);
|
|
if (transition.pressed) repeatAt.set(transition.token, now + INITIAL_REPEAT_MS);
|
|
}
|
|
}
|
|
for (const token of currentTokens) {
|
|
if (!previousTokens.has(token) || !canRepeat(token)) continue;
|
|
const nextRepeat = repeatAt.get(token) ?? 0;
|
|
if (now >= nextRepeat) {
|
|
emitControllerToken({ token, repeat: true, pressed: true });
|
|
repeatAt.set(token, now + REPEAT_MS);
|
|
}
|
|
}
|
|
for (const token of repeatAt.keys()) if (!currentTokens.has(token)) repeatAt.delete(token);
|
|
const scratch = previousTokens;
|
|
previousTokens = currentTokens;
|
|
currentTokens = scratch;
|
|
scheduleFrame();
|
|
}
|
|
};
|
|
|
|
const onGamepadConnected = () => scheduleFrame();
|
|
const onGamepadDisconnected = (event: Event) => {
|
|
const disconnectedIndex = (event as GamepadEvent).gamepad?.index;
|
|
if (disconnectedIndex === undefined || disconnectedIndex === activeGamepadIndex) {
|
|
resetControllerState();
|
|
hadGamepad = false;
|
|
activeGamepadIndex = null;
|
|
}
|
|
scheduleFrame();
|
|
};
|
|
const onVisibility = () => {
|
|
if (document.visibilityState === "visible") scheduleFrame();
|
|
else {
|
|
suspendPolling();
|
|
if (!webGamepadPolling) resetControllerState();
|
|
}
|
|
};
|
|
const onFocus = () => scheduleFrame();
|
|
const onBlur = () => suspendPolling();
|
|
|
|
document.addEventListener("visibilitychange", onVisibility);
|
|
if (webGamepadPolling) {
|
|
window.addEventListener("gamepadconnected", onGamepadConnected);
|
|
window.addEventListener("gamepaddisconnected", onGamepadDisconnected);
|
|
window.addEventListener("focus", onFocus);
|
|
window.addEventListener("blur", onBlur);
|
|
scheduleFrame();
|
|
}
|
|
|
|
stopService = () => {
|
|
window.removeEventListener(NATIVE_TOKEN_EVENT, onNativeToken);
|
|
window.removeEventListener(NATIVE_MOTION_EVENT, onNativeMotion);
|
|
window.removeEventListener(NATIVE_RESET_EVENT, onReset);
|
|
document.removeEventListener("visibilitychange", onVisibility);
|
|
if (webGamepadPolling) {
|
|
window.removeEventListener("gamepadconnected", onGamepadConnected);
|
|
window.removeEventListener("gamepaddisconnected", onGamepadDisconnected);
|
|
window.removeEventListener("focus", onFocus);
|
|
window.removeEventListener("blur", onBlur);
|
|
}
|
|
cancelScheduledPoll();
|
|
resetControllerState();
|
|
stopService = null;
|
|
};
|
|
return stopService;
|
|
}
|