|
|
@@ -8,11 +8,20 @@ export interface DetachedComponent {
|
|
|
/** w × h materials, 0 where the pixel is not part of the component. */
|
|
|
mask: Uint8Array;
|
|
|
pixelCount: number;
|
|
|
+ /** Index of the region (in the list given to find) whose seed found it. */
|
|
|
+ region: number;
|
|
|
}
|
|
|
|
|
|
const STAMP_LIMIT = 0xfffffff0;
|
|
|
+// Local copies: the hot loops below must not read imported bindings (some module loaders, e.g.
|
|
|
+// the test runner's, turn each read into a property lookup on the module object).
|
|
|
+const EMPTY = MATERIAL_EMPTY;
|
|
|
+const PLATE = MATERIAL_PLATE;
|
|
|
+const MAX_PIXELS = MAX_COMPONENT_PIXELS;
|
|
|
/** Pixels explored breadth-first before a search switches to a downward depth-first walk. */
|
|
|
const LOCAL_SEARCH_BUDGET = 2048;
|
|
|
+/** Pixels walked (breadth then depth first) before a search switches to a scanline fill. */
|
|
|
+const WALK_BUDGET = 12_000;
|
|
|
|
|
|
/**
|
|
|
* Rule (readme §5): every solid pixel not 4-connected to a plate pixel falls.
|
|
|
@@ -20,161 +29,328 @@ const LOCAL_SEARCH_BUDGET = 2048;
|
|
|
* The search is seeded from every solid pixel in `region` (the area an op just changed,
|
|
|
* grown by one pixel). Each seed first explores breadth-first on a small budget, which
|
|
|
* usually meets a pixel an earlier seed already proved anchored; then depth-first, downward
|
|
|
- * first, since plates sit under the terrain. Only detached components get explored completely.
|
|
|
+ * first, since plates sit under the terrain; then, for big components, by scanline fill.
|
|
|
+ * Only detached components get explored completely.
|
|
|
*
|
|
|
* Within one call, every pixel reached by a search that found an anchor is anchored too,
|
|
|
* so later seeds stop as soon as they touch one.
|
|
|
*/
|
|
|
-export class DetachFinder {
|
|
|
+/**
|
|
|
+ * Working memory of the search. Tens of megabytes for a 2 Mpx map: allocated once per map size
|
|
|
+ * and shared by every finder (a new Game, hence a new finder, starts each round), so a round's
|
|
|
+ * first big search does not pay for fresh pages. Searches never overlap (single-threaded), and
|
|
|
+ * the stamp counters live here too so stamps stay unique across finders.
|
|
|
+ */
|
|
|
+interface SearchBuffers {
|
|
|
/** Search id that last pushed each pixel. */
|
|
|
- private readonly seen: Uint32Array;
|
|
|
+ seen: Uint32Array;
|
|
|
/** Per call: 2·call = anchored, 2·call + 1 = detached. */
|
|
|
+ known: Uint32Array;
|
|
|
+ /** Walk stack (indices into `pushed`), then scanline seeds (pixel index and row). */
|
|
|
+ stack: Int32Array;
|
|
|
+ stackY: Int16Array;
|
|
|
+ /** Every pixel reached by the current search, with its coordinates. */
|
|
|
+ pushed: Int32Array;
|
|
|
+ pushedX: Int16Array;
|
|
|
+ pushedY: Int16Array;
|
|
|
+ searchId: number;
|
|
|
+ callId: number;
|
|
|
+}
|
|
|
+
|
|
|
+const sharedBuffers = new Map<string, SearchBuffers>();
|
|
|
+
|
|
|
+function buffersFor(width: number, height: number): SearchBuffers {
|
|
|
+ const key = `${width}x${height}`;
|
|
|
+ let b = sharedBuffers.get(key);
|
|
|
+ if (!b) {
|
|
|
+ const n = width * height;
|
|
|
+ // A component never exceeds MAX_PIXELS (plus the row being filled); scanline seeds are at most
|
|
|
+ // two per run.
|
|
|
+ const pushedSize = Math.min(n, MAX_PIXELS + width + 4);
|
|
|
+ const stackSize = Math.min(n, 2 * pushedSize + 4 * width);
|
|
|
+ b = {
|
|
|
+ seen: new Uint32Array(n),
|
|
|
+ known: new Uint32Array(n),
|
|
|
+ stack: new Int32Array(stackSize),
|
|
|
+ stackY: new Int16Array(stackSize),
|
|
|
+ pushed: new Int32Array(pushedSize),
|
|
|
+ pushedX: new Int16Array(pushedSize),
|
|
|
+ pushedY: new Int16Array(pushedSize),
|
|
|
+ searchId: 0,
|
|
|
+ callId: 0,
|
|
|
+ };
|
|
|
+ sharedBuffers.set(key, b);
|
|
|
+ }
|
|
|
+ return b;
|
|
|
+}
|
|
|
+
|
|
|
+export class DetachFinder {
|
|
|
+ private readonly seen: Uint32Array;
|
|
|
private readonly known: Uint32Array;
|
|
|
private readonly stack: Int32Array;
|
|
|
+ private readonly stackY: Int16Array;
|
|
|
private readonly pushed: Int32Array;
|
|
|
- private searchId = 0;
|
|
|
- private callId = 0;
|
|
|
+ private readonly pushedX: Int16Array;
|
|
|
+ private readonly pushedY: Int16Array;
|
|
|
+ private readonly buffers: SearchBuffers;
|
|
|
|
|
|
constructor(private readonly bitmap: TerrainBitmap) {
|
|
|
- const n = bitmap.width * bitmap.height;
|
|
|
- this.seen = new Uint32Array(n);
|
|
|
- this.known = new Uint32Array(n);
|
|
|
- this.stack = new Int32Array(n);
|
|
|
- this.pushed = new Int32Array(n);
|
|
|
+ const b = buffersFor(bitmap.width, bitmap.height);
|
|
|
+ this.buffers = b;
|
|
|
+ this.seen = b.seen;
|
|
|
+ this.known = b.known;
|
|
|
+ this.stack = b.stack;
|
|
|
+ this.stackY = b.stackY;
|
|
|
+ this.pushed = b.pushed;
|
|
|
+ this.pushedX = b.pushedX;
|
|
|
+ this.pushedY = b.pushedY;
|
|
|
}
|
|
|
|
|
|
- /** Finds detached components touching `region`. Does not modify the bitmap. */
|
|
|
- find(region: Rect): DetachedComponent[] {
|
|
|
+ /**
|
|
|
+ * Finds detached components touching `regions` (e.g. every area changed during a tick).
|
|
|
+ * Searching them all in one call lets what one region proved anchored speed up the others.
|
|
|
+ * Does not modify the bitmap.
|
|
|
+ */
|
|
|
+ find(regions: Rect | readonly Rect[]): DetachedComponent[] {
|
|
|
const { width, height, data } = this.bitmap;
|
|
|
const call = this.nextCall();
|
|
|
- const x0 = Math.max(0, region.x0 - 1);
|
|
|
- const y0 = Math.max(0, region.y0 - 1);
|
|
|
- const x1 = Math.min(width, region.x1 + 1);
|
|
|
- const y1 = Math.min(height, region.y1 + 1);
|
|
|
+ const list = Array.isArray(regions) ? regions : [regions as Rect];
|
|
|
const out: DetachedComponent[] = [];
|
|
|
- for (let y = y0; y < y1; y++) {
|
|
|
- for (let x = x0; x < x1; x++) {
|
|
|
- const i = y * width + x;
|
|
|
- if (data[i] === MATERIAL_EMPTY) continue;
|
|
|
- const k = this.known[i];
|
|
|
- if (k === 2 * call || k === 2 * call + 1) continue;
|
|
|
- const component = this.explore(i, call);
|
|
|
- if (component) out.push(component);
|
|
|
+ list.forEach((region, r) => {
|
|
|
+ const x0 = Math.max(0, region.x0 - 1);
|
|
|
+ const y0 = Math.max(0, region.y0 - 1);
|
|
|
+ const x1 = Math.min(width, region.x1 + 1);
|
|
|
+ const y1 = Math.min(height, region.y1 + 1);
|
|
|
+ for (let y = y0; y < y1; y++) {
|
|
|
+ for (let x = x0; x < x1; x++) {
|
|
|
+ const i = y * width + x;
|
|
|
+ if (data[i] === EMPTY) continue;
|
|
|
+ const k = this.known[i];
|
|
|
+ if (k === 2 * call || k === 2 * call + 1) continue;
|
|
|
+ const component = this.explore(i, call);
|
|
|
+ if (component) out.push({ ...component, region: r });
|
|
|
+ }
|
|
|
}
|
|
|
- }
|
|
|
+ });
|
|
|
return out;
|
|
|
}
|
|
|
|
|
|
/** True if the solid pixel at `index` is connected to a plate. */
|
|
|
isAnchored(index: number): boolean {
|
|
|
- if (this.bitmap.data[index] === MATERIAL_EMPTY) return false;
|
|
|
+ if (this.bitmap.data[index] === EMPTY) return false;
|
|
|
return this.explore(index, this.nextCall()) === null;
|
|
|
}
|
|
|
|
|
|
private nextCall(): number {
|
|
|
- if (this.callId >= STAMP_LIMIT / 2 || this.searchId >= STAMP_LIMIT) {
|
|
|
- this.seen.fill(0);
|
|
|
- this.known.fill(0);
|
|
|
- this.callId = 0;
|
|
|
- this.searchId = 0;
|
|
|
+ const b = this.buffers;
|
|
|
+ if (b.callId >= STAMP_LIMIT / 2 || b.searchId >= STAMP_LIMIT) {
|
|
|
+ b.seen.fill(0);
|
|
|
+ b.known.fill(0);
|
|
|
+ b.callId = 0;
|
|
|
+ b.searchId = 0;
|
|
|
}
|
|
|
- return ++this.callId;
|
|
|
+ return ++b.callId;
|
|
|
}
|
|
|
|
|
|
- /** DFS from `start`. Returns null if anchored, the component otherwise. Hot path: kept flat on purpose. */
|
|
|
+ /**
|
|
|
+ * Explores the component of `start`. Returns null if anchored, the component otherwise.
|
|
|
+ * Three phases, each the cheapest for a different situation:
|
|
|
+ * 1. Breadth-first on a small budget: spreads sideways and quickly meets pixels an earlier
|
|
|
+ * search proved anchored (common after a cut, where many seeds share one anchor).
|
|
|
+ * 2. Depth-first, downward first, on a larger budget: plates sit under the terrain, so an
|
|
|
+ * anchored component is usually proved by a narrow walk straight down.
|
|
|
+ * 3. Scanline fill: whole horizontal runs at a time. Reading contiguous memory is several
|
|
|
+ * times faster than a pixel walk on a 2 Mpx map, which matters when a whole tier comes
|
|
|
+ * loose and every one of its pixels must be visited.
|
|
|
+ * Hot path, kept flat on purpose.
|
|
|
+ */
|
|
|
private explore(start: number, call: number): DetachedComponent | null {
|
|
|
const { width, height, data } = this.bitmap;
|
|
|
- const { seen, known, stack, pushed } = this;
|
|
|
+ const { seen, known, pushed, pushedX, pushedY, stack } = this;
|
|
|
const anchoredMark = 2 * call;
|
|
|
- const search = ++this.searchId;
|
|
|
- const last = width * height - width; // first index of the bottom row
|
|
|
- let count = 0;
|
|
|
+ const search = ++this.buffers.searchId;
|
|
|
+ const maxX = width - 1;
|
|
|
+ const maxY = height - 1;
|
|
|
+ const sx = start % width;
|
|
|
+ const sy = (start - sx) / width;
|
|
|
+ let count = 1;
|
|
|
let anchored = false;
|
|
|
seen[start] = search;
|
|
|
- pushed[count++] = start;
|
|
|
+ pushed[0] = start;
|
|
|
+ pushedX[0] = sx;
|
|
|
+ pushedY[0] = sy;
|
|
|
|
|
|
- // Phase 1: breadth-first on `pushed` (used as a queue) within a small budget. It spreads
|
|
|
- // sideways and quickly meets pixels an earlier search already proved anchored.
|
|
|
- // Phase 2, if still unresolved: depth-first on `stack`, heading for the plates below.
|
|
|
+ // ---- Phases 1 and 2: pixel walk. Phase 1 reads `pushed` as a queue; phase 2 uses `stack`
|
|
|
+ // (indices into `pushed`), pushing up, left and right before down so down is explored first.
|
|
|
let head = 0;
|
|
|
let top = 0;
|
|
|
let deep = false;
|
|
|
- search: for (;;) {
|
|
|
- let i: number;
|
|
|
+ walk: while (true) {
|
|
|
+ let k: number;
|
|
|
if (!deep) {
|
|
|
if (head >= count) break;
|
|
|
if (count >= LOCAL_SEARCH_BUDGET) {
|
|
|
deep = true;
|
|
|
- for (let k = head; k < count; k++) stack[top++] = pushed[k];
|
|
|
+ for (let q = head; q < count; q++) stack[top++] = q;
|
|
|
continue;
|
|
|
}
|
|
|
- i = pushed[head++];
|
|
|
+ k = head++;
|
|
|
} else {
|
|
|
- if (top === 0) break;
|
|
|
- i = stack[--top];
|
|
|
+ if (top === 0 || count >= WALK_BUDGET) break;
|
|
|
+ k = stack[--top];
|
|
|
}
|
|
|
- if (data[i] === MATERIAL_PLATE || count > MAX_COMPONENT_PIXELS) {
|
|
|
- // Plate reached, or too big to fall: anchored.
|
|
|
+ const i = pushed[k];
|
|
|
+ if (data[i] === PLATE) {
|
|
|
anchored = true;
|
|
|
break;
|
|
|
}
|
|
|
- const x = i % width;
|
|
|
- // In the deep phase the last pushed is explored first: up, left, right, then down.
|
|
|
+ const x = pushedX[k];
|
|
|
+ const y = pushedY[k];
|
|
|
for (let d = 0; d < 4; d++) {
|
|
|
let n: number;
|
|
|
+ let nx = x;
|
|
|
+ let ny = y;
|
|
|
if (d === 0) {
|
|
|
- if (i < width) continue;
|
|
|
+ if (y === 0) continue;
|
|
|
n = i - width;
|
|
|
+ ny--;
|
|
|
} else if (d === 1) {
|
|
|
if (x === 0) continue;
|
|
|
n = i - 1;
|
|
|
+ nx--;
|
|
|
} else if (d === 2) {
|
|
|
- if (x === width - 1) continue;
|
|
|
+ if (x === maxX) continue;
|
|
|
n = i + 1;
|
|
|
+ nx++;
|
|
|
} else {
|
|
|
- if (i >= last) continue;
|
|
|
+ if (y === maxY) continue;
|
|
|
n = i + width;
|
|
|
+ ny++;
|
|
|
}
|
|
|
- if (data[n] === MATERIAL_EMPTY || seen[n] === search) continue;
|
|
|
+ if (data[n] === EMPTY || seen[n] === search) continue;
|
|
|
if (known[n] === anchoredMark) {
|
|
|
anchored = true;
|
|
|
- break search;
|
|
|
+ break walk;
|
|
|
}
|
|
|
seen[n] = search;
|
|
|
- pushed[count++] = n;
|
|
|
- if (deep) stack[top++] = n;
|
|
|
+ pushed[count] = n;
|
|
|
+ pushedX[count] = nx;
|
|
|
+ pushedY[count] = ny;
|
|
|
+ if (deep) stack[top++] = count;
|
|
|
+ count++;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // ---- Phase 3: the walk ran out of budget, so this is a big component (or a long winding
|
|
|
+ // path to an anchor). Start over with a scanline fill: revisiting the walked pixels is cheap
|
|
|
+ // there, and a fresh, standard fill is several times faster than resuming from the walk's
|
|
|
+ // scattered frontier.
|
|
|
+ if (!anchored && deep && top > 0) {
|
|
|
+ for (let q = 0; q < count; q++) known[pushed[q]] = 0; // nothing learned yet
|
|
|
+ count = this.scanFill(start, sy, ++this.buffers.searchId, anchoredMark);
|
|
|
+ if (count < 0) {
|
|
|
+ anchored = true;
|
|
|
+ count = -count;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
if (anchored) {
|
|
|
- for (let k = 0; k < count; k++) known[pushed[k]] = anchoredMark;
|
|
|
+ for (let q = 0; q < count; q++) known[pushed[q]] = anchoredMark;
|
|
|
return null;
|
|
|
}
|
|
|
|
|
|
// Exhausted without meeting an anchor: detached.
|
|
|
- let minX = width;
|
|
|
- let minY = height;
|
|
|
- let maxX = -1;
|
|
|
- let maxY = -1;
|
|
|
- for (let k = 0; k < count; k++) {
|
|
|
- const i = pushed[k];
|
|
|
- known[i] = anchoredMark + 1;
|
|
|
- const x = i % width;
|
|
|
- const y = (i - x) / width;
|
|
|
- if (x < minX) minX = x;
|
|
|
- if (x > maxX) maxX = x;
|
|
|
- if (y < minY) minY = y;
|
|
|
- if (y > maxY) maxY = y;
|
|
|
+ let minBX = maxX;
|
|
|
+ let maxBX = 0;
|
|
|
+ let minBY = maxY;
|
|
|
+ let maxBY = 0;
|
|
|
+ for (let q = 0; q < count; q++) {
|
|
|
+ const x = pushedX[q];
|
|
|
+ const y = pushedY[q];
|
|
|
+ if (x < minBX) minBX = x;
|
|
|
+ if (x > maxBX) maxBX = x;
|
|
|
+ if (y < minBY) minBY = y;
|
|
|
+ if (y > maxBY) maxBY = y;
|
|
|
}
|
|
|
- const w = maxX - minX + 1;
|
|
|
- const h = maxY - minY + 1;
|
|
|
+ const w = maxBX - minBX + 1;
|
|
|
+ const h = maxBY - minBY + 1;
|
|
|
const mask = new Uint8Array(w * h);
|
|
|
- for (let k = 0; k < count; k++) {
|
|
|
- const i = pushed[k];
|
|
|
- const x = i % width;
|
|
|
- const y = (i - x) / width;
|
|
|
- mask[(y - minY) * w + (x - minX)] = data[i];
|
|
|
+ const detachedMark = anchoredMark + 1;
|
|
|
+ for (let q = 0; q < count; q++) {
|
|
|
+ const i = pushed[q];
|
|
|
+ known[i] = detachedMark;
|
|
|
+ mask[(pushedY[q] - minBY) * w + (pushedX[q] - minBX)] = data[i];
|
|
|
+ }
|
|
|
+ return { bounds: { x0: minBX, y0: minBY, x1: maxBX + 1, y1: maxBY + 1 }, mask, pixelCount: count, region: 0 };
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Phase 3 of explore(): standard scanline fill of the component of `start` (row `startY`),
|
|
|
+ * with a fresh `search` stamp. Writes the pixels it reaches to `pushed` from index 0 and
|
|
|
+ * returns their count, negated if an anchor (plate or known anchored pixel) was met.
|
|
|
+ */
|
|
|
+ private scanFill(start: number, startY: number, search: number, anchoredMark: number): number {
|
|
|
+ const { width, height, data } = this.bitmap;
|
|
|
+ const { seen, known, pushed, pushedX, pushedY, stack, stackY } = this;
|
|
|
+ const maxX = width - 1;
|
|
|
+ const maxY = height - 1;
|
|
|
+ let count = 0;
|
|
|
+ let top = 0;
|
|
|
+ stack[top] = start;
|
|
|
+ stackY[top++] = startY;
|
|
|
+ while (top > 0) {
|
|
|
+ const y = stackY[--top];
|
|
|
+ const s0 = stack[top];
|
|
|
+ if (seen[s0] === search) continue; // already filled by another run
|
|
|
+ const row = y * width;
|
|
|
+ let l = s0 - row;
|
|
|
+ while (l > 0 && data[row + l - 1] !== EMPTY && seen[row + l - 1] !== search) l--;
|
|
|
+ let r = s0 - row;
|
|
|
+ while (r < maxX && data[row + r + 1] !== EMPTY && seen[row + r + 1] !== search) r++;
|
|
|
+ for (let x = l; x <= r; x++) {
|
|
|
+ const n = row + x;
|
|
|
+ if (data[n] === PLATE || known[n] === anchoredMark) return -count;
|
|
|
+ seen[n] = search;
|
|
|
+ pushed[count] = n;
|
|
|
+ pushedX[count] = x;
|
|
|
+ pushedY[count] = y;
|
|
|
+ count++;
|
|
|
+ }
|
|
|
+ if (count > MAX_PIXELS) return -count; // too big to fall: anchored
|
|
|
+ // Seed the runs touching this one: above first, so rows below are explored first.
|
|
|
+ if (y > 0) {
|
|
|
+ const nrow = row - width;
|
|
|
+ let inRun = false;
|
|
|
+ for (let x = l; x <= r; x++) {
|
|
|
+ const n = nrow + x;
|
|
|
+ if (data[n] === EMPTY || seen[n] === search) {
|
|
|
+ inRun = false;
|
|
|
+ } else if (known[n] === anchoredMark) {
|
|
|
+ return -count;
|
|
|
+ } else if (!inRun) {
|
|
|
+ stack[top] = n;
|
|
|
+ stackY[top++] = y - 1;
|
|
|
+ inRun = true;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (y < maxY) {
|
|
|
+ const nrow = row + width;
|
|
|
+ let inRun = false;
|
|
|
+ for (let x = l; x <= r; x++) {
|
|
|
+ const n = nrow + x;
|
|
|
+ if (data[n] === EMPTY || seen[n] === search) {
|
|
|
+ inRun = false;
|
|
|
+ } else if (known[n] === anchoredMark) {
|
|
|
+ return -count;
|
|
|
+ } else if (!inRun) {
|
|
|
+ stack[top] = n;
|
|
|
+ stackY[top++] = y + 1;
|
|
|
+ inRun = true;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
}
|
|
|
- return { bounds: { x0: minX, y0: minY, x1: maxX + 1, y1: maxY + 1 }, mask, pixelCount: count };
|
|
|
+ return count;
|
|
|
}
|
|
|
}
|