import { eventBus } from "#app/event-bus"; import { globalScene } from "#app/global-scene"; import { settings } from "#app/global-settings-manager"; import { TouchControl } from "#app/touch-controls"; import { Button } from "#enums/buttons"; import { Device } from "#enums/devices"; import { UiMode } from "#enums/ui-mode"; import { CFG_KEYBOARD_QWERTY } from "#inputs/cfg-keyboard-qwerty"; import { assign, getButtonWithKeycode, getIconForLatestInput, swap } from "#inputs/config-handler"; import { PAD_DUALSHOCK } from "#inputs/pad-dualshock"; import { PAD_GENERIC } from "#inputs/pad-generic"; import { PAD_PROCON } from "#inputs/pad-procon"; import { PAD_UNLICENSED_SNES } from "#inputs/pad-unlicensed-snes"; import { PAD_XBOX360 } from "#inputs/pad-xbox360"; import type { SettingsUpdateEventArgs } from "#types/event-bus-types"; import type { CustomInterfaceConfig, CustomKeyboardConfig, CustomPadConfig, Interaction, InterfaceConfig, MappingSettingName, SelectedDevice, } from "#types/inputs"; import type { AnySettingKey } from "#types/settings"; import type { SettingsGamepadUiHandler } from "#ui/gamepad-settings-ui-handler"; import type { SettingsKeyboardUiHandler } from "#ui/keyboard-settings-ui-handler"; import { MoveTouchControlsHandler } from "#ui/move-touch-controls-handler"; import { deepCopy } from "#utils/data"; import { getEnumValues } from "#utils/enums"; import Phaser from "phaser"; const repeatInputDelayMillis = 250; /** * Manages and handles all input controls for the game, including keyboard and gamepad interactions. * * @remarks * This class is designed to centralize input management across the game. It facilitates the setup, * configuration, and handling of all game inputs, making it easier to manage various input devices * such as keyboards and gamepads. The class provides methods for setting up input devices, handling * their events, and responding to changes in input state (e.g., button presses, releases). * * The `InputsController` class also includes mechanisms to handle game focus events to ensure input * states are correctly reset and managed when the game loses or regains focus, maintaining robust * and responsive control handling throughout the game's lifecycle. * * Key responsibilities include: * - Initializing and configuring gamepad and keyboard controls. * - Emitting events related to specific input actions. * - Responding to external changes such as gamepad connection/disconnection. * - Managing game state transitions in response to input events, particularly focus loss and recovery. * * Usage of this class is intended to simplify input management across various parts of the game, * providing a unified interface for all input-related interactions. */ export class InputsController { private gamepads: Phaser.Input.Gamepad.Gamepad[] = []; public events: Phaser.Events.EventEmitter; private buttonLock: Button[] = []; private readonly interactions: Partial> = {}; private configs: Record = {}; public gamepadSupport = true; public selectedDevice: SelectedDevice; private disconnectedGamepads: string[] = []; public lastSource = "keyboard"; private readonly inputInterval: NodeJS.Timeout[] = []; private touchControls: TouchControl; public moveTouchControlsHandler: MoveTouchControlsHandler; /** * Initializes a new instance of the game control system, setting up initial state and configurations. * * @remarks * This constructor initializes the game control system with necessary setups for handling inputs. * It prepares an interactions array indexed by button identifiers and configures default states for each button. * Specific buttons like MENU and STATS are set not to repeat their actions. * It concludes by calling the `init` method to complete the setup. */ constructor() { this.selectedDevice = { [Device.KEYBOARD]: "default", }; for (const b of getEnumValues(Button)) { // We don't want the menu key to be repeated if (b === Button.MENU || b === Button.STATS) { continue; } this.interactions[b] = { pressTime: false, isPressed: false, source: null, }; } this.init(); } /** * Sets up event handlers and initializes gamepad and keyboard controls. * * @remarks * This method configures event listeners for both gamepad and keyboard inputs. * It handles gamepad connections/disconnections and button press events, and ensures keyboard controls are set up. * Additionally, it manages the game's behavior when it loses focus to prevent unwanted game actions during this state. */ init(): void { this.events = globalScene.game.events; globalScene.game.events.on(Phaser.Core.Events.BLUR, () => { this.loseFocus(); }); if (globalScene.input.gamepad != null) { globalScene.input.gamepad.on( "connected", function (this: InputsController, gamepad: Phaser.Input.Gamepad.Gamepad) { if (!gamepad) { return; } this.refreshGamepads(); this.setupGamepads(); this.onReconnect(gamepad); }, this, ); globalScene.input.gamepad.on( "disconnected", function (this: InputsController, gamepad: Phaser.Input.Gamepad.Gamepad) { this.onDisconnect(gamepad); // when a gamepad is disconnected }, this, ); // Check to see if the gamepad has already been setup by the browser globalScene.input.gamepad.refreshPads(); if (globalScene.input.gamepad.total) { this.refreshGamepads(); for (const gamepad of this.gamepads) { globalScene.input.gamepad.emit("connected", gamepad); } } globalScene.input.gamepad // .on("down", this.gamepadButtonDown, this) .on("up", this.gamepadButtonUp, this); } globalScene.input.keyboard // ?.on("keydown", this.keyboardKeyDown, this) .on("keyup", this.keyboardKeyUp, this); this.touchControls = new TouchControl(); this.moveTouchControlsHandler = new MoveTouchControlsHandler(this.touchControls); this.touchControls.render(); this.setGamepadSupport(settings.gamepad.enabled); eventBus.on("settings/update/success", ({ category, key, value }: SettingsUpdateEventArgs) => { const uiKeys: readonly AnySettingKey[] = ["uiWindowStyle", "uiTheme"]; if (category === "display" && uiKeys.includes(key)) { this.touchControls.render(); } else if (category === "gamepad" && key === "enabled" && typeof value === "boolean") { this.setGamepadSupport(value); } }); } /** * Handles actions to take when the game loses focus, such as deactivating pressed keys. * * @remarks * This method is triggered when the game or the browser tab loses focus. \ * It ensures that any keys pressed are deactivated to prevent stuck keys affecting gameplay when the game is not active. */ loseFocus(): void { this.deactivatePressedKey(); this.touchControls.deactivatePressedKey(); } /** * Enables or disables support for gamepad input. * * @param value - A boolean indicating whether gamepad support should be enabled (true) or disabled (false). * * @remarks * This method toggles gamepad support. If disabled, it also ensures that all currently pressed gamepad buttons are deactivated to avoid stuck inputs. */ setGamepadSupport(value: boolean): void { if (value) { this.gamepadSupport = true; } else { this.gamepadSupport = false; this.deactivatePressedKey(); } } /** * Sets the currently chosen gamepad and initializes related settings. * This method first deactivates any active key presses and then initializes the gamepad settings. * * @param gamepad - The identifier of the gamepad to set as chosen. */ setChosenGamepad(gamepad: string): void { this.deactivatePressedKey(); this.initChosenGamepad(gamepad); } /** * Sets the currently chosen keyboard layout and initializes related settings. * * @param layoutKeyboard - The identifier of the keyboard layout to set as chosen. */ setChosenKeyboardLayout(layoutKeyboard: string): void { this.deactivatePressedKey(); this.initChosenLayoutKeyboard(layoutKeyboard); } /** * Retrieves the identifiers of all connected gamepads, excluding any that are currently marked as disconnected. * @returns Array An array of strings representing the IDs of the connected gamepads. */ getGamepadsName(): string[] { return this.gamepads.filter(g => !this.disconnectedGamepads.includes(g.id)).map(g => g.id); } /** * Initializes the chosen gamepad by setting its identifier in the local storage and updating the UI to reflect the chosen gamepad. * If a gamepad name is provided, it uses that as the chosen gamepad; otherwise, it defaults to the currently chosen gamepad. * @param gamepadName Optional parameter to specify the name of the gamepad to initialize as chosen. */ initChosenGamepad(gamepadName?: string): void { if (gamepadName) { this.selectedDevice[Device.GAMEPAD] = gamepadName.toLowerCase(); } const handler = globalScene.ui?.handlers[UiMode.SETTINGS_GAMEPAD] as SettingsGamepadUiHandler; handler?.updateChosenGamepadDisplay(); } /** * Initializes the chosen keyboard layout by setting its identifier in the local storage and updating the UI to reflect the chosen layout. * If a layout name is provided, it uses that as the chosen layout; otherwise, it defaults to the currently chosen layout. * @param layoutKeyboard Optional parameter to specify the name of the keyboard layout to initialize as chosen. */ initChosenLayoutKeyboard(layoutKeyboard?: string): void { if (layoutKeyboard) { this.selectedDevice[Device.KEYBOARD] = layoutKeyboard.toLowerCase(); } const handler = globalScene.ui?.handlers[UiMode.SETTINGS_KEYBOARD] as SettingsKeyboardUiHandler; handler?.updateChosenKeyboardDisplay(); } /** * Handles the disconnection of a gamepad by adding its identifier to a list of disconnected gamepads. * This is necessary because Phaser retains memory of previously connected gamepads, and without tracking * disconnections, it would be impossible to determine the connection status of gamepads. This method ensures * that disconnected gamepads are recognized and can be appropriately hidden in the gamepad selection menu. * * @param gamepad - The gamepad that has been disconnected. */ onDisconnect(gamepad: Phaser.Input.Gamepad.Gamepad | undefined): void { if (!gamepad) { return; } this.disconnectedGamepads.push(gamepad.id); } /** * Updates the tracking of disconnected gamepads when a gamepad is reconnected. * It removes the reconnected gamepad's identifier from the `disconnectedGamepads` array, * effectively updating its status to connected. * * @param thisGamepad The gamepad that has been reconnected. */ onReconnect(thisGamepad: Phaser.Input.Gamepad.Gamepad): void { this.disconnectedGamepads = this.disconnectedGamepads.filter(g => g !== thisGamepad.id); } /** * Initializes or updates configurations for connected gamepads. * It retrieves the names of all connected gamepads, sets up their configurations according to stored or default settings, * and ensures these configurations are saved. */ private setupGamepads(): void { const allGamepads = this.getGamepadsName(); for (const gamepad of allGamepads) { const gamepadID = gamepad.toLowerCase(); if (!this.selectedDevice[Device.GAMEPAD]) { this.setChosenGamepad(gamepadID); } const config = deepCopy(this.getConfig(gamepadID)) as InterfaceConfig; config.custom = this.configs[gamepadID]?.custom || { ...config.default }; this.configs[gamepadID] = config; globalScene.gameData?.saveMappingConfigs(gamepadID, this.configs[gamepadID]); } this.lastSource = "gamepad"; const handler = globalScene.ui?.handlers[UiMode.SETTINGS_GAMEPAD] as SettingsGamepadUiHandler; handler?.updateChosenGamepadDisplay(); } /** * Initializes or updates configurations for connected keyboards. */ private setupKeyboard(): void { for (const layout of ["default"]) { const config = deepCopy(this.getConfigKeyboard(layout)) as InterfaceConfig; config.custom = this.configs[layout]?.custom || { ...config.default }; this.configs[layout] = config; globalScene.gameData?.saveMappingConfigs(this.selectedDevice[Device.KEYBOARD], this.configs[layout]); } this.initChosenLayoutKeyboard(this.selectedDevice[Device.KEYBOARD]); } /** * Refreshes and re-indexes the list of connected gamepads. * * @remarks * This method updates the list of gamepads to exclude any that are undefined. * It corrects the index of each gamepad to account for any previously undefined entries, * ensuring that all gamepads are properly indexed and can be accurately referenced within the game. */ refreshGamepads(): void { // Sometimes, gamepads are undefined. For some reason. this.gamepads = globalScene.input.gamepad?.gamepads.filter(el => el !== null) ?? []; for (const [index, thisGamepad] of this.gamepads.entries()) { thisGamepad.index = index; // Overwrite the gamepad index, in case we had undefined gamepads earlier } } /** * Ensures the keyboard is initialized by checking if there is an active configuration for the keyboard. * If not, it sets up the keyboard with default configurations. */ ensureKeyboardIsInit(): void { if (!this.getActiveConfig(Device.KEYBOARD)?.padID) { this.setupKeyboard(); } } /** * Handles the keydown event for the keyboard. * * @param event The keyboard event. */ keyboardKeyDown(event: KeyboardEvent): void { this.lastSource = "keyboard"; this.ensureKeyboardIsInit(); const buttonDown = getButtonWithKeycode(this.getActiveConfig(Device.KEYBOARD)!, event.keyCode); if (buttonDown != null) { if (this.buttonLock.includes(buttonDown)) { return; } this.events.emit("input_down", { controller_type: "keyboard", button: buttonDown, }); clearInterval(this.inputInterval[buttonDown]); this.inputInterval[buttonDown] = setInterval(() => { this.events.emit("input_down", { controller_type: "keyboard", button: buttonDown, }); }, repeatInputDelayMillis); this.buttonLock.push(buttonDown); } } /** * Handles the keyup event for the keyboard. * * @param event The keyboard event. */ keyboardKeyUp(event: KeyboardEvent): void { this.lastSource = "keyboard"; // Bang is safe here; can't receive keyboard input if no active keyboard const buttonUp = getButtonWithKeycode(this.getActiveConfig(Device.KEYBOARD)!, event.keyCode); if (buttonUp != null) { this.events.emit("input_up", { controller_type: "keyboard", button: buttonUp, }); const index = this.buttonLock.indexOf(buttonUp); this.buttonLock.splice(index, 1); clearInterval(this.inputInterval[buttonUp]); } } /** * Handles button press events on a gamepad. This method sets the gamepad as chosen on the first input if no gamepad is currently chosen. * It checks if gamepad support is enabled and if the event comes from the chosen gamepad. If so, it maps the button press to a specific * action using a custom configuration, emits an event for the button press, and records the time of the action. * * @param pad The gamepad on which the button was pressed. * @param button The specific button that was pressed. * @param value The intensity or value of the button press, if applicable. */ gamepadButtonDown(pad: Phaser.Input.Gamepad.Gamepad, button: Phaser.Input.Gamepad.Button, _value: number): void { if (!this.configs[this.selectedDevice[Device.KEYBOARD]]?.padID) { this.setupKeyboard(); } if (!pad) { return; } this.lastSource = "gamepad"; if ( !this.selectedDevice[Device.GAMEPAD] || (globalScene.ui.mode !== UiMode.GAMEPAD_BINDING && this.selectedDevice[Device.GAMEPAD] !== pad.id.toLowerCase()) ) { this.setChosenGamepad(pad.id); } if (!this.gamepadSupport || pad.id.toLowerCase() !== this.selectedDevice[Device.GAMEPAD]?.toLowerCase()) { return; } const activeConfig = this.getActiveConfig(Device.GAMEPAD); const buttonDown = activeConfig && getButtonWithKeycode(activeConfig, button.index); if (buttonDown != null) { if (this.buttonLock.includes(buttonDown)) { return; } this.events.emit("input_down", { controller_type: "gamepad", button: buttonDown, }); clearInterval(this.inputInterval[buttonDown]); this.inputInterval[buttonDown] = setInterval(() => { if (!this.buttonLock.includes(buttonDown)) { clearInterval(this.inputInterval[buttonDown]); return; } this.events.emit("input_down", { controller_type: "gamepad", button: buttonDown, }); }, repeatInputDelayMillis); this.buttonLock.push(buttonDown); } } /** * Responds to a button release event on a gamepad by checking if the gamepad is supported and currently chosen. * If conditions are met, it identifies the configured action for the button, emits an event signaling the button release, * and clears the record of the button. * * @param pad The gamepad from which the button was released. * @param button The specific button that was released. * @param value The intensity or value of the button release, if applicable. */ gamepadButtonUp(pad: Phaser.Input.Gamepad.Gamepad, button: Phaser.Input.Gamepad.Button, _value: number): void { if (!pad) { return; } this.lastSource = "gamepad"; if (!this.gamepadSupport || pad.id.toLowerCase() !== this.selectedDevice[Device.GAMEPAD]) { return; } // Bang is safe here; can't receive gamepad input if no active gamepad const buttonUp = getButtonWithKeycode(this.getActiveConfig(Device.GAMEPAD)!, button.index); if (buttonUp !== undefined) { this.events.emit("input_up", { controller_type: "gamepad", button: buttonUp, }); const index = this.buttonLock.indexOf(buttonUp); this.buttonLock.splice(index, 1); clearInterval(this.inputInterval[buttonUp]); } } /** * Retrieves the configuration object for a gamepad based on its identifier. The method identifies specific gamepad models * based on substrings in the identifier and returns predefined configurations for recognized models. * If no specific configuration matches, it defaults to a generic gamepad configuration. * * @param id The identifier string of the gamepad. * @returns InterfaceConfig The configuration object corresponding to the identified gamepad type. */ getConfig(id: string): InterfaceConfig { id = id.toLowerCase(); if (id.includes("081f") && id.includes("e401")) { return PAD_UNLICENSED_SNES as InterfaceConfig; } if (id.includes("xbox") && id.includes("360")) { return PAD_XBOX360 as InterfaceConfig; } if (id.includes("054c")) { return PAD_DUALSHOCK as InterfaceConfig; } if (id.includes("057e") && id.includes("2009")) { return PAD_PROCON as InterfaceConfig; } return PAD_GENERIC as InterfaceConfig; } /** * Retrieves the configuration object for a keyboard layout based on its identifier. * * @param id The identifier string of the keyboard layout. * @returns InterfaceConfig The configuration object corresponding to the identified keyboard layout. */ getConfigKeyboard(id: string): InterfaceConfig { if (id === "default") { return CFG_KEYBOARD_QWERTY; } return CFG_KEYBOARD_QWERTY; } /** * Deactivates all currently pressed keys. */ deactivatePressedKey(): void { for (const value of Object.values(this.inputInterval)) { clearInterval(value); } this.buttonLock = []; } /** * Retrieves the active configuration for the currently chosen device. * It checks if a specific device ID is stored in configurations and returns it. * * @returns InterfaceConfig The configuration object for the active gamepad, or null if not set. */ getActiveConfig(device: Device.KEYBOARD): CustomKeyboardConfig | null; getActiveConfig(device: Device.GAMEPAD): CustomPadConfig | null; getActiveConfig(device: Device): CustomInterfaceConfig | null; getActiveConfig(device: Device): CustomInterfaceConfig | null { const activeDevice = this.selectedDevice[device]; if (activeDevice == null) { return null; } const config = this.configs[activeDevice]; if (config?.padID) { config.custom ??= { ...config.default }; // Cast is safe here as we assign `custom` above if it was undefined return config as CustomInterfaceConfig; } return null; } getIconForLatestInputRecorded(settingName: MappingSettingName): string | undefined { if (this.lastSource === "keyboard") { this.ensureKeyboardIsInit(); } return getIconForLatestInput(this.configs, this.lastSource, this.selectedDevice, settingName); } getLastSourceDevice(): Device { if (this.lastSource === "gamepad") { return Device.GAMEPAD; } return Device.KEYBOARD; } getLastSourceConfig(): InterfaceConfig | null { const sourceDevice = this.getLastSourceDevice(); if (sourceDevice === Device.KEYBOARD) { this.ensureKeyboardIsInit(); } return this.getActiveConfig(sourceDevice); } getLastSourceType(): string | undefined { return this.getLastSourceConfig()?.padType; } /** * Injects a custom mapping configuration into the configuration for a specific gamepad. * If the device does not have an existing configuration, it initializes one first. * * @param selectedDevice The identifier of the device to configure. * @param mappingConfigs The mapping configuration to apply to the device. */ injectConfig(selectedDevice: string, mappingConfigs: InterfaceConfig): void { if (!this.configs[selectedDevice]) { this.configs[selectedDevice] = {} as InterfaceConfig; } // A proper way of handling migrating keybinds would be much better const mappingOverrides = { BUTTON_CYCLE_VARIANT: "BUTTON_CYCLE_TERA", }; type MappingConfigKey = keyof typeof mappingConfigs.custom; for (const key in mappingConfigs.custom) { if (mappingConfigs.custom[key as MappingConfigKey] in mappingOverrides) { // @ts-expect-error - TS narrows mappingConfigs.custom[key] to never for some reason mappingConfigs.custom[key as MappingConfigKey] = mappingOverrides[mappingConfigs.custom[key as MappingConfigKey] as keyof typeof mappingOverrides]; } } this.configs[selectedDevice].custom = mappingConfigs.custom; } /** * Reset the mapping config for the selected device. \ * If it's a Gamepad, only reset the config for the one currently in use * @param device - The {@linkcode Device} to reset config for */ resetConfig(device: Device): void { const deviceName = this.selectedDevice[device]; if (deviceName == null) { return; } if (this.configs[deviceName]) { delete this.configs[deviceName]; switch (device) { case Device.KEYBOARD: this.setupKeyboard(); break; case Device.GAMEPAD: this.setupGamepads(); break; } } } /** * Swaps a binding in the configuration. * * @param config - The configuration object. * @param settingName - The name of the setting to swap. * @param pressedButton - The keycode of the button that was pressed. */ assignBinding(config: CustomInterfaceConfig, settingName: MappingSettingName, pressedButton: number): boolean { this.deactivatePressedKey(); if (config.padType === "keyboard") { return assign(config, settingName, pressedButton); } return swap(config, settingName, pressedButton); } }