Add unit tests for player locomotion and update collision handling logic
This commit is contained in:
@@ -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
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
119
tests/unit/player-locomotion.test.ts
Normal file
119
tests/unit/player-locomotion.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user