Release Healer Man 0.1.16
This commit is contained in:
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);
|
||||
}
|
||||
@@ -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`);
|
||||
@@ -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;
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user