[QoL] Improve Input Accuracy by Refactoring Button Handling (#1936)

* refactored inputs-controller for better hold button management

* refactored the touch controls file to use a class and add holding button system

* added a method to deactivate pressed key for touch on focus lost

* better lost focus management
This commit is contained in:
Greenlamp2 2024-06-10 01:15:37 +02:00 committed by GitHub
parent f6ad30b58f
commit d03c75c2f9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 211 additions and 337 deletions

View File

@ -314,7 +314,6 @@ export default class BattleScene extends SceneBase {
}
update() {
this.inputController.update();
this.ui?.update();
}

View File

@ -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<Button, Map<string, boolean>> = new Map();
private configs: Map<string, InterfaceConfig> = new Map();
@ -101,10 +101,10 @@ export class InputsController {
private disconnectedGamepads: Array<String> = new Array();
private pauseUpdate: boolean = false;
public lastSource: string = "keyboard";
private keys: Array<number> = [];
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<String> 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 {

View File

@ -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);

View File

@ -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();

View File

@ -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<string, string>();
// Create a map to store keys that are currently pressed
export const keysDown = new Map<string, string>();
// 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();
}
});
}