Release Healer Man 0.1.39
This commit is contained in:
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;
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user