Release Healer Man 0.1.5
This commit is contained in:
@@ -0,0 +1,245 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user