812 lines
22 KiB
TypeScript
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);
|
|
});
|
|
});
|