Files
healer-man/scripts/arena-pipeline/build-custom-arenas.mjs
T
2026-09-11 14:26:15 -04:00

319 lines
15 KiB
JavaScript

import { createHash } from "node:crypto";
import { access, mkdir, readFile, readdir, unlink, writeFile } from "node:fs/promises";
import path from "node:path";
import { bakeNavigationFromCollisionAssets } from "../dungeon-pipeline/recast-bake.mjs";
import { extractNavigationComponent } from "./extract-navigation-component.mjs";
import { MeshoptDecoder } from "meshoptimizer";
import { Vector3 } from "three";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import {
geometryBounds,
parseRootGroupCount,
parseWmoGroup,
rebaseWmoGroups,
wmoChunks,
writeWmoGeometry,
} from "./build-wmo-arenas.mjs";
const projectRoot = path.resolve(import.meta.dirname, "../..");
const extractedRoot = path.resolve(projectRoot, "../LadiksMPQEditor");
const outputRoot = path.join(projectRoot, "public/assets/game/arenas");
const sourceLayers = ["patch-WC1", "patch-WB2", "patch-WB1", "patch-2", "patch", "lichking", "common-2.mpq"];
const DEFINITIONS = Object.freeze([
{
slug: "black-rook-hold-arena",
title: "Black Rook Hold Arena",
mapDirectory: "patch-WB1/world/maps/blackrookholdarena",
primaryPattern: /7vs_blackrookhold_arena\.wmo$/i,
alignmentOffset: [117.542488, -102.29949, -224.463489],
legacyCollision: "public/assets/game/manastorm/1784-blackrookholdarena/collision.glb",
gameplayBounds: { min: [-37, -32, -286], max: [126, 20, -138] },
teamSpawns: [[-35.043, -28.317, -204.435], [108.031, -28.317, -205.573]],
},
{
slug: "ashamanes-fall",
title: "Ashamane's Fall",
mapDirectory: "patch-WB2/world/maps/valsharaharena",
primaryPattern: /custom_valsharah_arena\.wmo$/i,
alignmentOffset: [34.174934, -32.347685, 20.31486],
legacyCollision: "public/assets/game/manastorm/1785-valsharaharena/collision.glb",
gameplayBounds: { min: [14, -4, 2], max: [58, 16, 36] },
teamSpawns: [[18.223099, -0.480263, 32.034554], [55.023094, -0.480263, 7.123436]],
},
]);
function strings(buffer) {
return buffer.toString("utf8").split("\0").filter(Boolean);
}
export function parseAdtPlacements(buffer) {
const top = wmoChunks(buffer);
const mwmo = top.get("MWMO");
const mwid = top.get("MWID");
const modf = top.get("MODF");
if (!mwmo || !mwid || !modf) return [];
const names = new Map();
for (let offset = 0; offset < mwmo.length;) {
const end = mwmo.indexOf(0, offset);
if (end < 0) break;
if (end > offset) names.set(offset, mwmo.toString("utf8", offset, end));
offset = end + 1;
}
const offsets = [];
for (let offset = 0; offset + 4 <= mwid.length; offset += 4) offsets.push(mwid.readUInt32LE(offset));
const placements = [];
for (let offset = 0; offset + 64 <= modf.length; offset += 64) {
const nameIndex = modf.readUInt32LE(offset);
placements.push({
path: names.get(offsets[nameIndex]) ?? strings(mwmo)[nameIndex] ?? null,
uniqueId: modf.readUInt32LE(offset + 4),
position: [modf.readFloatLE(offset + 8), modf.readFloatLE(offset + 12), modf.readFloatLE(offset + 16)],
rotation: [modf.readFloatLE(offset + 20), modf.readFloatLE(offset + 24), modf.readFloatLE(offset + 28)],
scale: modf.readUInt16LE(offset + 62) / 1024 || 1,
});
}
return placements.filter((placement) => placement.path);
}
async function exists(file) {
try { await access(file); return true; } catch { return false; }
}
async function resolveWmo(relativePath) {
const normalized = relativePath.replace(/\\/g, "/");
for (const layer of sourceLayers) {
const file = path.join(extractedRoot, layer, ...normalized.split("/"));
if (await exists(file)) return file;
}
return null;
}
function rotate([x, y, z], [xDegrees, yDegrees, zDegrees]) {
const radians = [xDegrees, yDegrees, zDegrees].map((degrees) => degrees * Math.PI / 180);
let [nextX, nextY, nextZ] = [x, y, z];
let cosine = Math.cos(radians[0]); let sine = Math.sin(radians[0]);
[nextY, nextZ] = [nextY * cosine - nextZ * sine, nextY * sine + nextZ * cosine];
cosine = Math.cos(radians[1]); sine = Math.sin(radians[1]);
[nextX, nextZ] = [nextX * cosine + nextZ * sine, -nextX * sine + nextZ * cosine];
cosine = Math.cos(radians[2]); sine = Math.sin(radians[2]);
[nextX, nextY] = [nextX * cosine - nextY * sine, nextX * sine + nextY * cosine];
return [nextX, nextY, nextZ];
}
function placementBase(position) {
return [17066.6666666667 - position[2], 17066.6666666667 - position[0], position[1]];
}
function placeGroup(group, placement, primaryBase) {
const base = placementBase(placement.position);
const positions = new Float32Array(group.positions.length);
const normals = group.normals ? new Float32Array(group.normals.length) : null;
for (let offset = 0; offset < group.positions.length; offset += 3) {
// Undo the direct parser's Three-axis conversion before applying the MODF transform.
const local = [-group.positions[offset], group.positions[offset + 2], group.positions[offset + 1]];
const transformed = rotate(local.map((value) => value * placement.scale), placement.rotation);
positions[offset] = -(base[0] + transformed[0] - primaryBase[0]);
positions[offset + 1] = base[2] + transformed[2] - primaryBase[2];
positions[offset + 2] = base[1] + transformed[1] - primaryBase[1];
if (normals) {
const localNormal = [-group.normals[offset], group.normals[offset + 2], group.normals[offset + 1]];
const transformedNormal = rotate(localNormal, placement.rotation);
normals[offset] = -transformedNormal[0];
normals[offset + 1] = transformedNormal[2];
normals[offset + 2] = transformedNormal[1];
}
}
return { ...group, positions, normals };
}
async function loadPlacementGroups(placement, placementIndex, primaryBase) {
const rootFile = await resolveWmo(placement.path);
if (!rootFile) throw new Error(`Missing placed WMO: ${placement.path}`);
const root = await readFile(rootFile);
const groupCount = parseRootGroupCount(root);
const extension = path.extname(rootFile);
const base = rootFile.slice(0, -extension.length);
const groups = [];
for (let groupIndex = 0; groupIndex < groupCount; groupIndex += 1) {
const groupFile = `${base}_${String(groupIndex).padStart(3, "0")}${extension}`;
let parsed;
try {
parsed = parseWmoGroup(await readFile(groupFile), `placement-${placementIndex}-group-${groupIndex}`);
} catch (error) {
throw new Error(`${placement.path} group ${groupIndex}: ${error.message}`, { cause: error });
}
groups.push(placeGroup(parsed, placement, primaryBase));
}
return { rootFile, groups };
}
function splitCollisionGroups(groups, triangleLimit = 90000) {
const chunks = [];
let current = [];
let currentTriangles = 0;
for (const group of groups) {
let indexOffset = 0;
while (indexOffset < group.collisionIndices.length) {
const availableTriangles = triangleLimit - currentTriangles;
if (availableTriangles <= 0) {
chunks.push(current);
current = [];
currentTriangles = 0;
continue;
}
const takeIndices = Math.min(group.collisionIndices.length - indexOffset, availableTriangles * 3);
current.push({ ...group, collisionIndices: group.collisionIndices.slice(indexOffset, indexOffset + takeIndices) });
currentTriangles += takeIndices / 3;
indexOffset += takeIndices;
}
}
if (current.length) chunks.push(current);
return chunks;
}
async function hash(file) {
return createHash("sha256").update(await readFile(file)).digest("hex");
}
function triangleInsideGameplayBounds(points, bounds) {
const centroid = [0, 1, 2].map((axis) => (points[0][axis] + points[1][axis] + points[2][axis]) / 3);
return centroid[0] >= bounds.min[0] - 6 && centroid[0] <= bounds.max[0] + 6
&& centroid[1] >= bounds.min[1] - 6 && centroid[1] <= bounds.max[1] + 16
&& centroid[2] >= bounds.min[2] - 6 && centroid[2] <= bounds.max[2] + 6;
}
async function croppedNavigationGroup(legacyCollisionFile, groups, bounds) {
await MeshoptDecoder.ready;
const bytes = await readFile(legacyCollisionFile);
const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
const scene = await new Promise((resolve, reject) => new GLTFLoader()
.setMeshoptDecoder(MeshoptDecoder)
.parse(buffer, "", (gltf) => resolve(gltf.scene), reject));
scene.updateMatrixWorld(true);
const output = [];
const a = new Vector3();
const b = new Vector3();
const c = new Vector3();
scene.traverse((object) => {
if (!object.isMesh || !object.geometry?.getAttribute("position")) return;
const positions = object.geometry.getAttribute("position");
const indices = object.geometry.getIndex();
const count = indices?.count ?? positions.count;
const vertexIndex = (element) => indices ? indices.getX(element) : element;
for (let element = 0; element < count; element += 3) {
const points = [a, b, c].map((point, corner) => point.fromBufferAttribute(positions, vertexIndex(element + corner)).applyMatrix4(object.matrixWorld).toArray());
if (triangleInsideGameplayBounds(points, bounds)) output.push(...points.flat());
}
});
for (const group of groups) {
for (let offset = 0; offset < group.collisionIndices.length; offset += 3) {
const points = [0, 1, 2].map((corner) => {
const index = group.collisionIndices[offset + corner] * 3;
return [group.positions[index], group.positions[index + 1], group.positions[index + 2]];
});
if (triangleInsideGameplayBounds(points, bounds)) output.push(...points.flat());
}
}
const positions = new Float32Array(output);
const indices = Uint32Array.from({ length: positions.length / 3 }, (_, index) => index);
return { name: "rated-gameplay-collision", positions, normals: null, indices, collisionIndices: indices, flagCounts: {} };
}
async function build(definition) {
const mapRoot = path.join(extractedRoot, ...definition.mapDirectory.split("/"));
const adtFiles = (await readdir(mapRoot)).filter((name) => name.toLowerCase().endsWith(".adt")).sort();
const placementsById = new Map();
for (const adt of adtFiles) {
for (const placement of parseAdtPlacements(await readFile(path.join(mapRoot, adt)))) placementsById.set(placement.uniqueId, placement);
}
const placements = [...placementsById.values()];
const primary = placements.find((placement) => definition.primaryPattern.test(placement.path));
if (!primary) throw new Error(`${definition.slug}: primary arena WMO placement was not found.`);
const primaryBase = placementBase(primary.position);
const groups = [];
const sources = [];
for (const [index, placement] of placements.entries()) {
const loaded = await loadPlacementGroups(placement, index, primaryBase);
groups.push(...loaded.groups);
sources.push(path.relative(projectRoot, loaded.rootFile).replace(/\\/g, "/"));
}
const transform = rebaseWmoGroups(groups);
for (const group of groups) {
for (let offset = 0; offset < group.positions.length; offset += 3) {
group.positions[offset] += definition.alignmentOffset[0];
group.positions[offset + 1] += definition.alignmentOffset[1];
group.positions[offset + 2] += definition.alignmentOffset[2];
}
}
const alignedBounds = geometryBounds(groups);
const outputDirectory = path.join(outputRoot, definition.slug);
await mkdir(outputDirectory, { recursive: true });
const visualFile = path.join(outputDirectory, "visual.glb");
await writeWmoGeometry(visualFile, groups, true);
const collisionChunks = splitCollisionGroups(groups);
const collisionFiles = [];
for (const [index, chunk] of collisionChunks.entries()) {
const file = path.join(outputDirectory, index === 0 ? "collision.glb" : `collision-${String(index).padStart(3, "0")}.glb`);
await writeWmoGeometry(file, chunk, false);
collisionFiles.push(file);
}
const navigationFile = path.join(outputDirectory, "navigation.glb");
const rawNavigationFile = path.join(outputDirectory, "navigation-raw.glb");
const legacyCollisionFile = path.join(projectRoot, ...definition.legacyCollision.split("/"));
const navigationCollisionFile = path.join(outputDirectory, "navigation-collision.glb");
const navigationGroup = await croppedNavigationGroup(legacyCollisionFile, groups, definition.gameplayBounds);
await writeWmoGeometry(navigationCollisionFile, [navigationGroup], false);
const navigation = await bakeNavigationFromCollisionAssets({
slug: definition.slug,
collisionAssets: [{ id: "rated-gameplay-collision", role: "collision", path: path.relative(projectRoot, navigationCollisionFile).replace(/\\/g, "/") }],
settings: { cellSize: 0.2, cellHeight: 0.1, agentHeight: 1.8, agentRadius: 0.36, agentMaxClimb: 0.5, agentMaxSlope: 50 },
output: rawNavigationFile,
reportFile: path.join(outputDirectory, "recast-report.json"),
source: { kind: "adt-placed-wmo-repair", adtFiles, placedWmos: sources, legacyCollision: definition.legacyCollision, gameplayBounds: definition.gameplayBounds },
});
const connectedNavigation = await extractNavigationComponent({
source: rawNavigationFile,
output: navigationFile,
teamSpawns: definition.teamSpawns,
});
await Promise.all([unlink(rawNavigationFile), unlink(navigationCollisionFile)]);
const files = [visualFile, ...collisionFiles, navigationFile];
const report = {
schemaVersion: 1,
id: definition.slug,
title: definition.title,
status: "generated-review-required",
source: { mapDirectory: definition.mapDirectory, adtFiles, placementCount: placements.length, placedWmos: sources },
coordinateSystem: {
conversion: "three(x,y,z)=(-wow.x,wow.z,wow.y)",
rebasedOrigin: transform.origin,
alignmentOffset: definition.alignmentOffset,
alignment: "primary WMO matched to the existing HealerMan stage coordinate frame",
},
bounds: alignedBounds,
geometry: {
groups: groups.length,
triangles: groups.reduce((sum, group) => sum + group.indices.length / 3, 0),
collisionTriangles: groups.reduce((sum, group) => sum + group.collisionIndices.length / 3, 0),
collisionChunks: collisionFiles.length,
},
navigation: {
triangles: connectedNavigation.triangles,
vertices: connectedNavigation.vertices,
sourceComponents: connectedNavigation.sourceComponents,
spawnDistances: connectedNavigation.spawnDistances,
rawTriangles: navigation.geometry.triangles,
report: "recast-report.json",
},
assets: await Promise.all(files.map(async (file) => ({ file: path.basename(file), bytes: (await readFile(file)).length, sha256: await hash(file) }))),
};
await writeFile(path.join(outputDirectory, "stage-pack.json"), `${JSON.stringify(report, null, 2)}\n`);
console.log(`${definition.slug}: ${placements.length} placed WMOs, ${report.geometry.triangles} triangles, ${report.navigation.triangles} nav triangles`);
}
if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(import.meta.filename)) {
const requested = new Set(process.argv.slice(2));
for (const definition of DEFINITIONS.filter((entry) => !requested.size || requested.has(entry.slug))) await build(definition);
}