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) => 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(); const movementListeners = new Set(); const repeatAt = new Map(); const lastNativeTokenAt = new Map(); const heldTokens = new Set(); let previousTokens = new Set(); let currentTokens = new Set(); 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): 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, right: ReadonlySet): 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, current: ReadonlySet, ): 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 { 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).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).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 | 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; }