diff --git a/CHANGELOG.md b/CHANGELOG.md index 47f10b492..9d5b94d05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,6 +63,8 @@ So your change should look like: by CSS and wasn't rendered at its natural size (#1096) - @Stanko - Modified `pos`, `skew` and `scale` components to make operations like `obj.pos.x += 1` work again (#1109) - @ErikGXDev +- Fixed `isKeyDown` and `isButtonDown` getting stuck on game loosing focus + (#1101) - @Stanko ## [4000.0.0-alpha.27.1] - 2026-05-12 diff --git a/src/app/app.ts b/src/app/app.ts index 7e1dbfecd..4c14a9e4b 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -89,6 +89,11 @@ export class ButtonState { state.events.trigger(this._releaseEv as any, btn, this._arg); } } + releaseAll(state: AppState) { + for (const btn of this.down) { + this.release(btn, state); + } + } } class GamepadState { @@ -978,8 +983,39 @@ export const initApp = ( const docEvents: EventList = {}; const winEvents: EventList = {}; + let releaseHeldInputsQueued = false; + + function releaseHeldInputs() { + state.buttonHandler.releaseKeyboardMouse(state); + state.keyState.releaseAll(state); + state.mouseState.releaseAll(state); + } + + function queueReleaseHeldInputs() { + if (releaseHeldInputsQueued) { + return; + } + releaseHeldInputsQueued = true; + state.events.onOnce("input", () => { + releaseHeldInputsQueued = false; + releaseHeldInputs(); + }); + } + + function releaseHeldInputsOnFocusLoss() { + // Release all inputs immediately when blur/hide happens, + // and then queue it to to catch any queued events on the next input tick, + // that wouldn't be processed otherwise + releaseHeldInputs(); + queueReleaseHeldInputs(); + } + const pd = opt.pixelDensity || 1; + canvasEvents.blur = () => { + releaseHeldInputsOnFocusLoss(); + }; + canvasEvents.mousemove = (e) => { // 🍝 Here we depend of GFX Context even if initGfx needs initApp for being used // Letterbox creates some black bars so we need to remove that for calculating @@ -1259,11 +1295,16 @@ export const initApp = ( state.events.trigger("show"); } else { + releaseHeldInputsOnFocusLoss(); state.isHidden = true; state.events.trigger("hide"); } }; + winEvents.blur = () => { + releaseHeldInputsOnFocusLoss(); + }; + winEvents.gamepadconnected = (e) => { const kbGamepad = registerGamepad(e.gamepad); state.events.onOnce("input", () => { diff --git a/src/app/inputBindings.ts b/src/app/inputBindings.ts index a9841e18e..1c9cefbe4 100644 --- a/src/app/inputBindings.ts +++ b/src/app/inputBindings.ts @@ -134,6 +134,15 @@ class ChordedButtonDetector { } return canceledButtons; } + releaseAll(): string[] { + const buttons = [...this.buttonsUsed]; + this.buttonsUsed.clear(); + this.mods.forEach((_, mod) => this.mods.set(mod, false)); + return buttons; + } + isButtonUsed(button: string): boolean { + return this.buttonsUsed.has(button); + } } export class ButtonProcessor { @@ -176,6 +185,16 @@ export class ButtonProcessor { this.state.release(button, state); } } + private _releaseKeyboardMouse(buttons: string[], state: AppState) { + for (let button of buttons) { + if ( + this.state.down.has(button) + && !this.byGamepad.isButtonUsed(button) + ) { + this.state.release(button, state); + } + } + } processKeydown(key: Key, keyCode: string, state: AppState) { this._maybePress(this.byKey.handleDown(key), state); this._maybePress(this.byKeyCode.handleDown(keyCode), state); @@ -196,6 +215,16 @@ export class ButtonProcessor { processGamepadButtonUp(gb: KGamepadButton, state: AppState) { this._maybeRelease(this.byGamepad.handleUp(gb), state); } + releaseKeyboardMouse(state: AppState) { + this._releaseKeyboardMouse( + [ + ...this.byKey.releaseAll(), + ...this.byKeyCode.releaseAll(), + ...this.byMouse.releaseAll(), + ], + state, + ); + } update() { this.state.update(); }