Files
webeditor3d/tests/unit/player-locomotion.test.ts

812 lines
22 KiB
TypeScript

import { describe, expect, it } from "vitest";
import type { Vec3 } from "../../src/core/vector";
import { createPlayerStartMovementTemplate } from "../../src/entities/entity-instances";
import { FIRST_PERSON_PLAYER_SHAPE } from "../../src/runtime-three/player-collision";
import type {
PlayerGroundProbeResult,
ResolvedPlayerMotion
} from "../../src/runtime-three/player-collision";
import { createIdleRuntimeLocomotionState } from "../../src/runtime-three/player-locomotion";
import { stepPlayerLocomotion } from "../../src/runtime-three/player-locomotion";
import type { PlayerStartActionInputState } from "../../src/runtime-three/player-input-bindings";
import type { RuntimePlayerMovement } from "../../src/runtime-three/runtime-scene-build";
import { smoothGroundedStairHeight } from "../../src/runtime-three/stair-height-smoothing";
const movementTemplate = createPlayerStartMovementTemplate();
const DEFAULT_MOVEMENT: RuntimePlayerMovement = {
templateKind: "default",
moveSpeed: movementTemplate.moveSpeed,
maxSpeed: movementTemplate.maxSpeed,
maxStepHeight: movementTemplate.maxStepHeight,
capabilities: movementTemplate.capabilities,
jump: movementTemplate.jump,
sprint: movementTemplate.sprint,
crouch: movementTemplate.crouch
};
const FORWARD_INPUT: PlayerStartActionInputState = {
moveForward: 1,
moveBackward: 0,
moveLeft: 0,
moveRight: 0,
jump: 0,
sprint: 0,
crouch: 0,
interact: 0,
clearTarget: 0,
pauseTime: 0
};
function createVolumeState(
overrides: {
inWater?: boolean;
inFog?: boolean;
waterSurfaceHeight?: number | null;
} = {}
) {
return {
inWater: false,
inFog: false,
waterSurfaceHeight: null,
...overrides
};
}
function createGroundProbeResult(normal: Vec3): PlayerGroundProbeResult {
return {
grounded: true,
distance: 0,
normal,
slopeDegrees:
(Math.acos(Math.max(-1, Math.min(1, normal.y))) * 180) / Math.PI
};
}
function stepForwardOnSlope(normal: Vec3) {
return stepPlayerLocomotion({
dt: 0.1,
feetPosition: {
x: 0,
y: 0,
z: 0
},
movementYawRadians: 0,
standingShape: FIRST_PERSON_PLAYER_SHAPE,
verticalVelocity: 0,
previousLocomotionState: undefined,
previousPlanarDisplacement: {
x: 0,
y: 0,
z: 0
},
jumpBufferRemainingMs: 0,
coyoteTimeRemainingMs: 0,
jumpHoldRemainingMs: 0,
crouched: false,
wasJumpPressed: false,
input: FORWARD_INPUT,
movement: DEFAULT_MOVEMENT,
resolveMotion: (feetPosition, motion): ResolvedPlayerMotion => ({
feetPosition: {
x: feetPosition.x + motion.x,
y: feetPosition.y + motion.y,
z: feetPosition.z + motion.z
},
grounded: true,
collisionCount: 1,
groundCollisionNormal: normal,
collidedAxes: {
x: false,
y: false,
z: false
}
}),
resolveVolumeState: () => ({
...createVolumeState()
}),
probeGround: () => createGroundProbeResult(normal),
canOccupyShape: () => true
});
}
describe("player-locomotion", () => {
it("keeps uphill planar speed on walkable slopes", () => {
const slopeAngleRadians = Math.PI / 6;
const uphillNormal = {
x: 0,
y: Math.cos(slopeAngleRadians),
z: -Math.sin(slopeAngleRadians)
};
const step = stepForwardOnSlope(uphillNormal);
expect(step).not.toBeNull();
expect(step?.locomotionState.grounded).toBe(true);
expect(step?.locomotionState.planarSpeed).toBeCloseTo(4.5);
expect(step?.locomotionState.requestedPlanarSpeed).toBeCloseTo(4.5);
expect(step?.feetPosition.z).toBeCloseTo(0.45);
expect(step?.feetPosition.y ?? 0).toBeGreaterThan(0.25);
});
it("keeps downhill planar speed on walkable slopes", () => {
const slopeAngleRadians = Math.PI / 6;
const downhillNormal = {
x: 0,
y: Math.cos(slopeAngleRadians),
z: Math.sin(slopeAngleRadians)
};
const step = stepForwardOnSlope(downhillNormal);
expect(step).not.toBeNull();
expect(step?.locomotionState.grounded).toBe(true);
expect(step?.locomotionState.planarSpeed).toBeCloseTo(4.5);
expect(step?.locomotionState.requestedPlanarSpeed).toBeCloseTo(4.5);
expect(step?.feetPosition.z).toBeCloseTo(0.45);
expect(step?.feetPosition.y ?? 0).toBeLessThan(-0.25);
});
it("preserves airborne planar momentum when movement input is released", () => {
const step = stepPlayerLocomotion({
dt: 0.1,
feetPosition: {
x: 0,
y: 1,
z: 0
},
movementYawRadians: 0,
standingShape: FIRST_PERSON_PLAYER_SHAPE,
verticalVelocity: 2,
previousLocomotionState: createIdleRuntimeLocomotionState("airborne"),
previousPlanarDisplacement: {
x: 0,
y: 0,
z: 0.45
},
jumpBufferRemainingMs: 0,
coyoteTimeRemainingMs: 0,
jumpHoldRemainingMs: 0,
crouched: false,
wasJumpPressed: false,
input: {
...FORWARD_INPUT,
moveForward: 0
},
movement: DEFAULT_MOVEMENT,
resolveMotion: (feetPosition, motion): 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
}
}),
resolveVolumeState: () => ({
...createVolumeState()
}),
probeGround: () => ({
grounded: false,
distance: null,
normal: null,
slopeDegrees: null
}),
canOccupyShape: () => true
});
expect(step).not.toBeNull();
expect(step?.locomotionState.locomotionMode).toBe("airborne");
expect(step?.locomotionState.inputMagnitude).toBe(0);
expect(step?.locomotionState.requestedPlanarSpeed).toBeCloseTo(4.5);
expect(step?.locomotionState.planarSpeed).toBeCloseTo(4.5);
expect(step?.planarDisplacement.z).toBeCloseTo(0.45);
});
it("keeps shallow water grounded instead of switching to swim locomotion", () => {
const step = stepPlayerLocomotion({
dt: 0.1,
feetPosition: {
x: 0,
y: 0,
z: 0
},
movementYawRadians: 0,
standingShape: FIRST_PERSON_PLAYER_SHAPE,
verticalVelocity: 0,
previousLocomotionState: createIdleRuntimeLocomotionState("grounded"),
previousPlanarDisplacement: {
x: 0,
y: 0,
z: 0
},
jumpBufferRemainingMs: 0,
coyoteTimeRemainingMs: 0,
jumpHoldRemainingMs: 0,
crouched: false,
wasJumpPressed: false,
input: FORWARD_INPUT,
movement: DEFAULT_MOVEMENT,
resolveMotion: (feetPosition, motion): ResolvedPlayerMotion => ({
feetPosition: {
x: feetPosition.x + motion.x,
y: feetPosition.y + motion.y,
z: feetPosition.z + motion.z
},
grounded: true,
collisionCount: 0,
groundCollisionNormal: { x: 0, y: 1, z: 0 },
collidedAxes: {
x: false,
y: false,
z: false
}
}),
resolveVolumeState: () =>
createVolumeState({
inWater: true,
waterSurfaceHeight: 1.5
}),
probeGround: () => ({
grounded: true,
distance: 0,
normal: { x: 0, y: 1, z: 0 },
slopeDegrees: 0
}),
canOccupyShape: () => true
});
expect(step).not.toBeNull();
expect(step?.locomotionState.locomotionMode).toBe("grounded");
expect(step?.inWaterVolume).toBe(false);
});
it("keeps a sprint jump airborne while crossing narrow water", () => {
const step = stepPlayerLocomotion({
dt: 0.1,
feetPosition: {
x: 0,
y: 0,
z: 0
},
movementYawRadians: 0,
standingShape: FIRST_PERSON_PLAYER_SHAPE,
verticalVelocity: 0,
previousLocomotionState: createIdleRuntimeLocomotionState("grounded"),
previousPlanarDisplacement: {
x: 0,
y: 0,
z: 0
},
jumpBufferRemainingMs: 0,
coyoteTimeRemainingMs: 0,
jumpHoldRemainingMs: 0,
crouched: false,
wasJumpPressed: false,
input: {
...FORWARD_INPUT,
jump: 1,
sprint: 1
},
movement: DEFAULT_MOVEMENT,
resolveMotion: (feetPosition, motion): 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
}
}),
resolveVolumeState: () =>
createVolumeState({
inWater: true,
waterSurfaceHeight: 1.5
}),
probeGround: () => ({
grounded: true,
distance: 0,
normal: { x: 0, y: 1, z: 0 },
slopeDegrees: 0
}),
canOccupyShape: () => true
});
expect(step).not.toBeNull();
expect(step?.jumpStarted).toBe(true);
expect(step?.locomotionState.locomotionMode).toBe("airborne");
expect(step?.inWaterVolume).toBe(false);
expect(step?.locomotionState.sprinting).toBe(true);
expect(step?.verticalVelocity).toBeCloseTo(DEFAULT_MOVEMENT.jump.speed);
});
it("stays airborne when a wall edge reports grounded without floor support", () => {
const step = stepPlayerLocomotion({
dt: 0.1,
feetPosition: {
x: 0,
y: 1,
z: 0
},
movementYawRadians: 0,
standingShape: FIRST_PERSON_PLAYER_SHAPE,
verticalVelocity: -1.5,
previousLocomotionState: createIdleRuntimeLocomotionState("airborne"),
previousPlanarDisplacement: {
x: 0,
y: 0,
z: 0.45
},
jumpBufferRemainingMs: 0,
coyoteTimeRemainingMs: 0,
jumpHoldRemainingMs: 0,
crouched: false,
wasJumpPressed: false,
input: FORWARD_INPUT,
movement: DEFAULT_MOVEMENT,
resolveMotion: (feetPosition, motion): ResolvedPlayerMotion => ({
feetPosition: {
x: feetPosition.x + motion.x * 0.2,
y: feetPosition.y + motion.y * 0.2,
z: feetPosition.z + motion.z * 0.2
},
grounded: true,
collisionCount: 1,
groundCollisionNormal: null,
collidedAxes: {
x: true,
y: false,
z: false
}
}),
resolveVolumeState: () => createVolumeState(),
probeGround: () => ({
grounded: false,
distance: null,
normal: null,
slopeDegrees: null
}),
canOccupyShape: () => true
});
expect(step).not.toBeNull();
expect(step?.locomotionState.grounded).toBe(false);
expect(step?.locomotionState.locomotionMode).toBe("airborne");
expect(step?.verticalVelocity).toBeLessThan(0);
});
it("disables jump-phase air movement when move while jumping is off", () => {
const step = stepPlayerLocomotion({
dt: 0.1,
feetPosition: {
x: 0,
y: 1,
z: 0
},
movementYawRadians: 0,
standingShape: FIRST_PERSON_PLAYER_SHAPE,
verticalVelocity: 2,
previousLocomotionState: createIdleRuntimeLocomotionState("airborne"),
previousPlanarDisplacement: {
x: 0,
y: 0,
z: 0
},
jumpBufferRemainingMs: 0,
coyoteTimeRemainingMs: 0,
jumpHoldRemainingMs: 0,
crouched: false,
wasJumpPressed: false,
input: FORWARD_INPUT,
movement: {
...DEFAULT_MOVEMENT,
jump: {
...DEFAULT_MOVEMENT.jump,
moveWhileJumping: false
}
},
resolveMotion: (feetPosition, motion): 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
}
}),
resolveVolumeState: () => createVolumeState(),
probeGround: () => ({
grounded: false,
distance: null,
normal: null,
slopeDegrees: null
}),
canOccupyShape: () => true
});
expect(step).not.toBeNull();
expect(step?.locomotionState.locomotionMode).toBe("airborne");
expect(step?.planarDisplacement.z).toBeCloseTo(0);
expect(step?.locomotionState.planarSpeed).toBeCloseTo(0);
});
it("keeps falling when airborne input pushes into a wall and the pre-move probe flickers grounded", () => {
let probeCount = 0;
const step = stepPlayerLocomotion({
dt: 0.1,
feetPosition: {
x: 0,
y: 1,
z: 0
},
movementYawRadians: 0,
standingShape: FIRST_PERSON_PLAYER_SHAPE,
verticalVelocity: -1.5,
previousLocomotionState: createIdleRuntimeLocomotionState("airborne"),
previousPlanarDisplacement: {
x: 0,
y: 0,
z: 0.45
},
jumpBufferRemainingMs: 0,
coyoteTimeRemainingMs: 0,
jumpHoldRemainingMs: 0,
crouched: false,
wasJumpPressed: false,
input: FORWARD_INPUT,
movement: DEFAULT_MOVEMENT,
resolveMotion: (feetPosition, motion): ResolvedPlayerMotion => ({
feetPosition: {
x: feetPosition.x + motion.x * 0.2,
y: feetPosition.y + motion.y,
z: feetPosition.z + motion.z
},
grounded: false,
collisionCount: 1,
groundCollisionNormal: null,
collidedAxes: {
x: true,
y: false,
z: false
}
}),
resolveVolumeState: () => createVolumeState(),
probeGround: () => {
probeCount += 1;
if (probeCount === 1) {
return {
grounded: true,
distance: 0,
normal: { x: 0, y: 1, z: 0 },
slopeDegrees: 0
};
}
return {
grounded: false,
distance: null,
normal: null,
slopeDegrees: null
};
},
canOccupyShape: () => true
});
expect(step).not.toBeNull();
expect(step?.locomotionState.grounded).toBe(false);
expect(step?.locomotionState.locomotionMode).toBe("airborne");
expect(step?.verticalVelocity).toBeLessThan(-1.5);
});
it("disables falling air movement when move while falling is off", () => {
const step = stepPlayerLocomotion({
dt: 0.1,
feetPosition: {
x: 0,
y: 1,
z: 0
},
movementYawRadians: 0,
standingShape: FIRST_PERSON_PLAYER_SHAPE,
verticalVelocity: -2,
previousLocomotionState: createIdleRuntimeLocomotionState("airborne"),
previousPlanarDisplacement: {
x: 0,
y: 0,
z: 0
},
jumpBufferRemainingMs: 0,
coyoteTimeRemainingMs: 0,
jumpHoldRemainingMs: 0,
crouched: false,
wasJumpPressed: false,
input: FORWARD_INPUT,
movement: {
...DEFAULT_MOVEMENT,
jump: {
...DEFAULT_MOVEMENT.jump,
moveWhileFalling: false
}
},
resolveMotion: (feetPosition, motion): 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
}
}),
resolveVolumeState: () => createVolumeState(),
probeGround: () => ({
grounded: false,
distance: null,
normal: null,
slopeDegrees: null
}),
canOccupyShape: () => true
});
expect(step).not.toBeNull();
expect(step?.locomotionState.locomotionMode).toBe("airborne");
expect(step?.planarDisplacement.z).toBeCloseTo(0);
expect(step?.locomotionState.planarSpeed).toBeCloseTo(0);
});
it("reorients airborne movement using existing speed without adding more", () => {
const step = stepPlayerLocomotion({
dt: 0.1,
feetPosition: {
x: 0,
y: 1,
z: 0
},
movementYawRadians: 0,
airDirectionYawRadians: Math.PI / 2,
standingShape: FIRST_PERSON_PLAYER_SHAPE,
verticalVelocity: -2,
previousLocomotionState: createIdleRuntimeLocomotionState("airborne"),
previousPlanarDisplacement: {
x: 0,
y: 0,
z: 0.45
},
jumpBufferRemainingMs: 0,
coyoteTimeRemainingMs: 0,
jumpHoldRemainingMs: 0,
crouched: false,
wasJumpPressed: false,
input: FORWARD_INPUT,
movement: {
...DEFAULT_MOVEMENT,
jump: {
...DEFAULT_MOVEMENT.jump,
directionOnly: true
}
},
resolveMotion: (feetPosition, motion): 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
}
}),
resolveVolumeState: () => createVolumeState(),
probeGround: () => ({
grounded: false,
distance: null,
normal: null,
slopeDegrees: null
}),
canOccupyShape: () => true
});
expect(step).not.toBeNull();
expect(step?.locomotionState.locomotionMode).toBe("airborne");
expect(step?.planarDisplacement.x).toBeCloseTo(0.45);
expect(step?.planarDisplacement.z).toBeCloseTo(0);
expect(step?.locomotionState.requestedPlanarSpeed).toBeCloseTo(4.5);
expect(step?.locomotionState.planarSpeed).toBeCloseTo(4.5);
});
it("smooths grounded stair height changes instead of snapping", () => {
const smoothedHeight = smoothGroundedStairHeight({
currentSmoothedFeetY: 0,
targetFeetY: 0.2,
grounded: true,
dt: 1 / 60,
maxStepHeight: 0.5
});
expect(smoothedHeight).toBeGreaterThan(0);
expect(smoothedHeight).toBeLessThan(0.2);
});
it("snaps grounded height smoothing when the player leaves the ground", () => {
const smoothedHeight = smoothGroundedStairHeight({
currentSmoothedFeetY: 0,
targetFeetY: 0.2,
grounded: false,
dt: 1 / 60,
maxStepHeight: 0.5
});
expect(smoothedHeight).toBeCloseTo(0.2);
});
it("snaps grounded height smoothing for ledge-sized vertical jumps", () => {
const smoothedHeight = smoothGroundedStairHeight({
currentSmoothedFeetY: 0,
targetFeetY: 1,
grounded: true,
dt: 1 / 60,
maxStepHeight: 0.35
});
expect(smoothedHeight).toBeCloseTo(1);
});
it("sinks toward the water surface while keeping the head above water", () => {
const step = stepPlayerLocomotion({
dt: 0.1,
feetPosition: {
x: 0,
y: 1.2,
z: 0
},
movementYawRadians: 0,
standingShape: FIRST_PERSON_PLAYER_SHAPE,
verticalVelocity: 0,
previousLocomotionState: createIdleRuntimeLocomotionState("swimming"),
previousPlanarDisplacement: {
x: 0,
y: 0,
z: 0
},
jumpBufferRemainingMs: 0,
coyoteTimeRemainingMs: 0,
jumpHoldRemainingMs: 0,
crouched: false,
wasJumpPressed: false,
input: {
...FORWARD_INPUT,
moveForward: 0
},
movement: DEFAULT_MOVEMENT,
resolveMotion: (feetPosition, motion): 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
}
}),
resolveVolumeState: () =>
createVolumeState({
inWater: true,
waterSurfaceHeight: 2.4
}),
probeGround: () => ({
grounded: false,
distance: null,
normal: null,
slopeDegrees: null
}),
canOccupyShape: () => true
});
expect(step).not.toBeNull();
expect(step?.locomotionState.locomotionMode).toBe("swimming");
expect(step?.inWaterVolume).toBe(true);
expect(step?.feetPosition.y).toBeCloseTo(0.84, 5);
expect(
(step?.feetPosition.y ?? 0) + FIRST_PERSON_PLAYER_SHAPE.eyeHeight
).toBeGreaterThan(2.4);
});
it("uses sprint input to dive downward while submerged", () => {
const step = stepPlayerLocomotion({
dt: 0.1,
feetPosition: {
x: 0,
y: 0.5,
z: 0
},
movementYawRadians: 0,
standingShape: FIRST_PERSON_PLAYER_SHAPE,
verticalVelocity: 0,
previousLocomotionState: createIdleRuntimeLocomotionState("diving"),
previousPlanarDisplacement: {
x: 0,
y: 0,
z: 0
},
jumpBufferRemainingMs: 0,
coyoteTimeRemainingMs: 0,
jumpHoldRemainingMs: 0,
crouched: false,
wasJumpPressed: false,
input: {
...FORWARD_INPUT,
moveForward: 0,
sprint: 1
},
movement: DEFAULT_MOVEMENT,
resolveMotion: (feetPosition, motion): 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
}
}),
resolveVolumeState: () =>
createVolumeState({
inWater: true,
waterSurfaceHeight: 2.4
}),
probeGround: () => ({
grounded: false,
distance: null,
normal: null,
slopeDegrees: null
}),
canOccupyShape: () => true
});
expect(step).not.toBeNull();
expect(step?.locomotionState.locomotionMode).toBe("diving");
expect(step?.verticalVelocity).toBeCloseTo(-DEFAULT_MOVEMENT.moveSpeed);
expect(step?.feetPosition.y).toBeCloseTo(0.05, 5);
});
});