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