forked from phenom/healer-man
Release Healer Man 0.1.33
This commit is contained in:
1 parent
6dcb1034e5
commit
05b25c4341
20 files changed
+573
-51
No files matched your search
@@ -0,0 +1,174 @@
|
||||
import { NodeIO } from "@gltf-transform/core";
|
||||
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
|
||||
import { MeshoptDecoder } from "meshoptimizer";
|
||||
import { BufferAttribute, BufferGeometry, Group, Mesh } from "three";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import { requireDungeonDefinition } from "./dungeonRegistry";
|
||||
import {
|
||||
findNearestPartyNavigationNode,
|
||||
findNearestPartyNavigationSurface,
|
||||
findPartyNavigationPath,
|
||||
type PartyNavigationGraph,
|
||||
} from "./partyNavigation";
|
||||
import { preparePartyNavigationGraph } from "./partyNavigationAssetCache";
|
||||
import { shouldLoadPartyNavigation } from "./partyNavigationPolicy";
|
||||
import { planPartyRoute } from "./partyPathing";
|
||||
import { partyCombatMovementGoal, stepPartyPosition } from "./partyMovement";
|
||||
import { derivePartySpawnSlots } from "./partySpawnSlots";
|
||||
|
||||
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;
|
||||
|
||||
beforeAll(async () => {
|
||||
await MeshoptDecoder.ready;
|
||||
const document = await new NodeIO()
|
||||
.registerExtensions(ALL_EXTENSIONS)
|
||||
.registerDependencies({ "meshopt.decoder": MeshoptDecoder })
|
||||
.read(STOCKADES_NAVIGATION);
|
||||
const meshNode = document.getRoot().listNodes().find((node) => node.getMesh());
|
||||
const primitive = meshNode?.getMesh()?.listPrimitives()[0];
|
||||
const accessor = primitive?.getAttribute("POSITION");
|
||||
const sourcePositions = accessor?.getArray();
|
||||
if (!meshNode || !accessor || !sourcePositions) {
|
||||
throw new Error("Stormwind Stockade navigation GLB is missing its triangle surface.");
|
||||
}
|
||||
if (!(sourcePositions instanceof Int16Array) || !accessor.getNormalized()) {
|
||||
throw new Error("Stormwind Stockade navigation regression expects normalized Int16 positions.");
|
||||
}
|
||||
|
||||
const geometry = new BufferGeometry();
|
||||
geometry.setAttribute("position", new BufferAttribute(sourcePositions, 3, accessor.getNormalized()));
|
||||
const sourceIndices = primitive?.getIndices()?.getArray();
|
||||
if (sourceIndices) geometry.setIndex(Array.from(sourceIndices));
|
||||
const mesh = new Mesh(geometry);
|
||||
mesh.matrixAutoUpdate = false;
|
||||
mesh.matrix.fromArray(meshNode.getWorldMatrix());
|
||||
const scene = new Group();
|
||||
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);
|
||||
geometry.dispose();
|
||||
});
|
||||
|
||||
function distance(left: readonly number[], right: readonly number[]): number {
|
||||
return Math.hypot(left[0] - right[0], left[1] - right[1], left[2] - right[2]);
|
||||
}
|
||||
|
||||
describe("Stormwind Stockade party navigation", () => {
|
||||
it("uses the reviewed warm indoor lighting profile", () => {
|
||||
expect(stockades.presentation).toMatchObject({
|
||||
background: "#171815",
|
||||
fog: { color: "#1b1c18", near: 44 },
|
||||
ambientLight: { color: "#d8c9aa", intensity: 0.58 },
|
||||
hemisphereLight: { skyColor: "#cfc3a5", groundColor: "#292820", intensity: 0.86 },
|
||||
directionalLight: { color: "#ffe0a8", intensity: 1.1, offset: [10, 18, -5] },
|
||||
});
|
||||
});
|
||||
|
||||
it("loads its independently reviewed, budgeted navigation graph", () => {
|
||||
expect(stockades.validation).toMatchObject({ status: "unvalidated", navigation: "green" });
|
||||
expect(stockades.navigationLinks).toHaveLength(3);
|
||||
expect(shouldLoadPartyNavigation("dungeon", stockades, null)).toBe(true);
|
||||
expect(graph).toMatchObject({
|
||||
sourceTriangleCount: 3_538,
|
||||
componentCount: 31,
|
||||
});
|
||||
expect(graph.discardedNodeCount).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("projects every entrance formation slot onto the retained walkable surface", () => {
|
||||
const slots = derivePartySpawnSlots({
|
||||
footPosition: stockades.entrance.footPosition,
|
||||
yaw: stockades.entrance.yaw,
|
||||
});
|
||||
|
||||
expect(slots).toHaveLength(5);
|
||||
expect(findNearestPartyNavigationNode(graph, stockades.entrance.footPosition)?.distance)
|
||||
.toBeLessThanOrEqual(6);
|
||||
for (const slot of slots) {
|
||||
expect(findNearestPartyNavigationSurface(graph, slot.footPosition, 6)).not.toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("routes from the shipping entrance to every authored boss objective", () => {
|
||||
const entrance = findNearestPartyNavigationSurface(
|
||||
graph,
|
||||
stockades.entrance.footPosition,
|
||||
6,
|
||||
);
|
||||
expect(entrance).not.toBeNull();
|
||||
|
||||
for (const objective of stockades.objectiveOrder) {
|
||||
const approach = findNearestPartyNavigationSurface(graph, objective.position, 15);
|
||||
expect(approach, objective.name).not.toBeNull();
|
||||
expect(findPartyNavigationPath(
|
||||
graph,
|
||||
entrance!.position,
|
||||
approach!.position,
|
||||
{ maxStartSnapDistance: 0.1, maxEndSnapDistance: 0.1 },
|
||||
), objective.name).not.toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("gives the tank a usable route into melee range of the first enemy", () => {
|
||||
const tankSlot = derivePartySpawnSlots({
|
||||
footPosition: stockades.entrance.footPosition,
|
||||
yaw: stockades.entrance.yaw,
|
||||
}, 2)[1];
|
||||
const tankStart = findNearestPartyNavigationSurface(graph, tankSlot.footPosition, 6);
|
||||
expect(tankStart).not.toBeNull();
|
||||
const firstEnemy = [...stockades.staticSpawns].sort((left, right) => (
|
||||
distance(left.position, stockades.entrance.footPosition)
|
||||
- distance(right.position, stockades.entrance.footPosition)
|
||||
))[0];
|
||||
const desired: [number, number, number] = [0, 0, 0];
|
||||
expect(partyCombatMovementGoal(
|
||||
tankStart!.position,
|
||||
firstEnemy.position,
|
||||
true,
|
||||
TANK_RANGE.minimum,
|
||||
TANK_RANGE.preferred,
|
||||
TANK_RANGE.maximum,
|
||||
desired,
|
||||
)).toBe("approach");
|
||||
|
||||
const route = planPartyRoute(
|
||||
graph,
|
||||
`tank:${firstEnemy.id}`,
|
||||
tankStart!.position,
|
||||
desired,
|
||||
0,
|
||||
{
|
||||
maxOriginSnapDistance: 3,
|
||||
maxDestinationSnapDistance: 5,
|
||||
localFinishDistance: TANK_RANGE.maximum,
|
||||
directSegmentAllowed: () => true,
|
||||
},
|
||||
);
|
||||
expect(route.blocked).toBe(false);
|
||||
expect(route.waypoints.length).toBeGreaterThan(1);
|
||||
|
||||
let current: [number, number, number] = [...tankStart!.position];
|
||||
const next: [number, number, number] = [0, 0, 0];
|
||||
for (const waypoint of route.waypoints) {
|
||||
while (distance(current, waypoint) > 0.01) {
|
||||
stepPartyPosition(current, waypoint, 0.5, next);
|
||||
current = [...next];
|
||||
}
|
||||
}
|
||||
expect(distance(current, firstEnemy.position)).toBeLessThanOrEqual(TANK_RANGE.maximum);
|
||||
expect(partyCombatMovementGoal(
|
||||
current,
|
||||
firstEnemy.position,
|
||||
true,
|
||||
TANK_RANGE.minimum,
|
||||
TANK_RANGE.preferred,
|
||||
TANK_RANGE.maximum,
|
||||
desired,
|
||||
)).toBe("hold");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user