694 lines
20 KiB
TypeScript
694 lines
20 KiB
TypeScript
import { PerspectiveCamera, Vector3 } from "three";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import type { Vec3 } from "../../src/core/vector";
|
|
import { createEmptySceneDocument } from "../../src/document/scene-document";
|
|
import { createPlayerStartEntity } from "../../src/entities/entity-instances";
|
|
import type {
|
|
FirstPersonPlayerShape,
|
|
PlayerGroundProbeResult,
|
|
ResolvedPlayerMotion
|
|
} from "../../src/runtime-three/player-collision";
|
|
import { buildRuntimeSceneFromDocument } from "../../src/runtime-three/runtime-scene-build";
|
|
import { ThirdPersonNavigationController } from "../../src/runtime-three/third-person-navigation-controller";
|
|
|
|
function createMockGamepad(options: {
|
|
axes?: number[];
|
|
} = {}): Gamepad {
|
|
return {
|
|
connected: true,
|
|
axes: options.axes ?? [0, 0, 0, 0],
|
|
buttons: Array.from({ length: 16 }, () => ({
|
|
pressed: false,
|
|
touched: false,
|
|
value: 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",
|
|
inputBindings: {
|
|
keyboard: {
|
|
moveForward: "ArrowUp",
|
|
moveBackward: "ArrowDown",
|
|
moveLeft: "ArrowLeft",
|
|
moveRight: "ArrowRight"
|
|
}
|
|
}
|
|
})
|
|
) {
|
|
const runtimeScene = buildRuntimeSceneFromDocument(
|
|
{
|
|
...createEmptySceneDocument({ name: "Third Person Binding Scene" }),
|
|
entities: {
|
|
[playerStart.id]: playerStart
|
|
}
|
|
},
|
|
{
|
|
navigationMode: "thirdPerson"
|
|
}
|
|
);
|
|
const domElement = document.createElement("canvas");
|
|
const createVolumeState = () => ({
|
|
inWater: false,
|
|
inFog: false,
|
|
waterSurfaceHeight: null
|
|
});
|
|
|
|
return {
|
|
context: {
|
|
camera: new PerspectiveCamera(70, 1, 0.05, 1000),
|
|
domElement,
|
|
getRuntimeScene: () => runtimeScene,
|
|
resolveFirstPersonMotion: (
|
|
feetPosition: Vec3,
|
|
motion: Vec3,
|
|
_shape: FirstPersonPlayerShape
|
|
): ResolvedPlayerMotion => ({
|
|
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
|
|
}
|
|
}),
|
|
probePlayerGround: (): PlayerGroundProbeResult => ({
|
|
grounded: false,
|
|
distance: null,
|
|
normal: null,
|
|
slopeDegrees: null
|
|
}),
|
|
canOccupyPlayerShape: () => true,
|
|
resolvePlayerVolumeState: () => createVolumeState(),
|
|
resolveThirdPersonCameraCollision: (
|
|
_pivot: Vec3,
|
|
desiredCameraPosition: Vec3
|
|
) => ({
|
|
...desiredCameraPosition
|
|
}),
|
|
isCameraDrivenExternally: () => false,
|
|
getCameraYawRadians: () => 0,
|
|
isInputSuspended: () => false,
|
|
setRuntimeMessage: vi.fn(),
|
|
setPlayerControllerTelemetry: vi.fn()
|
|
}
|
|
};
|
|
}
|
|
|
|
describe("ThirdPersonNavigationController", () => {
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
Object.defineProperty(navigator, "getGamepads", {
|
|
configurable: true,
|
|
value: undefined
|
|
});
|
|
});
|
|
|
|
it("uses authored keyboard bindings instead of hardcoded WASD movement", () => {
|
|
const { context } = createRuntimeControllerContext();
|
|
const controller = new ThirdPersonNavigationController();
|
|
|
|
controller.activate(context);
|
|
|
|
const initialCameraZ = context.camera.position.z;
|
|
|
|
window.dispatchEvent(new KeyboardEvent("keydown", { code: "KeyW" }));
|
|
controller.update(1);
|
|
|
|
expect(context.camera.position.z).toBe(initialCameraZ);
|
|
|
|
window.dispatchEvent(new KeyboardEvent("keyup", { code: "KeyW" }));
|
|
window.dispatchEvent(new KeyboardEvent("keydown", { code: "ArrowUp" }));
|
|
controller.update(1);
|
|
|
|
expect(context.camera.position.z).toBeGreaterThan(initialCameraZ);
|
|
|
|
window.dispatchEvent(new KeyboardEvent("keyup", { code: "ArrowUp" }));
|
|
controller.deactivate(context);
|
|
});
|
|
|
|
it("clears held movement keys when pointer lock is released", () => {
|
|
const { context } = createRuntimeControllerContext();
|
|
const controller = new ThirdPersonNavigationController();
|
|
let pointerLockElement: Element | null = context.domElement;
|
|
|
|
Object.defineProperty(document, "pointerLockElement", {
|
|
configurable: true,
|
|
get: () => pointerLockElement
|
|
});
|
|
|
|
controller.activate(context);
|
|
|
|
const controllerInternals = controller as unknown as {
|
|
pressedKeys: Set<string>;
|
|
handleKeyDown(event: KeyboardEvent): void;
|
|
handlePointerLockChange(): void;
|
|
};
|
|
controllerInternals.handleKeyDown(
|
|
new KeyboardEvent("keydown", { code: "ArrowUp" })
|
|
);
|
|
|
|
expect(controllerInternals.pressedKeys.has("ArrowUp")).toBe(true);
|
|
|
|
pointerLockElement = null;
|
|
controllerInternals.handlePointerLockChange();
|
|
|
|
expect(controllerInternals.pressedKeys.size).toBe(0);
|
|
|
|
controller.deactivate(context, {
|
|
releasePointerLock: false
|
|
});
|
|
});
|
|
|
|
it("uses the gamepad right stick for third-person camera orbit", () => {
|
|
const { context } = createRuntimeControllerContext();
|
|
const controller = new ThirdPersonNavigationController();
|
|
const getGamepads = vi.fn<() => Gamepad[]>(() => [
|
|
createMockGamepad({
|
|
axes: [0, 0, 1, 0]
|
|
})
|
|
]);
|
|
|
|
Object.defineProperty(navigator, "getGamepads", {
|
|
configurable: true,
|
|
value: getGamepads
|
|
});
|
|
|
|
controller.activate(context);
|
|
|
|
const initialCameraX = context.camera.position.x;
|
|
const initialCameraZ = context.camera.position.z;
|
|
|
|
controller.update(1);
|
|
|
|
expect(
|
|
context.camera.position.x !== initialCameraX ||
|
|
context.camera.position.z !== initialCameraZ
|
|
).toBe(true);
|
|
|
|
controller.deactivate(context);
|
|
});
|
|
|
|
it("smooths the third-person camera back out when collision clears", () => {
|
|
const { context } = createRuntimeControllerContext();
|
|
const controller = new ThirdPersonNavigationController();
|
|
let collisionScale = 0.25;
|
|
let latestPivot: Vec3 | null = null;
|
|
let latestDesiredCameraPosition: Vec3 | null = null;
|
|
const distanceBetween = (left: Vec3, right: Vec3) =>
|
|
Math.hypot(left.x - right.x, left.y - right.y, left.z - right.z);
|
|
|
|
context.resolveThirdPersonCameraCollision = vi.fn(
|
|
(pivot: Vec3, desiredCameraPosition: Vec3) => {
|
|
latestPivot = { ...pivot };
|
|
latestDesiredCameraPosition = { ...desiredCameraPosition };
|
|
|
|
return {
|
|
x: pivot.x + (desiredCameraPosition.x - pivot.x) * collisionScale,
|
|
y: pivot.y + (desiredCameraPosition.y - pivot.y) * collisionScale,
|
|
z: pivot.z + (desiredCameraPosition.z - pivot.z) * collisionScale
|
|
};
|
|
}
|
|
);
|
|
|
|
controller.activate(context);
|
|
|
|
const blockedDistance = distanceBetween(
|
|
{
|
|
x: context.camera.position.x,
|
|
y: context.camera.position.y,
|
|
z: context.camera.position.z
|
|
},
|
|
latestPivot ?? { x: 0, y: 0, z: 0 }
|
|
);
|
|
|
|
collisionScale = 1;
|
|
controller.update(0.1);
|
|
|
|
const recoveringDistance = distanceBetween(
|
|
{
|
|
x: context.camera.position.x,
|
|
y: context.camera.position.y,
|
|
z: context.camera.position.z
|
|
},
|
|
latestPivot ?? { x: 0, y: 0, z: 0 }
|
|
);
|
|
const desiredDistance = distanceBetween(
|
|
latestDesiredCameraPosition ?? { x: 0, y: 0, z: 0 },
|
|
latestPivot ?? { x: 0, y: 0, z: 0 }
|
|
);
|
|
|
|
expect(recoveringDistance).toBeGreaterThan(blockedDistance);
|
|
expect(recoveringDistance).toBeLessThan(desiredDistance);
|
|
|
|
controller.update(1);
|
|
|
|
const finalDistance = distanceBetween(
|
|
{
|
|
x: context.camera.position.x,
|
|
y: context.camera.position.y,
|
|
z: context.camera.position.z
|
|
},
|
|
latestPivot ?? { x: 0, y: 0, z: 0 }
|
|
);
|
|
const finalDesiredDistance = distanceBetween(
|
|
latestDesiredCameraPosition ?? { x: 0, y: 0, z: 0 },
|
|
latestPivot ?? { x: 0, y: 0, z: 0 }
|
|
);
|
|
|
|
expect(finalDistance).toBeCloseTo(finalDesiredDistance);
|
|
controller.deactivate(context);
|
|
});
|
|
|
|
it("allows the third-person camera to pitch low enough for floor collision to pull it inward", () => {
|
|
const { context } = createRuntimeControllerContext();
|
|
const controller = new ThirdPersonNavigationController();
|
|
const controllerInternals = controller as unknown as {
|
|
pitchRadians: number;
|
|
};
|
|
const floorHeight = 0.2;
|
|
let latestPivot: Vec3 | null = null;
|
|
let latestDesiredCameraPosition: Vec3 | null = null;
|
|
const distanceBetween = (left: Vec3, right: Vec3) =>
|
|
Math.hypot(left.x - right.x, left.y - right.y, left.z - right.z);
|
|
|
|
context.resolveThirdPersonCameraCollision = vi.fn(
|
|
(pivot: Vec3, desiredCameraPosition: Vec3) => {
|
|
latestPivot = { ...pivot };
|
|
latestDesiredCameraPosition = { ...desiredCameraPosition };
|
|
|
|
if (desiredCameraPosition.y >= floorHeight) {
|
|
return { ...desiredCameraPosition };
|
|
}
|
|
|
|
const t =
|
|
(pivot.y - floorHeight) /
|
|
Math.max(pivot.y - desiredCameraPosition.y, 1e-6);
|
|
|
|
return {
|
|
x: pivot.x + (desiredCameraPosition.x - pivot.x) * t,
|
|
y: floorHeight,
|
|
z: pivot.z + (desiredCameraPosition.z - pivot.z) * t
|
|
};
|
|
}
|
|
);
|
|
|
|
controller.activate(context);
|
|
controllerInternals.pitchRadians = -1.2;
|
|
controller.update(0);
|
|
|
|
expect(latestDesiredCameraPosition).not.toBeNull();
|
|
const desiredCameraPosition =
|
|
latestDesiredCameraPosition ?? { x: 0, y: 0, z: 0 };
|
|
|
|
expect(desiredCameraPosition.y).toBeLessThan(floorHeight);
|
|
expect(context.camera.position.y).toBeCloseTo(floorHeight);
|
|
expect(
|
|
distanceBetween(
|
|
{
|
|
x: context.camera.position.x,
|
|
y: context.camera.position.y,
|
|
z: context.camera.position.z
|
|
},
|
|
latestPivot ?? { x: 0, y: 0, z: 0 }
|
|
)
|
|
).toBeLessThan(
|
|
distanceBetween(
|
|
desiredCameraPosition,
|
|
latestPivot ?? { x: 0, y: 0, z: 0 }
|
|
)
|
|
);
|
|
|
|
controller.deactivate(context);
|
|
});
|
|
|
|
it("uses lock-on look input as a temporary offset that returns to center", () => {
|
|
const { context } = createRuntimeControllerContext();
|
|
const controller = new ThirdPersonNavigationController();
|
|
let axes = [0, 0, 1, 0];
|
|
const getGamepads = vi.fn<() => Gamepad[]>(() => [
|
|
createMockGamepad({
|
|
axes
|
|
})
|
|
]);
|
|
const handleRuntimeTargetLookInput = vi.fn(() => ({
|
|
activeTargetLocked: true,
|
|
switchedTarget: false,
|
|
switchInputHeld: false
|
|
}));
|
|
const targetContext = {
|
|
...context,
|
|
handleRuntimeTargetLookInput
|
|
};
|
|
|
|
Object.defineProperty(navigator, "getGamepads", {
|
|
configurable: true,
|
|
value: getGamepads
|
|
});
|
|
|
|
controller.activate(targetContext);
|
|
|
|
const initialCameraX = targetContext.camera.position.x;
|
|
controller.update(0.1);
|
|
const offsetCameraX = targetContext.camera.position.x;
|
|
|
|
axes = [0, 0, 0, 0];
|
|
controller.update(0.5);
|
|
const returnedCameraX = targetContext.camera.position.x;
|
|
|
|
expect(offsetCameraX).not.toBeCloseTo(initialCameraX);
|
|
expect(Math.abs(returnedCameraX - initialCameraX)).toBeLessThan(
|
|
Math.abs(offsetCameraX - initialCameraX)
|
|
);
|
|
expect(handleRuntimeTargetLookInput).toHaveBeenCalledWith({
|
|
horizontal: expect.any(Number),
|
|
vertical: 0
|
|
});
|
|
|
|
controller.deactivate(targetContext);
|
|
});
|
|
|
|
it("requests target clear without snapping back when lock-on manual look reaches its boundary", () => {
|
|
const { context } = createRuntimeControllerContext();
|
|
const controller = new ThirdPersonNavigationController();
|
|
const controllerInternals = controller as unknown as {
|
|
cameraYawRadians: number;
|
|
targetLookOffsetYawRadians: number;
|
|
};
|
|
const getGamepads = vi.fn<() => Gamepad[]>(() => [
|
|
createMockGamepad({
|
|
axes: [0, 0, 1, 0]
|
|
})
|
|
]);
|
|
const handleRuntimeTargetLookBoundaryReached = vi.fn(() => false);
|
|
const targetContext = {
|
|
...context,
|
|
handleRuntimeTargetLookInput: vi.fn(() => ({
|
|
activeTargetLocked: true,
|
|
switchedTarget: false,
|
|
switchInputHeld: false
|
|
})),
|
|
handleRuntimeTargetLookBoundaryReached
|
|
};
|
|
|
|
Object.defineProperty(navigator, "getGamepads", {
|
|
configurable: true,
|
|
value: getGamepads
|
|
});
|
|
|
|
controller.activate(targetContext);
|
|
controller.update(1);
|
|
|
|
expect(handleRuntimeTargetLookBoundaryReached).toHaveBeenCalledTimes(1);
|
|
expect(controllerInternals.cameraYawRadians).toBeLessThan(-0.7);
|
|
expect(controllerInternals.targetLookOffsetYawRadians).toBe(0);
|
|
|
|
controller.deactivate(targetContext);
|
|
});
|
|
|
|
it("uses third-person target assist to adjust vertical camera aim", () => {
|
|
const { context } = createRuntimeControllerContext();
|
|
const controller = new ThirdPersonNavigationController();
|
|
const targetContext = {
|
|
...context,
|
|
resolveThirdPersonTargetAssist: () => ({
|
|
targetPosition: {
|
|
x: 0,
|
|
y: 4,
|
|
z: 5
|
|
},
|
|
strength: 1
|
|
})
|
|
};
|
|
const cameraDirection = new Vector3();
|
|
|
|
controller.activate(targetContext);
|
|
targetContext.camera.getWorldDirection(cameraDirection);
|
|
const initialForwardY = cameraDirection.y;
|
|
|
|
controller.update(1);
|
|
targetContext.camera.getWorldDirection(cameraDirection);
|
|
|
|
expect(cameraDirection.y).toBeGreaterThan(initialForwardY);
|
|
|
|
controller.deactivate(targetContext);
|
|
});
|
|
|
|
it("blends the previous high orbit pitch toward neutral when targeting starts", () => {
|
|
const { context } = createRuntimeControllerContext();
|
|
const controller = new ThirdPersonNavigationController();
|
|
let targetAssistActive = false;
|
|
const controllerInternals = controller as unknown as {
|
|
pitchRadians: number;
|
|
};
|
|
const targetContext = {
|
|
...context,
|
|
resolveThirdPersonTargetAssist: () =>
|
|
targetAssistActive
|
|
? {
|
|
targetPosition: {
|
|
x: 0,
|
|
y: 1,
|
|
z: 5
|
|
},
|
|
strength: 1
|
|
}
|
|
: null
|
|
};
|
|
|
|
controller.activate(targetContext);
|
|
const defaultCameraY = targetContext.camera.position.y;
|
|
|
|
controllerInternals.pitchRadians = 1.2;
|
|
controller.update(0);
|
|
const highOrbitCameraY = targetContext.camera.position.y;
|
|
|
|
targetAssistActive = true;
|
|
controller.update(0.016);
|
|
const firstTargetFrameCameraY = targetContext.camera.position.y;
|
|
controller.update(1);
|
|
|
|
expect(highOrbitCameraY).toBeGreaterThan(defaultCameraY + 2);
|
|
expect(firstTargetFrameCameraY).toBeGreaterThan(defaultCameraY + 1);
|
|
expect(firstTargetFrameCameraY).toBeLessThan(highOrbitCameraY);
|
|
expect(controllerInternals.pitchRadians).toBeCloseTo(0.35, 2);
|
|
|
|
controller.deactivate(targetContext);
|
|
});
|
|
|
|
it("pauses third-person target assist while the camera is actively moved with pointer drag", () => {
|
|
const { context } = createRuntimeControllerContext();
|
|
const controller = new ThirdPersonNavigationController();
|
|
const controllerInternals = controller as unknown as {
|
|
cameraYawRadians: number;
|
|
pitchRadians: number;
|
|
targetAssistLookOffsetY: number;
|
|
handlePointerDown(event: PointerEvent): void;
|
|
handlePointerMove(event: PointerEvent): void;
|
|
handlePointerUp(): void;
|
|
};
|
|
const targetContext = {
|
|
...context,
|
|
resolveThirdPersonTargetAssist: () => ({
|
|
targetPosition: {
|
|
x: 0,
|
|
y: 4,
|
|
z: 5
|
|
},
|
|
strength: 1
|
|
}),
|
|
handleRuntimeTargetLookInput: vi.fn(() => ({
|
|
activeTargetLocked: true,
|
|
switchedTarget: false,
|
|
switchInputHeld: false
|
|
}))
|
|
};
|
|
|
|
controller.activate(targetContext);
|
|
controllerInternals.cameraYawRadians = 1.1;
|
|
controllerInternals.pitchRadians = 1.1;
|
|
controllerInternals.targetAssistLookOffsetY = 0;
|
|
|
|
controllerInternals.handlePointerDown({
|
|
button: 0,
|
|
clientX: 0,
|
|
clientY: 0
|
|
} as PointerEvent);
|
|
controllerInternals.handlePointerMove({
|
|
clientX: 30,
|
|
clientY: 12
|
|
} as PointerEvent);
|
|
|
|
controller.update(0.016);
|
|
|
|
expect(controllerInternals.cameraYawRadians).toBeCloseTo(1.1, 5);
|
|
expect(controllerInternals.pitchRadians).toBeCloseTo(1.1, 5);
|
|
expect(controllerInternals.targetAssistLookOffsetY).toBeCloseTo(0, 5);
|
|
|
|
controllerInternals.handlePointerUp();
|
|
controller.deactivate(targetContext);
|
|
});
|
|
|
|
it("fades vertical target assist when camera collision pushes the camera close", () => {
|
|
const { context } = createRuntimeControllerContext();
|
|
const controller = new ThirdPersonNavigationController();
|
|
const targetContext = {
|
|
...context,
|
|
resolveThirdPersonCameraCollision: (
|
|
pivot: Vec3,
|
|
desiredCameraPosition: Vec3
|
|
) => ({
|
|
x: pivot.x + (desiredCameraPosition.x - pivot.x) * 0.12,
|
|
y: pivot.y + (desiredCameraPosition.y - pivot.y) * 0.12,
|
|
z: pivot.z + (desiredCameraPosition.z - pivot.z) * 0.12
|
|
}),
|
|
resolveThirdPersonTargetAssist: () => ({
|
|
targetPosition: {
|
|
x: 0,
|
|
y: 5,
|
|
z: 5
|
|
},
|
|
strength: 1
|
|
})
|
|
};
|
|
const cameraDirection = new Vector3();
|
|
|
|
controller.activate(targetContext);
|
|
controller.update(1);
|
|
targetContext.camera.getWorldDirection(cameraDirection);
|
|
|
|
expect(cameraDirection.y).toBeLessThan(0.15);
|
|
|
|
controller.deactivate(targetContext);
|
|
});
|
|
|
|
|
|
it("uses the authored movement template speed for third-person motion telemetry", () => {
|
|
const playerStart = createPlayerStartEntity({
|
|
id: "entity-player-start-custom-third-person-movement",
|
|
inputBindings: {
|
|
keyboard: {
|
|
moveForward: "ArrowUp",
|
|
moveBackward: "ArrowDown",
|
|
moveLeft: "ArrowLeft",
|
|
moveRight: "ArrowRight"
|
|
}
|
|
},
|
|
movementTemplate: {
|
|
moveSpeed: 2.25
|
|
}
|
|
});
|
|
const { context } = createRuntimeControllerContext(playerStart);
|
|
const controller = new ThirdPersonNavigationController();
|
|
|
|
controller.activate(context);
|
|
window.dispatchEvent(new KeyboardEvent("keydown", { code: "ArrowUp" }));
|
|
controller.update(1);
|
|
|
|
const telemetry =
|
|
context.setPlayerControllerTelemetry.mock.calls.at(-1)?.[0];
|
|
|
|
expect(
|
|
Math.hypot(
|
|
telemetry?.feetPosition.x ?? 0,
|
|
telemetry?.feetPosition.z ?? 0
|
|
)
|
|
).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: "ArrowUp" }));
|
|
controller.deactivate(context);
|
|
});
|
|
|
|
it("uses sprint input to raise gait and planar travel speed when grounded", () => {
|
|
const playerStart = createPlayerStartEntity({
|
|
id: "entity-player-start-third-person-sprint",
|
|
inputBindings: {
|
|
keyboard: {
|
|
moveForward: "ArrowUp",
|
|
moveBackward: "ArrowDown",
|
|
moveLeft: "ArrowLeft",
|
|
moveRight: "ArrowRight",
|
|
sprint: "ShiftLeft"
|
|
}
|
|
}
|
|
});
|
|
const { context } = createRuntimeControllerContext(playerStart);
|
|
context.probePlayerGround = vi.fn(() => ({
|
|
grounded: true,
|
|
distance: 0,
|
|
normal: { x: 0, y: 1, z: 0 },
|
|
slopeDegrees: 0
|
|
}));
|
|
context.resolveFirstPersonMotion = (
|
|
feetPosition: Vec3,
|
|
motion: Vec3,
|
|
_shape: FirstPersonPlayerShape
|
|
) => ({
|
|
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
|
|
}
|
|
});
|
|
const controller = new ThirdPersonNavigationController();
|
|
|
|
controller.activate(context);
|
|
window.dispatchEvent(new KeyboardEvent("keydown", { code: "ArrowUp" }));
|
|
window.dispatchEvent(
|
|
new KeyboardEvent("keydown", { code: "ShiftLeft" })
|
|
);
|
|
controller.update(1);
|
|
|
|
const telemetry =
|
|
context.setPlayerControllerTelemetry.mock.calls.at(-1)?.[0];
|
|
|
|
expect(telemetry?.locomotionState.gait).toBe("sprint");
|
|
expect(telemetry?.locomotionState.sprinting).toBe(true);
|
|
expect(telemetry?.locomotionState.locomotionMode).toBe("grounded");
|
|
expect(
|
|
Math.hypot(
|
|
telemetry?.feetPosition.x ?? 0,
|
|
telemetry?.feetPosition.z ?? 0
|
|
)
|
|
).toBeGreaterThan(7);
|
|
|
|
window.dispatchEvent(new KeyboardEvent("keyup", { code: "ArrowUp" }));
|
|
window.dispatchEvent(
|
|
new KeyboardEvent("keyup", { code: "ShiftLeft" })
|
|
);
|
|
controller.deactivate(context);
|
|
});
|
|
});
|