172 lines
7.7 KiB
JavaScript
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();
|
|
}
|