Fix arrow keys on safari + release keys and mouse on blur

This commit is contained in:
Clément Pasteau
2026-07-31 15:26:20 +02:00
parent b1e60169e4
commit bfc73e1b4e
3 changed files with 157 additions and 13 deletions
+49 -5
View File
@@ -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<boolean>;
private _justPressedKeys: Hashtable<boolean>;
private _releasedKeys: Hashtable<boolean>;
@@ -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).
@@ -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;
+68
View File
@@ -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);