From ad70053801d0d1dacd390d6190e03544d07fe6b8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 6 Jun 2026 10:06:53 +0000 Subject: [PATCH 1/2] Fix unreliable mouse movement breaking first-person cameras on Firefox The browser's MouseEvent.movementX/Y are only reliable while the pointer is locked. When not locked, they are inconsistent across browsers: notably Firefox adds the game canvas offset to them, so a first-person camera keeps drifting/sticking (e.g. stuck looking up) unless the game runs fullscreen. Now the movement deltas from the browser are only used when the pointer is locked (where the cursor position is frozen). Otherwise the InputManager derives the movement from the change of the cursor position, which is consistent across browsers and already expressed in game resolution units. The reference position is invalidated when the pointer lock state changes or when the mouse leaves/enters the canvas, to avoid reporting a spurious jump. Relates to #6338. --- GDJS/Runtime/inputmanager.ts | 38 ++++++++++- .../runtimegame-pixi-renderer.ts | 17 +++-- GDJS/tests/tests/inputmanager.js | 65 +++++++++++++++++++ 3 files changed, 113 insertions(+), 7 deletions(-) diff --git a/GDJS/Runtime/inputmanager.ts b/GDJS/Runtime/inputmanager.ts index f3aa859a83e2..a45317d20716 100644 --- a/GDJS/Runtime/inputmanager.ts +++ b/GDJS/Runtime/inputmanager.ts @@ -61,6 +61,12 @@ namespace gdjs { * The mouse movement Y (only moved by mouse events). */ private _mouseMovementY: float = 0; + /** + * Whether the last known mouse position can be used to compute a movement + * delta. Reset when the mouse leaves/enters the canvas or when the pointer + * lock state changes, to avoid reporting a spurious large movement. + */ + private _canComputeMouseMovementFromPosition: boolean = false; // TODO Remove _touches when there is no longer SpritePanelButton 1.2.0 // extension in the wild. @@ -273,17 +279,37 @@ namespace gdjs { y: float, options?: { movementX: float; movementY: float } ): void { + const previousMouseX = this._mouseX; + const previousMouseY = this._mouseY; + this._setCursorPosition(x, y); this._mouseX = x; this._mouseY = y; if (options) { - // Mouse movement can be accumulated over multiple calls to onMouseMove during a single frame. - // This is the case with Firefox which calls onMouseMove multiple times, including with - // values being 0 (so we can't just rely on the last one). + // The pointer is locked: the browser freezes the cursor position, so the + // only reliable source of movement is the browser-provided movementX/Y. + // These can be reported across multiple onMouseMove calls during a single + // frame (notably on Firefox, including with values being 0, so we can't + // just rely on the last one), hence the accumulation. const { movementX, movementY } = options; if (movementX !== undefined) this._mouseMovementX += movementX; if (movementY !== undefined) this._mouseMovementY += movementY; + // A position delta is meaningless while the pointer is locked. + this._canComputeMouseMovementFromPosition = false; + } else { + // The pointer is not locked: derive the movement from the change of the + // cursor position instead of relying on the browser movementX/Y. The + // latter are unreliable across browsers (for example Firefox adds the + // game canvas offset to them, which makes first-person cameras drift or + // stick unless the game is fullscreen) and are not expressed in the game + // resolution units. Using the position delta keeps the movement + // consistent with getCursorX/Y on every browser. + if (this._canComputeMouseMovementFromPosition) { + this._mouseMovementX += x - previousMouseX; + this._mouseMovementY += y - previousMouseY; + } + this._canComputeMouseMovementFromPosition = true; } if (this.isMouseButtonPressed(InputManager.MOUSE_LEFT_BUTTON)) { @@ -361,6 +387,9 @@ namespace gdjs { */ onMouseLeave(): void { this._isMouseInsideCanvas = false; + // The next position can be anywhere on the canvas, so don't use it to + // compute a movement delta. + this._canComputeMouseMovementFromPosition = false; } /** @@ -368,6 +397,9 @@ namespace gdjs { */ onMouseEnter(): void { this._isMouseInsideCanvas = true; + // The next position can be anywhere on the canvas, so don't use it to + // compute a movement delta. + this._canComputeMouseMovementFromPosition = false; } /** diff --git a/GDJS/Runtime/pixi-renderers/runtimegame-pixi-renderer.ts b/GDJS/Runtime/pixi-renderers/runtimegame-pixi-renderer.ts index 7eefac2bb4de..c952803495b5 100644 --- a/GDJS/Runtime/pixi-renderers/runtimegame-pixi-renderer.ts +++ b/GDJS/Runtime/pixi-renderers/runtimegame-pixi-renderer.ts @@ -811,10 +811,19 @@ namespace gdjs { } canvas.onmousemove = (e) => { const pos = this.convertPageToGameCoords(e.pageX, e.pageY); - manager.onMouseMove(pos[0], pos[1], { - movementX: e.movementX, - movementY: e.movementY, - }); + // The browser movementX/Y are only reliable while the pointer is locked + // (the cursor position is then frozen by the browser). Otherwise they are + // inconsistent across browsers (e.g. Firefox adds the game canvas offset + // to them, breaking first-person cameras unless fullscreen), so let the + // InputManager derive the movement from the cursor position instead. + // See https://github.com/4ian/GDevelop/issues/6338. + manager.onMouseMove( + pos[0], + pos[1], + this.isPointerLocked() + ? { movementX: e.movementX, movementY: e.movementY } + : undefined + ); }; canvas.onmousedown = (e) => { const pos = this.convertPageToGameCoords(e.pageX, e.pageY); diff --git a/GDJS/tests/tests/inputmanager.js b/GDJS/tests/tests/inputmanager.js index 1fd9bdcbaacf..c880549c4a85 100644 --- a/GDJS/tests/tests/inputmanager.js +++ b/GDJS/tests/tests/inputmanager.js @@ -134,6 +134,71 @@ describe('gdjs.InputManager', () => { expect(inputManager.isMouseInsideCanvas()).to.be(true); }); + it('should compute mouse movement from the cursor position when the pointer is not locked', () => { + // Reset the movement tracking deterministically. + inputManager.onMouseEnter(); + + // The first move only establishes the reference position: no movement yet. + inputManager.onMouseMove(500, 600); + expect(inputManager.getMouseMovementX()).to.be(0); + expect(inputManager.getMouseMovementY()).to.be(0); + + // Subsequent moves report the delta of the cursor position... + inputManager.onMouseMove(520, 615); + expect(inputManager.getMouseMovementX()).to.be(20); + expect(inputManager.getMouseMovementY()).to.be(15); + + // ...accumulated over the frame. + inputManager.onMouseMove(515, 605); + expect(inputManager.getMouseMovementX()).to.be(15); + expect(inputManager.getMouseMovementY()).to.be(5); + + // The movement is reset at the end of the frame, but the reference position + // is kept. + inputManager.onFrameEnded(); + expect(inputManager.getMouseMovementX()).to.be(0); + expect(inputManager.getMouseMovementY()).to.be(0); + + inputManager.onMouseMove(525, 600); + expect(inputManager.getMouseMovementX()).to.be(10); + expect(inputManager.getMouseMovementY()).to.be(-5); + }); + + it('should use the browser movement deltas when the pointer is locked', () => { + inputManager.onMouseEnter(); + inputManager.onMouseMove(500, 600); + inputManager.onFrameEnded(); + + // While the pointer is locked, the cursor position is frozen by the browser, + // so the browser-provided movement deltas are used (and accumulated over the + // frame, as Firefox can report them across several events). + inputManager.onMouseMove(500, 600, { movementX: 4, movementY: -3 }); + inputManager.onMouseMove(500, 600, { movementX: 1, movementY: 2 }); + expect(inputManager.getMouseMovementX()).to.be(5); + expect(inputManager.getMouseMovementY()).to.be(-1); + + inputManager.onFrameEnded(); + expect(inputManager.getMouseMovementX()).to.be(0); + expect(inputManager.getMouseMovementY()).to.be(0); + }); + + it('should not report a spurious movement after the mouse re-enters the canvas', () => { + inputManager.onMouseEnter(); + inputManager.onMouseMove(100, 100); + inputManager.onMouseMove(150, 130); + expect(inputManager.getMouseMovementX()).to.be(50); + expect(inputManager.getMouseMovementY()).to.be(30); + inputManager.onFrameEnded(); + + // Leaving then re-entering invalidates the reference position, so the next + // move doesn't report a large jump. + inputManager.onMouseLeave(); + inputManager.onMouseEnter(); + inputManager.onMouseMove(400, 500); + expect(inputManager.getMouseMovementX()).to.be(0); + expect(inputManager.getMouseMovementY()).to.be(0); + }); + it('should simulate touch events from mouse events', () => { inputManager.onMouseMove(500, 600); expect(inputTools.hasAnyTouchOrMouseStarted(runtimeScene)).to.be(false); From 7364bceae37cb3b27b240317e5f6d718cdd6ab13 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 6 Jun 2026 12:58:25 +0000 Subject: [PATCH 2/2] Centralize lock-aware mouse movement in the in-game editor Now that InputManager.getMouseMovementX/Y is itself pointer-lock aware (browser deltas when locked, cursor position delta otherwise), the in-game editor no longer needs to branch on the pointer lock state or track the previous cursor position itself. This removes the duplicated ternary in both editor camera controllers, the now-unused renderer locals and the _lastCursorX/_lastCursorY bookkeeping. --- GDJS/Runtime/InGameEditor/InGameEditor.tsx | 46 +++++++--------------- 1 file changed, 15 insertions(+), 31 deletions(-) diff --git a/GDJS/Runtime/InGameEditor/InGameEditor.tsx b/GDJS/Runtime/InGameEditor/InGameEditor.tsx index 72738b1b9016..946ebbdf6dc7 100644 --- a/GDJS/Runtime/InGameEditor/InGameEditor.tsx +++ b/GDJS/Runtime/InGameEditor/InGameEditor.tsx @@ -4405,8 +4405,6 @@ namespace gdjs { distance: float = 800; private _isEnabled: boolean = true; - private _lastCursorX: float = 0; - private _lastCursorY: float = 0; private _wasMouseRightButtonPressed = false; private _wasMouseMiddleButtonPressed = false; @@ -4430,7 +4428,6 @@ namespace gdjs { step(): void { const runtimeGame = this._editorCamera.editor.getRuntimeGame(); const inputManager = runtimeGame.getInputManager(); - const renderer = runtimeGame.getRenderer(); const isRightButtonPressed = inputManager.isMouseButtonPressed(1); const isMiddleButtonPressed = inputManager.isMouseButtonPressed(2); @@ -4445,13 +4442,11 @@ namespace gdjs { // The camera should not move the 1st frame this._wasMouseMiddleButtonPressed) ) { - // Use movement deltas when pointer is locked, otherwise use cursor position delta - const xDelta = renderer.isPointerLocked() - ? inputManager.getMouseMovementX() - : inputManager.getCursorX() - this._lastCursorX; - const yDelta = renderer.isPointerLocked() - ? inputManager.getMouseMovementY() - : inputManager.getCursorY() - this._lastCursorY; + // Movement is lock-aware: it uses the browser movement deltas when + // the pointer is locked, and the cursor position delta otherwise. + // See gdjs.InputManager.onMouseMove. + const xDelta = inputManager.getMouseMovementX(); + const yDelta = inputManager.getMouseMovementY(); const rotationSpeed = 0.2; this.rotationAngle += xDelta * rotationSpeed; @@ -4524,8 +4519,6 @@ namespace gdjs { this._wasMouseRightButtonPressed = isRightButtonPressed; this._wasMouseMiddleButtonPressed = isMiddleButtonPressed; - this._lastCursorX = inputManager.getCursorX(); - this._lastCursorY = inputManager.getCursorY(); } getAnchorX(): float { @@ -4660,8 +4653,6 @@ namespace gdjs { private _euler: THREE.Euler = new THREE.Euler(0, 0, 0, 'ZYX'); private _rotationMatrix: THREE.Matrix4 = new THREE.Matrix4(); - private _lastCursorX: float = 0; - private _lastCursorY: float = 0; private _wasMouseRightButtonPressed = false; // Touch gesture state @@ -4686,7 +4677,6 @@ namespace gdjs { step(): void { const runtimeGame = this._editorCamera.editor.getRuntimeGame(); const inputManager = runtimeGame.getInputManager(); - const renderer = runtimeGame.getRenderer(); const isRightButtonPressed = inputManager.isMouseButtonPressed(1); if (this._isEnabled) { const { right, up, forward } = this.getCameraVectors(); @@ -4813,13 +4803,11 @@ namespace gdjs { inputManager.isMouseButtonPressed(0)) || (isShiftPressed(inputManager) && inputManager.isMouseButtonPressed(2)) ) { - // Use movement deltas when pointer is locked, otherwise use cursor position delta - const xDelta = renderer.isPointerLocked() - ? inputManager.getMouseMovementX() - : inputManager.getCursorX() - this._lastCursorX; - const yDelta = renderer.isPointerLocked() - ? inputManager.getMouseMovementY() - : inputManager.getCursorY() - this._lastCursorY; + // Movement is lock-aware: it uses the browser movement deltas when + // the pointer is locked, and the cursor position delta otherwise. + // See gdjs.InputManager.onMouseMove. + const xDelta = inputManager.getMouseMovementX(); + const yDelta = inputManager.getMouseMovementY(); moveCameraByVector(up, yDelta); moveCameraByVector(right, -xDelta); } @@ -4830,13 +4818,11 @@ namespace gdjs { // The camera should not move the 1st frame this._wasMouseRightButtonPressed ) { - // Use movement deltas when pointer is locked, otherwise use cursor position delta - const xDelta = renderer.isPointerLocked() - ? inputManager.getMouseMovementX() - : inputManager.getCursorX() - this._lastCursorX; - const yDelta = renderer.isPointerLocked() - ? inputManager.getMouseMovementY() - : inputManager.getCursorY() - this._lastCursorY; + // Movement is lock-aware: it uses the browser movement deltas when + // the pointer is locked, and the cursor position delta otherwise. + // See gdjs.InputManager.onMouseMove. + const xDelta = inputManager.getMouseMovementX(); + const yDelta = inputManager.getMouseMovementY(); const rotationSpeed = 0.2; this.rotationAngle += xDelta * rotationSpeed; @@ -4848,8 +4834,6 @@ namespace gdjs { this._gestureActiveTouchIds = []; } this._wasMouseRightButtonPressed = isRightButtonPressed; - this._lastCursorX = inputManager.getCursorX(); - this._lastCursorY = inputManager.getCursorY(); } moveForward(distanceDelta: number) {