88 lines
4.8 KiB
TypeScript
88 lines
4.8 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { GLTFLoader, MeshoptDecoder } from "three-stdlib";
|
|
import { DUNGEON_REGISTRY } from "../../src/game/dungeonRegistry";
|
|
import { findPartyNavigationPath, type PartyNavigationPosition } from "../../src/game/partyNavigation";
|
|
import { preparePartyNavigationGraph } from "../../src/game/partyNavigationAssetCache";
|
|
|
|
// Observational plan only. The companion playtest uses ordinary UI/WASD and
|
|
// reports physical blocking; this planner never relocates a runtime player.
|
|
const output = path.resolve(process.argv.find(value => value.startsWith("--output="))?.slice(9)
|
|
?? "artifacts/playtest/rom-deep-review/walking-plans.json");
|
|
const maximumDistance = 45;
|
|
const plans: Record<string, unknown> = {};
|
|
const reports: unknown[] = [];
|
|
const loader = new GLTFLoader().setMeshoptDecoder(MeshoptDecoder);
|
|
|
|
function boundedWaypoints(route: readonly PartyNavigationPosition[]) {
|
|
const points: number[][] = [];
|
|
let distance = 0;
|
|
let sinceCapture = 0;
|
|
for (let index = 1; index < route.length; index += 1) {
|
|
const a = route[index - 1], b = route[index];
|
|
const length = Math.hypot(b[0] - a[0], b[2] - a[2]);
|
|
const retainedLength = Math.min(length, maximumDistance - distance);
|
|
const segments = Math.max(1, Math.ceil(retainedLength / 4));
|
|
for (let step = 1; step <= segments; step++) {
|
|
const ratio = length ? retainedLength / length * step / segments : 1;
|
|
const point = a.map((value, axis) => value + (b[axis] - value) * ratio);
|
|
sinceCapture += retainedLength / segments;
|
|
// Keep portal crossings even on short legs so walking follows actual links.
|
|
if (sinceCapture > 0.5 || step === segments) points.push(point);
|
|
sinceCapture = 0;
|
|
}
|
|
distance += retainedLength;
|
|
if (distance >= maximumDistance) break;
|
|
}
|
|
return { points, plannedDistance: distance };
|
|
}
|
|
|
|
for (const definition of Object.values(DUNGEON_REGISTRY).filter(row => row.expansion === "runewaker" && !row.id.startsWith("bite-sized-"))) {
|
|
const asset = definition.assets.navigation;
|
|
if (!asset) continue;
|
|
const file = `src/assets/game/dungeons/${definition.id}/${asset.fileName}`;
|
|
try {
|
|
const bytes = await readFile(file);
|
|
const checksum = createHash("sha256").update(bytes).digest("hex");
|
|
const gltf = await loader.parseAsync(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength), "");
|
|
const graph = preparePartyNavigationGraph(file, gltf.scene, definition.navigationLinks ?? [],
|
|
definition.traversalRamps ?? [], 250_000, false, gltf.userData.runewakerNavigationTopology,
|
|
asset.detourTopology, asset.navigationComponentPolicy, checksum);
|
|
const entry = definition.entrance.footPosition;
|
|
const targets = definition.bosses.map(boss => ({ name: boss.name, position: boss.position }));
|
|
const bossRoutes = targets.map(target => ({ target, route: findPartyNavigationPath(graph, entry, target.position, {
|
|
maxStartSnapDistance: 3, maxEndSnapDistance: 8,
|
|
}) }));
|
|
let selected = bossRoutes.find(row => row.route && row.route.length > 1);
|
|
if (!selected) {
|
|
const forward = definition.entrance.forward;
|
|
// Try a retained surface ahead of the entry when no boss is connected.
|
|
for (const distance of [45, 25, 12, 6]) {
|
|
const position = entry.map((value, axis) => value + forward[axis] * distance) as unknown as PartyNavigationPosition;
|
|
const route = findPartyNavigationPath(graph, entry, position, { maxStartSnapDistance: 3, maxEndSnapDistance: 8 });
|
|
if (route && route.length > 1) {
|
|
selected = { target: { name: `forward surface ${distance}m`, position }, route };
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
const bounded = selected?.route ? boundedWaypoints(selected.route) : {
|
|
points: [3, 6].map(distance => entry.map((value, axis) => value + definition.entrance.forward[axis] * distance)),
|
|
plannedDistance: 6,
|
|
source: "ordinary short forward collision probe; navigation did not certify a connected target",
|
|
};
|
|
plans[definition.id] = bounded;
|
|
reports.push({ id: definition.id, checksum, target: selected?.target.name ?? null,
|
|
bossRoutes: bossRoutes.map(row => ({ name: row.target.name, connected: row.route !== null })), ...bounded });
|
|
console.log(`${definition.id}: ${bounded.points.length} waypoints, ${bounded.plannedDistance.toFixed(1)}m, ${selected?.target.name ?? "no connected target"}`);
|
|
} catch (error) {
|
|
reports.push({ id: definition.id, error: String(error) });
|
|
plans[definition.id] = { points: [] };
|
|
console.log(`${definition.id}: ${String(error)}`);
|
|
}
|
|
}
|
|
await mkdir(path.dirname(output), { recursive: true });
|
|
await writeFile(output, `${JSON.stringify(plans, null, 2)}\n`);
|
|
await writeFile(output.replace(/\.json$/, "-proof.json"), `${JSON.stringify({ maximumDistance, reports }, null, 2)}\n`);
|