Add unit tests for player locomotion and update collision handling logic

This commit is contained in:
2026-04-11 19:54:24 +02:00
parent 17ee177ea2
commit cb24c0afe8
4 changed files with 250 additions and 7 deletions

View File

@@ -22,7 +22,6 @@ const JUMP_SPEED = 7.2;
const SPRINT_SPEED_MULTIPLIER = 1.65;
const CROUCH_SPEED_MULTIPLIER = 0.45;
const GROUND_PROBE_DISTANCE = 0.12;
const GROUND_STICK_DISTANCE = 0.08;
const VERTICAL_ASCENT_EPSILON = 1e-4;
const IDLE_SPEED_EPSILON = 0.05;
@@ -214,6 +213,25 @@ function computePlanarMotion(
};
}
function alignPlanarMotionToGround(
planarMotion: Vec3,
groundNormal: Vec3 | null
): Vec3 {
if (groundNormal === null || groundNormal.y <= VERTICAL_ASCENT_EPSILON) {
return planarMotion;
}
return {
x: planarMotion.x,
y:
-(
groundNormal.x * planarMotion.x +
groundNormal.z * planarMotion.z
) / groundNormal.y,
z: planarMotion.z
};
}
function resolveContactState(
resolvedMotion: ResolvedPlayerMotion,
groundProbe: PlayerGroundProbeResult,
@@ -319,6 +337,13 @@ export function stepPlayerLocomotion(
currentlyGrounded &&
!currentVolumeState.inWater &&
activeShape.mode !== "none";
const groundedPlanarMotion =
currentlyGrounded && !jumpTriggered
? alignPlanarMotionToGround(
planarMotion.motion,
currentGroundProbe.normal
)
: planarMotion.motion;
let verticalVelocity = options.verticalVelocity;
let verticalDisplacement = 0;
@@ -330,7 +355,7 @@ export function stepPlayerLocomotion(
verticalDisplacement = verticalVelocity * options.dt;
} else if (currentlyGrounded) {
verticalVelocity = 0;
verticalDisplacement = options.dt > 0 ? -GROUND_STICK_DISTANCE : 0;
verticalDisplacement = 0;
} else {
verticalVelocity -= GRAVITY * options.dt;
verticalDisplacement = verticalVelocity * options.dt;
@@ -339,9 +364,9 @@ export function stepPlayerLocomotion(
const resolvedMotion = options.resolveMotion(
options.feetPosition,
{
x: planarMotion.motion.x,
y: verticalDisplacement,
z: planarMotion.motion.z
x: groundedPlanarMotion.x,
y: verticalDisplacement + groundedPlanarMotion.y,
z: groundedPlanarMotion.z
},
activeShape
);

View File

@@ -22,7 +22,8 @@ const CHARACTER_CONTROLLER_OFFSET = 0.01;
const CHARACTER_CONTROLLER_SNAP_TO_GROUND_DISTANCE = 0.2;
const COLLISION_EPSILON = 1e-5;
const CAMERA_COLLISION_EPSILON = 1e-3;
const GROUND_NORMAL_Y_THRESHOLD = 0.45;
const MAX_WALKABLE_SLOPE_RADIANS = Math.PI * 0.25;
const GROUND_NORMAL_Y_THRESHOLD = Math.cos(MAX_WALKABLE_SLOPE_RADIANS);
const IDENTITY_ROTATION = {
x: 0,
y: 0,
@@ -352,7 +353,7 @@ export class RapierCollisionWorld {
CHARACTER_CONTROLLER_SNAP_TO_GROUND_DISTANCE
);
characterController.enableAutostep(0.35, 0.15, false);
characterController.setMaxSlopeClimbAngle(Math.PI * 0.45);
characterController.setMaxSlopeClimbAngle(MAX_WALKABLE_SLOPE_RADIANS);
characterController.setMinSlopeSlideAngle(Math.PI * 0.5);
}

View File

@@ -485,4 +485,102 @@ describe("RapierCollisionWorld", () => {
collisionWorld.dispose();
}
});
it("treats only gentle ramps as walkable ground", async () => {
const walkableSlopeAngle = Math.PI / 6;
const steepSlopeAngle = Math.PI / 3;
const walkableGeometry = new PlaneGeometry(8, 8, 1, 1);
const steepGeometry = new PlaneGeometry(8, 8, 1, 1);
walkableGeometry.rotateX(-Math.PI / 2 - walkableSlopeAngle);
steepGeometry.rotateX(-Math.PI / 2 - steepSlopeAngle);
const walkableAssetFixture = createFixtureLoadedModelAssetFromGeometry(
"asset-model-walkable-slope",
walkableGeometry
);
const steepAssetFixture = createFixtureLoadedModelAssetFromGeometry(
"asset-model-steep-slope",
steepGeometry
);
const walkableSlope = createModelInstance({
id: "model-instance-walkable-slope",
assetId: walkableAssetFixture.asset.id,
position: {
x: -6,
y: 4 * Math.sin(walkableSlopeAngle),
z: 4 * Math.cos(walkableSlopeAngle)
},
collision: {
mode: "static",
visible: true
}
});
const steepSlope = createModelInstance({
id: "model-instance-steep-slope",
assetId: steepAssetFixture.asset.id,
position: {
x: 6,
y: 4 * Math.sin(steepSlopeAngle),
z: 4 * Math.cos(steepSlopeAngle)
},
collision: {
mode: "static",
visible: true
}
});
const runtimeScene = buildRuntimeSceneFromDocument(
{
...createEmptySceneDocument({ name: "Slope Walkability Scene" }),
assets: {
[walkableAssetFixture.asset.id]: walkableAssetFixture.asset,
[steepAssetFixture.asset.id]: steepAssetFixture.asset
},
modelInstances: {
[walkableSlope.id]: walkableSlope,
[steepSlope.id]: steepSlope
}
},
{
loadedModelAssets: {
[walkableAssetFixture.asset.id]: walkableAssetFixture.loadedAsset,
[steepAssetFixture.asset.id]: steepAssetFixture.loadedAsset
}
}
);
const collisionWorld = await RapierCollisionWorld.create(
runtimeScene.colliders,
runtimeScene.playerCollider
);
try {
const walkableProbe = collisionWorld.probePlayerGround(
{
x: -6,
y: Math.tan(walkableSlopeAngle) * 2 + 0.05,
z: 2
},
runtimeScene.playerCollider,
0.2
);
const steepProbe = collisionWorld.probePlayerGround(
{
x: 6,
y: Math.tan(steepSlopeAngle) * 2 + 0.05,
z: 2
},
runtimeScene.playerCollider,
0.2
);
expect(walkableProbe.grounded).toBe(true);
expect(walkableProbe.slopeDegrees ?? 0).toBeGreaterThan(25);
expect(walkableProbe.slopeDegrees ?? 0).toBeLessThan(35);
expect(steepProbe.grounded).toBe(false);
expect(steepProbe.slopeDegrees ?? 0).toBeGreaterThan(55);
expect(steepProbe.slopeDegrees ?? 0).toBeLessThan(65);
} finally {
collisionWorld.dispose();
}
});
});

View File

@@ -0,0 +1,119 @@
import { describe, expect, it } from "vitest";
import type { Vec3 } from "../../src/core/vector";
import { FIRST_PERSON_PLAYER_SHAPE } from "../../src/runtime-three/player-collision";
import type {
PlayerGroundProbeResult,
ResolvedPlayerMotion
} from "../../src/runtime-three/player-collision";
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";
const DEFAULT_MOVEMENT: RuntimePlayerMovement = {
templateKind: "default",
moveSpeed: 4.5,
capabilities: {
jump: true,
sprint: true,
crouch: true
}
};
const FORWARD_INPUT: PlayerStartActionInputState = {
moveForward: 1,
moveBackward: 0,
moveLeft: 0,
moveRight: 0,
jump: 0,
sprint: 0,
crouch: 0
};
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,
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: () => ({
inWater: false,
inFog: false
}),
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);
});
});