Release Healer Man 0.1.8

This commit is contained in:
phenom
2026-08-27 13:29:58 -04:00
parent eca6b5db9b
commit c245a199b6
90 changed files with 8249 additions and 232 deletions
@@ -0,0 +1,122 @@
#!/usr/bin/env node
import path from "node:path";
import { fileURLToPath } from "node:url";
import { NodeIO } from "@gltf-transform/core";
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
import { BufferAttribute, BufferGeometry, Vector3 } from "three";
import { MeshBVH } from "three-mesh-bvh";
import { MeshoptDecoder } from "meshoptimizer";
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const campaign = await import("../../src/game/generated/dungeonCampaignCatalog.json", {
with: { type: "json" },
}).then((module) => module.default);
const useCollision = process.argv.includes("--collision");
const requestedSlugs = new Set(process.argv.slice(2).filter((argument) => argument !== "--collision"));
const definitions = campaign.definitions.filter((definition) => (
!requestedSlugs.size || requestedSlugs.has(definition.id)
));
if (!definitions.length) {
throw new Error("No matching dungeon definitions. Pass one or more dungeon slugs.");
}
await MeshoptDecoder.ready;
const io = new NodeIO()
.registerExtensions(ALL_EXTENSIONS)
.registerDependencies({ "meshopt.decoder": MeshoptDecoder });
const point = new Vector3();
function normalizerFor(accessor, array) {
if (!accessor.getNormalized()) return (value) => value;
if (array instanceof Int16Array) return (value) => Math.max(value / 32_767, -1);
if (array instanceof Uint16Array) return (value) => value / 65_535;
if (array instanceof Int8Array) return (value) => Math.max(value / 127, -1);
if (array instanceof Uint8Array) return (value) => value / 255;
return (value) => value;
}
async function geometryTrees(file) {
const document = await io.read(file);
const trees = [];
for (const node of document.getRoot().listNodes()) {
const mesh = node.getMesh();
if (!mesh) continue;
const matrix = node.getWorldMatrix();
for (const primitive of mesh.listPrimitives()) {
const accessor = primitive.getAttribute("POSITION");
const source = accessor?.getArray();
if (!accessor || !source) continue;
const normalize = normalizerFor(accessor, source);
const positions = new Float32Array(source.length);
for (let offset = 0; offset < source.length; offset += 3) {
const x = normalize(source[offset]);
const y = normalize(source[offset + 1]);
const z = normalize(source[offset + 2]);
positions[offset] = matrix[0] * x + matrix[4] * y + matrix[8] * z + matrix[12];
positions[offset + 1] = matrix[1] * x + matrix[5] * y + matrix[9] * z + matrix[13];
positions[offset + 2] = matrix[2] * x + matrix[6] * y + matrix[10] * z + matrix[14];
}
const geometry = new BufferGeometry();
geometry.setAttribute("position", new BufferAttribute(positions, 3));
const indices = primitive.getIndices()?.getArray();
if (indices) geometry.setIndex(new BufferAttribute(indices, 1));
trees.push(new MeshBVH(geometry));
}
}
return trees;
}
function nearestDistance(trees, position) {
point.fromArray(position);
let distance = Infinity;
for (const tree of trees) {
const result = tree.closestPointToPoint(point);
distance = Math.min(distance, result?.distance ?? Infinity);
}
return distance;
}
function percentile(sorted, proportion) {
return sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * proportion))] ?? 0;
}
const results = [];
for (const definition of definitions) {
const geometryUrls = useCollision
? definition.assets.collision.map((chunk) => chunk.url)
: [definition.assets.navigation.url];
const trees = (await Promise.all(geometryUrls.map((url) => (
geometryTrees(path.join(projectRoot, "public", url.replace(/^\//, "")))
)))).flat();
const samples = [
{ id: "entrance", position: definition.entrance.footPosition },
...definition.staticSpawns.map((spawn) => ({ id: spawn.id, position: spawn.position })),
...definition.roamingPacks.flatMap((pack) => (
pack.waypoints.map((position, index) => ({ id: `${pack.id}:${index}`, position }))
)),
];
const distances = samples
.map((sample) => ({ ...sample, distance: nearestDistance(trees, sample.position) }))
.sort((left, right) => left.distance - right.distance);
const values = distances.map((sample) => sample.distance);
results.push({
dungeonId: definition.id,
geometry: useCollision ? "collision" : "navigation",
coordinateAlignment: definition.provenance.coordinateAlignment,
coordinateTransform: definition.provenance.coordinateTransform,
samples: values.length,
withinOneMeter: values.filter((distance) => distance <= 1).length,
medianDistance: percentile(values, 0.5),
p90Distance: percentile(values, 0.9),
maximumDistance: values.at(-1) ?? 0,
furthest: distances.slice(-5).reverse().map(({ id, position, distance }) => ({
id,
position,
distance,
})),
});
}
console.log(JSON.stringify(results, null, 2));
@@ -175,6 +175,10 @@ function interpolate(left, right, alpha) {
return left.map((value, axis) => rounded(value + (right[axis] - value) * alpha));
}
function horizontalDistanceSquared(left, right) {
return (left[0] - right[0]) ** 2 + (left[2] - right[2]) ** 2;
}
function normalizedName(value) {
return String(value ?? "").toLowerCase().replace(/[^a-z0-9]+/g, "");
}
@@ -201,7 +205,7 @@ const ADT_SOURCE_BASES = new Set([
"z-y-negative-x",
]);
const GLOBAL_WMO_SOURCE_BASES = new Set(["draft", "flip-x"]);
const GLOBAL_WMO_ALIGNMENTS = new Set(["player-anchor", "source"]);
const COORDINATE_ALIGNMENTS = new Set(["player-anchor", "source", "source-transform"]);
function sourceBasisFor(recipe, environmentMode) {
if (environmentMode !== "adt-hybrid") {
@@ -222,14 +226,42 @@ function sourceBasisFor(recipe, environmentMode) {
}
function coordinateAlignmentFor(recipe, environmentMode) {
if (environmentMode !== "global-wmo") return "player-anchor";
const alignment = recipe.globalWmoAlignment ?? "player-anchor";
if (!GLOBAL_WMO_ALIGNMENTS.has(alignment)) {
throw new Error(`${recipe.slug}: unsupported global WMO alignment ${alignment}.`);
const legacyGlobalWmoAlignment = environmentMode === "global-wmo"
? recipe.globalWmoAlignment
: undefined;
if (
recipe.coordinateAlignment
&& legacyGlobalWmoAlignment
&& recipe.coordinateAlignment !== legacyGlobalWmoAlignment
) {
throw new Error(`${recipe.slug}: coordinate alignment overrides disagree.`);
}
const alignment = recipe.coordinateAlignment ?? legacyGlobalWmoAlignment ?? "player-anchor";
if (!COORDINATE_ALIGNMENTS.has(alignment)) {
throw new Error(`${recipe.slug}: unsupported coordinate alignment ${alignment}.`);
}
return alignment;
}
function sourceTransformFor(recipe, alignment) {
const transform = recipe.coordinateTransform;
if (alignment !== "source-transform") {
if (transform) {
throw new Error(`${recipe.slug}: coordinateTransform requires source-transform alignment.`);
}
return undefined;
}
if (!transform || !Array.isArray(transform.origin) || transform.origin.length !== 3) {
throw new Error(`${recipe.slug}: source-transform alignment requires a three-value coordinateTransform.origin.`);
}
const origin = transform.origin.map(Number);
const yawDegrees = Number(transform.yawDegrees ?? 0);
if (!origin.every(Number.isFinite) || !Number.isFinite(yawDegrees)) {
throw new Error(`${recipe.slug}: coordinateTransform values must be finite numbers.`);
}
return { origin, yawDegrees };
}
/**
* Runtime drafts retain the original pipeline convention. wow.export ADT
* packages have an additional horizontal basis change baked into their GLBs.
@@ -248,10 +280,39 @@ function sourceToPackageBasis(position, environmentMode, sourceBasis) {
: point;
}
function coordinateMapper(draft, environmentMode, playerAnchor, sourceBasis, alignment) {
function rotateAroundY(position, yawDegrees) {
const radians = yawDegrees * Math.PI / 180;
const cosine = Math.cos(radians);
const sine = Math.sin(radians);
return vector([
position[0] * cosine - position[2] * sine,
position[1],
position[0] * sine + position[2] * cosine,
]);
}
function coordinateMapper(
draft,
environmentMode,
playerAnchor,
sourceBasis,
alignment,
sourceTransform,
) {
if (alignment === "source") {
return (position) => sourceToPackageBasis(position, environmentMode, sourceBasis);
}
if (alignment === "source-transform") {
const basisOrigin = sourceToPackageBasis(
sourceTransform.origin,
environmentMode,
sourceBasis,
);
return (position) => rotateAroundY(
subtract(sourceToPackageBasis(position, environmentMode, sourceBasis), basisOrigin),
sourceTransform.yawDegrees,
);
}
const sourceEntrance = draft?.entrance
?? draft?.spawns?.[0]?.position
?? playerAnchor.position;
@@ -778,6 +839,7 @@ async function compile() {
const environmentMode = inferEnvironmentMode(recipe, assetPackage);
const sourceBasis = sourceBasisFor(recipe, environmentMode);
const coordinateAlignment = coordinateAlignmentFor(recipe, environmentMode);
const sourceTransform = sourceTransformFor(recipe, coordinateAlignment);
const playerAnchor = assetPackage.anchors.find((anchor) => anchor.kind === "player");
const trashAnchor = assetPackage.anchors.find((anchor) => anchor.kind === "trash");
const bossAnchor = assetPackage.anchors.find((anchor) => anchor.kind === "boss");
@@ -788,7 +850,7 @@ async function compile() {
trashAnchor.position[0] - playerAnchor.position[0],
trashAnchor.position[2] - playerAnchor.position[2],
);
const entranceYaw = Number.isFinite(playerAnchor.yaw)
let entranceYaw = Number.isFinite(playerAnchor.yaw)
? playerAnchor.yaw
: inferredEntranceYaw;
@@ -809,6 +871,7 @@ async function compile() {
playerAnchor,
sourceBasis,
coordinateAlignment,
sourceTransform,
);
const entrancePosition = mapPosition(
draft?.entrance
@@ -896,16 +959,49 @@ async function compile() {
const entry = Number(spawn.entityId.replace("entry-", ""));
if (!bossCandidatesByEntry.has(entry)) bossCandidatesByEntry.set(entry, spawn);
}
const mappedNativeRoutePositions = coordinateAlignment !== "player-anchor"
? (draft?.spawns ?? [])
.map((spawn) => mapPosition(spawn.position))
.filter((position) => position.every(Number.isFinite))
.sort((left, right) => (
horizontalDistanceSquared(left, entrancePosition)
- horizontalDistanceSquared(right, entrancePosition)
))
: [];
const entranceDirectionTarget = mappedNativeRoutePositions.find((position) => (
Math.abs(position[1] - entrancePosition[1]) <= 3
&& horizontalDistanceSquared(position, entrancePosition) >= 16
));
if (entranceDirectionTarget) {
entranceYaw = Math.atan2(
entranceDirectionTarget[0] - entrancePosition[0],
entranceDirectionTarget[2] - entrancePosition[2],
);
}
const entranceLevelRoutePositions = mappedNativeRoutePositions.filter(
(position) => Math.abs(position[1] - entrancePosition[1]) <= 2.5,
);
const nativeRoutePositions = (
bossCandidatesByEntry.size === 0
&& entranceLevelRoutePositions.length >= encounters.length
)
? entranceLevelRoutePositions
: mappedNativeRoutePositions;
const usedBossSpawnIds = new Set();
const bossObjectives = encounters.map((encounter, index) => {
const candidate = bossCandidatesByEntry.get(Number(encounter.creatureId));
const alpha = encounters.length <= 1 ? 1 : index / (encounters.length - 1);
const routePosition = interpolate(trashAnchor.position, bossAnchor.position, 0.18 + alpha * 0.82);
const routeAlpha = 0.18 + alpha * 0.82;
const nativeRoutePosition = nativeRoutePositions.length
? nativeRoutePositions[Math.round(routeAlpha * (nativeRoutePositions.length - 1))]
: undefined;
const routePosition = nativeRoutePosition
?? interpolate(trashAnchor.position, bossAnchor.position, routeAlpha);
const position = encounter.eventOnly
? [-151.27, -102.82, 252.26]
: candidate
? mapPosition(candidate.position)
: offsetPosition(routePosition, index % 5, 2.4);
: nativeRoutePosition ?? offsetPosition(routePosition, index % 5, 2.4);
const id = encounter.eventOnly
? "mutanus-spawn"
: candidate?.id ?? `encounter-${encounter.encounterId}`;
@@ -1000,8 +1096,9 @@ async function compile() {
const allPoints = [
entrancePosition,
trashAnchor.position,
bossAnchor.position,
...(coordinateAlignment === "player-anchor" || syntheticTrash
? [trashAnchor.position, bossAnchor.position]
: []),
...staticSpawns.map((spawn) => spawn.position),
...roamingPacks.flatMap((pack) => pack.waypoints),
];
@@ -1032,6 +1129,8 @@ async function compile() {
? ["Trash identities and placements are procedural; boss identities come from the installed Ascension encounter catalog."]
: coordinateAlignment === "source"
? [`Server positions use the packaged ${environmentMode} environment's native world coordinates.`]
: coordinateAlignment === "source-transform"
? [`Server positions use the packaged ${environmentMode} environment's authoritative WDT placement transform.`]
: [`Server positions were aligned to the packaged ${environmentMode} environment at its reviewed player anchor.`]),
];
@@ -1078,6 +1177,7 @@ async function compile() {
serverGameObjects: draft?.gameObjects ?? [],
provenance: {
clientBuild: recipe.clientBuild,
coordinateAlignment,
clientArchiveHashes: assetPackage.source.checksums ?? {},
databaseAdapter: databaseKind,
databaseRevision: draft?.spawns?.length
@@ -1090,6 +1190,8 @@ async function compile() {
? sourceBasis === "z-y-negative-x"
? "three(x,y,z)=(wow.y,wow.z,wow.x)"
: "three(x,y,z)=(-wow.y,wow.z,wow.x)"
: coordinateAlignment === "source-transform"
? `three(x,y,z)=rotate-y(${sourceBasis === "flip-x" ? "(-draft.x,draft.y,draft.z)" : "draft(x,y,z)"}-[${sourceTransform.origin.join(",")}],${sourceTransform.yawDegrees}deg)`
: coordinateAlignment === "source"
? sourceBasis === "flip-x"
? "three(x,y,z)=(-draft.x,draft.y,draft.z)"