diff --git a/src/battle-scene.ts b/src/battle-scene.ts index 183d47ee6cb3..e16506ef658e 100644 --- a/src/battle-scene.ts +++ b/src/battle-scene.ts @@ -314,7 +314,6 @@ export default class BattleScene extends SceneBase { } update() { - this.inputController.update(); this.ui?.update(); } diff --git a/src/inputs-controller.ts b/src/inputs-controller.ts index 3ef8fa241156..1f10197903cd 100644 --- a/src/inputs-controller.ts +++ b/src/inputs-controller.ts @@ -1,7 +1,6 @@ import Phaser from "phaser"; import * as Utils from "./utils"; import {deepCopy} from "./utils"; -import {initTouchControls} from "./touch-controls"; import pad_generic from "./configs/inputs/pad_generic"; import pad_unlicensedSNES from "./configs/inputs/pad_unlicensedSNES"; import pad_xbox360 from "./configs/inputs/pad_xbox360"; @@ -21,6 +20,7 @@ import { import BattleScene from "./battle-scene"; import {SettingGamepad} from "#app/system/settings/settings-gamepad.js"; import {SettingKeyboard} from "#app/system/settings/settings-keyboard"; +import TouchControl from "#app/touch-controls"; export interface DeviceMapping { [key: string]: number; @@ -48,7 +48,7 @@ export interface InterfaceConfig { custom?: MappingLayout; } -const repeatInputDelayMillis = 500; +const repeatInputDelayMillis = 250; // Phaser.Input.Gamepad.GamepadPlugin#refreshPads declare module "phaser" { @@ -92,7 +92,7 @@ export class InputsController { private scene: BattleScene; public events: Phaser.Events.EventEmitter; - private buttonLock: Button; + private buttonLock: Button[] = new Array(); private interactions: Map> = new Map(); private configs: Map = new Map(); @@ -101,10 +101,10 @@ export class InputsController { private disconnectedGamepads: Array = new Array(); - private pauseUpdate: boolean = false; public lastSource: string = "keyboard"; - private keys: Array = []; + private inputInterval: NodeJS.Timeout[] = new Array(); + private touchControls: TouchControl; /** * Initializes a new instance of the game control system, setting up initial state and configurations. @@ -181,7 +181,7 @@ export class InputsController { this.scene.input.keyboard.on("keydown", this.keyboardKeyDown, this); this.scene.input.keyboard.on("keyup", this.keyboardKeyUp, this); } - initTouchControls(this.events); + this.touchControls = new TouchControl(this.scene); } /** @@ -192,6 +192,7 @@ export class InputsController { */ loseFocus(): void { this.deactivatePressedKey(); + this.touchControls.deactivatePressedKey(); } /** @@ -232,47 +233,6 @@ export class InputsController { this.initChosenLayoutKeyboard(layoutKeyboard); } - /** - * Updates the interaction handling by processing input states. - * This method gives priority to certain buttons by reversing the order in which they are checked. - * This method loops through all button values, checks for valid and timely interactions, and conditionally processes - * or ignores them based on the current state of gamepad support and other criteria. - * - * It handles special conditions such as the absence of gamepad support or mismatches between the source of the input and - * the currently chosen gamepad. It also respects the paused state of updates to prevent unwanted input processing. - * - * If an interaction is valid and should be processed, it emits an 'input_down' event with details of the interaction. - */ - update(): void { - if (this.pauseUpdate) { - return; - } - for (const b of Utils.getEnumValues(Button).reverse()) { - if ( - this.interactions.hasOwnProperty(b) && - this.repeatInputDurationJustPassed(b as Button) && - this.interactions[b].isPressed - ) { - // Prevents repeating button interactions when gamepad support is disabled. - if ( - (!this.gamepadSupport && this.interactions[b].source === "gamepad") || - (this.interactions[b].source === "gamepad" && this.interactions[b].sourceName && this.interactions[b].sourceName !== this.selectedDevice[Device.GAMEPAD]) || - (this.interactions[b].source === "keyboard" && this.interactions[b].sourceName && this.interactions[b].sourceName !== this.selectedDevice[Device.KEYBOARD]) - ) { - // Deletes the last interaction for a button if gamepad is disabled. - this.delLastProcessedMovementTime(b as Button); - return; - } - // Emits an event for the button press. - this.events.emit("input_down", { - controller_type: this.interactions[b].source, - button: b, - }); - this.setLastProcessedMovementTime(b as Button, this.interactions[b].source, this.interactions[b].sourceName); - } - } - } - /** * 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. @@ -404,19 +364,24 @@ export class InputsController { */ keyboardKeyDown(event): void { this.lastSource = "keyboard"; - const keyDown = event.keyCode; this.ensureKeyboardIsInit(); - if (this.keys.includes(keyDown)) { - return; - } - this.keys.push(keyDown); - const buttonDown = getButtonWithKeycode(this.getActiveConfig(Device.KEYBOARD), keyDown); + const buttonDown = getButtonWithKeycode(this.getActiveConfig(Device.KEYBOARD), event.keyCode); if (buttonDown !== undefined) { + if (this.buttonLock.includes(buttonDown)) { + return; + } this.events.emit("input_down", { controller_type: "keyboard", button: buttonDown, }); - this.setLastProcessedMovementTime(buttonDown, "keyboard", this.selectedDevice[Device.KEYBOARD]); + clearInterval(this.inputInterval[buttonDown]); + this.inputInterval[buttonDown] = setInterval(() => { + this.events.emit("input_down", { + controller_type: "keyboard", + button: buttonDown, + }); + }, repeatInputDelayMillis); + this.buttonLock.push(buttonDown); } } @@ -427,16 +392,15 @@ export class InputsController { */ keyboardKeyUp(event): void { this.lastSource = "keyboard"; - const keyDown = event.keyCode; - this.keys = this.keys.filter(k => k !== keyDown); - this.ensureKeyboardIsInit(); - const buttonUp = getButtonWithKeycode(this.getActiveConfig(Device.KEYBOARD), keyDown); + const buttonUp = getButtonWithKeycode(this.getActiveConfig(Device.KEYBOARD), event.keyCode); if (buttonUp !== undefined) { this.events.emit("input_up", { controller_type: "keyboard", button: buttonUp, }); - this.delLastProcessedMovementTime(buttonUp); + const index = this.buttonLock.indexOf(buttonUp); + this.buttonLock.splice(index, 1); + clearInterval(this.inputInterval[buttonUp]); } } @@ -466,11 +430,25 @@ export class InputsController { const activeConfig = this.getActiveConfig(Device.GAMEPAD); const buttonDown = activeConfig && getButtonWithKeycode(activeConfig, button.index); if (buttonDown !== undefined) { + if (this.buttonLock.includes(buttonDown)) { + return; + } this.events.emit("input_down", { controller_type: "gamepad", button: buttonDown, }); - this.setLastProcessedMovementTime(buttonDown, "gamepad", pad.id); + 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); } } @@ -497,7 +475,9 @@ export class InputsController { controller_type: "gamepad", button: buttonUp, }); - this.delLastProcessedMovementTime(buttonUp); + const index = this.buttonLock.indexOf(buttonUp); + this.buttonLock.splice(index, 1); + clearInterval(this.inputInterval[buttonUp]); } } @@ -540,144 +520,13 @@ export class InputsController { } /** - * repeatInputDurationJustPassed returns true if @param button has been held down long - * enough to fire a repeated input. A button must claim the buttonLock before - * firing a repeated input - this is to prevent multiple buttons from firing repeatedly. - */ - repeatInputDurationJustPassed(button: Button): boolean { - if (!this.isButtonLocked(button)) { - return false; - } - const duration = Date.now() - this.interactions[button].pressTime; - if (duration >= repeatInputDelayMillis) { - return true; - } - } - - /** - * This method updates the interaction state to reflect that the button is pressed. - * - * @param button - The button for which to set the interaction. - * @param source - The source of the input (defaults to 'keyboard'). This helps identify the origin of the input, especially useful in environments with multiple input devices. - * - * @remarks - * This method is responsible for updating the interaction state of a button within the `interactions` dictionary. If the button is not already registered, this method returns immediately. - * When invoked, it performs the following updates: - * - `pressTime`: Sets this to the current time, representing when the button was initially pressed. - * - `isPressed`: Marks the button as currently being pressed. - * - `source`: Identifies the source device of the input, which can vary across different hardware (e.g., keyboard, gamepad). - * - * Additionally, this method locks the button (by calling `setButtonLock`) to prevent it from being re-processed until it is released, ensuring that each press is handled distinctly. - */ - setLastProcessedMovementTime(button: Button, source: String = "keyboard", sourceName?: String): void { - if (!this.interactions.hasOwnProperty(button)) { - return; - } - this.setButtonLock(button); - this.interactions[button].pressTime = Date.now(); - this.interactions[button].isPressed = true; - this.interactions[button].source = source; - this.interactions[button].sourceName = sourceName.toLowerCase(); - } - - /** - * Clears the last interaction for a specified button. - * - * @param button - The button for which to clear the interaction. - * - * @remarks - * This method resets the interaction details of the button, allowing it to be processed as a new input when pressed again. - * If the button is not registered in the `interactions` dictionary, this method returns immediately, otherwise: - * - `pressTime` is cleared. This was previously storing the timestamp of when the button was initially pressed. - * - `isPressed` is set to false, indicating that the button is no longer being pressed. - * - `source` is set to null, which had been indicating the device from which the button input was originating. - * - * It releases the button lock, which prevents the button from being processed repeatedly until it's explicitly released. - */ - delLastProcessedMovementTime(button: Button): void { - if (!this.interactions.hasOwnProperty(button)) { - return; - } - this.releaseButtonLock(button); - this.interactions[button].pressTime = null; - this.interactions[button].isPressed = false; - this.interactions[button].source = null; - this.interactions[button].sourceName = null; - } - - /** - * Deactivates all currently pressed keys and resets their interaction states. - * - * @remarks - * This method is used to reset the state of all buttons within the `interactions` dictionary, - * effectively deactivating any currently pressed keys. It performs the following actions: - * - * - Releases button lock for predefined buttons, allowing them - * to be pressed again or properly re-initialized in future interactions. - * - Iterates over all possible button values obtained via `Utils.getEnumValues(Button)`, and for - * each button: - * - Checks if the button is currently registered in the `interactions` dictionary. - * - Resets `pressTime` to null, indicating that there is no ongoing interaction. - * - Sets `isPressed` to false, marking the button as not currently active. - * - Clears the `source` field, removing the record of which device the button press came from. - * - * This method is typically called when needing to ensure that all inputs are neutralized. + * Deactivates all currently pressed keys. */ deactivatePressedKey(): void { - this.pauseUpdate = true; - this.releaseButtonLock(this.buttonLock); - for (const b of Utils.getEnumValues(Button)) { - if (this.interactions.hasOwnProperty(b)) { - this.interactions[b].pressTime = null; - this.interactions[b].isPressed = false; - this.interactions[b].source = null; - this.interactions[b].sourceName = null; - } - } - this.pauseUpdate = false; - } - - /** - * Checks if a specific button is currently locked. - * - * @param button - The button to check for a lock status. - * @returns `true` if the button is locked, otherwise `false`. - * - * @remarks - * This method is used to determine if a given button is currently prevented from being processed due to a lock. - * It checks against two separate lock variables, allowing for up to two buttons to be locked simultaneously. - */ - isButtonLocked(button: Button): boolean { - return this.buttonLock === button; - } - - /** - * Sets a lock on a given button. - * - * @param button - The button to lock. - * - * @remarks - * This method ensures that a button is not processed multiple times inadvertently. - * It checks if the button is already locked. - */ - setButtonLock(button: Button): void { - this.buttonLock = button; - } - - /** - * Releases a lock on a specific button, allowing it to be processed again. - * - * @param button - The button whose lock is to be released. - * - * @remarks - * This method checks lock variable. - * If either lock matches the specified button, that lock is cleared. - * This action frees the button to be processed again, ensuring it can respond to new inputs. - */ - releaseButtonLock(button: Button): void { - if (this.buttonLock === button) { - this.buttonLock = null; + for (const key of Object.keys(this.inputInterval)) { + clearInterval(this.inputInterval[key]); } + this.buttonLock = []; } /** @@ -751,8 +600,7 @@ export class InputsController { * @param pressedButton The button that was pressed. */ assignBinding(config, settingName, pressedButton): boolean { - this.pauseUpdate = true; - setTimeout(() => this.pauseUpdate = false, 500); + this.deactivatePressedKey(); if (config.padType === "keyboard") { return assign(config, settingName, pressedButton); } else { diff --git a/src/test/inputs/inputs.test.ts b/src/test/inputs/inputs.test.ts index 4924ade0fc40..e753e167b6bf 100644 --- a/src/test/inputs/inputs.test.ts +++ b/src/test/inputs/inputs.test.ts @@ -52,11 +52,6 @@ describe("Inputs", () => { expect(game.inputsHandler.log.length).toBe(4); }); - it("keyboard - test input holding for 1ms - 1 input", async() => { - await game.inputsHandler.pressKeyboardKey(cfg_keyboard_qwerty.deviceMapping.KEY_ARROW_UP, 1); - expect(game.inputsHandler.log.length).toBe(1); - }); - it("keyboard - test input holding for 200ms - 1 input", async() => { await game.inputsHandler.pressKeyboardKey(cfg_keyboard_qwerty.deviceMapping.KEY_ARROW_UP, 200); expect(game.inputsHandler.log.length).toBe(1); @@ -87,6 +82,11 @@ describe("Inputs", () => { expect(game.inputsHandler.log.length).toBe(1); }); + it("gamepad - test input holding for 249ms - 1 input", async() => { + await game.inputsHandler.pressGamepadButton(pad_xbox360.deviceMapping.RC_S, 249); + expect(game.inputsHandler.log.length).toBe(1); + }); + it("gamepad - test input holding for 300ms - 2 input", async() => { await game.inputsHandler.pressGamepadButton(pad_xbox360.deviceMapping.RC_S, 300); expect(game.inputsHandler.log.length).toBe(2); diff --git a/src/test/utils/inputsHandler.ts b/src/test/utils/inputsHandler.ts index fd961ed3ef6c..043dcffbdb90 100644 --- a/src/test/utils/inputsHandler.ts +++ b/src/test/utils/inputsHandler.ts @@ -3,7 +3,7 @@ import Phaser from "phaser"; import {InputsController} from "#app/inputs-controller"; import pad_xbox360 from "#app/configs/inputs/pad_xbox360"; import {holdOn} from "#app/test/utils/gameManagerUtils"; -import {initTouchControls} from "#app/touch-controls"; +import TouchControl from "#app/touch-controls"; import { JSDOM } from "jsdom"; import fs from "fs"; @@ -54,10 +54,8 @@ export default class InputsHandler { } init(): void { - setInterval(() => { - this.inputController.update(); - }); - initTouchControls(this.inputController.events); + const touchControl = new TouchControl(this.scene); + touchControl.deactivatePressedKey(); //test purpose this.events = this.inputController.events; this.scene.input.gamepad.emit("connected", this.fakePad); this.listenInputs(); diff --git a/src/touch-controls.ts b/src/touch-controls.ts index 401ae7c6b93f..d5a3197f8331 100644 --- a/src/touch-controls.ts +++ b/src/touch-controls.ts @@ -1,25 +1,164 @@ import {Button} from "./enums/buttons"; import EventEmitter = Phaser.Events.EventEmitter; +import BattleScene from "./battle-scene"; -// Create a map to store key bindings -export const keys = new Map(); -// Create a map to store keys that are currently pressed -export const keysDown = new Map(); -// Variable to store the ID of the last touched element -let lastTouchedId: string; +const repeatInputDelayMillis = 250; -/** - * Initialize touch controls by binding keys to buttons. - * - * @param events - The event emitter for handling input events. - */ -export function initTouchControls(events: EventEmitter): void { - preventElementZoom(document.querySelector("#dpad")); - preventElementZoom(document.querySelector("#apad")); - // Select all elements with the 'data-key' attribute and bind keys to them - for (const button of document.querySelectorAll("[data-key]")) { - // @ts-ignore - Bind the key to the button using the dataset key - bindKey(button, button.dataset.key, events); +export default class TouchControl { + events: EventEmitter; + private buttonLock: string[] = new Array(); + private inputInterval: NodeJS.Timeout[] = new Array(); + + constructor(scene: BattleScene) { + this.events = scene.game.events; + this.init(); + } + + /** + * Initialize touch controls by binding keys to buttons. + */ + init() { + this.preventElementZoom(document.querySelector("#dpad")); + this.preventElementZoom(document.querySelector("#apad")); + // Select all elements with the 'data-key' attribute and bind keys to them + for (const button of document.querySelectorAll("[data-key]")) { + // @ts-ignore - Bind the key to the button using the dataset key + this.bindKey(button, button.dataset.key); + } + } + + /** + * Binds a node to a specific key to simulate keyboard events on touch. + * + * @param node - The DOM element to bind the key to. + * @param key - The key to simulate. + * @param events - The event emitter for handling input events. + * + * @remarks + * This function binds touch events to a node to simulate 'keydown' and 'keyup' keyboard events. + * It adds the key to the keys map and tracks the keydown state. When a touch starts, it simulates + * a 'keydown' event and adds an 'active' class to the node. When the touch ends, it simulates a 'keyup' + * event, removes the keydown state, and removes the 'active' class from the node and the last touched element. + */ + bindKey(node: HTMLElement, key: string) { + node.addEventListener("touchstart", event => { + event.preventDefault(); + this.touchButtonDown(node, key); + }); + + node.addEventListener("touchend", event => { + event.preventDefault(); + this.touchButtonUp(node, key, event.target["id"]); + }); + } + + touchButtonDown(node: HTMLElement, key: string) { + if (this.buttonLock.includes(key)) { + return; + } + this.simulateKeyboardEvent("keydown", key); + clearInterval(this.inputInterval[key]); + this.inputInterval[key] = setInterval(() => { + this.simulateKeyboardEvent("keydown", key); + }, repeatInputDelayMillis); + this.buttonLock.push(key); + node.classList.add("active"); + + } + + touchButtonUp(node: HTMLElement, key: string, id: string) { + if (!this.buttonLock.includes(key)) { + return; + } + this.simulateKeyboardEvent("keyup", key); + + node.classList.remove("active"); + + document.getElementById(id)?.classList.remove("active"); + const index = this.buttonLock.indexOf(key); + this.buttonLock.splice(index, 1); + clearInterval(this.inputInterval[key]); + } + + /** + * Simulates a keyboard event on the canvas. + * + * @param eventType - The type of the keyboard event ('keydown' or 'keyup'). + * @param key - The key to simulate. + * + * @remarks + * This function checks if the key exists in the Button enum. If it does, it retrieves the corresponding button + * and emits the appropriate event ('input_down' or 'input_up') based on the event type. + */ + simulateKeyboardEvent(eventType: string, key: string) { + if (!Button.hasOwnProperty(key)) { + return; + } + const button = Button[key]; + + switch (eventType) { + case "keydown": + this.events.emit("input_down", { + controller_type: "keyboard", + button: button, + isTouch: true + }); + break; + case "keyup": + this.events.emit("input_up", { + controller_type: "keyboard", + button: button, + isTouch: true + }); + break; + } + } + + /** + * {@link https://stackoverflow.com/a/39778831/4622620|Source} + * + * Prevent zoom on specified element + * @param {HTMLElement} element + */ + preventElementZoom(element: HTMLElement): void { + if (!element) { + return; + } + element.addEventListener("touchstart", (event: TouchEvent) => { + + if (!(event.currentTarget instanceof HTMLElement)) { + return; + } + + const currentTouchTimeStamp = event.timeStamp; + const previousTouchTimeStamp = Number(event.currentTarget.dataset.lastTouchTimeStamp) || currentTouchTimeStamp; + const timeStampDifference = currentTouchTimeStamp - previousTouchTimeStamp; + const fingers = event.touches.length; + event.currentTarget.dataset.lastTouchTimeStamp = String(currentTouchTimeStamp); + + if (!timeStampDifference || timeStampDifference > 500 || fingers > 1) { + return; + } // not double-tap + + event.preventDefault(); + + if (event.target instanceof HTMLElement) { + event.target.click(); + } + }); + } + + /** + * Deactivates all currently pressed keys. + */ + deactivatePressedKey(): void { + for (const key of Object.keys(this.inputInterval)) { + clearInterval(this.inputInterval[key]); + } + for (const button of document.querySelectorAll("[data-key]")) { + button.classList.remove("active"); + } + this.buttonLock = []; } } @@ -47,113 +186,3 @@ export function isMobile(): boolean { })(navigator.userAgent || navigator.vendor || window["opera"]); return ret; } - -/** - * Simulates a keyboard event on the canvas. - * - * @param eventType - The type of the keyboard event ('keydown' or 'keyup'). - * @param key - The key to simulate. - * @param events - The event emitter for handling input events. - * - * @remarks - * This function checks if the key exists in the Button enum. If it does, it retrieves the corresponding button - * and emits the appropriate event ('input_down' or 'input_up') based on the event type. - */ -function simulateKeyboardEvent(eventType: string, key: string, events: EventEmitter) { - if (!Button.hasOwnProperty(key)) { - return; - } - const button = Button[key]; - - switch (eventType) { - case "keydown": - events.emit("input_down", { - controller_type: "keyboard", - button: button, - isTouch: true - }); - break; - case "keyup": - events.emit("input_up", { - controller_type: "keyboard", - button: button, - isTouch: true - }); - break; - } -} - -/** - * Binds a node to a specific key to simulate keyboard events on touch. - * - * @param node - The DOM element to bind the key to. - * @param key - The key to simulate. - * @param events - The event emitter for handling input events. - * - * @remarks - * This function binds touch events to a node to simulate 'keydown' and 'keyup' keyboard events. - * It adds the key to the keys map and tracks the keydown state. When a touch starts, it simulates - * a 'keydown' event and adds an 'active' class to the node. When the touch ends, it simulates a 'keyup' - * event, removes the keydown state, and removes the 'active' class from the node and the last touched element. - */ -function bindKey(node: HTMLElement, key: string, events) { - keys.set(node.id, key); - - node.addEventListener("touchstart", event => { - event.preventDefault(); - simulateKeyboardEvent("keydown", key, events); - keysDown.set(event.target["id"], node.id); - node.classList.add("active"); - }); - - node.addEventListener("touchend", event => { - event.preventDefault(); - - const pressedKey = keysDown.get(event.target["id"]); - if (pressedKey && keys.has(pressedKey)) { - const key = keys.get(pressedKey); - simulateKeyboardEvent("keyup", key, events); - } - - keysDown.delete(event.target["id"]); - node.classList.remove("active"); - - if (lastTouchedId) { - document.getElementById(lastTouchedId).classList.remove("active"); - } - }); -} - -/** - * {@link https://stackoverflow.com/a/39778831/4622620|Source} - * - * Prevent zoom on specified element - * @param {HTMLElement} element - */ -function preventElementZoom(element: HTMLElement): void { - if (!element) { - return; - } - element.addEventListener("touchstart", (event: TouchEvent) => { - - if (!(event.currentTarget instanceof HTMLElement)) { - return; - } - - const currentTouchTimeStamp = event.timeStamp; - const previousTouchTimeStamp = Number(event.currentTarget.dataset.lastTouchTimeStamp) || currentTouchTimeStamp; - const timeStampDifference = currentTouchTimeStamp - previousTouchTimeStamp; - const fingers = event.touches.length; - event.currentTarget.dataset.lastTouchTimeStamp = String(currentTouchTimeStamp); - - if (!timeStampDifference || timeStampDifference > 500 || fingers > 1) { - return; - } // not double-tap - - event.preventDefault(); - - if (event.target instanceof HTMLElement) { - event.target.click(); - } - }); -}