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) { 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);