Release Healer Man 0.1.39

This commit is contained in:
phenom committed 2026-09-13 11:07:56 -04:00
1 parent b417b7c3c3
commit ed0d2fea3d
51 files changed
+17811 -49

No files matched your search

@@ -0,0 +1,142 @@
import { readFileSync, writeFileSync } from "node:fs";
import { createHash } from "node:crypto";
import path from "node:path";
import { fileURLToPath } from "node:url";
import type { DungeonDefinition } from "../../src/game/dungeonTypes";
import { detectDungeonStairRamps, dungeonStairNavigationBridges, dungeonStairRisers, readDungeonGeometry } from "./wow-stair-geometry.ts";
import RAPIER from "@dimforge/rapier3d-compat";
import { dungeonRampHeight, smoothDungeonStairGeometry } from "../../src/game/dungeonTraversalRamps.ts";
import type { DungeonTraversalRamp } from "../../src/game/dungeonTypes";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const catalog = JSON.parse(readFileSync(path.join(root, "src/game/generated/dungeonCampaignCatalog.json"), "utf8"));
const outputFile = path.join(root, "src/game/generated/wowDungeonStairs.generated.json");
const output: Record<string, unknown> = process.argv.includes("--update")
? JSON.parse(readFileSync(outputFile, "utf8")).dungeons : {};
const requested = process.argv.slice(2).find((arg) => !arg.startsWith("--"));
if (requested && !catalog.definitions.some((dungeon: DungeonDefinition) => dungeon.id === requested)) {
throw new Error(`Unknown WoW dungeon: ${requested}`);
}
await RAPIER.init();
function rampTraversable(world: RAPIER.World, ramp: DungeonTraversalRamp): boolean {
const rejected = (reason: string, detail: unknown) => {
if (process.argv.includes("--diagnostics")) console.log(ramp.id, reason, JSON.stringify(detail));
return false;
};
const dx = ramp.end[0] - ramp.start[0];
const dz = ramp.end[2] - ramp.start[2];
const length = Math.hypot(dx, dz);
const shape = new RAPIER.Capsule(0.32, 0.34);
const rotation = { x: 0, y: 0, z: 0, w: 1 };
// Test both directions across the center and two capsule-safe lateral lanes.
const lateral = Math.max(0, Math.min(ramp.width / 4, ramp.width / 2 - 0.45));
for (const lane of [0, -lateral, lateral]) {
const count = Math.max(3, Math.ceil(length / 0.35) + 1);
const points = Array.from({ length: count }, (_, index) => {
const t = index / (count - 1);
return {
x: ramp.start[0] + dx * t - dz / length * lane,
y: ramp.start[1] + (ramp.end[1] - ramp.start[1]) * t,
z: ramp.start[2] + dz * t + dx / length * lane,
};
});
for (const point of points) {
const floor = world.castRayAndGetNormal(new RAPIER.Ray({ ...point, y: point.y + 1.25 }, { x: 0, y: -1, z: 0 }), 2, true);
if (!floor || floor.normal.y < 0.65 || Math.abs(1.25 - floor.timeOfImpact) > 0.55) return rejected("floor", { lane, point, normal: floor?.normal, heightDelta: floor ? 1.25 - floor.timeOfImpact : null });
}
for (let index = 1; index < points.length; index += 1) {
for (const [start, end] of [[points[index - 1], points[index]], [points[index], points[index - 1]]]) {
const delta = { x: end.x - start.x, y: end.y - start.y, z: end.z - start.z };
const distance = Math.hypot(delta.x, delta.y, delta.z);
const hit = world.castShape({ ...start, y: start.y + 1.02 }, rotation, delta, shape, 0.025, 1, false);
if (hit && hit.time_of_impact < 1 - 0.04 / distance) return rejected("sweep", { lane, start, end, hit: hit.time_of_impact });
}
}
}
return true;
}
for (const dungeon of catalog.definitions as DungeonDefinition[]) {
if (requested && dungeon.id !== requested) continue;
if (dungeon.id === "stormwind-stockade") {
output[dungeon.id] = { collision: dungeon.assets.collision, ramps: [], reviewedOverride: true };
console.log(`${dungeon.id}: preserving three reviewed ramps`);
continue;
}
const risers = [];
const geometries = [];
// Wailing Caverns retains its authored environment rather than the campaign's
// canonical Manastorm package. Audit the assets actually used by that entry.
const collisionAssets = dungeon.id === "wailing-caverns" ? [{
fileName: "wailing-caverns-collision.glb", url: undefined,
checksum: createHash("sha256").update(readFileSync(path.join(root, "src/assets/game/dungeons/wailing-caverns/wailing-caverns-collision.glb"))).digest("hex"),
}] : dungeon.assets.collision;
const assetFile = (asset: { url?: string; fileName: string }) => asset.url
? path.join(root, "public", asset.url)
: path.join(root, "src/assets/game/dungeons", dungeon.id, asset.fileName);
const navigationAsset = dungeon.id === "wailing-caverns" ? {
fileName: "wailing-caverns-navigation.glb", url: undefined,
checksum: createHash("sha256").update(readFileSync(path.join(root, "src/assets/game/dungeons/wailing-caverns/wailing-caverns-navigation.glb"))).digest("hex"),
} : dungeon.assets.navigation;
for (const asset of collisionAssets) {
for (const geometry of await readDungeonGeometry(assetFile(asset))) {
risers.push(...dungeonStairRisers(geometry));
geometries.push(geometry);
}
}
const candidates = detectDungeonStairRamps(risers);
const verify = (input: DungeonTraversalRamp[]): DungeonTraversalRamp[] => {
let ramps = input;
// Rebuild after rejection: accepted ramps must not depend on rejected geometry changes.
while (ramps.length) {
const world = new RAPIER.World({ x: 0, y: -18, z: 0 });
try {
for (const source of geometries) {
const geometry = source.clone();
smoothDungeonStairGeometry(geometry, ramps);
world.createCollider(RAPIER.ColliderDesc.trimesh(
Float32Array.from(geometry.getAttribute("position").array),
Uint32Array.from(geometry.index?.array ?? Array.from({ length: geometry.getAttribute("position").count }, (_, index) => index)),
));
geometry.dispose();
}
world.step();
const accepted = ramps.filter((ramp) => rampTraversable(world, ramp));
if (accepted.length === ramps.length) break;
ramps = accepted;
} finally { world.free(); }
}
return ramps;
};
let ramps = verify(candidates);
// If a long merged flight was unsafe, retain independently verified shorter
// flights instead. Do not let fallback fragments reshape an accepted plane.
const fallback = detectDungeonStairRamps(risers, false).filter((ramp) => !ramps.some((accepted) => {
const within = (a: DungeonTraversalRamp, b: DungeonTraversalRamp) => [b.start, b.end,
b.start.map((v, i) => (v + b.end[i]) / 2)].some((point) => {
const height = dungeonRampHeight(a, point[0], point[2]);
return height !== null && Math.abs(point[1] - height) <= 0.85;
});
return within(accepted, ramp) || within(ramp, accepted);
})).map((ramp, index) => ({ ...ramp, id: `stair-fallback-${index + 1}` }));
ramps = verify([...ramps, ...fallback]);
output[dungeon.id] = {
collision: collisionAssets.map(({ fileName, url, checksum }) => ({ fileName, url, checksum })),
navigation: navigationAsset,
ramps, candidateCount: candidates.length + fallback.length,
rejectedCount: candidates.length + fallback.length - ramps.length,
};
if (ramps.length && navigationAsset) {
const navigation = await readDungeonGeometry(assetFile(navigationAsset));
const largest = navigation.sort((a, b) => b.getAttribute("position").count - a.getAttribute("position").count)[0];
(output[dungeon.id] as { ramps: DungeonTraversalRamp[] }).ramps = dungeonStairNavigationBridges(largest, ramps);
navigation.forEach((geometry) => geometry.dispose());
}
console.log(`${dungeon.id}: ${ramps.length}/${candidates.length + fallback.length} collision-verified stair proxies`);
geometries.forEach((geometry) => geometry.dispose());
}
if (process.argv.includes("--write") || process.argv.includes("--update")) {
if (requested && !process.argv.includes("--update")) throw new Error("Refusing to replace the fleet catalog with a single-dungeon scan.");
writeFileSync(outputFile, `${JSON.stringify({ schemaVersion: 1, dungeons: output }, null, 2)}\n`);
}
@@ -0,0 +1,249 @@
import { NodeIO } from "@gltf-transform/core";
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
import { MeshoptDecoder } from "meshoptimizer";
import { BufferAttribute, BufferGeometry, Float32BufferAttribute, Matrix4 } from "three";
import type { DungeonTraversalRamp } from "../../src/game/dungeonTypes";
import { dungeonRampHeight } from "../../src/game/dungeonTraversalRamps.ts";
const io = new NodeIO().registerExtensions(ALL_EXTENSIONS)
.registerDependencies({ "meshopt.decoder": MeshoptDecoder });
/** Decode exactly as GLTFLoader does, expanding quantized coordinates before baking transforms. */
export async function readDungeonGeometry(file: string): Promise<BufferGeometry[]> {
await MeshoptDecoder.ready;
const document = await io.read(file);
const geometries: BufferGeometry[] = [];
for (const node of document.getRoot().listNodes()) {
for (const primitive of node.getMesh()?.listPrimitives() ?? []) {
const accessor = primitive.getAttribute("POSITION");
if (!accessor?.getArray()) continue;
const source = new BufferAttribute(accessor.getArray()!, 3, accessor.getNormalized());
const positions = new Float32Array(source.count * 3);
for (let index = 0; index < source.count; index += 1) {
positions.set([source.getX(index), source.getY(index), source.getZ(index)], index * 3);
}
const geometry = new BufferGeometry();
geometry.setAttribute("position", new Float32BufferAttribute(positions, 3));
const indices = primitive.getIndices()?.getArray();
if (indices) geometry.setIndex(Array.from(indices));
geometry.applyMatrix4(new Matrix4().fromArray(node.getWorldMatrix()));
geometries.push(geometry);
}
}
return geometries;
}
interface Riser {
direction: number;
along: number;
low: number;
high: number;
left: number;
right: number;
}
/** Repeated short vertical faces, not arbitrary slopes or nearby disconnected floors. */
export function dungeonStairRisers(geometry: BufferGeometry): Riser[] {
const p = geometry.getAttribute("position");
const indices = geometry.index?.array ?? Array.from({ length: p.count }, (_, i) => i);
const faces = new Map<string, Riser>();
for (let offset = 0; offset + 2 < indices.length; offset += 3) {
const vertices = [indices[offset], indices[offset + 1], indices[offset + 2]];
const points = vertices.map((i) => [p.getX(i), p.getY(i), p.getZ(i)]);
const [a, b, c] = points;
const ab = b.map((v, i) => v - a[i]);
const ac = c.map((v, i) => v - a[i]);
const nx = ab[1] * ac[2] - ab[2] * ac[1];
const ny = ab[2] * ac[0] - ab[0] * ac[2];
const nz = ab[0] * ac[1] - ab[1] * ac[0];
if (Math.hypot(nx, nz) < 0.1 || Math.abs(ny) / Math.hypot(nx, ny, nz) > 0.025) continue;
const low = Math.min(...points.map((v) => v[1]));
const high = Math.max(...points.map((v) => v[1]));
if (high - low < 0.08 || high - low > 0.82) continue;
// Opposite winding still describes the same plane. Quantization permits ~1 degree noise.
const direction = ((Math.round(Math.atan2(nz, nx) / Math.PI * 180) % 180) + 180) % 180;
const angle = direction * Math.PI / 180;
const along = points.reduce((sum, v) => sum + v[0] * Math.cos(angle) + v[2] * Math.sin(angle), 0) / 3;
const across = points.map((v) => -v[0] * Math.sin(angle) + v[2] * Math.cos(angle));
const left = Math.min(...across);
const right = Math.max(...across);
if (right - left < 0.9) continue;
const key = [direction, Math.round(along / 0.04), Math.round(low / 0.04), Math.round(high / 0.04)].join(":");
const previous = faces.get(key);
if (previous && Math.min(previous.right, right) - Math.max(previous.left, left) >= -0.04) {
previous.left = Math.min(previous.left, left);
previous.right = Math.max(previous.right, right);
} else faces.set(`${key}${previous ? `:${Math.round(left / 0.04)}` : ""}`, { direction, along, low, high, left, right });
}
return [...faces.values()];
}
export function detectDungeonStairRamps(risers: Riser[], merge = true): DungeonTraversalRamp[] {
const ramps: DungeonTraversalRamp[] = [];
const used = new Set<Riser>();
const sorted = [...risers].sort((a, b) => a.direction - b.direction || a.along - b.along || a.low - b.low);
for (const first of sorted) {
if (used.has(first)) continue;
const neighbors = sorted.filter((r) => Math.min(Math.abs(r.direction - first.direction), 180 - Math.abs(r.direction - first.direction)) <= 2)
.map((source) => {
const difference = (source.direction - first.direction) * Math.PI / 180;
const across = (source.left + source.right) / 2;
const halfWidth = (source.right - source.left) / 2 * Math.abs(Math.cos(difference));
const center = source.along * Math.sin(difference) + across * Math.cos(difference);
return { source, ...source, along: source.along * Math.cos(difference) - across * Math.sin(difference), left: center - halfWidth, right: center + halfWidth };
}).sort((a, b) => a.along - b.along || a.low - b.low);
const chain = [first];
const chainSources = [first];
let sign = 0;
while (true) {
const current = chain[chain.length - 1];
const next = neighbors.find((candidate) => {
if (used.has(candidate.source)) return false;
const run = candidate.along - current.along;
const rise = candidate.low - current.low;
const overlap = Math.min(...chain.map((r) => r.right), candidate.right)
- Math.max(...chain.map((r) => r.left), candidate.left);
return run > 0.12 && run < 2.5 && Math.abs(rise) >= 0.08 && Math.abs(rise) <= 0.82
&& Math.abs(rise) / run < 0.85 && overlap >= 1
&& (!sign || Math.sign(rise) === sign)
&& Math.min(Math.abs(candidate.low - current.high), Math.abs(candidate.high - current.low)) < 0.22;
});
if (!next) break;
if (chain.length > 1) {
const run = chain[1].along - first.along;
const rise = chain[1].low - first.low;
if (Math.abs(next.low - (first.low + rise / run * (next.along - first.along))) > 0.1) break;
}
chain.push(next);
chainSources.push(next.source);
sign = Math.sign(next.low - current.low);
}
if (chain.length < 3) continue;
const last = chain[chain.length - 1];
const run = (last.along - first.along) / (chain.length - 1);
const rise = (last.low - first.low) / (chain.length - 1);
const left = Math.max(...chain.map((r) => r.left));
const right = Math.min(...chain.map((r) => r.right));
const across = (left + right) / 2;
const angle = first.direction * Math.PI / 180;
const point = (along: number, y: number): [number, number, number] => [
along * Math.cos(angle) - across * Math.sin(angle), y,
along * Math.sin(angle) + across * Math.cos(angle),
];
// Meet the lower/upper landings, including the first/last riser, without a lip.
ramps.push({
id: `stair-flight-${ramps.length + 1}`,
start: point(first.along - (sign < 0 ? run : 0.04), sign < 0 ? first.high : first.low),
end: point(last.along + (sign > 0 ? run : 0.04), sign > 0 ? last.high : last.low),
width: right - left + 0.02,
});
chainSources.forEach((r) => used.add(r));
}
return merge ? mergeDungeonStairRamps(ramps) : ramps;
}
/** Keep one plane per continuous flight; independent overlapping fragments introduce new lips. */
export function mergeDungeonStairRamps(input: DungeonTraversalRamp[]): DungeonTraversalRamp[] {
const ramps = [...input].sort((a, b) => Math.hypot(b.end[0] - b.start[0], b.end[2] - b.start[2])
- Math.hypot(a.end[0] - a.start[0], a.end[2] - a.start[2]));
const result: DungeonTraversalRamp[] = [];
for (const ramp of ramps) {
let combined = false;
for (let index = 0; index < result.length; index += 1) {
const previous = result[index];
const dx = previous.end[0] - previous.start[0]; const dz = previous.end[2] - previous.start[2];
const length = Math.hypot(dx, dz);
const rdX = ramp.end[0] - ramp.start[0]; const rdZ = ramp.end[2] - ramp.start[2];
const rLength = Math.hypot(rdX, rdZ);
if ((dx * rdX + dz * rdZ) / length / rLength < 0.999) continue;
const along = (p: readonly number[]) => ((p[0] - previous.start[0]) * dx + (p[2] - previous.start[2]) * dz) / length;
const across = (p: readonly number[]) => (-(p[0] - previous.start[0]) * dz + (p[2] - previous.start[2]) * dx) / length;
const start = along(ramp.start); const end = along(ramp.end);
const acrossOffset = (across(ramp.start) + across(ramp.end)) / 2;
const left = Math.max(-previous.width / 2, acrossOffset - ramp.width / 2);
const right = Math.min(previous.width / 2, acrossOffset + ramp.width / 2);
if (right - left < 1 || start > length + 1.5 || end < -1.5) continue;
const slope = (previous.end[1] - previous.start[1]) / length;
const rSlope = (ramp.end[1] - ramp.start[1]) / rLength;
if (Math.abs(slope - rSlope) > 0.12
|| Math.abs(ramp.start[1] - (previous.start[1] + start * slope)) > 0.32
|| Math.abs(ramp.end[1] - (previous.start[1] + end * slope)) > 0.32) continue;
const low = Math.min(0, start); const high = Math.max(length, end);
const center = (left + right) / 2;
const point = (t: number, y: number): [number, number, number] => [
previous.start[0] + dx / length * t - dz / length * center, y,
previous.start[2] + dz / length * t + dx / length * center,
];
result[index] = {
id: previous.id,
start: point(low, start < 0 ? ramp.start[1] : previous.start[1]),
end: point(high, end > length ? ramp.end[1] : previous.end[1]),
width: right - left,
};
combined = true;
break;
}
if (!combined) result.push(ramp);
}
return result.map((ramp, index) => ({ ...ramp, id: `stair-flight-${index + 1}` }));
}
/** Fill only separate Recast boundary portals wholly inside a verified stair footprint. */
export function dungeonStairNavigationBridges(geometry: BufferGeometry, ramps: DungeonTraversalRamp[]): DungeonTraversalRamp[] {
const p = geometry.getAttribute("position");
const indices = geometry.index?.array ?? Array.from({ length: p.count }, (_, i) => i);
const parents = Array.from({ length: p.count }, (_, i) => i);
const root = (i: number): number => {
while (parents[i] !== i) { parents[i] = parents[parents[i]]; i = parents[i]; }
return i;
};
const weld = new Map<string, number>();
const point = (i: number): [number, number, number] => [p.getX(i), p.getY(i), p.getZ(i)];
for (let i = 0; i < p.count; i += 1) {
const key = point(i).map((v) => Math.round(v / 0.002)).join(":");
const previous = weld.get(key);
if (previous !== undefined) parents[i] = previous;
else weld.set(key, i);
}
const edges = new Map<string, { a: number; b: number; count: number }>();
for (let offset = 0; offset + 2 < indices.length; offset += 3) {
const triangle = [indices[offset], indices[offset + 1], indices[offset + 2]];
for (let side = 0; side < 3; side += 1) {
const a = triangle[side]; const b = triangle[(side + 1) % 3];
const wa = weld.get(point(a).map((v) => Math.round(v / 0.002)).join(":"))!;
const wb = weld.get(point(b).map((v) => Math.round(v / 0.002)).join(":"))!;
parents[root(a)] = root(b);
const key = wa < wb ? `${wa}:${wb}` : `${wb}:${wa}`;
const edge = edges.get(key);
if (edge) edge.count += 1;
else edges.set(key, { a, b, count: 1 });
}
}
const boundary = [...edges.values()].filter((e) => e.count === 1);
return ramps.map((ramp) => {
const dx = ramp.end[0] - ramp.start[0]; const dz = ramp.end[2] - ramp.start[2];
const length = Math.hypot(dx, dz);
const along = (v: number[]) => ((v[0] - ramp.start[0]) * dx + (v[2] - ramp.start[2]) * dz) / length;
const across = (v: number[]) => (-(v[0] - ramp.start[0]) * dz + (v[2] - ramp.start[2]) * dx) / length;
const portals = boundary.flatMap((e) => {
const points = [point(e.a), point(e.b)].sort((a, b) => across(a) - across(b));
if (points.some((v) => {
const y = dungeonRampHeight(ramp, v[0], v[2]);
return y === null || Math.abs(v[1] - y) > 0.8;
})) return [];
if (Math.abs(along(points[0]) - along(points[1])) > 0.15 || across(points[1]) - across(points[0]) < 0.7) return [];
return [{ ...e, points: points as [[number, number, number], [number, number, number]], along: (along(points[0]) + along(points[1])) / 2 }];
}).sort((a, b) => a.along - b.along);
const bridges = [];
for (let index = 1; index < portals.length; index += 1) {
const a = portals[index - 1]; const b = portals[index];
const gap = b.along - a.along;
if (gap < 0.15 || gap > 2.5 || root(a.a) === root(b.a)) continue;
const overlap = Math.min(across(a.points[1]), across(b.points[1])) - Math.max(across(a.points[0]), across(b.points[0]));
if (overlap < 0.7) continue;
bridges.push({ linkId: `${ramp.id}-seam-${bridges.length + 1}`, startPortal: a.points, endPortal: b.points });
parents[root(a.a)] = root(b.a);
}
return bridges.length ? { ...ramp, navigationBridges: bridges } : ramp;
});
}