SpaceCities / engine /grid.js
Claude
Sim core: fix stale balance comment, reuse queryNeighbors' scratch buffer
1702aab unverified
Raw History Blame Contribute Delete
4.42 kB
// @ts-check
/* ============================================================
Uniform spatial hash over units, rebuilt once per tick by sim.js. It's a
BROAD PHASE only: a query returns candidate units whose cell is near a point,
and the caller still does the exact live-distance test it did before. That
turns the three per-tick O(n^2) neighbour scans β€” separation, movement
avoidance, and combat target acquisition β€” into local lookups, which is what
lets a Gigantic (4x) map with hundreds of units stay inside the frame budget.
The grid is built from pre-movement positions and reused through the whole
tick (units move, then separate, after the build), so every query box is
padded by an extra ring of cells. One tick's displacement is far under a
cell, so the padded candidate set is always a superset of the true
neighbours β€” no interaction is ever missed, only a few extra candidates get
the cheap distance check and fall out.
When state.unitGrid is absent (the many unit tests that call movement /
combat / separation directly without a full tick) every consumer falls back
to the original full scan, so their behaviour is byte-for-byte unchanged.
============================================================ */
"use strict";
const CELL = 96;
// Integer cell key instead of a "cx,cy" string: the old key allocated + hashed a string for
// every unit inserted AND every cell queried, every tick β€” pure per-tick garbage on the hot
// path. Packing (cx,cy) into one int is a plain arithmetic Map key. KEY_PAD offsets the few
// negative cells the query pad reaches; KEY_STRIDE exceeds the max cells-per-axis of any map
// (Gigantic β‰ˆ 67), so the packing is collision-free. This changes NOTHING observable: cells
// bucket the same units in the same order, and queryNeighbors visits cells in the same fixed
// loop, so candidate lists are byte-for-byte identical β€” the determinism test stays green.
const KEY_PAD = 16; // headroom for the most negative cell any query pad reaches (radius up to ~1400px)
const KEY_STRIDE = 4096; // > max cells per axis (a huge map is ~67), so (cx+PAD) and (cy+PAD) never overlap
function cellKey(cx, cy) { return (cx + KEY_PAD) * KEY_STRIDE + (cy + KEY_PAD); }
/** @param {State} state */
export function buildUnitGrid(state) {
const buckets = new Map();
let i = 0;
for (const u of state.units.values()) {
u._gi = i++; // stable Map-order index: lets separation process each pair once, deterministically
const k = cellKey(Math.floor(u.x / CELL), Math.floor(u.y / CELL));
let arr = buckets.get(k);
if (!arr) buckets.set(k, (arr = []));
arr.push(u);
}
return { cell: CELL, buckets };
}
// Reused across calls instead of allocating a fresh result array every query:
// every caller (repair.js, movement.js, separation.js, combat.js) reads the
// returned array in an immediate for-of and discards it before making its next
// move β€” none stores it past that, and none makes a second queryNeighbors call
// while a prior result is still being iterated (all call sites are synchronous,
// single-threaded, read-then-discard) β€” so one shared scratch buffer is safe and
// matches this file's stated no-per-tick-garbage goal (see header comment).
const _scratch = [];
// Candidate units in every cell overlapping the (radius, +1 ring of padding)
// box around (x, y). A superset of the units within `radius` β€” callers filter
// by exact distance. Cells are visited in a fixed numeric order and bucket
// contents keep Map insertion order, so iteration is fully deterministic.
// Returns a reused scratch array (see _scratch above) β€” read it immediately
// and don't hold onto it past that; the next queryNeighbors call overwrites it.
/** @param {*} grid @param {number} x @param {number} y @param {number} radius @returns {(Unit)[]} */
export function queryNeighbors(grid, x, y, radius) {
const cell = grid.cell;
const mincx = Math.floor((x - radius) / cell) - 1;
const maxcx = Math.floor((x + radius) / cell) + 1;
const mincy = Math.floor((y - radius) / cell) - 1;
const maxcy = Math.floor((y + radius) / cell) + 1;
_scratch.length = 0;
for (let cy = mincy; cy <= maxcy; cy++) {
for (let cx = mincx; cx <= maxcx; cx++) {
const arr = grid.buckets.get(cellKey(cx, cy));
if (arr) for (const u of arr) _scratch.push(u);
}
}
return _scratch;
}