forked from phenom/healer-man
148 lines
7.0 KiB
TypeScript
148 lines
7.0 KiB
TypeScript
import { NodeIO } from "@gltf-transform/core";
|
|
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
|
|
import { createRequire } from "node:module";
|
|
import { existsSync, readFileSync } from "node:fs";
|
|
import { resolve } from "node:path";
|
|
import { MeshoptDecoder } from "meshoptimizer";
|
|
import { BufferAttribute, BufferGeometry, Group, Mesh } from "three";
|
|
import {
|
|
findNearestPartyNavigationSurface,
|
|
findPartyNavigationPath,
|
|
} from "../src/game/partyNavigation";
|
|
import { preparePartyNavigationGraph } from "../src/game/partyNavigationAssetCache";
|
|
import type { DungeonDefinition } from "../src/game/dungeonTypes";
|
|
import { withWowDungeonEnvironment } from "../src/game/wowDungeonEnvironment";
|
|
|
|
const require = createRequire(import.meta.url);
|
|
const { DEFAULT_TEST_DUNGEON_ID, FULL_STOCKADE_DUNGEON_ID } = require("./playtest-defaults.cjs") as {
|
|
DEFAULT_TEST_DUNGEON_ID: string;
|
|
FULL_STOCKADE_DUNGEON_ID: string;
|
|
};
|
|
type Position = readonly [number, number, number];
|
|
type ProbeDungeon = {
|
|
id: string;
|
|
title: string;
|
|
entrance: { footPosition: Position };
|
|
assets: { navigation?: { fileName: string; url: string } | null };
|
|
navigationLinks?: readonly { start: Position; end: Position; bidirectional: boolean }[];
|
|
traversalRamps?: DungeonDefinition["traversalRamps"];
|
|
bosses: readonly { id: string; name: string; position: Position; eventOnly?: boolean }[];
|
|
};
|
|
const campaignCatalogPath = resolve(process.cwd(), "src", "game", "generated", "dungeonCampaignCatalog.json");
|
|
const campaignCatalog = JSON.parse(readFileSync(campaignCatalogPath, "utf8")) as { definitions: ProbeDungeon[] };
|
|
function requireGeneratedDungeonDefinition(dungeonId: string): ProbeDungeon {
|
|
const dungeon = campaignCatalog.definitions.find((candidate) => candidate.id === dungeonId);
|
|
if (!dungeon) throw new Error(`Generated campaign catalog has no dungeon ${dungeonId}.`);
|
|
return dungeon;
|
|
}
|
|
const navigationIo = new NodeIO()
|
|
.registerExtensions(ALL_EXTENSIONS)
|
|
.registerDependencies({ "meshopt.decoder": MeshoptDecoder });
|
|
const requestedDungeonIds = process.argv.slice(2).filter((argument) => !argument.startsWith("--"));
|
|
const dungeonIds = requestedDungeonIds.length ? requestedDungeonIds : [DEFAULT_TEST_DUNGEON_ID];
|
|
|
|
async function navigationGraph(dungeonId: string) {
|
|
const dungeon = requireGeneratedDungeonDefinition(dungeonId);
|
|
const navigation = dungeon.assets.navigation;
|
|
if (!navigation) throw new Error(`${dungeonId} has no navigation asset.`);
|
|
const publicAssetPath = navigation.url.startsWith("/")
|
|
? resolve(process.cwd(), "public", navigation.url.slice(1))
|
|
: resolve(process.cwd(), "public", "assets", "game", "dungeons", dungeonId, navigation.fileName);
|
|
const sourceAssetPath = resolve(process.cwd(), "src", "assets", "game", "dungeons", dungeonId, navigation.fileName);
|
|
const file = [publicAssetPath, sourceAssetPath].find(existsSync);
|
|
if (!file) throw new Error(`${dungeonId} navigation asset is missing at ${publicAssetPath} and ${sourceAssetPath}.`);
|
|
await MeshoptDecoder.ready;
|
|
const document = await navigationIo.read(file);
|
|
const scene = new Group();
|
|
for (const node of document.getRoot().listNodes()) {
|
|
const mesh = node.getMesh();
|
|
if (!mesh) continue;
|
|
for (const primitive of mesh.listPrimitives()) {
|
|
const accessor = primitive.getAttribute("POSITION");
|
|
const source = accessor?.getArray();
|
|
if (!source || !accessor) continue;
|
|
const geometry = new BufferGeometry();
|
|
geometry.setAttribute("position", new BufferAttribute(source, 3, accessor.getNormalized()));
|
|
const sourceIndices = primitive.getIndices()?.getArray();
|
|
if (sourceIndices) geometry.setIndex(Array.from(sourceIndices));
|
|
const navigationMesh = new Mesh(geometry);
|
|
navigationMesh.matrixAutoUpdate = false;
|
|
navigationMesh.matrix.fromArray(node.getWorldMatrix());
|
|
scene.add(navigationMesh);
|
|
}
|
|
}
|
|
const definition = withWowDungeonEnvironment(dungeon as unknown as DungeonDefinition);
|
|
try {
|
|
return preparePartyNavigationGraph(navigation.url, scene, definition.navigationLinks ?? [], definition.traversalRamps ?? []);
|
|
} finally {
|
|
scene.traverse((node) => {
|
|
if ((node as Mesh).isMesh) (node as Mesh).geometry.dispose();
|
|
});
|
|
}
|
|
}
|
|
|
|
for (const dungeonId of dungeonIds) {
|
|
const dungeon = requireGeneratedDungeonDefinition(dungeonId);
|
|
const eligibleBosses = dungeon.bosses.filter((candidate) => !candidate.eventOnly);
|
|
const bosses = dungeonId === DEFAULT_TEST_DUNGEON_ID || dungeonId === FULL_STOCKADE_DUNGEON_ID
|
|
? eligibleBosses : eligibleBosses.slice(0, 1);
|
|
if (!bosses.length) throw new Error(`${dungeonId} has no normal campaign bosses.`);
|
|
const graph = await navigationGraph(dungeonId);
|
|
const start = findNearestPartyNavigationSurface(graph, dungeon.entrance.footPosition, 3);
|
|
if (!start) throw new Error(`${dungeonId} entrance does not snap to navigation.`);
|
|
const waypoints: number[][] = [[...start.position]];
|
|
const objectives: Array<{ id: string; name: string; position: readonly number[]; navigationPosition: readonly number[];
|
|
waypointIndex: number; expectedNavigationDistance: number; snapDistance: number }> = [];
|
|
let segmentStart = start.position;
|
|
let expectedDistance = 0;
|
|
for (const boss of bosses) {
|
|
const end = findNearestPartyNavigationSurface(graph, boss.position, 6);
|
|
if (!end) throw new Error(`${dungeonId} ${boss.name} does not snap to navigation.`);
|
|
const path = findPartyNavigationPath(graph, segmentStart, end.position, {
|
|
maxStartSnapDistance: 3,
|
|
maxEndSnapDistance: 6,
|
|
simplifyTolerance: 0.01,
|
|
});
|
|
if (!path?.length) throw new Error(`${dungeonId} has no navigation path to ${boss.name}.`);
|
|
const startIndex = waypoints.length - 1;
|
|
let segmentDistance = 0;
|
|
for (let index = 1; index < path.length; index += 1) {
|
|
const segmentLength = Math.hypot(
|
|
path[index][0] - path[index - 1][0],
|
|
path[index][1] - path[index - 1][1],
|
|
path[index][2] - path[index - 1][2],
|
|
);
|
|
segmentDistance += segmentLength;
|
|
expectedDistance += segmentLength;
|
|
waypoints.push([...path[index]]);
|
|
}
|
|
objectives.push({
|
|
id: boss.id,
|
|
name: boss.name,
|
|
position: boss.position,
|
|
navigationPosition: end.position,
|
|
waypointIndex: waypoints.length - 1,
|
|
expectedNavigationDistance: Number(segmentDistance.toFixed(2)),
|
|
snapDistance: end.distance,
|
|
});
|
|
if (waypoints.length - 1 <= startIndex) {
|
|
throw new Error(`${dungeonId} navigation path to ${boss.name} has no traversable segment.`);
|
|
}
|
|
segmentStart = end.position;
|
|
}
|
|
process.stdout.write(`${JSON.stringify({
|
|
dungeonId,
|
|
status: "navigation-guidance-only; live UI traversal required",
|
|
objective: bosses.at(-1)!.name,
|
|
objectiveId: bosses.at(-1)!.id,
|
|
objectives,
|
|
entrance: start.position,
|
|
objectivePosition: bosses.at(-1)!.position,
|
|
startSnapDistance: start.distance,
|
|
objectiveSnapDistance: objectives.at(-1)!.snapDistance,
|
|
waypointCount: waypoints.length,
|
|
expectedNavigationDistance: Number(expectedDistance.toFixed(2)),
|
|
waypoints,
|
|
})}\n`);
|
|
}
|