diff --git a/GDJS/Runtime/inputmanager.ts b/GDJS/Runtime/inputmanager.ts index f3aa859a83e2..993fa956ee58 100644 --- a/GDJS/Runtime/inputmanager.ts +++ b/GDJS/Runtime/inputmanager.ts @@ -25,6 +25,11 @@ namespace gdjs { * if location is not specified. */ private static _DEFAULT_LEFT_VARIANT_KEYS: integer[] = [16, 17, 18, 91]; + + /** + * The `KeyboardEvent.location` of keys on the numeric keypad. + */ + private static _NUMPAD_LOCATION: integer = 3; private _pressedKeys: Hashtable; private _justPressedKeys: Hashtable; private _releasedKeys: Hashtable; @@ -111,11 +116,24 @@ namespace gdjs { * * @param keyCode The raw key code * @param location The location + * @param code The KeyboardEvent.code, used to know if the key really is on + * the numpad when a numpad location is reported. */ static getLocationAwareKeyCode( keyCode: number, - location: number | null | undefined + location: number | null | undefined, + code?: string | null ): integer { + if ( + location === InputManager._NUMPAD_LOCATION && + code && + !code.startsWith('Numpad') + ) { + // macOS sets the "numeric pad" modifier flag for the arrow keys, which makes + // WebKit (Safari) report them with a numpad location. Only `code` tells the + // numpad keys apart from the arrow keys, so trust it over the location. + location = 0; + } if (location) { // If it is a numpad number, do not modify it. if (96 <= keyCode && keyCode <= 105) { @@ -135,11 +153,13 @@ namespace gdjs { * 2 for right keys, and 3 for numpad keys. * @param keyCode The raw key code associated to the key press. * @param location The location of the event. + * @param code The KeyboardEvent.code of the event. */ - onKeyPressed(keyCode: number, location?: number): void { + onKeyPressed(keyCode: number, location?: number, code?: string): void { const locationAwareKeyCode = InputManager.getLocationAwareKeyCode( keyCode, - location + location, + code ); this._pressedKeys.put(locationAwareKeyCode, true); this._justPressedKeys.put(locationAwareKeyCode, true); @@ -152,11 +172,13 @@ namespace gdjs { * 2 for right keys, and 3 for numpad keys. * @param keyCode The raw key code associated to the key release. * @param location The location of the event. + * @param code The KeyboardEvent.code of the event. */ - onKeyReleased(keyCode: number, location?: number): void { + onKeyReleased(keyCode: number, location?: number, code?: string): void { const locationAwareKeyCode = InputManager.getLocationAwareKeyCode( keyCode, - location + location, + code ); this._pressedKeys.put(locationAwareKeyCode, false); this._justPressedKeys.put(locationAwareKeyCode, false); @@ -170,6 +192,11 @@ namespace gdjs { */ releaseAllPressedKeys(): void { for (const locationAwareKeyCode in this._pressedKeys.items) { + // Keys keep an entry once released, so only the keys actually held down + // must be made to go through the release state. + if (!this._pressedKeys.items[locationAwareKeyCode]) { + continue; + } this._pressedKeys.put(locationAwareKeyCode, false); this._justPressedKeys.put(locationAwareKeyCode, false); this._releasedKeys.put(locationAwareKeyCode, true); @@ -427,6 +454,23 @@ namespace gdjs { this._releasedMouseButtons[buttonCode] = true; } + /** + * Release all the mouse buttons that are currently pressed. + */ + releaseAllPressedMouseButtons(): void { + for ( + let buttonCode = 0; + buttonCode < this._pressedMouseButtons.length; + buttonCode++ + ) { + if (this._pressedMouseButtons[buttonCode]) { + // Go through `onMouseButtonReleased` so that the touch simulated by + // the left button is ended too. + this.onMouseButtonReleased(buttonCode); + } + } + } + /** * Return true if the mouse button corresponding to buttonCode is pressed. * @param buttonCode The mouse button code (0: Left button, 1: Right button). diff --git a/GDJS/Runtime/pixi-renderers/runtimegame-pixi-renderer.ts b/GDJS/Runtime/pixi-renderers/runtimegame-pixi-renderer.ts index 7eefac2bb4de..6098966f3cef 100644 --- a/GDJS/Runtime/pixi-renderers/runtimegame-pixi-renderer.ts +++ b/GDJS/Runtime/pixi-renderers/runtimegame-pixi-renderer.ts @@ -62,6 +62,8 @@ namespace gdjs { _wasDisposed: boolean = false; + _unregisterFocusListeners: (() => void) | null = null; + /** * @param game The game that is being rendered * @param forceFullscreen If fullscreen should be always activated @@ -753,7 +755,7 @@ namespace gdjs { return; } - manager.onKeyPressed(e.keyCode, e.location); + manager.onKeyPressed(e.keyCode, e.location, e.code); }; document.onkeyup = (e) => { if (isFocusingDomElement()) { @@ -770,11 +772,11 @@ namespace gdjs { // This means the key would be considered as "stuck" from the game's perspective // it would never be released unless it's pressed and released again (without meta). // Out of caution, we simulate a release of the key that were pressed with meta key. - for (const { - location, - keyCode, - } of keysPressedWithMetaPressedByCode.values()) { - manager.onKeyReleased(keyCode, location); + for (const [ + code, + { location, keyCode }, + ] of keysPressedWithMetaPressedByCode) { + manager.onKeyReleased(keyCode, location, code); } keysPressedWithMetaPressedByCode.clear(); } @@ -792,7 +794,32 @@ namespace gdjs { e.preventDefault(); } - manager.onKeyReleased(e.keyCode, e.location); + manager.onKeyReleased(e.keyCode, e.location, e.code); + }; + + // No "keyup" or "mouseup" is received for the keys and mouse buttons that are + // still held down when the game loses the focus (when switching to another + // window or tab), which would leave them stuck in a pressed state. + // Release them all instead. + const releaseAllPressedInputs = () => { + keysPressedWithMetaPressedByCode.clear(); + manager.releaseAllPressedKeys(); + manager.releaseAllPressedMouseButtons(); + }; + const onBlur = () => { + releaseAllPressedInputs(); + }; + const onVisibilityChange = () => { + // On mobile, switching to another app can hide the game without blurring it. + if (document.visibilityState === 'hidden') { + releaseAllPressedInputs(); + } + }; + window.addEventListener('blur', onBlur); + document.addEventListener('visibilitychange', onVisibilityChange); + this._unregisterFocusListeners = () => { + window.removeEventListener('blur', onBlur); + document.removeEventListener('visibilitychange', onVisibilityChange); }; // Mouse: @@ -1099,11 +1126,16 @@ namespace gdjs { /** * Dispose the renderers (PixiJS and/or Three.js) as well as DOM elements * used for the game (the canvas, if specified, and the additional DOM container - * created on top of it to allow display HTML elements, for example for text inputs). + * created on top of it to allow display HTML elements, for example for text inputs), + * and the events listeners registered on the window and document. * * @param removeCanvas If true, the canvas will be removed from the DOM. */ dispose(removeCanvas?: boolean) { + if (this._unregisterFocusListeners) { + this._unregisterFocusListeners(); + this._unregisterFocusListeners = null; + } this._pixiRenderer?.destroy(); this._threeRenderer?.dispose(); this._pixiRenderer = null; diff --git a/GDJS/tests/tests/inputmanager.js b/GDJS/tests/tests/inputmanager.js index 1fd9bdcbaacf..a3caa898f7d2 100644 --- a/GDJS/tests/tests/inputmanager.js +++ b/GDJS/tests/tests/inputmanager.js @@ -91,6 +91,74 @@ describe('gdjs.InputManager', () => { inputManager.onKeyReleased(17); }); + it('should ignore a numpad location that is contradicted by the code', () => { + // Safari on macOS reports the arrow keys with a numpad location, because macOS + // sets the "numeric pad" modifier flag for them. + inputManager.onKeyPressed(38, 3, 'ArrowUp'); + expect(inputManager.getLastPressedKey()).to.be(38); + expect(inputManager.isKeyPressed(38)).to.be(true); + expect(inputManager.isKeyPressed(3038)).to.be(false); + inputManager.onKeyReleased(38, 3, 'ArrowUp'); + expect(inputManager.wasKeyReleased(38)).to.be(true); + expect(inputManager.wasKeyReleased(3038)).to.be(false); + inputManager.onFrameEnded(); + + // A numpad key with NumLock off keeps its numpad location. + inputManager.onKeyPressed(38, 3, 'Numpad8'); + expect(inputManager.getLastPressedKey()).to.be(3038); + expect(inputManager.isKeyPressed(3038)).to.be(true); + expect(inputManager.isKeyPressed(38)).to.be(false); + inputManager.onKeyReleased(38, 3, 'Numpad8'); + expect(inputManager.wasKeyReleased(3038)).to.be(true); + inputManager.onFrameEnded(); + + // Without a code, the location is trusted as before. + inputManager.onKeyPressed(38, 3); + expect(inputManager.getLastPressedKey()).to.be(3038); + inputManager.onKeyReleased(38, 3); + }); + + it('should release the held keys and mouse buttons when the game loses the focus', () => { + // A key that was already pressed and released before losing the focus. + inputManager.onKeyPressed(65, 0, 'KeyA'); + inputManager.onKeyReleased(65, 0, 'KeyA'); + inputManager.onFrameEnded(); + + // Keys and mouse buttons still held down when the focus is lost. + inputManager.onKeyPressed(87, 0, 'KeyW'); + inputManager.onKeyPressed(16, 1, 'ShiftLeft'); + inputManager.onMouseButtonPressed(gdjs.InputManager.MOUSE_LEFT_BUTTON); + expect(inputManager.isKeyPressed(87)).to.be(true); + expect(inputManager.isKeyPressed(1016)).to.be(true); + expect( + inputManager.isMouseButtonPressed(gdjs.InputManager.MOUSE_LEFT_BUTTON) + ).to.be(true); + inputManager.onFrameEnded(); + + inputManager.releaseAllPressedKeys(); + inputManager.releaseAllPressedMouseButtons(); + expect(inputManager.isKeyPressed(87)).to.be(false); + expect(inputManager.isKeyPressed(1016)).to.be(false); + expect(inputManager.wasKeyReleased(87)).to.be(true); + expect(inputManager.wasKeyReleased(1016)).to.be(true); + expect(inputManager.anyKeyPressed()).to.be(false); + expect( + inputManager.isMouseButtonPressed(gdjs.InputManager.MOUSE_LEFT_BUTTON) + ).to.be(false); + expect( + inputManager.isMouseButtonReleased(gdjs.InputManager.MOUSE_LEFT_BUTTON) + ).to.be(true); + expect(inputManager.anyMouseButtonPressed()).to.be(false); + // The touch simulated by the left button must have ended too. + expect( + inputManager.hasTouchEnded(gdjs.InputManager.MOUSE_TOUCH_ID) + ).to.be(true); + + // The key released before the focus loss must not be released a second time. + expect(inputManager.wasKeyReleased(65)).to.be(false); + inputManager.onFrameEnded(); + }); + it('should handle mouse events', () => { inputManager.onMouseMove(500, 600); expect(inputManager.getCursorX()).to.be(500);