Release Healer Man 0.1.16

This commit is contained in:
phenom committed 2026-09-05 20:42:38 -04:00
1 parent f49e54f35f
commit b57d2b2839
89 files changed
+6055 -17

No files matched your search

@@ -0,0 +1,322 @@
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: [-82, -10, -242], max: [312, 30, -110] },
legacyNavigation: "public/assets/game/manastorm/1784-blackrookholdarena/navigation.glb",
teamSpawns: [[-66.098564, 8.416428, -201.852448], [123.158585, 9.916428, -219.909592]],
},
{
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 selectionSource = definition.legacyNavigation
? path.join(projectRoot, ...definition.legacyNavigation.split("/"))
: rawNavigationFile;
const connectedNavigation = await extractNavigationComponent({
source: selectionSource,
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);
}
+249
View File
@@ -0,0 +1,249 @@
import { createHash } from "node:crypto";
import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
import path from "node:path";
import { Document, NodeIO } from "@gltf-transform/core";
import { bakeNavigationFromCollisionAssets } from "../dungeon-pipeline/recast-bake.mjs";
import { extractNavigationComponent } from "./extract-navigation-component.mjs";
const projectRoot = path.resolve(import.meta.dirname, "../..");
const sourceRoot = path.resolve(projectRoot, "../LadiksMPQEditor/common-2.mpq");
const outputRoot = path.join(projectRoot, "public/assets/game/arenas");
const ARENAS = Object.freeze([
{ slug: "nagrand-arena", title: "Nagrand Arena", root: "World/wmo/Dungeon/OL_OgreHuts/PVP_Ogre_Arena01.wmo", teamSpawns: [[-43.41, 13.4, -42.04], [42.03, 14.5, -44.44]] },
{ slug: "blades-edge-arena", title: "Blade's Edge Arena", root: "World/wmo/PvP/Buildings/AncientOrcArena/ancorc_PVPstadium.wmo", teamSpawns: [[29.2, 50.7, -51.49], [31.13, 54.4, 49.51]] },
{ slug: "ruins-of-lordaeron", title: "Ruins of Lordaeron", root: "World/wmo/PvP/Buildings/Lordaeron/PVP_Lordaeron_Arena.wmo", teamSpawns: [[-8.68, 30.1, -73.96], [-5.08, 27.6, 57.28]] },
{ slug: "dalaran-sewers", title: "Dalaran Sewers", root: "World/wmo/PvP/Buildings/Dalaran/dalaran_sewer_arena.wmo", teamSpawns: [[-12.41, 20.1, -63.05], [21.79, 20.2, 25.15]] },
{ slug: "ring-of-valor", title: "Ring of Valor", root: "World/wmo/PvP/Buildings/Orgrimmar/OrgrimmarArena.wmo", teamSpawns: [[0, 27, 25], [0, 27, -25]] },
]);
function tag(buffer, offset) {
return buffer.toString("ascii", offset, offset + 4).split("").reverse().join("");
}
export function wmoChunks(buffer, start = 0, end = buffer.length) {
const result = new Map();
let offset = start;
while (offset + 8 <= end) {
const id = tag(buffer, offset);
const size = buffer.readUInt32LE(offset + 4);
const payloadStart = offset + 8;
let payloadEnd = payloadStart + size;
// Some Legion-era custom group files report the original uncompressed MOGP
// container length even though trailing optional chunks were stripped.
// MOGP is the terminal top-level container, so the physical EOF is safe.
if (id === "MOGP" && payloadEnd > end) payloadEnd = end;
if (payloadEnd > end) throw new Error(`Invalid ${id} chunk at ${offset}: ${size} bytes exceeds the WMO boundary.`);
result.set(id, buffer.subarray(payloadStart, payloadEnd));
offset = payloadEnd;
}
return result;
}
export function parseRootGroupCount(buffer) {
const root = wmoChunks(buffer);
const header = root.get("MOHD");
if (!header || header.length < 8) throw new Error("WMO root is missing MOHD.");
return header.readUInt32LE(4);
}
export function parseWmoGroup(buffer, name) {
const top = wmoChunks(buffer);
const group = top.get("MOGP");
if (!group || group.length <= 68) throw new Error(`${name}: missing MOGP geometry.`);
const nested = wmoChunks(group, 68, group.length);
const vertexBytes = nested.get("MOVT");
const normalBytes = nested.get("MONR");
const indexBytes = nested.get("MOVI");
const materialBytes = nested.get("MOPY");
if (!vertexBytes || !indexBytes) throw new Error(`${name}: missing MOVT or MOVI.`);
const positions = new Float32Array(vertexBytes.length / 4);
for (let offset = 0; offset < vertexBytes.length; offset += 4) positions[offset / 4] = vertexBytes.readFloatLE(offset);
const sourceNormals = normalBytes ? new Float32Array(normalBytes.length / 4) : null;
if (normalBytes) for (let offset = 0; offset < normalBytes.length; offset += 4) sourceNormals[offset / 4] = normalBytes.readFloatLE(offset);
const indices = new Uint32Array(indexBytes.length / 2);
for (let offset = 0; offset < indexBytes.length; offset += 2) indices[offset / 2] = indexBytes.readUInt16LE(offset);
const transformed = new Float32Array(positions.length);
const normals = sourceNormals?.length === positions.length ? new Float32Array(positions.length) : null;
for (let index = 0; index < positions.length; index += 3) {
transformed[index] = -positions[index];
transformed[index + 1] = positions[index + 2];
transformed[index + 2] = positions[index + 1];
if (normals) {
normals[index] = -sourceNormals[index];
normals[index + 1] = sourceNormals[index + 2];
normals[index + 2] = sourceNormals[index + 1];
}
}
const flagCounts = {};
const collisionIndexValues = [];
if (materialBytes) {
for (let offset = 0; offset < materialBytes.length; offset += 2) {
const flag = materialBytes[offset];
flagCounts[flag] = (flagCounts[flag] ?? 0) + 1;
if (flag & 0x04) {
const triangleOffset = (offset / 2) * 3;
collisionIndexValues.push(indices[triangleOffset], indices[triangleOffset + 1], indices[triangleOffset + 2]);
}
}
}
return {
name, positions: transformed, normals, indices, flagCounts,
// Retain every structural WMO triangle for authoritative collision. MOPY
// bit meanings vary by client generation and several arena floors use flag
// combinations that would otherwise be omitted; Recast filters slopes.
collisionIndices: indices,
flaggedCollisionIndices: materialBytes ? new Uint32Array(collisionIndexValues) : indices,
};
}
export function geometryBounds(groups) {
const min = [Infinity, Infinity, Infinity];
const max = [-Infinity, -Infinity, -Infinity];
for (const group of groups) {
for (let offset = 0; offset < group.positions.length; offset += 3) {
for (let axis = 0; axis < 3; axis += 1) {
min[axis] = Math.min(min[axis], group.positions[offset + axis]);
max[axis] = Math.max(max[axis], group.positions[offset + axis]);
}
}
}
return { min, max };
}
export function rebaseWmoGroups(groups) {
const sourceBounds = geometryBounds(groups);
const center = [
(sourceBounds.min[0] + sourceBounds.max[0]) / 2,
sourceBounds.min[1],
(sourceBounds.min[2] + sourceBounds.max[2]) / 2,
];
for (const group of groups) {
for (let offset = 0; offset < group.positions.length; offset += 3) {
group.positions[offset] -= center[0];
group.positions[offset + 1] -= center[1];
group.positions[offset + 2] -= center[2];
}
}
return { sourceBounds, origin: center, bounds: geometryBounds(groups) };
}
export async function writeWmoGeometry(file, groups, visual) {
const document = new Document();
const buffer = document.createBuffer("arena-buffer");
const scene = document.createScene("arena");
const colors = [
[0.37, 0.32, 0.22, 1], [0.29, 0.34, 0.3, 1], [0.42, 0.4, 0.34, 1],
[0.31, 0.27, 0.24, 1], [0.34, 0.37, 0.42, 1],
];
for (const [index, group] of groups.entries()) {
const groupIndices = visual ? group.indices : group.collisionIndices;
if (!groupIndices.length) continue;
const position = document.createAccessor(`${group.name}-position`, buffer).setType("VEC3").setArray(group.positions);
const indices = document.createAccessor(`${group.name}-indices`, buffer).setType("SCALAR").setArray(groupIndices);
const primitive = document.createPrimitive().setAttribute("POSITION", position).setIndices(indices);
if (visual && group.normals) {
primitive.setAttribute("NORMAL", document.createAccessor(`${group.name}-normal`, buffer).setType("VEC3").setArray(group.normals));
}
if (visual) {
primitive.setMaterial(document.createMaterial(`${group.name}-stone`)
.setBaseColorFactor(colors[index % colors.length])
.setRoughnessFactor(0.84)
.setMetallicFactor(0.02)
.setDoubleSided(true));
}
const mesh = document.createMesh(group.name).addPrimitive(primitive);
scene.addChild(document.createNode(group.name).setMesh(mesh));
}
await new NodeIO().write(file, document);
}
async function sha256(file) {
const buffer = await readFile(file);
return createHash("sha256").update(buffer).digest("hex");
}
async function buildArena(definition) {
const rootFile = path.join(sourceRoot, ...definition.root.split("/"));
const root = await readFile(rootFile);
const groupCount = parseRootGroupCount(root);
const extension = path.extname(rootFile);
const base = rootFile.slice(0, -extension.length);
const groups = [];
const sourceFiles = [rootFile];
for (let index = 0; index < groupCount; index += 1) {
const groupFile = `${base}_${String(index).padStart(3, "0")}${extension}`;
const data = await readFile(groupFile);
sourceFiles.push(groupFile);
groups.push(parseWmoGroup(data, `group-${String(index).padStart(3, "0")}`));
}
const transform = rebaseWmoGroups(groups);
const outputDirectory = path.join(outputRoot, definition.slug);
await mkdir(outputDirectory, { recursive: true });
const visualFile = path.join(outputDirectory, "visual.glb");
const collisionFile = path.join(outputDirectory, "collision.glb");
const navigationFile = path.join(outputDirectory, "navigation.glb");
const rawNavigationFile = path.join(outputDirectory, "navigation-raw.glb");
const recastReport = path.join(outputDirectory, "recast-report.json");
await writeWmoGeometry(visualFile, groups, true);
await writeWmoGeometry(collisionFile, groups, false);
const triangles = groups.reduce((sum, group) => sum + group.indices.length / 3, 0);
const collisionTriangles = groups.reduce((sum, group) => sum + group.collisionIndices.length / 3, 0);
if (collisionTriangles > 100000) throw new Error(`${definition.slug}: ${collisionTriangles} collision triangles exceed the rated limit.`);
const relativeCollision = path.relative(projectRoot, collisionFile).replace(/\\/g, "/");
const navigation = await bakeNavigationFromCollisionAssets({
slug: definition.slug,
collisionAssets: [{ id: "collision", role: "collision", path: relativeCollision }],
settings: { cellSize: 0.2, cellHeight: 0.1, agentHeight: 1.8, agentRadius: 0.36, agentMaxClimb: 0.5, agentMaxSlope: 50 },
output: rawNavigationFile,
reportFile: recastReport,
source: { kind: "direct-wmo-parser", files: sourceFiles.map((file) => path.relative(projectRoot, file).replace(/\\/g, "/")) },
});
const connectedNavigation = await extractNavigationComponent({
source: rawNavigationFile,
output: navigationFile,
teamSpawns: definition.teamSpawns,
});
await unlink(rawNavigationFile);
const assets = await Promise.all([
["visual", visualFile], ["collision", collisionFile], ["navigation", navigationFile],
].map(async ([role, file]) => ({ role, file: path.basename(file), bytes: (await readFile(file)).length, sha256: await sha256(file) })));
const report = {
schemaVersion: 1,
id: definition.slug,
title: definition.title,
status: "generated-review-required",
source: { root: path.relative(projectRoot, rootFile).replace(/\\/g, "/"), groupCount },
coordinateSystem: { conversion: "three(x,y,z)=(-wow.x,wow.z,wow.y)", rebasedOrigin: transform.origin },
sourceBounds: transform.sourceBounds,
bounds: transform.bounds,
geometry: {
groups: groups.length,
vertices: groups.reduce((sum, group) => sum + group.positions.length / 3, 0),
triangles,
collisionTriangles,
triangleFlags: groups.map((group) => ({ group: group.name, counts: group.flagCounts })),
},
navigation: {
triangles: connectedNavigation.triangles,
vertices: connectedNavigation.vertices,
sourceComponents: connectedNavigation.sourceComponents,
spawnDistances: connectedNavigation.spawnDistances,
rawTriangles: navigation.geometry.triangles,
report: "recast-report.json",
},
assets,
};
await writeFile(path.join(outputDirectory, "stage-pack.json"), `${JSON.stringify(report, null, 2)}\n`);
return report;
}
if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(import.meta.filename)) {
const requested = new Set(process.argv.slice(2));
const selected = requested.size ? ARENAS.filter((arena) => requested.has(arena.slug)) : ARENAS;
if (!selected.length) throw new Error(`No matching arena. Choose: ${ARENAS.map((arena) => arena.slug).join(", ")}`);
for (const arena of selected) {
const report = await buildArena(arena);
console.log(`${arena.slug}: ${report.geometry.triangles} triangles, ${report.navigation.triangles} navigation triangles`);
}
}
@@ -0,0 +1,101 @@
import { readFile } from "node:fs/promises";
import { MeshoptDecoder } from "meshoptimizer";
import { Vector3 } from "three";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { writeWmoGeometry } from "./build-wmo-arenas.mjs";
globalThis.self = globalThis;
globalThis.ProgressEvent ??= class ProgressEvent {
constructor(type, values = {}) { this.type = type; Object.assign(this, values); }
};
class UnionFind {
constructor(size) { this.parent = Array.from({ length: size }, (_, index) => index); }
find(value) {
if (this.parent[value] !== value) this.parent[value] = this.find(this.parent[value]);
return this.parent[value];
}
union(left, right) {
const a = this.find(left);
const b = this.find(right);
if (a !== b) this.parent[b] = a;
}
}
function key(point) {
return point.map((value) => Math.round(value * 1000)).join(":");
}
async function loadTriangles(file) {
await MeshoptDecoder.ready;
const bytes = await readFile(file);
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 triangles = [];
const points = [new Vector3(), new Vector3(), 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) {
triangles.push(points.map((point, corner) => [
...point.fromBufferAttribute(positions, vertexIndex(element + corner)).applyMatrix4(object.matrixWorld).toArray(),
]));
}
});
return triangles;
}
function distanceSquared(point, target) {
return (point[0] - target[0]) ** 2 + (point[1] - target[1]) ** 2 + (point[2] - target[2]) ** 2;
}
export async function extractNavigationComponent({ source, output, teamSpawns }) {
const triangles = await loadTriangles(source);
const union = new UnionFind(triangles.length);
const ownerByVertex = new Map();
for (const [triangleIndex, triangle] of triangles.entries()) {
for (const point of triangle) {
const positionKey = key(point);
const owner = ownerByVertex.get(positionKey);
if (owner == null) ownerByVertex.set(positionKey, triangleIndex);
else union.union(owner, triangleIndex);
}
}
const components = new Map();
for (let index = 0; index < triangles.length; index += 1) {
const root = union.find(index);
const component = components.get(root) ?? [];
component.push(triangles[index]);
components.set(root, component);
}
const scored = [...components.values()].map((component) => {
const vertices = component.flat();
const spawnDistances = teamSpawns.map((spawn) => Math.sqrt(Math.min(...vertices.map((point) => distanceSquared(point, spawn)))));
return { component, spawnDistances, score: Math.max(...spawnDistances) };
}).sort((left, right) => left.score - right.score || right.component.length - left.component.length);
const selected = scored[0];
if (!selected?.component.length) throw new Error(`${source}: no navigation component could be selected.`);
const values = selected.component.flat(2);
const positions = new Float32Array(values);
const indices = Uint32Array.from({ length: positions.length / 3 }, (_, index) => index);
await writeWmoGeometry(output, [{
name: "rated-connected-navigation",
positions,
normals: null,
indices,
collisionIndices: indices,
flagCounts: {},
}], false);
return {
sourceComponents: components.size,
triangles: selected.component.length,
vertices: positions.length / 3,
spawnDistances: selected.spawnDistances,
};
}
@@ -0,0 +1,84 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { NodeIO } from "@gltf-transform/core";
const projectRoot = path.resolve(import.meta.dirname, "../..");
const catalog = JSON.parse(await readFile(path.join(projectRoot, "shared/arena-stages.json"), "utf8"));
const io = new NodeIO();
const requested = new Set(process.argv.slice(2).filter((value) => !value.startsWith("--")));
const detail = process.argv.includes("--all");
class UnionFind {
constructor(size) { this.parent = Array.from({ length: size }, (_, index) => index); }
find(value) { if (this.parent[value] !== value) this.parent[value] = this.find(this.parent[value]); return this.parent[value]; }
union(left, right) { const a = this.find(left); const b = this.find(right); if (a !== b) this.parent[b] = a; }
}
function round(values) { return values.map((value) => Math.round(value * 100) / 100); }
for (const stage of catalog.stages) {
if (requested.size && !requested.has(stage.id)) continue;
const base = path.join(projectRoot, "public", ...stage.assetBaseUrl.split("/").filter(Boolean));
let document;
try { document = await io.read(path.join(base, "navigation.glb")); } catch { continue; }
const vertices = [];
const triangles = [];
const byPosition = new Map();
for (const mesh of document.getRoot().listMeshes()) {
for (const primitive of mesh.listPrimitives()) {
const positions = primitive.getAttribute("POSITION")?.getArray();
if (!positions) continue;
const indices = primitive.getIndices()?.getArray() ?? Uint32Array.from({ length: positions.length / 3 }, (_, index) => index);
for (let offset = 0; offset < indices.length; offset += 3) {
const triangle = [];
for (let corner = 0; corner < 3; corner += 1) {
const index = indices[offset + corner];
const vertex = [positions[index * 3], positions[index * 3 + 1], positions[index * 3 + 2]];
const key = vertex.map((value) => Math.round(value * 1000)).join(":");
if (!byPosition.has(key)) {
byPosition.set(key, vertices.length);
vertices.push(vertex);
}
triangle.push(byPosition.get(key));
}
triangles.push(triangle);
}
}
}
const union = new UnionFind(vertices.length);
for (const triangle of triangles) {
union.union(triangle[0], triangle[1]);
union.union(triangle[0], triangle[2]);
}
const grouped = new Map();
for (let index = 0; index < vertices.length; index += 1) {
const root = union.find(index);
const component = grouped.get(root) ?? [];
component.push(vertices[index]);
grouped.set(root, component);
}
const components = [...grouped.values()];
components.sort((left, right) => right.length - left.length);
const main = components[0] ?? [];
const min = [0, 1, 2].map((axis) => Math.min(...main.map((vertex) => vertex[axis])));
const max = [0, 1, 2].map((axis) => Math.max(...main.map((vertex) => vertex[axis])));
const center = min.map((value, axis) => (value + max[axis]) / 2);
const axis = max[0] - min[0] >= max[2] - min[2] ? 0 : 2;
const sorted = [...main].sort((left, right) => left[axis] - right[axis]);
const inset = Math.max(1, Math.floor(sorted.length * 0.08));
const teamSpawns = [sorted[inset] ?? sorted[0], sorted[sorted.length - inset - 1] ?? sorted.at(-1)];
console.log(JSON.stringify({
id: stage.id,
componentCount: components.length,
mainVertices: main.length,
nextVertices: components[1]?.length ?? 0,
mainBounds: { min: round(min), max: round(max) },
center: round(center),
suggestedTeamSpawns: teamSpawns.map(round),
topComponents: components.slice(0, detail ? components.length : 8).map((component) => ({
vertices: component.length,
min: round([0, 1, 2].map((componentAxis) => Math.min(...component.map((vertex) => vertex[componentAxis])))),
max: round([0, 1, 2].map((componentAxis) => Math.max(...component.map((vertex) => vertex[componentAxis])))),
})),
}));
}
@@ -0,0 +1,33 @@
import { createHash } from "node:crypto";
import { readFile, stat, writeFile } from "node:fs/promises";
import path from "node:path";
import { NodeIO } from "@gltf-transform/core";
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
import { MeshoptDecoder } from "meshoptimizer";
import { extractNavigationComponent } from "./extract-navigation-component.mjs";
const projectRoot = path.resolve(import.meta.dirname, "../..");
const directory = path.join(projectRoot, "public/assets/game/arenas/warnorken-arena");
const source = path.join(directory, "navigation-source.glb");
const output = path.join(directory, "navigation.glb");
const teamSpawns = [[-42.7, -21.75, 14.12], [41.3, -21.65, -6.58]];
const connected = await extractNavigationComponent({ source, output, teamSpawns });
const io = new NodeIO().registerExtensions(ALL_EXTENSIONS).registerDependencies({ "meshopt.decoder": MeshoptDecoder });
const assets = [];
for (const name of ["visual", "collision", "navigation"]) {
const file = path.join(directory, `${name}.glb`);
const bytes = await readFile(file);
const document = await io.read(file);
const triangles = document.getRoot().listMeshes().reduce((sum, mesh) => sum + mesh.listPrimitives().reduce((primitiveSum, primitive) => primitiveSum + (primitive.getIndices()?.getCount() ?? primitive.getAttribute("POSITION")?.getCount() ?? 0) / 3, 0), 0);
assets.push({ file: `${name}.glb`, bytes: (await stat(file)).size, triangles: Math.round(triangles), sha256: createHash("sha256").update(bytes).digest("hex") });
}
await writeFile(path.join(directory, "stage-pack.json"), `${JSON.stringify({
schemaVersion: 1,
id: "warnorken-arena",
title: "Warnorken Arena",
status: "validated",
source: { kind: "validated-runewaker-package", navigationSource: "navigation-source.glb" },
navigation: connected,
assets,
}, null, 2)}\n`);
console.log(`warnorken-arena: ${connected.triangles} connected navigation triangles`);
+118
View File
@@ -0,0 +1,118 @@
import { access, readFile, stat } from "node:fs/promises";
import path from "node:path";
import { NodeIO } from "@gltf-transform/core";
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
import { MeshoptDecoder } from "meshoptimizer";
const projectRoot = path.resolve(import.meta.dirname, "../..");
const catalog = JSON.parse(await readFile(path.join(projectRoot, "shared/arena-stages.json"), "utf8"));
const failures = [];
const reports = [];
const io = new NodeIO().registerExtensions(ALL_EXTENSIONS).registerDependencies({ "meshopt.decoder": MeshoptDecoder });
class UnionFind {
constructor(size) { this.parent = Array.from({ length: size }, (_, index) => index); }
find(value) {
if (this.parent[value] !== value) this.parent[value] = this.find(this.parent[value]);
return this.parent[value];
}
union(left, right) {
const a = this.find(left);
const b = this.find(right);
if (a !== b) this.parent[b] = a;
}
}
function inspectConnectedNavigation(document, spawns) {
const vertices = [];
const triangles = [];
const byPosition = new Map();
for (const mesh of document.getRoot().listMeshes()) {
for (const primitive of mesh.listPrimitives()) {
const positions = primitive.getAttribute("POSITION")?.getArray();
if (!positions) continue;
const indices = primitive.getIndices()?.getArray() ?? Uint32Array.from({ length: positions.length / 3 }, (_, index) => index);
for (let offset = 0; offset < indices.length; offset += 3) {
const triangle = [];
for (let corner = 0; corner < 3; corner += 1) {
const index = indices[offset + corner];
const point = [positions[index * 3], positions[index * 3 + 1], positions[index * 3 + 2]];
const key = point.map((value) => Math.round(value * 1000)).join(":");
if (!byPosition.has(key)) {
byPosition.set(key, vertices.length);
vertices.push(point);
}
triangle.push(byPosition.get(key));
}
triangles.push(triangle);
}
}
}
const union = new UnionFind(vertices.length);
for (const triangle of triangles) {
union.union(triangle[0], triangle[1]);
union.union(triangle[0], triangle[2]);
}
const components = new Set(vertices.map((_, index) => union.find(index)));
const spawnDistances = spawns.map((spawn) => Math.sqrt(Math.min(...vertices.map((point) => (
(point[0] - spawn[0]) ** 2 + (point[1] - spawn[1]) ** 2 + (point[2] - spawn[2]) ** 2
)))));
return { components: components.size, vertices: vertices.length, triangles: triangles.length, spawnDistances };
}
function validateVec3(value, label, stage) {
if (!Array.isArray(value) || value.length !== 3 || value.some((entry) => !Number.isFinite(entry))) failures.push(`${stage.id}: invalid ${label}.`);
}
for (const stage of catalog.stages) {
validateVec3(stage.bounds?.min, "minimum bounds", stage);
validateVec3(stage.bounds?.max, "maximum bounds", stage);
for (const [index, spawn] of (stage.teamSpawns ?? []).entries()) validateVec3(spawn, `team spawn ${index}`, stage);
if (stage.teamSpawns?.length !== 2) failures.push(`${stage.id}: two team spawns are required.`);
if (stage.shadowSight?.length !== 2) failures.push(`${stage.id}: two Shadow Sight locations are required.`);
for (const [index, spawn] of (stage.teamSpawns ?? []).entries()) {
if (spawn.some((value, axis) => value < stage.bounds.min[axis] || value > stage.bounds.max[axis])) failures.push(`${stage.id}: team spawn ${index} is outside the sealed gameplay bounds.`);
}
for (const object of stage.lineOfSight ?? []) {
validateVec3(object.position, `${object.id} LOS position`, stage);
validateVec3(object.size, `${object.id} LOS size`, stage);
if (object.position[0] < stage.bounds.min[0] || object.position[0] > stage.bounds.max[0]
|| object.position[2] < stage.bounds.min[2] || object.position[2] > stage.bounds.max[2]) failures.push(`${stage.id}: ${object.id} LOS geometry is outside the gameplay bounds.`);
}
const dynamicIds = new Set((stage.dynamicObjects ?? []).map((object) => object.id));
for (const object of stage.lineOfSight ?? []) {
if (object.dynamicObjectId && !dynamicIds.has(object.dynamicObjectId)) failures.push(`${stage.id}: ${object.id} references a missing dynamic object.`);
}
const base = path.join(projectRoot, "public", ...stage.assetBaseUrl.split("/").filter(Boolean));
const assets = {};
for (const name of ["visual", "collision", "navigation"]) {
const file = path.join(base, `${name}.glb`);
try {
await access(file);
const info = await stat(file);
const document = await io.read(file);
const triangles = document.getRoot().listMeshes().reduce((sum, mesh) => sum + mesh.listPrimitives().reduce((primitiveSum, primitive) => primitiveSum + (primitive.getIndices()?.getCount() ?? primitive.getAttribute("POSITION")?.getCount() ?? 0) / 3, 0), 0);
assets[name] = { bytes: info.size, triangles: Math.round(triangles) };
if (name === "navigation") {
const navigation = inspectConnectedNavigation(document, stage.teamSpawns);
assets[name].components = navigation.components;
assets[name].spawnDistances = navigation.spawnDistances.map((distance) => Math.round(distance * 1000) / 1000);
if (navigation.components !== 1) failures.push(`${stage.id}: rated navigation must contain one connected gameplay component.`);
for (const [index, distance] of navigation.spawnDistances.entries()) {
if (distance > 3) failures.push(`${stage.id}: team spawn ${index} is ${distance.toFixed(2)}m from the navigation surface.`);
}
}
if (!triangles) failures.push(`${stage.id}: ${name}.glb has no triangles.`);
} catch {
if (stage.validation.status === "validated") failures.push(`${stage.id}: required ${name}.glb is missing or unreadable.`);
}
}
if (assets.visual?.bytes > 25 * 1024 * 1024) failures.push(`${stage.id}: visual asset exceeds 25 MiB.`);
if (stage.validation.status === "validated" && ["traversal", "collision", "spawnSafety", "lineOfSight", "performance"].some((key) => stage.validation[key] !== true)) {
failures.push(`${stage.id}: validated stages must carry every validation flag.`);
}
reports.push({ id: stage.id, validation: stage.validation, assets });
}
console.log(JSON.stringify({ schemaVersion: 1, arenaCount: reports.length, failures, stages: reports }, null, 2));
if (failures.length) process.exitCode = 1;
+3 -1
View File
@@ -13,6 +13,7 @@ import {
import { threeToSoloNavMesh, threeToTiledNavMesh, NavMeshHelper } from "@recast-navigation/three";
import { Vector3 } from "three";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { MeshoptDecoder } from "meshoptimizer";
import {
exists,
fixturesRoot,
@@ -39,9 +40,10 @@ globalThis.ProgressEvent ??= class ProgressEvent {
globalThis.createImageBitmap ??= async () => ({ width: 1, height: 1, close() {} });
async function loadScene(file) {
await MeshoptDecoder.ready;
const bytes = await readFile(file);
const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
return new Promise((resolve, reject) => new GLTFLoader().parse(buffer, "", (gltf) => resolve(gltf.scene), reject));
return new Promise((resolve, reject) => new GLTFLoader().setMeshoptDecoder(MeshoptDecoder).parse(buffer, "", (gltf) => resolve(gltf.scene), reject));
}
function meshesIn(scene) {