import geometry from "./generated/arena-geometry.json" with { type: "json" }; const CELL = 4; const indexes = new Map(); const navigationGraphs = new Map(); function index(stageId, kind) { const key = `${stageId}:${kind}`; if (indexes.has(key)) return indexes.get(key); const triangles = geometry[stageId]?.[kind] ?? []; const cells = new Map(); for (let offset = 0; offset < triangles.length; offset += 9) { const xs = [triangles[offset], triangles[offset + 3], triangles[offset + 6]]; const zs = [triangles[offset + 2], triangles[offset + 5], triangles[offset + 8]]; for (let x = Math.floor(Math.min(...xs) / CELL); x <= Math.floor(Math.max(...xs) / CELL); x++) { for (let z = Math.floor(Math.min(...zs) / CELL); z <= Math.floor(Math.max(...zs) / CELL); z++) { const cell = `${x},${z}`; if (!cells.has(cell)) cells.set(cell, []); cells.get(cell).push(offset); } } } const value = { triangles, cells }; indexes.set(key, value); return value; } export function arenaFloorHeight(stageId, x, z, previousY, maximumRise = 1) { const { triangles: t, cells } = index(stageId, "navigation"); let height = null; for (const o of cells.get(`${Math.floor(x / CELL)},${Math.floor(z / CELL)}`) ?? []) { const ax = t[o], az = t[o + 2], bx = t[o + 3], bz = t[o + 5], cx = t[o + 6], cz = t[o + 8]; const denominator = (bz - cz) * (ax - cx) + (cx - bx) * (az - cz); if (Math.abs(denominator) < .000001) continue; const a = ((bz - cz) * (x - cx) + (cx - bx) * (z - cz)) / denominator; const b = ((cz - az) * (x - cx) + (ax - cx) * (z - cz)) / denominator; if (a < -.001 || b < -.001 || a + b > 1.001) continue; const y = a * t[o + 1] + b * t[o + 4] + (1 - a - b) * t[o + 7]; if (y > previousY + maximumRise) continue; if (height === null || y > height) height = y; } return height; } function hitsTriangle(from, to, t, o) { const dx = to[0] - from[0], dy = to[1] - from[1], dz = to[2] - from[2]; const e1x = t[o + 3] - t[o], e1y = t[o + 4] - t[o + 1], e1z = t[o + 5] - t[o + 2]; const e2x = t[o + 6] - t[o], e2y = t[o + 7] - t[o + 1], e2z = t[o + 8] - t[o + 2]; const px = dy * e2z - dz * e2y, py = dz * e2x - dx * e2z, pz = dx * e2y - dy * e2x; const determinant = e1x * px + e1y * py + e1z * pz; if (Math.abs(determinant) < 1e-8) return false; const inverse = 1 / determinant, sx = from[0] - t[o], sy = from[1] - t[o + 1], sz = from[2] - t[o + 2]; const u = (sx * px + sy * py + sz * pz) * inverse; if (u < 0 || u > 1) return false; const qx = sy * e1z - sz * e1y, qy = sz * e1x - sx * e1z, qz = sx * e1y - sy * e1x; const v = (dx * qx + dy * qy + dz * qz) * inverse; if (v < 0 || u + v > 1) return false; const distance = (e2x * qx + e2y * qy + e2z * qz) * inverse; return distance > .0001 && distance < .9999; } export function arenaGeometryLineClear(stageId, from, to) { const { triangles, cells } = index(stageId, "collision"); const steps = Math.max(1, Math.ceil(Math.hypot(to[0] - from[0], to[2] - from[2]) / (CELL * .5))); const visited = new Set(); for (let step = 0; step <= steps; step++) { const ratio = step / steps; const cell = `${Math.floor((from[0] + (to[0] - from[0]) * ratio) / CELL)},${Math.floor((from[2] + (to[2] - from[2]) * ratio) / CELL)}`; for (const offset of cells.get(cell) ?? []) { if (visited.has(offset)) continue; visited.add(offset); if (hitsTriangle(from, to, triangles, offset)) return false; } } return true; } function navigationGraph(stageId) { if (navigationGraphs.has(stageId)) return navigationGraphs.get(stageId); const triangles = geometry[stageId]?.navigation ?? []; const nodes = [], edges = new Map(); for (let offset = 0; offset < triangles.length; offset += 9) { const vertices = [0, 3, 6].map((axis) => triangles.slice(offset + axis, offset + axis + 3)); const center = [0, 1, 2].map((axis) => vertices.reduce((sum, point) => sum + point[axis], 0) / 3); const node = { id: nodes.length, center, neighbors: [], vertices }; nodes.push(node); for (let edge = 0; edge < 3; edge++) { const a = vertices[edge], b = vertices[(edge + 1) % 3]; const key = [a.join(","), b.join(",")].sort().join("/"); const portal = a.map((value, axis) => (value + b[axis]) / 2); for (const other of edges.get(key) ?? []) { node.neighbors.push({ id: other.id, portal }); other.neighbors.push({ id: node.id, portal }); } if (!edges.has(key)) edges.set(key, []); edges.get(key).push(node); } } navigationGraphs.set(stageId, nodes); return nodes; } const distance3 = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]); function navigationStepClear(stageId, from, to) { let height = from[1]; const steps = Math.max(1, Math.ceil(distance3(from, to) / .4)); for (let step = 1; step <= steps; step++) { const ratio = step / steps; const x = from[0] + (to[0] - from[0]) * ratio, z = from[2] + (to[2] - from[2]) * ratio; const floor = arenaFloorHeight(stageId, x, z, height, .6); if (floor === null || height - floor > .8) return false; height = floor; } return Math.abs(height - to[1]) < .8; } function navigationNode(nodes, point) { let nearest = null, nearestDistance = Infinity; for (const node of nodes) { const [a, b, c] = node.vertices; const denominator = (b[2] - c[2]) * (a[0] - c[0]) + (c[0] - b[0]) * (a[2] - c[2]); if (Math.abs(denominator) < .000001) continue; const u = ((b[2] - c[2]) * (point[0] - c[0]) + (c[0] - b[0]) * (point[2] - c[2])) / denominator; const v = ((c[2] - a[2]) * (point[0] - c[0]) + (a[0] - c[0]) * (point[2] - c[2])) / denominator; const inside = u >= -.001 && v >= -.001 && u + v <= 1.001; const height = u * a[1] + v * b[1] + (1 - u - v) * c[1]; const distance = inside ? Math.abs(height - point[1]) : distance3(node.center, point) + 5; if (distance < nearestDistance) { nearest = node; nearestDistance = distance; } } return nearest; } /** Follow the shipped walkable mesh around walls, ramps and starting rooms. */ export function arenaNavigationPath(stageId, from, to) { const nodes = navigationGraph(stageId); const start = navigationNode(nodes, from), goal = navigationNode(nodes, to); if (!start || !goal) return []; const open = new Set([start.id]), costs = new Map([[start.id, 0]]), previous = new Map(); let reached = false; while (open.size) { let current = null, score = Infinity; for (const id of open) { const candidate = costs.get(id) + distance3(nodes[id].center, goal.center); if (candidate < score) { current = nodes[id]; score = candidate; } } if (current.id === goal.id) { reached = true; break; } open.delete(current.id); for (const edge of current.neighbors) { const next = nodes[edge.id]; edge.walkable ??= navigationStepClear(stageId, current.center, edge.portal) && navigationStepClear(stageId, edge.portal, next.center) && [.55, 1.4].every((height) => { const lift = (point) => [point[0], point[1] + height, point[2]]; return arenaGeometryLineClear(stageId, lift(current.center), lift(edge.portal)) && arenaGeometryLineClear(stageId, lift(edge.portal), lift(next.center)); }); if (!edge.walkable) continue; const cost = costs.get(current.id) + distance3(current.center, edge.portal) + distance3(edge.portal, next.center); if (cost >= (costs.get(next.id) ?? Infinity)) continue; costs.set(next.id, cost); previous.set(next.id, { id: current.id, portal: edge.portal }); open.add(next.id); } } if (!reached) return []; const path = [[...to]]; for (let id = goal.id; id !== start.id;) { const edge = previous.get(id); path.push(edge.portal); id = edge.id; } return path.reverse(); }