From 2d194832aa2a3bf68d8a63f0f644fcb3e8d7f7e5 Mon Sep 17 00:00:00 2001 From: Victor Giers Date: Mon, 27 Apr 2026 18:43:10 +0200 Subject: [PATCH] Improve pointer lock handling and context usage across runtime components --- src/app/App.tsx | 12 +--- src/document/migrate-scene-document.ts | 1 + .../first-person-navigation-controller.ts | 12 ++-- src/runtime-three/runtime-host.ts | 43 +++++++++++++ .../domain/scene-document-validation.test.ts | 1 + tests/unit/runtime-host.test.ts | 62 ++++++++++++++++++- 6 files changed, 116 insertions(+), 15 deletions(-) diff --git a/src/app/App.tsx b/src/app/App.tsx index d6def651..c04f94ea 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -4899,9 +4899,7 @@ export function App({ store, initialStatusMessage }: AppProps) { return; } - const pointerCaptured = - activeNavigationMode === "firstPerson" && - firstPersonTelemetry?.pointerLocked === true; + const pointerCaptured = firstPersonTelemetry?.pointerLocked === true; if (pointerCaptured) { return; @@ -4916,7 +4914,7 @@ export function App({ store, initialStatusMessage }: AppProps) { return () => { window.removeEventListener("keydown", handleWindowKeyDown); }; - }, [activeNavigationMode, editorState.toolMode, firstPersonTelemetry]); + }, [editorState.toolMode, firstPersonTelemetry]); const applyProjectName = () => { const normalizedName = projectNameDraft.trim() || DEFAULT_PROJECT_NAME; @@ -13359,11 +13357,7 @@ export function App({ store, initialStatusMessage }: AppProps) {
Pointer Lock
- {activeNavigationMode === "firstPerson" - ? firstPersonTelemetry?.pointerLocked - ? "active" - : "idle" - : "not used"} + {firstPersonTelemetry?.pointerLocked ? "active" : "idle"}
diff --git a/src/document/migrate-scene-document.ts b/src/document/migrate-scene-document.ts index 91c5e328..5bb59a53 100644 --- a/src/document/migrate-scene-document.ts +++ b/src/document/migrate-scene-document.ts @@ -198,6 +198,7 @@ import { PLAYER_START_GAMEPAD_CAMERA_LOOK_SCENE_DOCUMENT_VERSION, PLAYER_START_INTERACT_BINDINGS_SCENE_DOCUMENT_VERSION, PLAYER_START_INPUT_BINDINGS_SCENE_DOCUMENT_VERSION, + PLAYER_START_MOUSE_INVERT_SCENE_DOCUMENT_VERSION, PLAYER_START_TARGETING_SETTINGS_SCENE_DOCUMENT_VERSION, PLAYER_START_NAVIGATION_MODE_SCENE_DOCUMENT_VERSION, PLAYER_START_PAUSE_BINDINGS_SCENE_DOCUMENT_VERSION, diff --git a/src/runtime-three/first-person-navigation-controller.ts b/src/runtime-three/first-person-navigation-controller.ts index 274c7ff1..4517b9d3 100644 --- a/src/runtime-three/first-person-navigation-controller.ts +++ b/src/runtime-three/first-person-navigation-controller.ts @@ -510,23 +510,25 @@ export class FirstPersonNavigationController implements NavigationController { }; private handleMouseMove = (event: MouseEvent) => { + const context = this.context; + if ( !this.pointerLocked || - this.context?.isInputSuspended() === true || - this.context?.isCameraDrivenExternally() === true + context === null || + context.isInputSuspended() === true || + context.isCameraDrivenExternally() === true ) { return; } const horizontalMouseLookSign = - this.context.getRuntimeScene().playerStart?.invertMouseCameraHorizontal === - true + context.getRuntimeScene().playerStart?.invertMouseCameraHorizontal === true ? -1 : 1; const horizontalMovement = event.movementX * horizontalMouseLookSign; const targetLookResult = - this.context?.handleRuntimeTargetLookInput?.({ + context.handleRuntimeTargetLookInput?.({ horizontal: horizontalMovement, vertical: -event.movementY }) ?? null; diff --git a/src/runtime-three/runtime-host.ts b/src/runtime-three/runtime-host.ts index 6872891f..4c351967 100644 --- a/src/runtime-three/runtime-host.ts +++ b/src/runtime-three/runtime-host.ts @@ -969,13 +969,56 @@ export class RuntimeHost { this.runtimeMessageHandler?.(message); }, setPlayerControllerTelemetry: (telemetry) => { + const pointerLockReleasedWithEscapeTargetClear = + this.currentPlayerControllerTelemetry?.pointerLocked === true && + telemetry !== null && + telemetry.pointerLocked === false && + this.activeController === this.thirdPersonController && + this.activeRuntimeTargetReference !== null && + this.resolveRuntimePlayerInputBindings().keyboard.clearTarget === + "Escape"; + this.currentPlayerControllerTelemetry = telemetry; this.currentPlayerAudioHooks = telemetry?.hooks.audio ?? null; + + if (pointerLockReleasedWithEscapeTargetClear) { + this.clearActiveRuntimeTarget(); + this.requestRuntimePointerLock(); + } + this.playerControllerTelemetryHandler?.(telemetry); } }; } + private requestRuntimePointerLock() { + if ( + document.pointerLockElement === this.domElement || + (this.activeController !== this.firstPersonController && + this.activeController !== this.thirdPersonController) + ) { + return; + } + + const pointerLockCapableElement = this.domElement as HTMLCanvasElement & { + requestPointerLock?: () => void | Promise; + }; + + if (typeof pointerLockCapableElement.requestPointerLock !== "function") { + return; + } + + try { + const pointerLockResult = pointerLockCapableElement.requestPointerLock(); + + if (pointerLockResult instanceof Promise) { + pointerLockResult.catch(() => {}); + } + } catch { + // Browser Escape handling can reject immediate recapture; clearing wins. + } + } + private resolvePlayerVolumeState(feetPosition: { x: number; y: number; diff --git a/tests/domain/scene-document-validation.test.ts b/tests/domain/scene-document-validation.test.ts index cdee47ab..33231a98 100644 --- a/tests/domain/scene-document-validation.test.ts +++ b/tests/domain/scene-document-validation.test.ts @@ -705,6 +705,7 @@ describe("validateSceneDocument", () => { interactionAngleDegrees: Number.NaN, allowLookInputTargetSwitch: "yes" as unknown as boolean, targetButtonCyclesActiveTarget: 1 as unknown as boolean, + invertMouseCameraHorizontal: "yes" as unknown as boolean, movementTemplate: { kind: "invalidTemplate", moveSpeed: 0, diff --git a/tests/unit/runtime-host.test.ts b/tests/unit/runtime-host.test.ts index 8b9749bf..d68d9881 100644 --- a/tests/unit/runtime-host.test.ts +++ b/tests/unit/runtime-host.test.ts @@ -191,7 +191,7 @@ describe("RuntimeHost", () => { message: null }); expect(runtimeMessages).toContain( - "Third Person active. Drag to orbit the camera, use the right stick for gamepad camera look, move with your authored bindings, and scroll to zoom." + "Third Person active. Click inside the runner viewport to capture mouse look, or drag to orbit if pointer lock is unavailable. Scroll to zoom and use the right stick for gamepad camera look." ); }); @@ -3889,6 +3889,66 @@ describe("RuntimeHost", () => { host.dispose(); }); + it("clears active target when third-person Escape releases pointer lock", () => { + const host = new RuntimeHost({ + enableRendering: false + }); + const hostInternals = host as unknown as { + runtimeScene: unknown; + activeController: unknown; + thirdPersonController: unknown; + activeRuntimeTargetReference: { + kind: "npc" | "interactable"; + entityId: string; + } | null; + domElement: HTMLCanvasElement; + controllerContext: { + setPlayerControllerTelemetry(telemetry: unknown): void; + }; + }; + const requestPointerLock = vi.fn(); + + Object.defineProperty(hostInternals.domElement, "requestPointerLock", { + configurable: true, + value: requestPointerLock + }); + + hostInternals.runtimeScene = { + playerInputBindings: { + keyboard: { + clearTarget: "Escape" + } + }, + entities: { + cameraRigs: [], + interactables: [], + npcs: [] + } + } as never; + hostInternals.activeController = hostInternals.thirdPersonController; + hostInternals.activeRuntimeTargetReference = { + kind: "npc", + entityId: "npc-active" + }; + + hostInternals.controllerContext.setPlayerControllerTelemetry({ + pointerLocked: true, + hooks: { + audio: null + } + }); + hostInternals.controllerContext.setPlayerControllerTelemetry({ + pointerLocked: false, + hooks: { + audio: null + } + }); + + expect(hostInternals.activeRuntimeTargetReference).toBeNull(); + expect(requestPointerLock).toHaveBeenCalledTimes(1); + host.dispose(); + }); + it("switches an active target once from directional screen-space look input", () => { const host = new RuntimeHost({ enableRendering: false