Files
healer-man/src/game/partyNavigationAssetCache.ts
T
2026-09-12 15:14:56 -04:00

95 lines
3.4 KiB
TypeScript

import { BufferGeometry, Float32BufferAttribute, type Group, type Mesh } from "three";
import type { DungeonNavigationLink, DungeonTraversalRamp } from "./dungeonTypes";
import { bridgeDungeonStairNavigation, smoothDungeonStairGeometry } from "./dungeonTraversalRamps";
import {
buildPartyNavigationGraph,
type PartyNavigationGraph,
} from "./partyNavigation";
import { MAX_SYNCHRONOUS_PARTY_NAVIGATION_TRIANGLES } from "./partyNavigationPolicy";
const MAX_CACHED_NAVIGATION_GRAPHS = 8;
const graphCache = new Map<string, PartyNavigationGraph>();
function graphCacheKey(
url: string,
offMeshLinks: readonly DungeonNavigationLink[],
ramps: readonly DungeonTraversalRamp[],
): string {
const links = offMeshLinks.map((link) => [
link.id,
link.start,
link.end,
link.bidirectional,
]);
return `${url}:${JSON.stringify([links, ramps])}`;
}
function navigationGeometry(scene: Group): BufferGeometry {
scene.updateMatrixWorld(true);
let sourceMesh: Mesh | null = null;
let largestVertexCount = 0;
scene.traverse((node) => {
const mesh = node as Mesh;
const count = mesh.isMesh ? mesh.geometry.getAttribute("position")?.count ?? 0 : 0;
if (count > largestVertexCount) {
sourceMesh = mesh;
largestVertexCount = count;
}
});
if (!sourceMesh) throw new Error("The party navigation GLB has no triangle geometry.");
const geometry = (sourceMesh as Mesh).geometry.clone();
const position = geometry.getAttribute("position");
// BufferAttribute.applyMatrix4 writes back through the source component
// type. Quantized normalized integers therefore clamp world coordinates to
// [-1, 1] while baking their node transform. Expand them to floats first.
if (position.normalized) {
const expanded = new Float32Array(position.count * 3);
for (let index = 0; index < position.count; index += 1) {
expanded[index * 3] = position.getX(index);
expanded[index * 3 + 1] = position.getY(index);
expanded[index * 3 + 2] = position.getZ(index);
}
geometry.setAttribute("position", new Float32BufferAttribute(expanded, 3));
}
geometry.applyMatrix4((sourceMesh as Mesh).matrixWorld);
return geometry;
}
export function preparePartyNavigationGraph(
url: string,
scene: Group,
offMeshLinks: readonly DungeonNavigationLink[],
ramps: readonly DungeonTraversalRamp[] = [],
): PartyNavigationGraph {
const cacheKey = graphCacheKey(url, offMeshLinks, ramps);
const cached = graphCache.get(cacheKey);
if (cached) {
graphCache.delete(cacheKey);
graphCache.set(cacheKey, cached);
return cached;
}
const geometry = navigationGeometry(scene);
let graph: PartyNavigationGraph;
try {
if (ramps.length) {
// Recast normally lifts its walkable surface slightly above collision.
smoothDungeonStairGeometry(geometry, ramps, 0.05);
bridgeDungeonStairNavigation(geometry, ramps, 0.05);
}
const bridgedLinks = new Set(ramps.map((ramp) => ramp.navigationBridge.linkId));
graph = buildPartyNavigationGraph(geometry, {
maxSourceTriangles: MAX_SYNCHRONOUS_PARTY_NAVIGATION_TRIANGLES,
offMeshLinks: offMeshLinks.filter((link) => !bridgedLinks.has(link.id)),
});
} finally {
geometry.dispose();
}
graphCache.set(cacheKey, graph);
if (graphCache.size > MAX_CACHED_NAVIGATION_GRAPHS) {
const oldest = graphCache.keys().next().value as string | undefined;
if (oldest) graphCache.delete(oldest);
}
return graph;
}