Release Healer Man 0.1.5

This commit is contained in:
phenom
2026-08-18 16:22:19 -04:00
parent f4c9cf356c
commit 55cfd43d66
62 changed files with 3030 additions and 153 deletions
+245
View File
@@ -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);
});
});