Release Healer Man 0.1.8
This commit is contained in:
@@ -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)"
|
||||
|
||||
Reference in New Issue
Block a user