2026-04-11 05:16:53 +02:00
|
|
|
import { PerspectiveCamera } from "three";
|
|
|
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
|
|
|
|
2026-04-11 11:19:19 +02:00
|
|
|
import type { Vec3 } from "../../src/core/vector";
|
2026-04-11 05:16:53 +02:00
|
|
|
import { createEmptySceneDocument } from "../../src/document/scene-document";
|
|
|
|
|
import { createPlayerStartEntity } from "../../src/entities/entity-instances";
|
2026-04-11 12:19:46 +02:00
|
|
|
import type {
|
|
|
|
|
FirstPersonPlayerShape,
|
2026-04-11 18:43:31 +02:00
|
|
|
PlayerGroundProbeResult,
|
2026-04-11 12:19:46 +02:00
|
|
|
ResolvedPlayerMotion
|
|
|
|
|
} from "../../src/runtime-three/player-collision";
|
2026-04-11 05:16:53 +02:00
|
|
|
import { buildRuntimeSceneFromDocument } from "../../src/runtime-three/runtime-scene-build";
|
|
|
|
|
import { FirstPersonNavigationController } from "../../src/runtime-three/first-person-navigation-controller";
|
|
|
|
|
|
2026-04-11 12:16:01 +02:00
|
|
|
function createMockGamepad(options: {
|
|
|
|
|
axes?: number[];
|
|
|
|
|
pressedButtons?: number[];
|
|
|
|
|
} = {}): Gamepad {
|
|
|
|
|
return {
|
|
|
|
|
connected: true,
|
|
|
|
|
axes: options.axes ?? [0, 0],
|
|
|
|
|
buttons: Array.from({ length: 16 }, (_, index) => ({
|
|
|
|
|
pressed: options.pressedButtons?.includes(index) ?? false,
|
|
|
|
|
touched: false,
|
|
|
|
|
value: options.pressedButtons?.includes(index) ?? false ? 1 : 0
|
|
|
|
|
})),
|
|
|
|
|
id: "mock-standard-gamepad",
|
|
|
|
|
index: 0,
|
|
|
|
|
mapping: "standard",
|
|
|
|
|
timestamp: 0,
|
|
|
|
|
vibrationActuator: null,
|
|
|
|
|
hapticActuators: []
|
|
|
|
|
} as unknown as Gamepad;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function createRuntimeControllerContext(
|
|
|
|
|
playerStart = createPlayerStartEntity({
|
|
|
|
|
id: "entity-player-start-main"
|
|
|
|
|
}),
|
|
|
|
|
resolveFirstPersonMotion: (
|
|
|
|
|
feetPosition: Vec3,
|
|
|
|
|
motion: Vec3
|
2026-04-11 18:43:31 +02:00
|
|
|
) => ResolvedPlayerMotion | null = () => null,
|
|
|
|
|
options: {
|
|
|
|
|
probePlayerGround?: (
|
|
|
|
|
feetPosition: Vec3,
|
|
|
|
|
shape: FirstPersonPlayerShape,
|
|
|
|
|
maxDistance: number
|
|
|
|
|
) => PlayerGroundProbeResult;
|
|
|
|
|
canOccupyPlayerShape?: (
|
|
|
|
|
feetPosition: Vec3,
|
|
|
|
|
shape: FirstPersonPlayerShape
|
|
|
|
|
) => boolean;
|
2026-04-11 19:20:32 +02:00
|
|
|
} = {}
|
|
|
|
|
) {
|
|
|
|
|
const runtimeScene = buildRuntimeSceneFromDocument(
|
|
|
|
|
{
|
|
|
|
|
...createEmptySceneDocument({ name: "Pointer Lock Scene" }),
|
|
|
|
|
entities: {
|
2026-04-11 12:16:01 +02:00
|
|
|
[playerStart.id]: playerStart
|
2026-04-11 05:16:53 +02:00
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
navigationMode: "firstPerson"
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
const domElement = document.createElement("canvas");
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
domElement,
|
|
|
|
|
context: {
|
|
|
|
|
camera: new PerspectiveCamera(70, 1, 0.05, 1000),
|
|
|
|
|
domElement,
|
|
|
|
|
getRuntimeScene: () => runtimeScene,
|
2026-04-11 12:16:01 +02:00
|
|
|
resolveFirstPersonMotion: (
|
|
|
|
|
feetPosition: Vec3,
|
|
|
|
|
motion: Vec3,
|
2026-04-11 12:19:46 +02:00
|
|
|
_shape: FirstPersonPlayerShape
|
2026-04-11 12:16:01 +02:00
|
|
|
) => resolveFirstPersonMotion(feetPosition, motion),
|
2026-04-11 18:43:31 +02:00
|
|
|
probePlayerGround: (
|
|
|
|
|
feetPosition: Vec3,
|
|
|
|
|
shape: FirstPersonPlayerShape,
|
|
|
|
|
maxDistance: number
|
|
|
|
|
) =>
|
|
|
|
|
options.probePlayerGround?.(feetPosition, shape, maxDistance) ?? {
|
|
|
|
|
grounded: false,
|
|
|
|
|
distance: null,
|
|
|
|
|
normal: null,
|
|
|
|
|
slopeDegrees: null
|
|
|
|
|
},
|
|
|
|
|
canOccupyPlayerShape: (
|
|
|
|
|
feetPosition: Vec3,
|
|
|
|
|
shape: FirstPersonPlayerShape
|
|
|
|
|
) => options.canOccupyPlayerShape?.(feetPosition, shape) ?? true,
|
2026-04-11 05:16:53 +02:00
|
|
|
resolvePlayerVolumeState: () => ({
|
|
|
|
|
inWater: false,
|
|
|
|
|
inFog: false
|
|
|
|
|
}),
|
2026-04-11 11:19:19 +02:00
|
|
|
resolveThirdPersonCameraCollision: (
|
|
|
|
|
_pivot: Vec3,
|
|
|
|
|
desiredCameraPosition: Vec3
|
|
|
|
|
) => ({
|
2026-04-11 11:19:00 +02:00
|
|
|
...desiredCameraPosition
|
|
|
|
|
}),
|
2026-04-11 05:16:53 +02:00
|
|
|
setRuntimeMessage: vi.fn(),
|
2026-04-11 19:14:01 +02:00
|
|
|
setPlayerControllerTelemetry: vi.fn()
|
2026-04-11 05:16:53 +02:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
describe("FirstPersonNavigationController", () => {
|
|
|
|
|
afterEach(() => {
|
|
|
|
|
vi.restoreAllMocks();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("can deactivate during a scene transition without releasing pointer lock", () => {
|
|
|
|
|
const { context, domElement } = createRuntimeControllerContext();
|
|
|
|
|
const controller = new FirstPersonNavigationController();
|
2026-04-11 05:17:24 +02:00
|
|
|
const exitPointerLockSpy = vi.fn();
|
|
|
|
|
|
|
|
|
|
Object.defineProperty(document, "exitPointerLock", {
|
|
|
|
|
configurable: true,
|
|
|
|
|
value: exitPointerLockSpy
|
|
|
|
|
});
|
2026-04-11 05:16:53 +02:00
|
|
|
|
|
|
|
|
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();
|
2026-04-11 05:17:24 +02:00
|
|
|
const exitPointerLockSpy = vi.fn();
|
|
|
|
|
|
|
|
|
|
Object.defineProperty(document, "exitPointerLock", {
|
|
|
|
|
configurable: true,
|
|
|
|
|
value: exitPointerLockSpy
|
|
|
|
|
});
|
2026-04-11 05:16:53 +02:00
|
|
|
|
|
|
|
|
Object.defineProperty(document, "pointerLockElement", {
|
|
|
|
|
configurable: true,
|
|
|
|
|
get: () => domElement
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
controller.activate(context);
|
|
|
|
|
controller.deactivate(context);
|
|
|
|
|
|
|
|
|
|
expect(exitPointerLockSpy).toHaveBeenCalledTimes(1);
|
|
|
|
|
});
|
2026-04-11 12:16:01 +02:00
|
|
|
|
|
|
|
|
it("uses authored gamepad bindings instead of the hardcoded stick mapping", () => {
|
|
|
|
|
const playerStart = createPlayerStartEntity({
|
|
|
|
|
id: "entity-player-start-custom-gamepad",
|
|
|
|
|
inputBindings: {
|
|
|
|
|
gamepad: {
|
|
|
|
|
moveForward: "dpadUp",
|
|
|
|
|
moveBackward: "dpadDown",
|
|
|
|
|
moveLeft: "dpadLeft",
|
|
|
|
|
moveRight: "dpadRight"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
const { context } = createRuntimeControllerContext(
|
|
|
|
|
playerStart,
|
|
|
|
|
(feetPosition, motion) => ({
|
|
|
|
|
feetPosition: {
|
|
|
|
|
x: feetPosition.x + motion.x,
|
|
|
|
|
y: feetPosition.y + motion.y,
|
|
|
|
|
z: feetPosition.z + motion.z
|
|
|
|
|
},
|
2026-04-11 12:19:46 +02:00
|
|
|
grounded: false,
|
2026-04-11 18:43:59 +02:00
|
|
|
collisionCount: 0,
|
|
|
|
|
groundCollisionNormal: null,
|
2026-04-11 12:19:46 +02:00
|
|
|
collidedAxes: {
|
|
|
|
|
x: false,
|
|
|
|
|
y: false,
|
|
|
|
|
z: false
|
|
|
|
|
}
|
2026-04-11 12:16:01 +02:00
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
const controller = new FirstPersonNavigationController();
|
|
|
|
|
const getGamepads = vi.fn<() => Gamepad[]>(() => [
|
|
|
|
|
createMockGamepad({
|
|
|
|
|
axes: [0, -1]
|
|
|
|
|
})
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
Object.defineProperty(navigator, "getGamepads", {
|
|
|
|
|
configurable: true,
|
|
|
|
|
value: getGamepads
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
controller.activate(context);
|
|
|
|
|
controller.update(1);
|
|
|
|
|
|
|
|
|
|
expect(context.camera.position.z).toBe(0);
|
|
|
|
|
|
|
|
|
|
getGamepads.mockReturnValue([
|
|
|
|
|
createMockGamepad({
|
|
|
|
|
pressedButtons: [12]
|
|
|
|
|
})
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
controller.update(1);
|
|
|
|
|
|
|
|
|
|
expect(context.camera.position.z).toBeGreaterThan(0);
|
|
|
|
|
|
|
|
|
|
controller.deactivate(context, {
|
|
|
|
|
releasePointerLock: false
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-04-11 12:33:38 +02:00
|
|
|
|
2026-04-11 18:02:50 +02:00
|
|
|
it("uses the authored movement template speed for first-person motion telemetry", () => {
|
|
|
|
|
const playerStart = createPlayerStartEntity({
|
|
|
|
|
id: "entity-player-start-custom-movement",
|
|
|
|
|
movementTemplate: {
|
|
|
|
|
moveSpeed: 2.25
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
const { context } = createRuntimeControllerContext(
|
|
|
|
|
playerStart,
|
|
|
|
|
(feetPosition, motion) => ({
|
|
|
|
|
feetPosition: {
|
|
|
|
|
x: feetPosition.x + motion.x,
|
|
|
|
|
y: feetPosition.y + motion.y,
|
|
|
|
|
z: feetPosition.z + motion.z
|
|
|
|
|
},
|
|
|
|
|
grounded: true,
|
2026-04-11 18:43:59 +02:00
|
|
|
collisionCount: 1,
|
|
|
|
|
groundCollisionNormal: { x: 0, y: 1, z: 0 },
|
2026-04-11 18:02:50 +02:00
|
|
|
collidedAxes: {
|
|
|
|
|
x: false,
|
|
|
|
|
y: false,
|
|
|
|
|
z: false
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
const controller = new FirstPersonNavigationController();
|
|
|
|
|
|
|
|
|
|
controller.activate(context);
|
|
|
|
|
window.dispatchEvent(new KeyboardEvent("keydown", { code: "KeyW" }));
|
|
|
|
|
controller.update(1);
|
|
|
|
|
|
2026-04-11 19:14:01 +02:00
|
|
|
const telemetry =
|
|
|
|
|
context.setPlayerControllerTelemetry.mock.calls.at(-1)?.[0];
|
2026-04-11 18:02:50 +02:00
|
|
|
|
|
|
|
|
expect(telemetry?.feetPosition.z).toBeCloseTo(2.25);
|
|
|
|
|
expect(telemetry?.movement).toMatchObject({
|
|
|
|
|
templateKind: "default",
|
|
|
|
|
moveSpeed: 2.25,
|
|
|
|
|
capabilities: {
|
|
|
|
|
jump: true,
|
|
|
|
|
sprint: true,
|
|
|
|
|
crouch: true
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
window.dispatchEvent(new KeyboardEvent("keyup", { code: "KeyW" }));
|
|
|
|
|
controller.deactivate(context, {
|
|
|
|
|
releasePointerLock: false
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-11 12:33:38 +02:00
|
|
|
it("uses the gamepad right stick for camera look without requiring pointer lock", () => {
|
|
|
|
|
const { context } = createRuntimeControllerContext();
|
|
|
|
|
const controller = new FirstPersonNavigationController();
|
|
|
|
|
const getGamepads = vi.fn<() => Gamepad[]>(() => [
|
|
|
|
|
createMockGamepad({
|
|
|
|
|
axes: [0, 0, 1, 0]
|
|
|
|
|
})
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
Object.defineProperty(navigator, "getGamepads", {
|
|
|
|
|
configurable: true,
|
|
|
|
|
value: getGamepads
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
controller.activate(context);
|
|
|
|
|
|
|
|
|
|
const initialCameraYaw = context.camera.rotation.y;
|
|
|
|
|
|
|
|
|
|
controller.update(1);
|
|
|
|
|
|
|
|
|
|
expect(context.camera.rotation.y).not.toBe(initialCameraYaw);
|
|
|
|
|
|
|
|
|
|
controller.deactivate(context, {
|
|
|
|
|
releasePointerLock: false
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-04-11 18:43:31 +02:00
|
|
|
|
2026-04-11 19:27:04 +02:00
|
|
|
it("keeps jump ascent alive when the ground probe still sees nearby floor", () => {
|
|
|
|
|
const probePlayerGround = vi.fn(
|
|
|
|
|
(
|
|
|
|
|
feetPosition: Vec3,
|
|
|
|
|
_shape: FirstPersonPlayerShape,
|
|
|
|
|
_maxDistance: number
|
|
|
|
|
): PlayerGroundProbeResult => {
|
|
|
|
|
if (feetPosition.y <= 0.13) {
|
|
|
|
|
return {
|
|
|
|
|
grounded: true,
|
|
|
|
|
distance: feetPosition.y,
|
|
|
|
|
normal: { x: 0, y: 1, z: 0 },
|
|
|
|
|
slopeDegrees: 0
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
grounded: false,
|
|
|
|
|
distance: null,
|
|
|
|
|
normal: null,
|
|
|
|
|
slopeDegrees: null
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
|
2026-04-11 18:43:31 +02:00
|
|
|
const { context } = createRuntimeControllerContext(
|
|
|
|
|
createPlayerStartEntity({
|
|
|
|
|
id: "entity-player-start-jump"
|
|
|
|
|
}),
|
|
|
|
|
(feetPosition, motion) => ({
|
|
|
|
|
feetPosition: {
|
|
|
|
|
x: feetPosition.x + motion.x,
|
|
|
|
|
y: feetPosition.y + motion.y,
|
|
|
|
|
z: feetPosition.z + motion.z
|
|
|
|
|
},
|
|
|
|
|
grounded: false,
|
|
|
|
|
collisionCount: 0,
|
|
|
|
|
groundCollisionNormal: null,
|
|
|
|
|
collidedAxes: {
|
|
|
|
|
x: false,
|
|
|
|
|
y: false,
|
|
|
|
|
z: false
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
{
|
2026-04-11 19:27:04 +02:00
|
|
|
probePlayerGround
|
2026-04-11 18:43:31 +02:00
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
const controller = new FirstPersonNavigationController();
|
|
|
|
|
|
|
|
|
|
controller.activate(context);
|
2026-04-11 19:27:04 +02:00
|
|
|
controller.update(1 / 60);
|
2026-04-11 18:43:31 +02:00
|
|
|
window.dispatchEvent(new KeyboardEvent("keydown", { code: "Space" }));
|
2026-04-11 19:27:04 +02:00
|
|
|
controller.update(1 / 60);
|
|
|
|
|
|
|
|
|
|
const jumpTelemetry =
|
|
|
|
|
context.setPlayerControllerTelemetry.mock.calls.at(-1)?.[0];
|
|
|
|
|
|
|
|
|
|
controller.update(1 / 60);
|
2026-04-11 18:43:31 +02:00
|
|
|
|
2026-04-11 19:14:01 +02:00
|
|
|
const telemetry =
|
|
|
|
|
context.setPlayerControllerTelemetry.mock.calls.at(-1)?.[0];
|
2026-04-11 18:43:31 +02:00
|
|
|
|
2026-04-11 19:27:04 +02:00
|
|
|
expect(jumpTelemetry?.grounded).toBe(false);
|
|
|
|
|
expect(jumpTelemetry?.locomotionState.locomotionMode).toBe("airborne");
|
|
|
|
|
expect(jumpTelemetry?.locomotionState.airborneKind).toBe("jumping");
|
|
|
|
|
expect(jumpTelemetry?.signals.jumpStarted).toBe(true);
|
|
|
|
|
expect(jumpTelemetry?.signals.leftGround).toBe(true);
|
|
|
|
|
|
2026-04-11 18:43:31 +02:00
|
|
|
expect(telemetry?.grounded).toBe(false);
|
|
|
|
|
expect(telemetry?.locomotionState.locomotionMode).toBe("airborne");
|
|
|
|
|
expect(telemetry?.locomotionState.airborneKind).toBe("jumping");
|
|
|
|
|
expect(telemetry?.locomotionState.verticalVelocity).toBeGreaterThan(0);
|
2026-04-11 19:27:04 +02:00
|
|
|
expect(telemetry?.feetPosition.y ?? 0).toBeGreaterThan(
|
|
|
|
|
jumpTelemetry?.feetPosition.y ?? 0
|
|
|
|
|
);
|
|
|
|
|
expect(telemetry?.signals.jumpStarted).toBe(false);
|
|
|
|
|
expect(telemetry?.signals.leftGround).toBe(false);
|
2026-04-11 19:14:01 +02:00
|
|
|
expect(telemetry?.hooks.camera.jumping).toBe(true);
|
|
|
|
|
expect(telemetry?.hooks.animation.airborneKind).toBe("jumping");
|
2026-04-11 19:27:04 +02:00
|
|
|
expect(probePlayerGround).toHaveBeenCalled();
|
2026-04-11 18:43:31 +02:00
|
|
|
|
|
|
|
|
window.dispatchEvent(new KeyboardEvent("keyup", { code: "Space" }));
|
|
|
|
|
controller.deactivate(context, {
|
|
|
|
|
releasePointerLock: false
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("lowers the eye height and locomotion gait when crouch is held", () => {
|
|
|
|
|
const { context } = createRuntimeControllerContext(
|
|
|
|
|
createPlayerStartEntity({
|
|
|
|
|
id: "entity-player-start-crouch"
|
|
|
|
|
}),
|
|
|
|
|
(feetPosition, motion) => ({
|
|
|
|
|
feetPosition: {
|
|
|
|
|
x: feetPosition.x + motion.x,
|
|
|
|
|
y: feetPosition.y,
|
|
|
|
|
z: feetPosition.z + motion.z
|
|
|
|
|
},
|
|
|
|
|
grounded: true,
|
|
|
|
|
collisionCount: 1,
|
|
|
|
|
groundCollisionNormal: { x: 0, y: 1, z: 0 },
|
|
|
|
|
collidedAxes: {
|
|
|
|
|
x: false,
|
|
|
|
|
y: true,
|
|
|
|
|
z: false
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
{
|
|
|
|
|
probePlayerGround: () => ({
|
|
|
|
|
grounded: true,
|
|
|
|
|
distance: 0,
|
|
|
|
|
normal: { x: 0, y: 1, z: 0 },
|
|
|
|
|
slopeDegrees: 0
|
|
|
|
|
}),
|
|
|
|
|
canOccupyPlayerShape: () => true
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
const controller = new FirstPersonNavigationController();
|
|
|
|
|
|
|
|
|
|
controller.activate(context);
|
|
|
|
|
window.dispatchEvent(
|
|
|
|
|
new KeyboardEvent("keydown", { code: "ControlLeft" })
|
|
|
|
|
);
|
|
|
|
|
controller.update(0.1);
|
|
|
|
|
|
2026-04-11 19:14:01 +02:00
|
|
|
const telemetry =
|
|
|
|
|
context.setPlayerControllerTelemetry.mock.calls.at(-1)?.[0];
|
2026-04-11 18:43:31 +02:00
|
|
|
|
|
|
|
|
expect(telemetry?.locomotionState.gait).toBe("crouch");
|
|
|
|
|
expect(telemetry?.locomotionState.crouched).toBe(true);
|
|
|
|
|
expect(telemetry?.eyePosition.y ?? Number.POSITIVE_INFINITY).toBeLessThan(
|
|
|
|
|
1.6
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
window.dispatchEvent(
|
|
|
|
|
new KeyboardEvent("keyup", { code: "ControlLeft" })
|
|
|
|
|
);
|
|
|
|
|
controller.deactivate(context, {
|
|
|
|
|
releasePointerLock: false
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-04-11 05:16:53 +02:00
|
|
|
});
|