175 lines
6.7 KiB
TypeScript
175 lines
6.7 KiB
TypeScript
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");
|
|
});
|
|
});
|