Files
healer-man/scripts/dungeon-pipeline/recast-policy.mjs
T
2026-08-14 15:56:39 -04:00

149 lines
5.3 KiB
JavaScript

export const DEFAULT_TILED_NAVMESH_SIZE = 512;
export const MIN_TILED_NAVMESH_SIZE = 128;
export const MAX_SOLO_HEIGHTFIELD_AXIS = 4096;
export const MAX_SOLO_HEIGHTFIELD_CELLS = MAX_SOLO_HEIGHTFIELD_AXIS ** 2;
export const MAX_DETOUR_TILES = 16384;
function finiteBounds(bounds) {
return Array.isArray(bounds)
&& bounds.length === 2
&& bounds.every((corner) => Array.isArray(corner)
&& corner.length === 3
&& corner.every(Number.isFinite));
}
export function planNavigationBuild(bounds, cellSize, {
tileSize = DEFAULT_TILED_NAVMESH_SIZE,
maxSoloAxis = MAX_SOLO_HEIGHTFIELD_AXIS,
maxSoloCells = MAX_SOLO_HEIGHTFIELD_CELLS,
maxTiles = MAX_DETOUR_TILES,
} = {}) {
if (!finiteBounds(bounds)) throw new Error("Navigation bounds must contain two finite XYZ coordinates.");
if (!Number.isFinite(cellSize) || cellSize <= 0) throw new Error("Navigation cell size must be positive and finite.");
if (!Number.isInteger(tileSize) || tileSize <= 0) throw new Error("Navigation tile size must be a positive integer.");
const [minimum, maximum] = bounds;
const gridWidth = Math.max(1, Math.ceil((maximum[0] - minimum[0]) / cellSize));
const gridHeight = Math.max(1, Math.ceil((maximum[2] - minimum[2]) / cellSize));
if (maximum.some((value, index) => value < minimum[index])) {
throw new Error("Navigation bounds maximum must not be below its minimum.");
}
const gridCells = gridWidth * gridHeight;
const mode = gridWidth > maxSoloAxis || gridHeight > maxSoloAxis || gridCells > maxSoloCells
? "tiled"
: "solo";
const tileColumns = mode === "tiled" ? Math.ceil(gridWidth / tileSize) : 1;
const tileRows = mode === "tiled" ? Math.ceil(gridHeight / tileSize) : 1;
const tileCount = tileColumns * tileRows;
if (mode === "tiled" && tileCount > maxTiles) {
throw new Error(
`Navigation bounds require ${tileCount} tiles, above Detour's ${maxTiles}-tile pipeline limit.`,
);
}
return {
mode,
gridWidth,
gridHeight,
gridCells,
tileSize: mode === "tiled" ? tileSize : null,
tileColumns,
tileRows,
tileCount,
};
}
function tileKey(x, y) {
return `${x},${y}`;
}
function tileCoordinatesFromLog(message) {
const match = /^Building tile at x:\s*(-?\d+), y:\s*(-?\d+)$/.exec(message);
return match ? { x: Number(match[1]), y: Number(match[2]) } : null;
}
function count(intermediate, property, method) {
return intermediate?.[property]?.[method]?.() ?? null;
}
export function validateTiledBuild(intermediates, {
errorCategory = 3,
warningCategory = 2,
intermediateRetention = "retained",
populatedTileCount = null,
} = {}) {
if (!["retained", "released"].includes(intermediateRetention)) {
throw new Error(`Unsupported tiled intermediate retention mode: ${intermediateRetention}.`);
}
const tiles = new Map(
(intermediates?.tileIntermediates ?? []).map((tile) => [tileKey(tile.x, tile.y), tile]),
);
const failures = [];
const ignoredEmptyTilePackingFailures = [];
let currentTile = null;
for (const log of intermediates?.buildContext?.logs ?? []) {
currentTile = tileCoordinatesFromLog(log.msg) ?? currentTile;
const failedToAddTile = log.category === warningCategory
&& log.msg.startsWith("Failed to add tile to nav mesh");
if (log.category !== errorCategory && !failedToAddTile) continue;
const tile = currentTile ? tiles.get(tileKey(currentTile.x, currentTile.y)) : null;
const polygonCount = count(tile, "polyMesh", "npolys");
const contourCount = count(tile, "contourSet", "nconts");
const confirmedEmptyPackingFailure = log.msg === "Failed to create Detour navmesh data"
&& (
(polygonCount === 0 && contourCount === 0)
|| (
intermediateRetention === "released"
&& polygonCount === null
&& contourCount === null
)
);
const issue = {
x: currentTile?.x ?? null,
y: currentTile?.y ?? null,
message: log.msg,
polygonCount,
contourCount,
verification: intermediateRetention,
};
if (confirmedEmptyPackingFailure) ignoredEmptyTilePackingFailures.push(issue);
else failures.push(issue);
}
const populatedTiles = [...tiles.values()].filter(
(tile) => (count(tile, "polyMesh", "npolys") ?? 0) > 0,
);
const effectivePopulatedTileCount = populatedTileCount ?? populatedTiles.length;
return {
valid: failures.length === 0 && effectivePopulatedTileCount > 0,
failures,
ignoredEmptyTilePackingFailures,
populatedTileCount: effectivePopulatedTileCount,
tileCount: tiles.size,
intermediateRetention,
};
}
export function nextTiledNavMeshSize(validation, currentTileSize, {
minimumTileSize = MIN_TILED_NAVMESH_SIZE,
} = {}) {
const retryable = validation?.failures?.length > 0
&& validation.failures.every((failure) => (
/^rcBuildContours: Bad outline for region \d+,/.test(failure.message)
|| /^rcBuildRegions: \d+ overlapping regions\.$/.test(failure.message)
));
if (!retryable || currentTileSize <= minimumTileSize) return null;
return Math.max(minimumTileSize, Math.floor(currentTileSize / 2));
}
export function shouldUseMonotonePartition(validation) {
return validation?.failures?.length > 0
&& validation.failures.every(
(failure) => /^rcBuildRegions: \d+ overlapping regions\.$/.test(failure.message),
);
}