Files
healer-man/server/arena-geometry.mjs
T
2026-09-11 14:26:15 -04:00

172 lines
7.7 KiB
JavaScript

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();
}