Release Healer Man 0.1.34

This commit is contained in:
phenom committed 2026-09-12 15:14:56 -04:00
1 parent 05b25c4341
commit 46c272f889
19 files changed
+568 -121

No files matched your search

+184 -4
View File
@@ -1,4 +1,5 @@
import { NodeIO } from "@gltf-transform/core";
import RAPIER from "@dimforge/rapier3d-compat";
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
import { MeshoptDecoder } from "meshoptimizer";
import { BufferAttribute, BufferGeometry, Group, Mesh } from "three";
@@ -15,14 +16,19 @@ import { shouldLoadPartyNavigation } from "./partyNavigationPolicy";
import { planPartyRoute } from "./partyPathing";
import { partyCombatMovementGoal, stepPartyPosition } from "./partyMovement";
import { derivePartySpawnSlots } from "./partySpawnSlots";
import { collisionTrimeshes, prepareCollision } from "../scene/productionDungeonCollision";
import { createPartyDirectSegmentValidator, createPartyRouteSegmentValidator } from "../scene/partyTraversal";
import { configurePlayerCharacterController, PLAYER_CHARACTER_CONTROLLER_OFFSET } from "./playerMovement";
const STOCKADES_NAVIGATION = "public/assets/game/manastorm/34-stormwindjail/navigation.glb";
const TANK_RANGE = Object.freeze({ minimum: 0, preferred: 2.9, maximum: 3.15 });
const stockades = requireDungeonDefinition("stormwind-stockade");
let graph: PartyNavigationGraph;
let sourceCollision: Group;
beforeAll(async () => {
await RAPIER.init();
await MeshoptDecoder.ready;
const document = await new NodeIO()
.registerExtensions(ALL_EXTENSIONS)
@@ -50,10 +56,45 @@ beforeAll(async () => {
scene.add(mesh);
// Exercise the same quantized-buffer and node-transform path as GLTFLoader
// in the shipping runtime, not a separately pre-expanded test geometry.
graph = preparePartyNavigationGraph("/test/stockades-navigation.glb", scene, stockades.navigationLinks);
graph = preparePartyNavigationGraph(
"/test/stockades-navigation.glb", scene, stockades.navigationLinks, stockades.traversalRamps,
);
geometry.dispose();
const collisionDocument = await new NodeIO()
.registerExtensions(ALL_EXTENSIONS)
.registerDependencies({ "meshopt.decoder": MeshoptDecoder })
.read("public/assets/game/manastorm/34-stormwindjail/collision.glb");
sourceCollision = new Group();
for (const node of collisionDocument.getRoot().listNodes()) {
for (const collisionPrimitive of node.getMesh()?.listPrimitives() ?? []) {
const positions = collisionPrimitive.getAttribute("POSITION")!;
const values = positions.getArray();
if (!(values instanceof Int16Array)) throw new Error("Expected quantized Stockades collision positions.");
const collisionGeometry = new BufferGeometry();
collisionGeometry.setAttribute("position", new BufferAttribute(values, 3, positions.getNormalized()));
collisionGeometry.setIndex(Array.from(collisionPrimitive.getIndices()!.getArray()!));
const collisionMesh = new Mesh(collisionGeometry);
collisionMesh.matrixAutoUpdate = false;
collisionMesh.matrix.fromArray(node.getWorldMatrix());
sourceCollision.add(collisionMesh);
}
}
});
function collisionWorld(useRamps = true): RAPIER.World {
const world = new RAPIER.World({ x: 0, y: -18, z: 0 });
const prepared = prepareCollision(sourceCollision, useRamps ? stockades.traversalRamps : []);
for (const trimesh of collisionTrimeshes(prepared)) {
world.createCollider(RAPIER.ColliderDesc.trimesh(
Float32Array.from(trimesh.vertices), Uint32Array.from(trimesh.indices),
));
}
for (const child of prepared.children) (child as Mesh).geometry.dispose();
world.step();
return world;
}
function distance(left: readonly number[], right: readonly number[]): number {
return Math.hypot(left[0] - right[0], left[1] - right[1], left[2] - right[2]);
}
@@ -74,10 +115,12 @@ describe("Stormwind Stockade party navigation", () => {
expect(stockades.navigationLinks).toHaveLength(3);
expect(shouldLoadPartyNavigation("dungeon", stockades, null)).toBe(true);
expect(graph).toMatchObject({
sourceTriangleCount: 3_538,
sourceTriangleCount: 3_544,
componentCount: 31,
});
expect(graph.discardedNodeCount).toBeGreaterThan(0);
expect(graph.triangles?.length).toBeGreaterThan(0);
expect(graph.triangleNodes?.length).toBe(graph.triangles?.length);
});
it("projects every entrance formation slot onto the retained walkable surface", () => {
@@ -95,6 +138,8 @@ describe("Stormwind Stockade party navigation", () => {
});
it("routes from the shipping entrance to every authored boss objective", () => {
const world = collisionWorld();
const routeSegmentAllowed = createPartyRouteSegmentValidator(RAPIER, world);
const entrance = findNearestPartyNavigationSurface(
graph,
stockades.entrance.footPosition,
@@ -109,12 +154,15 @@ describe("Stormwind Stockade party navigation", () => {
graph,
entrance!.position,
approach!.position,
{ maxStartSnapDistance: 0.1, maxEndSnapDistance: 0.1 },
{ maxStartSnapDistance: 0.1, maxEndSnapDistance: 0.1, routeSegmentAllowed },
), objective.name).not.toBeNull();
}
world.free();
});
it("gives the tank a usable route into melee range of the first enemy", () => {
const world = collisionWorld();
const segmentAllowed = createPartyDirectSegmentValidator(RAPIER, world);
const tankSlot = derivePartySpawnSlots({
footPosition: stockades.entrance.footPosition,
yaw: stockades.entrance.yaw,
@@ -146,7 +194,8 @@ describe("Stormwind Stockade party navigation", () => {
maxOriginSnapDistance: 3,
maxDestinationSnapDistance: 5,
localFinishDistance: TANK_RANGE.maximum,
directSegmentAllowed: () => true,
directSegmentAllowed: segmentAllowed,
routeSegmentAllowed: createPartyRouteSegmentValidator(RAPIER, world),
},
);
expect(route.blocked).toBe(false);
@@ -170,5 +219,136 @@ describe("Stormwind Stockade party navigation", () => {
TANK_RANGE.maximum,
desired,
)).toBe("hold");
world.free();
});
it("traverses all three shipping stair flights in both directions with the live capsule check", () => {
const world = collisionWorld();
const allowed = createPartyDirectSegmentValidator(RAPIER, world);
expect(stockades.traversalRamps).toHaveLength(3);
for (const ramp of stockades.traversalRamps!) {
const point = (t: number, lateral: number): [number, number, number] => {
const dx = ramp.end[0] - ramp.start[0];
const dz = ramp.end[2] - ramp.start[2];
const length = Math.hypot(dx, dz);
return [
ramp.start[0] + dx * t + dz / length * lateral,
ramp.start[1] + (ramp.end[1] - ramp.start[1]) * t + 0.05,
ramp.start[2] + dz * t - dx / length * lateral,
];
};
for (const lateral of [-2, 0, 2]) {
const start = point(0.05, lateral);
const end = point(0.95, lateral);
expect(allowed(start, end), `${ramp.id}: downhill, offset ${lateral}`).toBe(true);
expect(allowed(end, start), `${ramp.id}: uphill, offset ${lateral}`).toBe(true);
}
const start = findNearestPartyNavigationSurface(graph, point(0.15, 0), 2)!.position;
const end = findNearestPartyNavigationSurface(graph, point(0.85, 0), 2)!.position;
expect(findPartyNavigationPath(graph, start, end, { maxSnapDistance: 0.1 }), ramp.id).not.toBeNull();
// Replanning midway through a stair must not require a blocked snap to
// a distant boundary vertex, as the previous off-mesh graph did.
for (const t of [0.2, 0.4, 0.6, 0.8]) {
const current = findNearestPartyNavigationSurface(graph, point(t, 0), 2)!.position;
const route = planPartyRoute(graph, ramp.id, current, end, t * 1_000, {
maxSnapDistance: 0.1,
directSegmentAllowed: allowed,
});
expect(route.blocked, `${ramp.id}: replan at ${t}`).toBe(false);
}
}
world.free();
});
it("reproduces the old step-face rejection on the unmodified source collision", () => {
const world = collisionWorld(false);
const allowed = createPartyDirectSegmentValidator(RAPIER, world);
for (const ramp of stockades.traversalRamps!) {
const point = (t: number): [number, number, number] => [
ramp.start[0] + (ramp.end[0] - ramp.start[0]) * t,
ramp.start[1] + (ramp.end[1] - ramp.start[1]) * t + 0.05,
ramp.start[2] + (ramp.end[2] - ramp.start[2]) * t,
];
expect(allowed(point(0.95), point(0.05)), ramp.id).toBe(false);
}
world.free();
});
it("still blocks movement through the stairwell walls", () => {
const world = collisionWorld();
const directAllowed = createPartyDirectSegmentValidator(RAPIER, world);
const routeAllowed = createPartyRouteSegmentValidator(RAPIER, world);
const ramp = stockades.traversalRamps![0];
const center: [number, number, number] = [
(ramp.start[0] + ramp.end[0]) / 2,
(ramp.start[1] + ramp.end[1]) / 2 + 0.05,
ramp.start[2],
];
const acrossWall: [number, number, number] = [center[0], center[1], center[2] + 9];
expect(directAllowed(center, acrossWall)).toBe(false);
expect(routeAllowed(center, acrossWall)).toBe(false);
world.free();
});
it("does not mistake the nearby player's kinematic capsule for stair terrain", () => {
const world = collisionWorld();
const ramp = stockades.traversalRamps![0];
const point = (t: number): [number, number, number] => [
ramp.start[0] + (ramp.end[0] - ramp.start[0]) * t,
ramp.start[1] + (ramp.end[1] - ramp.start[1]) * t + 0.05,
ramp.start[2],
];
const player = point(0.5);
const body = world.createRigidBody(RAPIER.RigidBodyDesc.kinematicPositionBased().setTranslation(
player[0], player[1] + 0.84, player[2],
));
world.createCollider(RAPIER.ColliderDesc.capsule(0.48, 0.36), body);
world.step();
expect(createPartyDirectSegmentValidator(RAPIER, world)(point(0.4), point(0.6))).toBe(true);
expect(createPartyRouteSegmentValidator(RAPIER, world)(point(0.4), point(0.6))).toBe(true);
world.free();
});
it("walks the player capsule down and back up every ramp without jumping", () => {
for (const ramp of stockades.traversalRamps!) {
const world = collisionWorld();
const dx = ramp.end[0] - ramp.start[0];
const dz = ramp.end[2] - ramp.start[2];
const length = Math.hypot(dx, dz);
const centerHeight = 0.36 + 0.48;
const startT = 0.03;
const startY = ramp.start[1] + (ramp.end[1] - ramp.start[1]) * startT;
const body = world.createRigidBody(RAPIER.RigidBodyDesc.kinematicPositionBased().setTranslation(
ramp.start[0] + dx * startT, startY + centerHeight + 0.02, ramp.start[2] + dz * startT,
));
const collider = world.createCollider(RAPIER.ColliderDesc.capsule(0.48, 0.36), body);
world.step();
const controller = configurePlayerCharacterController(world.createCharacterController(PLAYER_CHARACTER_CONTROLLER_OFFSET));
const progress = () => {
const position = body.translation();
return ((position.x - ramp.start[0]) * dx + (position.z - ramp.start[2]) * dz) / (length * length);
};
for (const direction of [1, -1]) {
// Rapier projects uphill movement onto the slope, reducing planar
// speed. Bound the walk by twice the flat-ground travel time.
const frames = Math.ceil(length * 2 / (4.8 / 60));
for (let frame = 0; frame < frames; frame += 1) {
if (direction === 1 ? progress() >= 0.95 : progress() <= 0.05) break;
controller.computeColliderMovement(collider, {
x: dx / length * direction * 4.8 / 60,
y: -0.5 / 60,
z: dz / length * direction * 4.8 / 60,
}, RAPIER.QueryFilterFlags.EXCLUDE_SENSORS | RAPIER.QueryFilterFlags.EXCLUDE_DYNAMIC);
const position = body.translation();
const move = controller.computedMovement();
body.setNextKinematicTranslation({ x: position.x + move.x, y: position.y + move.y, z: position.z + move.z });
world.step();
}
if (direction === 1) expect(progress(), `${ramp.id}: player downhill`).toBeGreaterThan(0.9);
else expect(progress(), `${ramp.id}: player uphill`).toBeLessThan(0.12);
}
world.removeCharacterController(controller);
world.free();
}
});
});