Improve pointer lock handling and context usage across runtime components

This commit is contained in:
2026-04-27 18:43:10 +02:00
parent c443db3006
commit 2d194832aa
6 changed files with 116 additions and 15 deletions

View File

@@ -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) {
<div className="stat-card">
<div className="label">Pointer Lock</div>
<div className="value">
{activeNavigationMode === "firstPerson"
? firstPersonTelemetry?.pointerLocked
? "active"
: "idle"
: "not used"}
{firstPersonTelemetry?.pointerLocked ? "active" : "idle"}
</div>
</div>
<div className="stat-card">

View File

@@ -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,

View File

@@ -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;

View File

@@ -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<void>;
};
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;

View File

@@ -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,

View File

@@ -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