483 lines
17 KiB
JavaScript
483 lines
17 KiB
JavaScript
// Total Annihilation — navigation: grid, clearance field, A*, path smoothing, spatial hash.
|
|
//
|
|
// Headless: no Phaser imports, runs in Node for tools/verifyTotalAnnihilation.js.
|
|
//
|
|
// The tile grid exists ONLY for pathfinding cost and build legality. Units carry float
|
|
// positions and a heading; they never snap to tiles. That is what lets three small units
|
|
// share one tile while a tank takes it alone — the packing falls out of collision radii,
|
|
// not out of a slot system.
|
|
//
|
|
// Two things here are load-bearing for performance:
|
|
// 1. Units are NOT stamped into the grid. Only terrain and buildings block. If every unit
|
|
// dirtied the grid, every path would be invalidated every tick.
|
|
// 2. Clearance-based pathfinding: clearance[i] is the side of the largest all-passable
|
|
// square with tile i at its top-left, so a large unit simply refuses tiles whose
|
|
// clearance is below its need. One DP pass at load, patched over a dirty rect when a
|
|
// building goes up or dies.
|
|
|
|
const SQRT2 = Math.SQRT2;
|
|
export const MAX_CLEARANCE = 8; // clamped so clearance fits a Uint8Array and the DP stays cheap
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Nav grid
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Build the navigation grid for a map.
|
|
* @param {object} rules compiled rules
|
|
* @param {{w:number,h:number,terrain:Uint8Array}} map terrain indices into rules.terrain
|
|
*/
|
|
export function createNav(rules, map) {
|
|
const { w, h } = map;
|
|
const n = w * h;
|
|
const moveClassIds = Object.keys(rules.moveClasses);
|
|
|
|
const nav = {
|
|
w, h, n,
|
|
tileSize: rules.constants.tileSize,
|
|
terrain: map.terrain,
|
|
blocked: new Uint8Array(n), // buildings and build sites
|
|
moveClassIds,
|
|
passable: Object.create(null), // mc -> Uint8Array
|
|
cost: Object.create(null), // mc -> Float32Array (Infinity where impassable)
|
|
clearance: Object.create(null), // mc -> Uint8Array
|
|
// Scratch reused by every A* call so a path request allocates nothing.
|
|
_g: new Float64Array(n),
|
|
_came: new Int32Array(n),
|
|
_stamp: new Int32Array(n),
|
|
_epoch: 0,
|
|
_heap: createHeap(Math.max(64, n >> 2)),
|
|
};
|
|
|
|
for (const mc of moveClassIds) {
|
|
nav.passable[mc] = new Uint8Array(n);
|
|
nav.cost[mc] = new Float32Array(n);
|
|
nav.clearance[mc] = new Uint8Array(n);
|
|
}
|
|
refreshRegion(nav, rules, 0, 0, w - 1, h - 1);
|
|
return nav;
|
|
}
|
|
|
|
/** Recompute passability, cost and clearance over a tile rect (inclusive). */
|
|
export function refreshRegion(nav, rules, x0, y0, x1, y1) {
|
|
const { w, h } = nav;
|
|
x0 = Math.max(0, x0); y0 = Math.max(0, y0);
|
|
x1 = Math.min(w - 1, x1); y1 = Math.min(h - 1, y1);
|
|
if (x1 < x0 || y1 < y0) return;
|
|
|
|
for (const mc of nav.moveClassIds) {
|
|
const costTable = rules.moveClasses[mc].costByTerrainIndex;
|
|
const pass = nav.passable[mc];
|
|
const cost = nav.cost[mc];
|
|
for (let y = y0; y <= y1; y++) {
|
|
for (let x = x0; x <= x1; x++) {
|
|
const i = y * w + x;
|
|
const c = nav.blocked[i] ? null : costTable[nav.terrain[i]];
|
|
pass[i] = c == null ? 0 : 1;
|
|
cost[i] = c == null ? Infinity : c;
|
|
}
|
|
}
|
|
}
|
|
// Clearance at (x,y) reads (x+1,y), (x,y+1), (x+1,y+1), so a change at (x,y) can only
|
|
// affect tiles up-left of it — expand the rect that way and sweep in reverse.
|
|
const cx0 = Math.max(0, x0 - MAX_CLEARANCE);
|
|
const cy0 = Math.max(0, y0 - MAX_CLEARANCE);
|
|
nav._fit = null; // clearance moved, so every dilated fit mask is stale
|
|
for (const mc of nav.moveClassIds) {
|
|
const pass = nav.passable[mc];
|
|
const cl = nav.clearance[mc];
|
|
for (let y = y1; y >= cy0; y--) {
|
|
for (let x = x1; x >= cx0; x--) {
|
|
const i = y * w + x;
|
|
if (!pass[i]) { cl[i] = 0; continue; }
|
|
if (x === w - 1 || y === h - 1) { cl[i] = 1; continue; }
|
|
const a = cl[i + 1], b = cl[i + w], d = cl[i + w + 1];
|
|
cl[i] = Math.min(MAX_CLEARANCE, 1 + Math.min(a, b, d));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Stamp (or clear) a building footprint and patch the affected clearance. */
|
|
export function stampFootprint(nav, rules, tx, ty, fw, fh, on) {
|
|
const { w, h } = nav;
|
|
for (let y = ty; y < ty + fh; y++) {
|
|
if (y < 0 || y >= h) continue;
|
|
for (let x = tx; x < tx + fw; x++) {
|
|
if (x < 0 || x >= w) continue;
|
|
nav.blocked[y * w + x] = on ? 1 : 0;
|
|
}
|
|
}
|
|
refreshRegion(nav, rules, tx, ty, tx + fw - 1, ty + fh - 1);
|
|
}
|
|
|
|
/** Clearance a unit of this collision radius needs, in tiles. */
|
|
export function clearanceFor(radius, tileSize) {
|
|
return Math.max(1, Math.ceil((radius * 2) / tileSize));
|
|
}
|
|
|
|
export const tileIndex = (nav, tx, ty) => ty * nav.w + tx;
|
|
export const tileX = (nav, i) => i % nav.w;
|
|
export const tileY = (nav, i) => (i / nav.w) | 0;
|
|
export const worldToTileX = (nav, x) => Math.max(0, Math.min(nav.w - 1, (x / nav.tileSize) | 0));
|
|
export const worldToTileY = (nav, y) => Math.max(0, Math.min(nav.h - 1, (y / nav.tileSize) | 0));
|
|
export const tileCenterX = (nav, tx) => tx * nav.tileSize + nav.tileSize / 2;
|
|
export const tileCenterY = (nav, ty) => ty * nav.tileSize + nav.tileSize / 2;
|
|
|
|
/**
|
|
* Per-tile "a unit this wide can stand here" mask, cached per move class and need.
|
|
*
|
|
* clearance[i] is the largest all-passable square whose TOP-LEFT corner is tile i, so
|
|
* reading it directly asks "does the square start here" — which wrongly rejects every tile
|
|
* along the bottom and right edge of an open area, even though a unit centred there fits
|
|
* perfectly well. A tile is genuinely usable when ANY anchor covering it qualifies, so this
|
|
* dilates the clearance test by the need window (a separable two-pass OR).
|
|
*/
|
|
export function fitField(nav, mc, need) {
|
|
if (need <= 1) return nav.clearance[mc];
|
|
const key = `${mc}:${need}`;
|
|
if (!nav._fit) nav._fit = Object.create(null);
|
|
const hit = nav._fit[key];
|
|
if (hit) return hit;
|
|
|
|
const { w, h } = nav;
|
|
const cl = nav.clearance[mc];
|
|
const rowPass = new Uint8Array(w * h);
|
|
for (let y = 0; y < h; y++) {
|
|
let run = 0; // how many of the last `need` tiles to the left qualify as anchors
|
|
for (let x = 0; x < w; x++) {
|
|
const i = y * w + x;
|
|
run = cl[i] >= need ? need : (run > 0 ? run - 1 : 0);
|
|
rowPass[i] = run > 0 ? 1 : 0;
|
|
}
|
|
}
|
|
const out = new Uint8Array(w * h);
|
|
for (let x = 0; x < w; x++) {
|
|
let run = 0;
|
|
for (let y = 0; y < h; y++) {
|
|
const i = y * w + x;
|
|
run = rowPass[i] ? need : (run > 0 ? run - 1 : 0);
|
|
out[i] = run > 0 ? need : 0;
|
|
}
|
|
}
|
|
nav._fit[key] = out;
|
|
return out;
|
|
}
|
|
|
|
/** Is this tile usable by a unit with the given move class and clearance need? */
|
|
export function tileOk(nav, mc, need, tx, ty) {
|
|
if (tx < 0 || ty < 0 || tx >= nav.w || ty >= nav.h) return false;
|
|
return fitField(nav, mc, need)[ty * nav.w + tx] >= need;
|
|
}
|
|
|
|
/**
|
|
* Nearest tile to (tx,ty) that a unit of this move class/clearance can stand on.
|
|
* Spiral BFS, bounded — used when an order lands on a wall or inside a building.
|
|
*/
|
|
export function nearestUsableTile(nav, mc, need, tx, ty, maxRings = 24) {
|
|
if (tileOk(nav, mc, need, tx, ty)) return tileIndex(nav, tx, ty);
|
|
for (let r = 1; r <= maxRings; r++) {
|
|
for (let dx = -r; dx <= r; dx++) {
|
|
for (const dy of (dx === -r || dx === r) ? range(-r, r) : [-r, r]) {
|
|
const x = tx + dx, y = ty + dy;
|
|
if (tileOk(nav, mc, need, x, y)) return tileIndex(nav, x, y);
|
|
}
|
|
}
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
function range(a, b) {
|
|
const out = [];
|
|
for (let v = a; v <= b; v++) out.push(v);
|
|
return out;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Binary min-heap (parallel arrays, grows in place, never allocates mid-search)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function createHeap(cap) {
|
|
return { f: new Float64Array(cap), k: new Int32Array(cap), size: 0, cap };
|
|
}
|
|
|
|
function heapClear(hp) { hp.size = 0; }
|
|
|
|
function heapPush(hp, f, k) {
|
|
if (hp.size === hp.cap) {
|
|
const cap = hp.cap * 2;
|
|
const nf = new Float64Array(cap); nf.set(hp.f);
|
|
const nk = new Int32Array(cap); nk.set(hp.k);
|
|
hp.f = nf; hp.k = nk; hp.cap = cap;
|
|
}
|
|
let i = hp.size++;
|
|
hp.f[i] = f; hp.k[i] = k;
|
|
while (i > 0) {
|
|
const p = (i - 1) >> 1;
|
|
if (hp.f[p] <= hp.f[i]) break;
|
|
swapHeap(hp, i, p);
|
|
i = p;
|
|
}
|
|
}
|
|
|
|
function heapPop(hp) {
|
|
const top = hp.k[0];
|
|
hp.size--;
|
|
if (hp.size > 0) {
|
|
hp.f[0] = hp.f[hp.size]; hp.k[0] = hp.k[hp.size];
|
|
let i = 0;
|
|
for (;;) {
|
|
const l = i * 2 + 1, r = l + 1;
|
|
let m = i;
|
|
if (l < hp.size && hp.f[l] < hp.f[m]) m = l;
|
|
if (r < hp.size && hp.f[r] < hp.f[m]) m = r;
|
|
if (m === i) break;
|
|
swapHeap(hp, i, m);
|
|
i = m;
|
|
}
|
|
}
|
|
return top;
|
|
}
|
|
|
|
function swapHeap(hp, a, b) {
|
|
const f = hp.f[a]; hp.f[a] = hp.f[b]; hp.f[b] = f;
|
|
const k = hp.k[a]; hp.k[a] = hp.k[b]; hp.k[b] = k;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// A*
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const NEIGHBOR_DX = [1, -1, 0, 0, 1, 1, -1, -1];
|
|
const NEIGHBOR_DY = [0, 0, 1, -1, 1, -1, 1, -1];
|
|
|
|
/**
|
|
* A* over the tile grid.
|
|
* @returns {Int32Array|null} tile indices from start to goal inclusive, or null if unreachable
|
|
*/
|
|
export function findPath(nav, mc, need, startIdx, goalIdx, expansionCap = 20000) {
|
|
if (startIdx === goalIdx) return Int32Array.of(startIdx);
|
|
const cl = fitField(nav, mc, need);
|
|
const cost = nav.cost[mc];
|
|
if (cl[goalIdx] < need || cl[startIdx] < need) return null;
|
|
|
|
const { w, h } = nav;
|
|
const g = nav._g, came = nav._came, stamp = nav._stamp, hp = nav._heap;
|
|
const epoch = ++nav._epoch;
|
|
heapClear(hp);
|
|
|
|
const gx = goalIdx % w, gy = (goalIdx / w) | 0;
|
|
const heuristic = (i) => {
|
|
const dx = Math.abs((i % w) - gx);
|
|
const dy = Math.abs(((i / w) | 0) - gy);
|
|
// Octile: exact for 8-way movement on a uniform grid, so it never over-estimates
|
|
// once terrain cost is >= 1 — which the rules validator guarantees.
|
|
return (dx > dy ? dx - dy + SQRT2 * dy : dy - dx + SQRT2 * dx);
|
|
};
|
|
|
|
g[startIdx] = 0;
|
|
came[startIdx] = -1;
|
|
stamp[startIdx] = epoch;
|
|
heapPush(hp, heuristic(startIdx), startIdx);
|
|
|
|
let expansions = 0;
|
|
const closed = new Set();
|
|
|
|
while (hp.size > 0) {
|
|
const cur = heapPop(hp);
|
|
if (cur === goalIdx) return reconstruct(came, startIdx, goalIdx);
|
|
if (closed.has(cur)) continue;
|
|
closed.add(cur);
|
|
if (++expansions > expansionCap) return null;
|
|
|
|
const cx = cur % w, cy = (cur / w) | 0;
|
|
const gcur = g[cur];
|
|
|
|
for (let d = 0; d < 8; d++) {
|
|
const nx = cx + NEIGHBOR_DX[d], ny = cy + NEIGHBOR_DY[d];
|
|
if (nx < 0 || ny < 0 || nx >= w || ny >= h) continue;
|
|
const ni = ny * w + nx;
|
|
if (cl[ni] < need) continue;
|
|
const diag = d >= 4;
|
|
if (diag) {
|
|
// No corner cutting: both orthogonal neighbours must also be usable, or a tank
|
|
// squeezes diagonally between two buildings that visually touch.
|
|
if (cl[cy * w + nx] < need || cl[ny * w + cx] < need) continue;
|
|
}
|
|
const step = cost[ni] * (diag ? SQRT2 : 1);
|
|
const tentative = gcur + step;
|
|
if (stamp[ni] === epoch && tentative >= g[ni]) continue;
|
|
stamp[ni] = epoch;
|
|
g[ni] = tentative;
|
|
came[ni] = cur;
|
|
heapPush(hp, tentative + heuristic(ni), ni);
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function reconstruct(came, startIdx, goalIdx) {
|
|
let len = 1;
|
|
for (let i = goalIdx; i !== startIdx; i = came[i]) len++;
|
|
const out = new Int32Array(len);
|
|
let p = len - 1;
|
|
for (let i = goalIdx; i !== -1; i = came[i]) {
|
|
out[p--] = i;
|
|
if (i === startIdx) break;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Line of sight and string pulling
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Can a unit of this clearance travel the straight segment a->b without clipping a
|
|
* blocked tile? Supercover walk (every tile the segment touches, not just Bresenham's).
|
|
*/
|
|
export function segmentClear(nav, mc, need, x0, y0, x1, y1) {
|
|
const ts = nav.tileSize;
|
|
const cl = fitField(nav, mc, need);
|
|
let tx = Math.floor(x0 / ts), ty = Math.floor(y0 / ts);
|
|
const tx1 = Math.floor(x1 / ts), ty1 = Math.floor(y1 / ts);
|
|
const dx = x1 - x0, dy = y1 - y0;
|
|
const stepX = dx > 0 ? 1 : -1, stepY = dy > 0 ? 1 : -1;
|
|
const tDeltaX = dx === 0 ? Infinity : Math.abs(ts / dx);
|
|
const tDeltaY = dy === 0 ? Infinity : Math.abs(ts / dy);
|
|
let tMaxX = dx === 0 ? Infinity
|
|
: Math.abs(((dx > 0 ? (tx + 1) * ts : tx * ts) - x0) / dx);
|
|
let tMaxY = dy === 0 ? Infinity
|
|
: Math.abs(((dy > 0 ? (ty + 1) * ts : ty * ts) - y0) / dy);
|
|
|
|
let guard = 0;
|
|
const limit = nav.w + nav.h + 4;
|
|
for (;;) {
|
|
if (tx < 0 || ty < 0 || tx >= nav.w || ty >= nav.h) return false;
|
|
if (cl[ty * nav.w + tx] < need) return false;
|
|
if (tx === tx1 && ty === ty1) return true;
|
|
if (++guard > limit) return false;
|
|
if (tMaxX < tMaxY) { tx += stepX; tMaxX += tDeltaX; }
|
|
else { ty += stepY; tMaxY += tDeltaY; }
|
|
}
|
|
}
|
|
|
|
/**
|
|
* String-pull a tile path into a short list of world waypoints. Greedily skips any
|
|
* waypoint the unit can reach in a straight line, so units drive smooth diagonals
|
|
* instead of staircasing along tile centres.
|
|
* @returns {number[]} flat [x0,y0, x1,y1, ...] world coordinates
|
|
*/
|
|
export function smoothPath(nav, mc, need, path, endX, endY) {
|
|
const out = [];
|
|
if (!path || path.length === 0) return out;
|
|
const px = (i) => tileCenterX(nav, path[i] % nav.w);
|
|
const py = (i) => tileCenterY(nav, (path[i] / nav.w) | 0);
|
|
|
|
let anchorX = px(0), anchorY = py(0);
|
|
let i = 0;
|
|
while (i < path.length - 1) {
|
|
// Advance as far as we can still see from the anchor.
|
|
let j = i + 1;
|
|
while (j + 1 < path.length && segmentClear(nav, mc, need, anchorX, anchorY, px(j + 1), py(j + 1))) j++;
|
|
out.push(px(j), py(j));
|
|
anchorX = px(j); anchorY = py(j);
|
|
i = j;
|
|
}
|
|
// Replace the final tile centre with the true destination when it's directly reachable.
|
|
if (endX != null && out.length >= 2) {
|
|
const lx = out[out.length - 2], ly = out[out.length - 1];
|
|
if (segmentClear(nav, mc, need, lx, ly, endX, endY)) {
|
|
out[out.length - 2] = endX;
|
|
out[out.length - 1] = endY;
|
|
} else {
|
|
out.push(endX, endY);
|
|
}
|
|
} else if (endX != null) {
|
|
out.push(endX, endY);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Formation slots
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Fan a group out around a destination on a hex-ish lattice, then assign units to slots
|
|
* greedily by distance. One shared path plus these offsets is what makes a 40-unit move
|
|
* order cost one A* instead of forty.
|
|
* @returns {number[]} flat [x,y] per unit, in the same order as `units`
|
|
*/
|
|
export function formationSlots(cx, cy, units, spacingScale = 1.15) {
|
|
const nMax = units.length;
|
|
let maxR = 0;
|
|
for (const u of units) maxR = Math.max(maxR, u.radius);
|
|
const spacing = maxR * 2 * spacingScale;
|
|
|
|
const slots = [[cx, cy]];
|
|
for (let ring = 1; slots.length < nMax; ring++) {
|
|
const count = 6 * ring;
|
|
for (let s = 0; s < count && slots.length < nMax; s++) {
|
|
const a = (s / count) * Math.PI * 2;
|
|
slots.push([cx + Math.cos(a) * spacing * ring, cy + Math.sin(a) * spacing * ring]);
|
|
}
|
|
}
|
|
|
|
// Greedy nearest assignment: sort both sides by distance from the destination and pair
|
|
// up, so the units already closest take the inner slots and nobody crosses the group.
|
|
const order = units.map((u, i) => ({
|
|
i, d: (u.x - cx) * (u.x - cx) + (u.y - cy) * (u.y - cy),
|
|
})).sort((a, b) => a.d - b.d || a.i - b.i);
|
|
|
|
const out = new Array(nMax * 2);
|
|
order.forEach((entry, rank) => {
|
|
out[entry.i * 2] = slots[rank][0];
|
|
out[entry.i * 2 + 1] = slots[rank][1];
|
|
});
|
|
return out;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Spatial hash — rebuilt every tick, drives separation and target acquisition
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export class SpatialHash {
|
|
constructor(cellSize, worldW, worldH) {
|
|
this.cell = cellSize;
|
|
this.cols = Math.max(1, Math.ceil(worldW / cellSize));
|
|
this.rows = Math.max(1, Math.ceil(worldH / cellSize));
|
|
this.buckets = Array.from({ length: this.cols * this.rows }, () => []);
|
|
}
|
|
|
|
clear() {
|
|
for (const b of this.buckets) b.length = 0;
|
|
}
|
|
|
|
_bucket(x, y) {
|
|
const cx = Math.max(0, Math.min(this.cols - 1, (x / this.cell) | 0));
|
|
const cy = Math.max(0, Math.min(this.rows - 1, (y / this.cell) | 0));
|
|
return cy * this.cols + cx;
|
|
}
|
|
|
|
insert(item, x, y) {
|
|
this.buckets[this._bucket(x, y)].push(item);
|
|
}
|
|
|
|
/** Append every item within `r` of (x,y) into `out`. Order is deterministic. */
|
|
query(x, y, r, out) {
|
|
const c0 = Math.max(0, ((x - r) / this.cell) | 0);
|
|
const c1 = Math.min(this.cols - 1, ((x + r) / this.cell) | 0);
|
|
const r0 = Math.max(0, ((y - r) / this.cell) | 0);
|
|
const r1 = Math.min(this.rows - 1, ((y + r) / this.cell) | 0);
|
|
for (let cy = r0; cy <= r1; cy++) {
|
|
const base = cy * this.cols;
|
|
for (let cx = c0; cx <= c1; cx++) {
|
|
const b = this.buckets[base + cx];
|
|
for (let i = 0; i < b.length; i++) out.push(b[i]);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
}
|