UNPKG

tiny-essentials

Version:

Collection of small, essential scripts designed to be used across various projects. These simple utilities are crafted for speed, ease of use, and versatility.

1,122 lines 97.9 kB
/** * @typedef {Object} KeyStatus * Describes the status of a key or button. * @property {boolean} pressed - Whether the key is currently pressed. * @property {number} [value] - Optional analog value associated with the key. * @property {number} [value2] - Optional second analog value. */ /** * @typedef {'gamepad-only' | 'keyboard-only' | 'both'} InputMode * Defines the available input modes. */ /** * A callback function that is invoked when a mapped logical input is activated or deactivated. * * This function receives the logical name associated with the input (e.g., "Jump", "Shoot", "Menu") * and can be used to handle input-related actions such as triggering game mechanics or UI behavior. * * @typedef {(payload: { logicalName: string, activeTime: number, comboTime: number }) => void} MappedInputCallback */ /** * A callback function that is invoked when a mapped key is activated or deactivated. * * This function receives the key name associated with the input (e.g., "KeyA", "KeyB", "KeyC") * and can be used to handle input-related actions such as triggering game mechanics or UI behavior. * * @typedef {(payload: { key: string, activeTime: number }) => void} MappedKeyCallback */ /** * @typedef {(payload: InputPayload|InputAnalogPayload) => void} PayloadCallback * Callback for handling input events. */ /** * @typedef {(payload: ConnectionPayload) => void} ConnectionCallback * Callback for handling gamepad connection events. */ /** * A callback function that is triggered when a registered input sequence is fully activated. * * @callback InputSequenceCallback * @param {number} timestamp - The moment in milliseconds when the sequence was successfully detected. */ /** * A callback function that is triggered when a registered key sequence is fully activated. * * @callback KeySequenceCallback * @param {number} timestamp - The moment in milliseconds when the sequence was successfully detected. */ /** * Represents any valid callback type used in the TinyGamepad event system. * This is a union of all supported callback signatures. * @typedef {ConnectionCallback | PayloadCallback | MappedInputCallback | MappedKeyCallback} CallbackList */ /** * Represents a specific input source from a gamepad. * - 'gamepad-analog' refers to analog sticks or analog triggers. * - 'gamepad-button' refers to digital buttons on the gamepad. * @typedef {'gamepad-analog'|'gamepad-button'} GamepadDeviceSource */ /** * Represents any possible physical device source that can be used for input. * - 'mouse': Input from a mouse. * - 'keyboard': Input from a keyboard. * - 'gamepad-analog': Analog input from a gamepad (e.g., sticks, triggers). * - 'gamepad-button': Digital button input from a gamepad. * @typedef {'mouse'|'keyboard'|GamepadDeviceSource} DeviceSource */ /** * Represents the type of input interaction detected. * - 'up': Input was released. * - 'down': Input was pressed. * - 'hold': Input is being held. * - 'change': Input value changed (e.g., pressure or axis). * - 'move': Analog movement detected (e.g., joystick motion). * @typedef {'up'|'down'|'hold'|'change'|'move'} DeviceInputType */ /** * @typedef {Object} InputPayload * Structure for digital button input payload. * @property {string} id - Unique identifier for the input. * @property {Event} [event] - Optional DOM event reference. * @property {DeviceInputType} type - Type of input event (down, up, hold). * @property {DeviceSource} source - Input source (keyboard, mouse, gamepad). * @property {string} key - Physical input identifier. * @property {boolean} isPressure - Whether the input is pressure sensitive. * @property {string} logicalName - Logical name associated with the input. * @property {number} value - Primary analog value. * @property {number} value2 - Secondary analog value. * @property {boolean} pressed - Current pressed status. * @property {boolean|null} [prevPressed] - Previous pressed status. * @property {number} timestamp - Timestamp of the event. * @property {Gamepad} [gp] - Reference to the originating Gamepad. */ /** * @typedef {Object} InputAnalogPayload * Structure for analog input payload. * @property {string} id - Unique identifier for the input. * @property {Event} [event] - Optional DOM event reference. * @property {DeviceInputType} type - Type of input event (change). * @property {DeviceSource} source - Input source. * @property {string} key - Physical input identifier. * @property {string} logicalName - Logical name associated with the input. * @property {number} value - Analog value. * @property {number} value2 - Secondary analog value. * @property {number} timestamp - Timestamp of the event. * @property {Gamepad} [gp] - Reference to the originating Gamepad. */ /** * @typedef {Object} InputEvents * Internal structure for digital input events. * @property {string} id - Unique identifier for the input. * @property {Event} [event] - Optional DOM event reference. * @property {string} key - Input key identifier. * @property {DeviceSource} source - Source of the input. * @property {number} value - Value of the input. * @property {number} value2 - Secondary value. * @property {DeviceInputType} type - Type of input event. * @property {boolean} isPressure - Whether it is pressure-sensitive. * @property {boolean} pressed - Pressed status. * @property {boolean|null} [prevPressed] - Previous pressed status. * @property {number} timestamp - Timestamp of the event. * @property {Gamepad} [gp] - Reference to the gamepad. */ /** * @typedef {Object} InputAnalogEvents * Internal structure for analog input events. * @property {string} id - Unique identifier for the input. * @property {Event} [event] - Optional DOM event reference. * @property {string} key - Analog key. * @property {DeviceSource} source - Source of input. * @property {number} value - Main analog value. * @property {number} value2 - Secondary analog value. * @property {DeviceInputType} type - Type of event. * @property {number} timestamp - Timestamp. * @property {Gamepad} [gp] - Gamepad reference. */ /** * @typedef {Object} ConnectionPayload * Payload for connection-related events. * @property {string} id - ID of the gamepad. * @property {number} timestamp - Timestamp of the event. * @property {Gamepad} gp - Gamepad instance. */ /** * @typedef {Object} ExportedConfig * @property {string | null} expectedId - The expected identifier for a specific gamepad device, or null if not set. * @property {string[]} ignoreIds - Array of device IDs to be ignored (excluded from input detection). * @property {number} deadZone - The threshold value below which analog stick inputs are ignored (dead zone). * @property {number} timeoutComboKeys - Time in milliseconds allowed between consecutive inputs in a combo sequence before it resets. * @property {number} axisActiveSensitivity - Sensitivity threshold for detecting significant analog axis movement (0 = most sensitive, 1 = least sensitive). * @property {[string, string | string[]][]} inputMap - Array of key-value pairs representing the mapping between logical input names and physical input(s). */ /** * TinyGamepad is a high-level and extensible input management system * designed for professional-level control schemes in games or applications. * * It supports input from gamepads, keyboards, and optionally mouse devices. * Key features include: * - Input mapping from physical controls to logical action names * - Pressure-sensitive trigger and button support * - Dead zone configuration for analog inputs * - Event-based input handling (start, hold, end) * - Multiple logical names per input binding * - Unified control model for both digital and analog inputs * * TinyGamepad allows seamless integration of fallback control types, * detailed input feedback, and JSON-based profile serialization for saving and restoring mappings. */ class TinyGamepad { /** @type {boolean} Indicates whether this instance has been destroyed and is no longer usable. */ #isDestroyed = false; /** @type {Set<string>} Currently held physical key/input identifiers. */ #heldKeys = new Set(); /** @type {Map<string, string|string[]>} Maps logical input names to one or more physical input identifiers. */ #inputMap = new Map(); /** * @type {Map<string, CallbackList[]>} * Stores all event callback arrays for different input-related events. */ #callbacks = new Map(); /** @type {null|Gamepad} Holds the currently connected Gamepad instance, or null if none is connected. */ #connectedGamepad = null; /** @type {KeyStatus[]} Stores the previous button states for the connected gamepad to detect changes. */ #lastButtonStates = []; /** @type {Record<string|number, KeyStatus>} Tracks the previous state of each key or button (keyboard/mouse/gamepad). */ #lastKeyStates = {}; /** @type {number[]} Stores the last known values of all gamepad analog axes. */ #lastAxes = []; /** @type {null|number} Holds the requestAnimationFrame ID for the gamepad update loop. */ #animationFrame = null; /** @type {null|number} Holds the setInterval ID for keyboard and mouse hold tracking. */ #mouseKeyboardHoldLoop = null; /** @type {InputMode} Defines the current input mode (keyboard, gamepad, or both). */ #inputMode; /** @type {Set<string>} A list of controller IDs to ignore during gamepad scanning. */ #ignoreIds; /** @type {number} Dead zone threshold for analog stick sensitivity. */ #deadZone; /** @type {string|null} The expected gamepad ID (if filtering by specific controller model). */ #expectedId; /** @type {boolean} Whether mouse inputs are accepted and mapped like other inputs. */ #allowMouse; /** @type {Window|Element} The element or window that receives input events (keyboard/mouse). */ #elementBase; /** * @type {number} * Time in milliseconds before resetting a combination of raw keys (used for basic key sequences). */ #timeoutComboKeys; /** * Axis movement threshold to consider an axis active combo. * Value range: 0 (most sensitive) to 1 (least sensitive). * @type {number} */ #axisActiveSensitivity; /** @type {string[]} Temporarily holds the current sequence of mapped inputs being pressed. */ #comboInputs = []; /** @type {number} Timestamp of the last mapped input combo trigger. */ #timeComboInputs = 0; /** @type {number} Timestamp of the last mapped input. */ #timeMappedInputs = 0; /** @type {string[]} Temporarily holds the current sequence of raw keys being pressed. */ #comboKeys = []; /** @type {number} Timestamp of the last key combo trigger. */ #timeComboKeys = 0; /** @type {NodeJS.Timeout|null} Timer for auto-resetting the raw key combo sequence. */ #intervalComboKeys = null; /** * @type {Set<string>} * Set of currently active logical keys (used to track which mapped actions are being held). */ #activeMappedKeys = new Set(); /** * @type {Set<string>} * Set of currently active logical inputs (used to track which mapped actions are being held). */ #activeMappedInputs = new Set(); /** * @type {Map<string, { sequence: string[], callback: InputSequenceCallback, triggered: boolean }>} * Stores all registered logical input sequences and their associated callbacks. */ #inputSequences = new Map(); /** * @type {Map<string, { sequence: string[], callback: KeySequenceCallback, triggered: boolean }>} * Stores all registered raw key sequences and their associated callbacks. */ #keySequences = new Map(); /** * Stores the previous values of each input key to track state changes between updates. * @type {Map<string, { value: number; value2: number }>} */ #keyOldValue = new Map(); /** @type {Record<string, string>} */ static #specialMap = { ' ': 'Space', '\n': 'Enter', '\r': 'Enter', '\t': 'Tab', '-': 'Minus', _: 'Minus', '=': 'Equal', '+': 'Equal', '[': 'BracketLeft', '{': 'BracketLeft', ']': 'BracketRight', '}': 'BracketRight', '\\': 'Backslash', '|': 'Backslash', ';': 'Semicolon', ':': 'Semicolon', "'": 'Quote', '"': 'Quote', ',': 'Comma', '<': 'Comma', '.': 'Period', '>': 'Period', '/': 'Slash', '?': 'Slash', '`': 'Backquote', '~': 'Backquote', }; /** * Add or update a special key mapping. * @param {string} char - The character to map. * @param {string} code - The corresponding key code. */ static addSpecialKey(char, code) { if (typeof char !== 'string') throw new TypeError(`Invalid char type: expected string, got ${typeof char}`); if (char.length !== 1) throw new Error(`Invalid char length: "${char}" (must be exactly one character)`); if (typeof code !== 'string') throw new TypeError(`Invalid code type: expected string, got ${typeof code}`); TinyGamepad.#specialMap[char] = code; } /** * Remove a special key mapping. * @param {string} char - The character to remove from mapping. */ static removeSpecialKey(char) { if (typeof char !== 'string') throw new TypeError(`Invalid char type: expected string, got ${typeof char}`); if (char.length !== 1) throw new Error(`Invalid char length: "${char}" (must be exactly one character)`); delete TinyGamepad.#specialMap[char]; } /** * Get the mapped code for a special character. * @param {string} char - The character to look up. * @returns {string | undefined} - The mapped code or undefined if not found. */ static getSpecialKey(char) { if (typeof char !== 'string') throw new TypeError(`Invalid char type: expected string, got ${typeof char}`); if (char.length !== 1) throw new Error(`Invalid char length: "${char}" (must be exactly one character)`); return TinyGamepad.#specialMap[char]; } /** * Get all current special key mappings. * @returns {Record<string, string>} */ static getAllSpecialKeys() { return { ...TinyGamepad.#specialMap }; } /** * Converts a string into an array of TinyGamepad-style key codes. * Example: "pudding" → ['KeyP', 'KeyU', 'KeyD', 'KeyD', 'KeyI', 'KeyN', 'KeyG'] * @param {string} text - Input text. * @returns {string[]} Array of key codes. */ static stringToKeys(text) { if (typeof text !== 'string') throw new TypeError(`Invalid text type: expected string, got ${typeof text}`); if (!text.length) throw new Error(`Invalid text: cannot be empty`); return Array.from(text).map((char) => { const upper = char.toUpperCase(); if (upper >= 'A' && upper <= 'Z') { return `Key${upper}`; } if (upper >= '0' && upper <= '9') { return `Digit${upper}`; } const mapped = TinyGamepad.#specialMap[char]; if (mapped !== undefined) { return mapped; } throw new Error(`Unsupported character: "${char}"`); }); } /** * Initializes a new instance of TinyGamepad with customizable input behavior. * * This constructor allows configuring the expected device ID, the type of inputs to listen for * (keyboard, gamepad, or both), analog dead zone sensitivity, and whether to allow mouse input. * It also supports filtering out specific devices by ID. * * @param {Object} options - Configuration object for TinyGamepad behavior. * @param {string | null} [options.expectedId=null] - Specific controller ID to expect. * @param {InputMode} [options.inputMode='both'] - Mode of input to use. * @param {string[]} [options.ignoreIds=[]] - List of device IDs to ignore. * @param {number} [options.deadZone=0.1] - Analog stick dead zone threshold. * @param {boolean} [options.allowMouse=false] - Whether mouse events should be treated as input triggers. * @param {number} [options.timeoutComboKeys=500] - Maximum time (in milliseconds) allowed between inputs in a key sequence before the reset time. * @param {number} [options.axisActiveSensitivity=0.3] - Threshold to detect meaningful axis movement (0 = most sensitive, 1 = least sensitive). * @param {Window|Element} [options.elementBase=window] - The DOM element or window to bind keyboard and mouse events to. */ constructor({ expectedId = null, inputMode = 'both', ignoreIds = [], deadZone = 0.1, axisActiveSensitivity = 0.3, timeoutComboKeys = 500, allowMouse = false, elementBase = window, } = {}) { // Validate expectedId if (expectedId !== null && typeof expectedId !== 'string') throw new TypeError(`"expectedId" must be a string or null, received: ${typeof expectedId}`); // Validate inputMode if (!['keyboard-only', 'gamepad-only', 'both'].includes(inputMode)) throw new TypeError(`"inputMode" must be 'keyboard-only', 'gamepad-only', or 'both', received: ${inputMode}`); // Validate ignoreIds if (!Array.isArray(ignoreIds) || !ignoreIds.every((id) => typeof id === 'string')) throw new TypeError(`"ignoreIds" must be an array of strings`); // Validate deadZone if (typeof deadZone !== 'number' || deadZone < 0 || deadZone > 1) throw new RangeError(`"deadZone" must be a number between 0 and 1, received: ${deadZone}`); // Validate axisActiveSensitivity if (typeof axisActiveSensitivity !== 'number' || axisActiveSensitivity < 0 || axisActiveSensitivity > 1) throw new RangeError(`"axisActiveSensitivity" must be a number between 0 and 1, received: ${axisActiveSensitivity}`); // Validate timeoutComboKeys if (typeof timeoutComboKeys !== 'number' || timeoutComboKeys < 0) throw new RangeError(`"timeoutComboKeys" must be a positive number, received: ${timeoutComboKeys}`); // Validate allowMouse if (typeof allowMouse !== 'boolean') throw new TypeError(`"allowMouse" must be a boolean, received: ${typeof allowMouse}`); // Validate elementBase if (!(elementBase instanceof Window || elementBase instanceof Element)) throw new TypeError(`"elementBase" must be a Window or Element instance`); this.#expectedId = expectedId; this.#inputMode = inputMode; this.#ignoreIds = new Set(ignoreIds); this.#deadZone = deadZone; this.#allowMouse = allowMouse; this.#elementBase = elementBase; this.#timeoutComboKeys = timeoutComboKeys; this.#axisActiveSensitivity = axisActiveSensitivity; if (['gamepad-only', 'both'].includes(this.#inputMode)) { this.#initGamepadEvents(); } if (['keyboard-only', 'both'].includes(this.#inputMode)) { this.#initKeyboardMouse(); } } ////////////////////////////////////////// /** @type {(this: Window, ev: GamepadEvent) => any} */ #gamepadConnected = (e) => this.#onGamepadConnect(e.gamepad); /** @type {(this: Window, ev: GamepadEvent) => any} */ #gamepadDisconnected = (e) => this.#onGamepadDisconnect(e.gamepad); /** * Initializes listeners for gamepad connection and disconnection. * Automatically detects and handles supported gamepads. */ #initGamepadEvents() { window.addEventListener('gamepadconnected', this.#gamepadConnected); window.addEventListener('gamepaddisconnected', this.#gamepadDisconnected); } /** * Internal callback when a gamepad is connected. * Starts polling and emits a "connected" event. * @param {Gamepad} gamepad */ #onGamepadConnect(gamepad) { if (this.#isDestroyed) return; if (this.#ignoreIds.has(gamepad.id)) return; if (this.#expectedId && gamepad.id !== this.#expectedId) return; if (!this.#connectedGamepad) { this.#connectedGamepad = gamepad; this.#expectedId = gamepad.id; this.#startPolling(); this.#emit('connected', { id: gamepad.id, gp: gamepad, timestamp: gamepad.timestamp }); } } /** * Internal callback when a gamepad is disconnected. * Cancels polling and emits a "disconnected" event. * @param {Gamepad} gamepad */ #onGamepadDisconnect(gamepad) { if (this.#isDestroyed) return; if (this.#connectedGamepad && gamepad.id === this.#connectedGamepad.id) { this.#connectedGamepad = null; if (this.#animationFrame) { cancelAnimationFrame(this.#animationFrame); this.#animationFrame = null; } this.#emit('disconnected', { id: gamepad.id, gp: gamepad, timestamp: gamepad.timestamp }); } } /** * Starts the polling loop for tracking gamepad state. */ #startPolling() { const loop = () => { if (this.#isDestroyed) return; this.#checkGamepadState(); this.#animationFrame = requestAnimationFrame(loop); }; loop(); } /** * Compares and emits input changes from buttons and axes on the gamepad. */ #checkGamepadState() { if (this.#isDestroyed) return; const pads = navigator.getGamepads(); const gp = Array.from(pads).find((g) => g && g.id === this.#expectedId); if (!gp) return; this.#connectedGamepad = gp; gp.buttons.forEach((btn, index) => { const key = `Button${index}`; const prev = this.#lastButtonStates[index]?.pressed || false; /** @type {DeviceInputType} */ let type; const source = 'gamepad-button'; let value; let isPressure = false; if (btn.pressed && !prev) { value = 1; type = 'down'; } else if (!btn.pressed && prev) { value = 0; type = 'up'; } else if (btn.pressed && prev) { value = 1; type = 'hold'; } if (btn.pressed && btn.value > 0 && btn.value < 1 && btn.value !== 1) { value = btn.value; isPressure = true; } // @ts-ignore if (typeof value === 'number' && typeof type === 'string') this.#handleInput({ key, source, value, value2: NaN, type, gp, isPressure, pressed: btn.pressed, prevPressed: prev, timestamp: gp.timestamp, id: gp.id, }); this.#lastButtonStates[index] = { pressed: btn.pressed, value: typeof value === 'number' ? value : this.#lastButtonStates[index]?.value, value2: NaN, }; }); gp.axes.forEach((val, index) => { if (Math.abs(val) < this.#deadZone) val = 0; const key = `Axis${index}`; const prev = this.#lastAxes[index] ?? 0; if (val !== prev) { this.#handleInput({ key, source: 'gamepad-analog', value: val, value2: NaN, type: 'change', timestamp: gp.timestamp, id: gp.id, gp, }); } this.#lastAxes[index] = val; }); } /////////////////////////////////// /** * Listener for the 'keydown' event. * Triggers when a key is pressed and marks it as held. * Avoids duplicate presses while the key remains down. * Reports the input as a 'keyboard' source. * * @type {EventListener} */ #keydown = (e) => { if (this.#isDestroyed) return; if (!(e instanceof KeyboardEvent)) throw new Error('Expected KeyboardEvent in keydown listener.'); if (!this.#heldKeys.has(e.code)) { this.#heldKeys.add(e.code); this.#handleInput({ event: e, key: e.code, source: 'keyboard', value: 1, value2: NaN, type: 'down', pressed: true, prevPressed: this.#lastKeyStates[e.code]?.pressed ?? false, id: 'native_keyboard', timestamp: e.timeStamp, }); this.#lastKeyStates[e.code] = { pressed: true }; } }; /** * Listener for the 'keyup' event. * Triggers when a key is released and removes it from the held list. * Reports the input as a 'keyboard' source. * * @type {EventListener} */ #keyup = (e) => { if (this.#isDestroyed) return; if (!(e instanceof KeyboardEvent)) throw new Error('Expected KeyboardEvent in keyup listener.'); if (this.#heldKeys.has(e.code)) { this.#heldKeys.delete(e.code); this.#handleInput({ event: e, key: e.code, source: 'keyboard', value: 0, value2: NaN, type: 'up', pressed: false, prevPressed: this.#lastKeyStates[e.code]?.pressed ?? false, id: 'native_keyboard', timestamp: e.timeStamp, }); this.#lastKeyStates[e.code] = { pressed: false }; } }; /** * Listener for the 'mousedown' event. * Fires when a mouse button is pressed. * Identifies each button as 'Mouse<button>' and tracks its held state. * * @type {EventListener} */ #mousedown = (e) => { if (this.#isDestroyed) return; if (!(e instanceof MouseEvent)) throw new Error('Expected MouseEvent in mousedown listener.'); const key = `Mouse${e.button}`; this.#heldKeys.add(key); this.#handleInput({ event: e, key, source: 'mouse', value: 1, value2: NaN, type: 'down', pressed: true, prevPressed: this.#lastKeyStates[key]?.pressed ?? false, id: 'native_mouse', timestamp: e.timeStamp, }); this.#lastKeyStates[key] = { pressed: true }; }; /** * Listener for the 'mouseup' event. * Fires when a mouse button is released. * Stops tracking the held state of the given button. * * @type {EventListener} */ #mouseup = (e) => { if (this.#isDestroyed) return; if (!(e instanceof MouseEvent)) throw new Error('Expected MouseEvent in mouseup listener.'); const key = `Mouse${e.button}`; this.#heldKeys.delete(key); this.#handleInput({ event: e, key, source: 'mouse', value: 0, value2: NaN, type: 'up', pressed: false, prevPressed: this.#lastKeyStates[key]?.pressed ?? false, id: 'native_mouse', timestamp: e.timeStamp, }); this.#lastKeyStates[key] = { pressed: false }; }; /** * Listener for the 'mousemove' event. * Tracks relative movement of the mouse using movementX and movementY. * Used to simulate analog movement via mouse input. * * @type {EventListener} */ #mousemove = (e) => { if (this.#isDestroyed) return; if (!(e instanceof MouseEvent)) throw new Error('Expected MouseEvent in mousemove listener.'); if (e.movementX !== 0 || e.movementY !== 0) { const key = 'MouseMove'; /** @type {KeyStatus} */ const old = this.#lastKeyStates[key] ?? { pressed: false, value: 0, value2: 0 }; this.#handleInput({ event: e, key, source: 'mouse', value: e.movementX + (old.value ?? 0), value2: e.movementY + (old.value ?? 0), id: 'native_mouse', type: 'move', pressed: true, prevPressed: null, timestamp: e.timeStamp, }); this.#lastKeyStates[key] = { pressed: false, value: e.movementX, value2: e.movementY }; } }; /** * Initializes keyboard and mouse event listeners to emulate input behavior. */ #initKeyboardMouse() { // Keyboard this.#elementBase.addEventListener('keydown', this.#keydown); this.#elementBase.addEventListener('keyup', this.#keyup); if (this.#allowMouse) { this.#elementBase.addEventListener('mousedown', this.#mousedown); this.#elementBase.addEventListener('mouseup', this.#mouseup); this.#elementBase.addEventListener('mousemove', this.#mousemove); } // Opcional: checagem contínua para "hold" const loop = () => { if (this.#isDestroyed) return; this.#heldKeys.forEach((key) => { const source = !key.startsWith('Mouse') ? 'keyboard' : 'mouse'; this.#handleInput({ key, source, id: `native_${source}`, value: 1, value2: NaN, type: 'hold', pressed: true, prevPressed: this.#lastKeyStates[key]?.pressed ?? false, timestamp: NaN, }); }); this.#mouseKeyboardHoldLoop = requestAnimationFrame(loop); }; loop(); } ////////////////////////////////// /** * Handles an input event by dispatching to registered listeners. * This method acts as the central hub for all input events (gamepad, keyboard, mouse, etc.), * handling both direct physical inputs and their mapped logical equivalents. * * Features inside this method: * - Wildcard callback support (global input listeners) * - Automatic detection of axis-based controls (analog sticks, triggers) * - Dead zone filtering for axis values (#axisActiveSensitivity) * - Dynamic key press/release tracking for both physical keys and mapped inputs * - Combination key sequence tracking with timeout handling * - Separate callback systems for: * -> Physical inputs * -> Logical (mapped) inputs * -> Start/End/Hold input events * -> Combo sequences (key-based and mapped input-based) * - Payload injection for callbacks with contextual data * * @param {InputEvents|InputAnalogEvents} settings - Input event data containing key, value, type, etc. */ #handleInput(settings) { if (this.#isDestroyed) return; /** * @type {PayloadCallback[]} * List of global "input-*" listeners that will receive *all* input events * regardless of the specific key, axis, or logical mapping. */ // @ts-ignore const globalCbs = this.#callbacks.get('input-*') || []; // Extract main properties from incoming settings // @ts-ignore const { pressed, key } = settings; /** * @type {boolean} * Detect if the incoming key belongs to an axis (e.g., 'Axis0', 'Axis1'). */ const isAxis = key.startsWith('Axis'); /** * @type {boolean} * Determines whether the input should be considered "active". * - For buttons: simply uses the `pressed` flag * - For axes: compares value to configured deadzone threshold (#axisActiveSensitivity) */ const isPressed = (typeof pressed === 'boolean' && pressed) || (isAxis && (settings.value > this.#axisActiveSensitivity || settings.value < -Math.abs(this.#axisActiveSensitivity))); /** * @type {string} * The "active key" represents the directional form of the key. * - Non-axis: same as the original key * - Axis: adds '+' or '-' depending on value direction */ const activeKey = !isAxis ? key : `${key}${settings.value > 0 ? '+' : settings.value < 0 ? '-' : ''}`; /** * @type {boolean|null} * Used to track if this event results in a key press (true), release (false), or no change (null). */ let keyResult = null; // ------------------------- // PHYSICAL KEY TRACKING // ------------------------- if (settings.type !== 'move' && settings.type !== 'hold') { if (isPressed) { // ------------------------- // NEW KEY PRESS DETECTION // ------------------------- if ((!isAxis && !this.#activeMappedKeys.has(key)) || (isAxis && !this.#activeMappedKeys.has(key) && !this.#activeMappedKeys.has(`${key}+`) && !this.#activeMappedKeys.has(`${key}-`))) { if (this.#timeComboKeys === 0) this.#timeComboKeys = Date.now(); this.#activeMappedKeys.add(activeKey); keyResult = true; // Combo tracking this.#comboKeys.push(activeKey); if (this.#comboInputs.length < 1) { if (this.#intervalComboKeys) clearTimeout(this.#intervalComboKeys); this.#intervalComboKeys = setTimeout(() => this.resetComboMapped(), this.#timeoutComboKeys); } /** * @type {MappedKeyCallback[]} * Notifies all "mapped-key-start" listeners that a key has been pressed. */ // @ts-ignore const cbs = this.#callbacks.get('mapped-key-start') ?? []; for (const cb of cbs) cb({ key: activeKey, activeTime: this.#timeComboKeys, }); } } else { // ------------------------- // KEY RELEASE DETECTION // ------------------------- if ((!isAxis && this.#activeMappedKeys.has(key)) || (isAxis && (this.#activeMappedKeys.has(key) || this.#activeMappedKeys.has(`${key}+`) || this.#activeMappedKeys.has(`${key}-`)))) { this.#activeMappedKeys.delete(key); this.#activeMappedKeys.delete(`${key}+`); this.#activeMappedKeys.delete(`${key}-`); keyResult = false; /** * @type {MappedKeyCallback[]} * Notifies all "mapped-key-end" listeners that a key has been released. */ // @ts-ignore const cbs = this.#callbacks.get('mapped-key-end') ?? []; for (const cb of cbs) cb({ key: activeKey, activeTime: this.#timeComboKeys, }); } } // ------------------------- // PHYSICAL KEY COMBO SEQUENCES // ------------------------- for (const { sequence, callback, triggered } of this.#keySequences.values()) { const keySequence = this.#keySequences.get(sequence.join('+')); if (!keySequence) continue; const allPressed = sequence.every((name, index) => this.#comboKeys[index] === name); if (allPressed && !triggered) { keySequence.triggered = true; callback(this.#timeComboKeys); } else if (!allPressed && triggered) { keySequence.triggered = false; } } } // ------------------------- // LOGICAL (MAPPED) INPUTS // ------------------------- for (const [logical, physical] of this.#inputMap.entries()) { /** * Checks if a given tinyKey matches the physical mapping of a logical input. * @param {string} tinyKey * @returns {boolean} */ const checkPhysical = (tinyKey) => (typeof physical === 'string' && tinyKey === physical) || (Array.isArray(physical) && physical.findIndex((value, i) => tinyKey === physical[i]) > -1); const mainKey = checkPhysical(activeKey); const baseAxisKeyP = isAxis && checkPhysical(`${key}+`); const baseAxisKeyN = isAxis && checkPhysical(`${key}-`); // ------------------------- // ACTIVE MAPPED INPUT LIST // ------------------------- if (mainKey || baseAxisKeyP || baseAxisKeyN) { if (isPressed && mainKey) { if (keyResult || !this.#activeMappedInputs.has(logical)) { if (this.#timeMappedInputs === 0) this.#timeMappedInputs = Date.now(); this.#activeMappedInputs.add(logical); if (this.#timeComboInputs === 0) this.#timeComboInputs = Date.now(); if (this.#intervalComboKeys) clearTimeout(this.#intervalComboKeys); this.#comboInputs.push(logical); this.#intervalComboKeys = setTimeout(() => this.resetComboMapped(), this.#timeoutComboKeys); /** * @type {MappedInputCallback[]} * Notifies all "mapped-input-start" listeners that a logical input has been activated. */ // @ts-ignore const cbs = this.#callbacks.get('mapped-input-start') ?? []; for (const cb of cbs) cb({ logicalName: logical, activeTime: this.#timeMappedInputs, comboTime: this.#timeComboInputs, }); } } else { if (!keyResult || this.#activeMappedInputs.has(logical)) { this.#activeMappedInputs.delete(logical); if (this.#activeMappedInputs.size < 1) this.#timeMappedInputs = 0; /** * @type {MappedInputCallback[]} * Notifies all "mapped-input-end" listeners that a logical input has been deactivated. */ // @ts-ignore const cbs = this.#callbacks.get('mapped-input-end') ?? []; for (const cb of cbs) cb({ logicalName: logical, activeTime: this.#timeMappedInputs, comboTime: this.#timeComboInputs, }); } } // ------------------------- // LOGICAL COMBO SEQUENCES // ------------------------- for (const { sequence, callback, triggered } of this.#inputSequences.values()) { const inputSequence = this.#inputSequences.get(sequence.join('+')); if (!inputSequence) continue; const activeSequence = Array.from(this.#activeMappedInputs); const allPressed = sequence.every((name, index) => activeSequence[index] === name); if (allPressed && !triggered) { inputSequence.triggered = true; callback(this.#timeMappedInputs); } else if (!allPressed && triggered) { inputSequence.triggered = false; } } } /** @type {string[]} */ const keys = []; if (!isAxis || settings.value !== 0) keys.push(activeKey); else { const { value: valueN } = this.#keyOldValue.get(`${key}-`) ?? { value: 0, value2: NaN }; const { value: valueP } = this.#keyOldValue.get(`${key}+`) ?? { value: 0, value2: NaN }; if (settings.value !== valueN) keys.push(`${key}-`); if (settings.value !== valueP) keys.push(`${key}+`); } keys.forEach((key) => { // ------------------------- // MATCH CHECKER (for physical <-> logical link) // ------------------------- const matches = physical === '*' || physical === key || (Array.isArray(physical) && physical.includes(key)); if (!matches) return; // ------------------------- // CALLBACK RETRIEVAL // ------------------------- /** @type {PayloadCallback[]} */ // @ts-ignore const typeCbs = this.#callbacks.get(`input-${settings.type}-${logical}`) || []; /** @type {PayloadCallback[]} */ // @ts-ignore const cbs = this.#callbacks.get(`input-${logical}`) || []; if (cbs.length < 1 && typeCbs.length < 1 && globalCbs.length < 1) return; // ------------------------- // PAYLOAD DISPATCH // ------------------------- /** @type {InputPayload|InputAnalogPayload} */ const payload = { ...settings, key, logicalName: logical }; for (const cb of globalCbs) cb(payload); for (const cb of cbs) cb(payload); // ➕ Separate event type callbacks for (const cb of typeCbs) cb(payload); this.#keyOldValue.set(key, { value: settings.value, value2: settings.value2 }); }); } } /** * Emits a custom internal event to all listeners. * @param {string} event - Event name. * @param {*} data - Payload data. */ #emit(event, data) { const cbs = this.#callbacks.get(event) || []; for (const cb of cbs) cb(data); } /** * Registers a callback for a logical template * @param {string} logicalName * @param {CallbackList} callback * @param {string} nameStart */ #onTemplate(logicalName, callback, nameStart) { if (typeof logicalName !== 'string') throw new TypeError(`"logicalName" must be a string, received ${logicalName}`); if (typeof callback !== 'function') throw new TypeError(`"callback" must be a function, received ${typeof callback}`); const id = nameStart.replace('{logicalName}', logicalName); let callbacks = this.#callbacks.get(id); if (!Array.isArray(callbacks)) { callbacks = []; this.#callbacks.set(id, callbacks); } callbacks.push(callback); } /** * Registers a one-time callback for a logical template. * The callback is removed after the first invocation. * @param {string} logicalName * @param {CallbackList} callback * @param {string} nameStart */ #onceTemplate(logicalName, callback, nameStart) { if (typeof logicalName !== 'string') throw new TypeError(`"logicalName" must be a string, received ${logicalName}`); if (typeof callback !== 'function') throw new TypeError(`"callback" must be a function, received ${typeof callback}`); /** @type {CallbackList} */ // @ts-ignore const wrapper = (payload) => { this.#offTemplate(logicalName, wrapper, nameStart); callback(payload); }; this.#onTemplate(logicalName, wrapper, nameStart); } /** * Prepends a callback to the template event list. * @param {string} logicalName * @param {CallbackList} callback * @param {string} nameStart */ #prependTemplate(logicalName, callback, nameStart) { if (typeof logicalName !== 'string') throw new TypeError(`"logicalName" must be a string, received ${logicalName}`); if (typeof callback !== 'function') throw new TypeError(`"callback" must be a function, received ${typeof callback}`); const id = nameStart.replace('{logicalName}', logicalName); const list = this.#callbacks.get(id) ?? []; list.unshift(callback); this.#callbacks.set(id, list); } /** * Removes a callback from a specific logical template event. * @param {string} logicalName * @param {CallbackList} callback * @param {string} nameStart */ #offTemplate(logicalName, callback, nameStart) { if (typeof logicalName !== 'string') throw new TypeError(`"logicalName" must be a string, received ${logicalName}`); if (typeof callback !== 'function') throw new TypeError(`"callback" must be a function, received ${typeof callback}`); const id = nameStart.replace('{logicalName}', logicalName); const list = this.#callbacks.get(id); if (Array.isArray(list)) this.#callbacks.set(id, list.filter((cb) => cb !== callback)); } /////////////////////////////////////////////////// /** * Waits for a single input event from the user and resolves with detailed input information. * This is typically used in control configuration screens to allow the user to choose an input * (keyboard, mouse, or gamepad) that will be mapped to a logical action. * * The function listens for the first eligible input (ignores 'MouseMove') and returns the key, * input source, and the Gamepad object if applicable. If no input is received before the timeout, * the promise resolves with a `null` key and source. * * @param {object} [options] - Optional configuration for input capture behavior. * @param {number} [options.timeout=10000] - Timeout in milliseconds before the promise resolves automatically with null values. * @param {string} [options.eventName='MappingInput'] - The temporary logical event name used internally to listen for input. * @param {boolean} [options.canMove=false] - Whether movement-based inputs (e.g., mouse movement) are allowed. * @returns {Promise<{ key: string | null, source: DeviceSource | null, gp?: Gamepad }>} * A promise that resolves with an object containing: * - `key`: the identifier of the pressed input (e.g., "KeyW", "Button0", "LeftClick"), * - `source`: the origin of the input ("keyboard", "mouse", "gamepad-button", or "gamepad-analog"), * - `gp`: the Gamepad object (only if the input source is a gamepad). */ awaitInputMapping({ timeout = 10000, eventName = 'MappingInput', canMove = false } = {}) { return new Promise((resolve, reject) => { // Argument validation if (typeof timeout !== 'number' || Number.isNaN(timeout) || timeout < 0) return reject(new TypeError(`Invalid "timeout": expected a positive number, got ${timeout}`)); if (typeof eventName !== 'string' || !eventName.trim()) return reject(new TypeError(`Invalid "eventName": expected a non-empty string, got ${eventName}`)); if (typeof canMove !== 'boolean') return reject(new TypeError(`Invalid "canMove": expected a boolean, got ${canMove}`)); /** @type {{ key: string|null; source: DeviceSource|null; gp?: Gamepad; }} */ const result = { key: null, source: null }; /** @type {PayloadCallback} */ const inputCallback = ({ key, type, source, gp, value }) => { if (!canMove && type === 'move') return; result.key = key; result.source = source; result.gp = gp; clearTimeout(timer); this.offInputStart(eventName, inputCallback); this.offInputChange(eventName, inputCallback); if (canMove) this.offInput