fertig-classic-games/src/games/totalannihilation/TANav.js

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