Skip to content

Commit

Permalink
[QoL] Improve Input Accuracy by Refactoring Button Handling (pagefaul…
Browse files Browse the repository at this point in the history
…tgames#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
  • Loading branch information
Greenlamp2 authored Jun 9, 2024
1 parent f6ad30b commit d03c75c
Show file tree
Hide file tree
Showing 5 changed files with 211 additions and 337 deletions.
1 change: 0 additions & 1 deletion src/battle-scene.ts
Original file line number Diff line number Diff line change
Expand Up @@ -314,7 +314,6 @@ export default class BattleScene extends SceneBase {
}

update() {
this.inputController.update();
this.ui?.update();
}

Expand Down
244 changes: 46 additions & 198 deletions src/inputs-controller.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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;
Expand Down Expand Up @@ -48,7 +48,7 @@ export interface InterfaceConfig {
custom?: MappingLayout;
}

const repeatInputDelayMillis = 500;
const repeatInputDelayMillis = 250;

// Phaser.Input.Gamepad.GamepadPlugin#refreshPads
declare module "phaser" {
Expand Down Expand Up @@ -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();

Expand All @@ -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.
Expand Down Expand Up @@ -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);
}

/**
Expand All @@ -192,6 +192,7 @@ export class InputsController {
*/
loseFocus(): void {
this.deactivatePressedKey();
this.touchControls.deactivatePressedKey();
}

/**
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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);
}
}

Expand All @@ -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]);
}
}

Expand Down Expand Up @@ -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);
}
}

Expand All @@ -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]);
}
}

Expand Down Expand Up @@ -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 = [];
}

/**
Expand Down Expand Up @@ -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 {
Expand Down
10 changes: 5 additions & 5 deletions src/test/inputs/inputs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
Loading

0 comments on commit d03c75c

Please sign in to comment.