import { NodeIO } from "@gltf-transform/core"; import { fileURLToPath } from "node:url"; import { dirname, resolve } from "node:path"; import { BufferAttribute, BufferGeometry, DoubleSide, Ray, Vector3 } from "three"; import { MeshBVH } from "three-mesh-bvh"; import { createLoopedPath, samplePackMember, WAILING_CAVERNS_BOSS_SPAWNS, WAILING_CAVERNS_ENTRANCE_SPAWNS, WAILING_CAVERNS_ROAMING_PACKS, } from "../src/game/mobPopulation"; const here = dirname(fileURLToPath(import.meta.url)); const collisionPath = resolve( here, "../src/assets/game/dungeons/wailing-caverns/wailing-caverns-collision.glb", ); const document = await new NodeIO().read(collisionPath); const primitive = document.getRoot().listMeshes()[0]?.listPrimitives()[0]; if (!primitive) throw new Error("Collision GLB has no mesh primitive."); const positions = primitive.getAttribute("POSITION")?.getArray(); const indices = primitive.getIndices()?.getArray(); if (!positions || !indices) throw new Error("Collision GLB lacks indexed positions."); const geometry = new BufferGeometry(); geometry.setAttribute("position", new BufferAttribute(positions, 3)); geometry.setIndex(new BufferAttribute(indices, 1)); const bvh = new MeshBVH(geometry); const down = new Vector3(0, -1, 0); const origin = new Vector3(); const sampledPosition: [number, number, number] = [0, 0, 0]; const sampledForward: [number, number, number] = [0, 0, 1]; const entranceCamera = new Vector3(168.44, -69.7, 135.31); const sightTarget = new Vector3(); const sightDirection = new Vector3(); function floorDelta(point: readonly [number, number, number]): number | null { origin.set(point[0], point[1] + 2.5, point[2]); const hit = bvh.raycastFirst(new Ray(origin, down), DoubleSide, 0, 7); return hit ? Math.abs(hit.point.y - point[1]) : null; } function hasEntranceLineOfSight(point: readonly [number, number, number]): boolean { sightTarget.set(point[0], point[1] + 1.1, point[2]); sightDirection.copy(sightTarget).sub(entranceCamera); const distance = sightDirection.length(); sightDirection.multiplyScalar(1 / distance); return bvh.raycastFirst( new Ray(entranceCamera, sightDirection), DoubleSide, 0, Math.max(0, distance - 0.25), ) === null; } const routeReports = WAILING_CAVERNS_ROAMING_PACKS.map((pack) => { const path = createLoopedPath(pack.waypoints); let missingFloorSamples = 0; let maxFloorDelta = 0; const sampleCount = Math.max(24, Math.ceil(path.totalLength / 2)); for (let sampleIndex = 0; sampleIndex < sampleCount; sampleIndex += 1) { const leaderDistance = (sampleIndex / sampleCount) * path.totalLength; for (const member of pack.members) { samplePackMember( path, leaderDistance, member.formationOffset, sampledPosition, sampledForward, ); const delta = floorDelta(sampledPosition); if (delta === null) missingFloorSamples += 1; else maxFloorDelta = Math.max(maxFloorDelta, delta); } } return { id: pack.id, pathId: pack.pathId, members: pack.members.length, pathLength: Number(path.totalLength.toFixed(2)), maxFloorDelta: Number(maxFloorDelta.toFixed(3)), missingFloorSamples, }; }); const bossReports = WAILING_CAVERNS_BOSS_SPAWNS.map((spawn) => { const closest = bvh.closestPointToPoint(new Vector3(...spawn.position)); return { id: spawn.id, closestSurfaceDistance: closest ? Number(closest.distance.toFixed(3)) : null, }; }); const entranceReports = WAILING_CAVERNS_ENTRANCE_SPAWNS.map((spawn) => ({ id: spawn.id, floorDelta: floorDelta(spawn.position), entranceLineOfSight: hasEntranceLineOfSight(spawn.position), })); const failedRoutes = routeReports.filter( (report) => report.missingFloorSamples > 0 || report.maxFloorDelta > 4, ); const failedEntranceSpawns = entranceReports.filter( (report) => report.floorDelta === null || report.floorDelta > 0.2 || !report.entranceLineOfSight, ); console.log(JSON.stringify({ routeReports, bossReports, entranceReports, failedRoutes, failedEntranceSpawns, }, null, 2)); if (failedRoutes.length > 0 || failedEntranceSpawns.length > 0) process.exitCode = 1;