246 lines
9.0 KiB
TypeScript
246 lines
9.0 KiB
TypeScript
import RAPIER from "@dimforge/rapier3d-compat";
|
|
import { NodeIO } from "@gltf-transform/core";
|
|
import { EXTMeshoptCompression, KHRMeshQuantization } from "@gltf-transform/extensions";
|
|
import { MeshoptDecoder } from "meshoptimizer";
|
|
import { beforeAll, describe, expect, it } from "vitest";
|
|
import {
|
|
PLAYER_GROUND_MIN_NORMAL_Y,
|
|
PLAYER_GROUND_PROBE_DISTANCE,
|
|
} from "./playerJump";
|
|
import {
|
|
PLAYER_CHARACTER_CONTROLLER_OFFSET,
|
|
PLAYER_GROUND_SNAP_DISTANCE,
|
|
PLAYER_GROUND_STICK_VELOCITY,
|
|
PLAYER_STEP_HEIGHT,
|
|
PLAYER_STEP_MIN_WIDTH,
|
|
airbornePlayerVerticalVelocity,
|
|
configurePlayerCharacterController,
|
|
playerFrameDeltaSeconds,
|
|
} from "./playerMovement";
|
|
|
|
const PLAYER_RADIUS = 0.36;
|
|
const PLAYER_CAPSULE_HALF_HEIGHT = 0.48;
|
|
const PLAYER_CENTER_HEIGHT = PLAYER_RADIUS + PLAYER_CAPSULE_HALF_HEIGHT;
|
|
const MOVE_SPEED = 4.8;
|
|
const FRAME_SECONDS = 1 / 60;
|
|
const STOCKADES_SPAWN = {
|
|
x: -126.4629,
|
|
y: -29.1512,
|
|
z: 21.2946,
|
|
forwardX: -0.2756962614591509,
|
|
forwardZ: 0.9612448030639477,
|
|
};
|
|
|
|
let stockadesCollision: { vertices: Float32Array; indices: Uint32Array };
|
|
|
|
beforeAll(async () => {
|
|
await RAPIER.init();
|
|
const io = new NodeIO()
|
|
.registerExtensions([EXTMeshoptCompression, KHRMeshQuantization])
|
|
.registerDependencies({ "meshopt.decoder": MeshoptDecoder });
|
|
const document = await io.read("public/assets/game/manastorm/34-stormwindjail/collision.glb");
|
|
const node = document.getRoot().listNodes().find((candidate) => candidate.getMesh());
|
|
const primitive = node?.getMesh()?.listPrimitives()[0];
|
|
const positions = primitive?.getAttribute("POSITION");
|
|
const sourceVertices = positions?.getArray();
|
|
const sourceIndices = primitive?.getIndices()?.getArray();
|
|
if (!node || !positions || !sourceVertices || !sourceIndices) {
|
|
throw new Error("Stormwind Stockade collision GLB is missing its trimesh primitive.");
|
|
}
|
|
|
|
const translation = node.getWorldTranslation();
|
|
const scale = node.getWorldScale();
|
|
const normalization = positions.getNormalized() ? 32_767 : 1;
|
|
const vertices = new Float32Array(sourceVertices.length);
|
|
for (let offset = 0; offset < sourceVertices.length; offset += 3) {
|
|
vertices[offset] = sourceVertices[offset] / normalization * scale[0] + translation[0];
|
|
vertices[offset + 1] = sourceVertices[offset + 1] / normalization * scale[1] + translation[1];
|
|
vertices[offset + 2] = sourceVertices[offset + 2] / normalization * scale[2] + translation[2];
|
|
}
|
|
stockadesCollision = {
|
|
vertices,
|
|
indices: Uint32Array.from(sourceIndices),
|
|
};
|
|
});
|
|
|
|
function walkIntoStep(stepHeight: number): { x: number; y: number; z: number } {
|
|
const world = new RAPIER.World({ x: 0, y: -18, z: 0 });
|
|
const floor = world.createRigidBody(RAPIER.RigidBodyDesc.fixed());
|
|
world.createCollider(
|
|
RAPIER.ColliderDesc.cuboid(5, 0.1, 8).setTranslation(0, -0.1, 3),
|
|
floor,
|
|
);
|
|
const step = world.createRigidBody(RAPIER.RigidBodyDesc.fixed());
|
|
world.createCollider(
|
|
RAPIER.ColliderDesc.cuboid(2, stepHeight / 2, 5)
|
|
.setTranslation(0, stepHeight / 2, 4.75),
|
|
step,
|
|
);
|
|
const body = world.createRigidBody(
|
|
RAPIER.RigidBodyDesc.kinematicPositionBased()
|
|
.setTranslation(0, PLAYER_CENTER_HEIGHT, -0.8),
|
|
);
|
|
const collider = world.createCollider(
|
|
RAPIER.ColliderDesc.capsule(PLAYER_CAPSULE_HALF_HEIGHT, PLAYER_RADIUS),
|
|
body,
|
|
);
|
|
world.step();
|
|
|
|
const controller = configurePlayerCharacterController(
|
|
world.createCharacterController(PLAYER_CHARACTER_CONTROLLER_OFFSET),
|
|
);
|
|
for (let frame = 0; frame < 50; frame += 1) {
|
|
controller.computeColliderMovement(
|
|
collider,
|
|
{
|
|
x: 0,
|
|
y: PLAYER_GROUND_STICK_VELOCITY * FRAME_SECONDS,
|
|
z: MOVE_SPEED * FRAME_SECONDS,
|
|
},
|
|
RAPIER.QueryFilterFlags.EXCLUDE_SENSORS | RAPIER.QueryFilterFlags.EXCLUDE_DYNAMIC,
|
|
);
|
|
const translation = body.translation();
|
|
const movement = controller.computedMovement();
|
|
body.setNextKinematicTranslation({
|
|
x: translation.x + movement.x,
|
|
y: translation.y + movement.y,
|
|
z: translation.z + movement.z,
|
|
});
|
|
world.step();
|
|
}
|
|
|
|
const translation = body.translation();
|
|
const result = { x: translation.x, y: translation.y, z: translation.z };
|
|
world.removeCharacterController(controller);
|
|
world.free();
|
|
return result;
|
|
}
|
|
|
|
describe("player movement", () => {
|
|
it("configures bounded autostep and ground snapping", () => {
|
|
const world = new RAPIER.World({ x: 0, y: -18, z: 0 });
|
|
const controller = configurePlayerCharacterController(
|
|
world.createCharacterController(PLAYER_CHARACTER_CONTROLLER_OFFSET),
|
|
);
|
|
|
|
expect(controller.autostepMaxHeight()).toBeCloseTo(PLAYER_STEP_HEIGHT);
|
|
expect(controller.autostepMinWidth()).toBeCloseTo(PLAYER_STEP_MIN_WIDTH);
|
|
expect(controller.autostepIncludesDynamicBodies()).toBe(false);
|
|
expect(controller.snapToGroundDistance()).toBeCloseTo(PLAYER_GROUND_SNAP_DISTANCE);
|
|
expect(controller.slideEnabled()).toBe(true);
|
|
|
|
world.removeCharacterController(controller);
|
|
world.free();
|
|
});
|
|
|
|
it("walks up an ordinary half-unit stair without jumping", () => {
|
|
const translation = walkIntoStep(0.5);
|
|
expect(translation.z).toBeGreaterThan(2.5);
|
|
expect(translation.y).toBeGreaterThan(PLAYER_CENTER_HEIGHT + 0.45);
|
|
});
|
|
|
|
it("does not autostep over a wall taller than the traversal limit", () => {
|
|
const translation = walkIntoStep(0.8);
|
|
expect(translation.z).toBeLessThan(-0.5);
|
|
expect(translation.y).toBeLessThan(PLAYER_CENTER_HEIGHT + 0.1);
|
|
});
|
|
|
|
it("walks back up the real Stockades entrance stairs without a jump impulse", () => {
|
|
const world = new RAPIER.World({ x: 0, y: -18, z: 0 });
|
|
const dungeonBody = world.createRigidBody(RAPIER.RigidBodyDesc.fixed());
|
|
world.createCollider(
|
|
RAPIER.ColliderDesc.trimesh(
|
|
stockadesCollision.vertices,
|
|
stockadesCollision.indices,
|
|
),
|
|
dungeonBody,
|
|
);
|
|
const body = world.createRigidBody(
|
|
RAPIER.RigidBodyDesc.kinematicPositionBased().setTranslation(
|
|
STOCKADES_SPAWN.x,
|
|
STOCKADES_SPAWN.y + PLAYER_CENTER_HEIGHT,
|
|
STOCKADES_SPAWN.z,
|
|
),
|
|
);
|
|
const collider = world.createCollider(
|
|
RAPIER.ColliderDesc.capsule(PLAYER_CAPSULE_HALF_HEIGHT, PLAYER_RADIUS),
|
|
body,
|
|
);
|
|
world.step();
|
|
const controller = configurePlayerCharacterController(
|
|
world.createCharacterController(PLAYER_CHARACTER_CONTROLLER_OFFSET),
|
|
);
|
|
const groundRay = new RAPIER.Ray(
|
|
{ x: 0, y: 0, z: 0 },
|
|
{ x: 0, y: -1, z: 0 },
|
|
);
|
|
const groundProbeLength = PLAYER_CENTER_HEIGHT + PLAYER_GROUND_PROBE_DISTANCE;
|
|
let verticalVelocity = 0;
|
|
|
|
const walkFrame = (direction: 1 | -1) => {
|
|
const translation = body.translation();
|
|
groundRay.origin.x = translation.x;
|
|
groundRay.origin.y = translation.y;
|
|
groundRay.origin.z = translation.z;
|
|
const groundHit = world.castRayAndGetNormal(
|
|
groundRay,
|
|
groundProbeLength,
|
|
true,
|
|
RAPIER.QueryFilterFlags.EXCLUDE_SENSORS | RAPIER.QueryFilterFlags.EXCLUDE_DYNAMIC,
|
|
undefined,
|
|
undefined,
|
|
body,
|
|
);
|
|
const groundedBeforeMove = verticalVelocity <= 0.5
|
|
&& Boolean(
|
|
groundHit
|
|
&& groundHit.timeOfImpact <= groundProbeLength
|
|
&& groundHit.normal.y >= PLAYER_GROUND_MIN_NORMAL_Y,
|
|
);
|
|
verticalVelocity = groundedBeforeMove
|
|
? PLAYER_GROUND_STICK_VELOCITY
|
|
: airbornePlayerVerticalVelocity(verticalVelocity, FRAME_SECONDS);
|
|
controller.computeColliderMovement(
|
|
collider,
|
|
{
|
|
x: direction * STOCKADES_SPAWN.forwardX * MOVE_SPEED * FRAME_SECONDS,
|
|
y: verticalVelocity * FRAME_SECONDS,
|
|
z: direction * STOCKADES_SPAWN.forwardZ * MOVE_SPEED * FRAME_SECONDS,
|
|
},
|
|
RAPIER.QueryFilterFlags.EXCLUDE_SENSORS | RAPIER.QueryFilterFlags.EXCLUDE_DYNAMIC,
|
|
);
|
|
const movement = controller.computedMovement();
|
|
body.setNextKinematicTranslation({
|
|
x: translation.x + movement.x,
|
|
y: translation.y + movement.y,
|
|
z: translation.z + movement.z,
|
|
});
|
|
if (controller.computedGrounded() && verticalVelocity <= 0) verticalVelocity = 0;
|
|
world.step();
|
|
};
|
|
|
|
for (let frame = 0; frame < 190; frame += 1) walkFrame(1);
|
|
const lowerFootHeight = body.translation().y - PLAYER_CENTER_HEIGHT;
|
|
for (let frame = 0; frame < 240; frame += 1) walkFrame(-1);
|
|
const returned = body.translation();
|
|
const returnedFootHeight = returned.y - PLAYER_CENTER_HEIGHT;
|
|
|
|
expect(lowerFootHeight).toBeLessThan(-33);
|
|
expect(returnedFootHeight).toBeGreaterThan(-30);
|
|
expect(Math.hypot(
|
|
returned.x - STOCKADES_SPAWN.x,
|
|
returned.z - STOCKADES_SPAWN.z,
|
|
)).toBeLessThan(3);
|
|
|
|
world.removeCharacterController(controller);
|
|
world.free();
|
|
});
|
|
|
|
it("clamps frame hitches and applies bounded airborne gravity", () => {
|
|
expect(playerFrameDeltaSeconds(Number.NaN)).toBe(0);
|
|
expect(playerFrameDeltaSeconds(1)).toBe(0.05);
|
|
expect(airbornePlayerVerticalVelocity(0, 1)).toBeCloseTo(-0.9);
|
|
expect(airbornePlayerVerticalVelocity(-100, FRAME_SECONDS)).toBe(-32);
|
|
});
|
|
});
|