From 75d87235655d865b4bb35e484c7a157fe2166ffc Mon Sep 17 00:00:00 2001 From: Victor Giers Date: Sat, 11 Apr 2026 05:16:53 +0200 Subject: [PATCH] Add tests and update navigation controller deactivation logic --- .../orbit-visitor-navigation-controller.ts | 7 +- src/runtime-three/runtime-host.ts | 8 +- ...first-person-navigation-controller.test.ts | 84 +++++++++++++++++++ 3 files changed, 97 insertions(+), 2 deletions(-) create mode 100644 tests/unit/first-person-navigation-controller.test.ts diff --git a/src/runtime-three/orbit-visitor-navigation-controller.ts b/src/runtime-three/orbit-visitor-navigation-controller.ts index 6cca9341..3877968f 100644 --- a/src/runtime-three/orbit-visitor-navigation-controller.ts +++ b/src/runtime-three/orbit-visitor-navigation-controller.ts @@ -3,6 +3,7 @@ import { Vector3 } from "three"; import type { Vec3 } from "../core/vector"; import type { + NavigationControllerDeactivateOptions, NavigationController, RuntimeControllerContext } from "./navigation-controller"; @@ -83,7 +84,11 @@ export class OrbitVisitorNavigationController implements NavigationController { this.updateCameraTransform(); } - deactivate(ctx: RuntimeControllerContext): void { + deactivate( + ctx: RuntimeControllerContext, + _options: NavigationControllerDeactivateOptions = {} + ): void { + void _options; ctx.domElement.removeEventListener("pointerdown", this.handlePointerDown); ctx.domElement.removeEventListener("wheel", this.handleWheel); ctx.domElement.removeEventListener("contextmenu", this.handleContextMenu); diff --git a/src/runtime-three/runtime-host.ts b/src/runtime-three/runtime-host.ts index b3080acc..f27931b1 100644 --- a/src/runtime-three/runtime-host.ts +++ b/src/runtime-three/runtime-host.ts @@ -342,11 +342,17 @@ export class RuntimeHost { loadScene(runtimeScene: RuntimeSceneDefinition) { const requestId = ++this.collisionWorldRequestId; + const preservePointerLockDuringLoad = + this.activeController === this.firstPersonController && + this.desiredNavigationMode === "firstPerson" && + document.pointerLockElement === this.domElement; this.sceneReady = false; this.runtimeScene = runtimeScene; this.currentWorld = runtimeScene.world; - this.activeController?.deactivate(this.controllerContext); + this.activeController?.deactivate(this.controllerContext, { + releasePointerLock: !preservePointerLockDuringLoad + }); this.activeController = null; this.firstPersonController.resetSceneState(); this.orbitVisitorController.resetSceneState(); diff --git a/tests/unit/first-person-navigation-controller.test.ts b/tests/unit/first-person-navigation-controller.test.ts new file mode 100644 index 00000000..89d30d74 --- /dev/null +++ b/tests/unit/first-person-navigation-controller.test.ts @@ -0,0 +1,84 @@ +import { PerspectiveCamera } from "three"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { createEmptySceneDocument } from "../../src/document/scene-document"; +import { createPlayerStartEntity } from "../../src/entities/entity-instances"; +import { buildRuntimeSceneFromDocument } from "../../src/runtime-three/runtime-scene-build"; +import { FirstPersonNavigationController } from "../../src/runtime-three/first-person-navigation-controller"; + +function createRuntimeControllerContext() { + const runtimeScene = buildRuntimeSceneFromDocument( + { + ...createEmptySceneDocument({ name: "Pointer Lock Scene" }), + entities: { + "entity-player-start-main": createPlayerStartEntity({ + id: "entity-player-start-main" + }) + } + }, + { + navigationMode: "firstPerson" + } + ); + const domElement = document.createElement("canvas"); + + return { + domElement, + context: { + camera: new PerspectiveCamera(70, 1, 0.05, 1000), + domElement, + getRuntimeScene: () => runtimeScene, + resolveFirstPersonMotion: () => null, + resolvePlayerVolumeState: () => ({ + inWater: false, + inFog: false + }), + setRuntimeMessage: vi.fn(), + setFirstPersonTelemetry: vi.fn() + } + }; +} + +describe("FirstPersonNavigationController", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("can deactivate during a scene transition without releasing pointer lock", () => { + const { context, domElement } = createRuntimeControllerContext(); + const controller = new FirstPersonNavigationController(); + const exitPointerLockSpy = vi + .spyOn(document, "exitPointerLock") + .mockImplementation(() => undefined); + + Object.defineProperty(document, "pointerLockElement", { + configurable: true, + get: () => domElement + }); + + controller.activate(context); + controller.deactivate(context, { + releasePointerLock: false + }); + + expect(exitPointerLockSpy).not.toHaveBeenCalled(); + }); + + it("still releases pointer lock for a normal deactivation", () => { + const { context, domElement } = createRuntimeControllerContext(); + const controller = new FirstPersonNavigationController(); + const exitPointerLockSpy = vi + .spyOn(document, "exitPointerLock") + .mockImplementation(() => undefined); + + Object.defineProperty(document, "pointerLockElement", { + configurable: true, + get: () => domElement + }); + + controller.activate(context); + controller.deactivate(context); + + expect(exitPointerLockSpy).toHaveBeenCalledTimes(1); + }); +});