forked from phenom/healer-man
Release Healer Man 0.1.34
This commit is contained in:
1 parent
05b25c4341
commit
46c272f889
19 files changed
+568
-121
No files matched your search
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user