143 lines
7.8 KiB
TypeScript
143 lines
7.8 KiB
TypeScript
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`);
|
|
}
|