perf(UnitLayer): batch trail clears to fix O(n²) cost on mass nuke explosions (#3808)

## Description:

When multiple nukes detonated in the same tick, clearTrail was called
once per dying unit. Each call scanned all remaining units to repaint
overlapping trail tiles — O(dead × alive × trail_len) per tick.

Replace with a deferred batch: dying units are queued into
pendingTrailClears during drawUnitsCells, then flushTrailClears()
processes them all at once after the draw pass. All trail tiles are
cleared in a single loop (skipping duplicates), followed by one repaint
scan of surviving units — O((dead + alive) × trail_len).

Also fixes a minor bug in the original: the surviving unit's
relationship is now used when repainting its trail (previously the dying
unit's relationship was used, which gave wrong colors in alternate-view
mode).

## Please complete the following:

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

## Please put your Discord username so you can be contacted if a bug or
regression is found:

evan
This commit is contained in:
Evan
2026-04-30 16:57:35 -06:00
committed by GitHub
parent ccb80f4245
commit 8a638a3842
+28 -12
View File
@@ -40,6 +40,7 @@ export class UnitLayer implements Layer {
private unitTrailContext: CanvasRenderingContext2D;
private unitToTrail = new Map<UnitView, TileRef[]>();
private pendingTrailClears: UnitView[] = [];
private theme: Theme;
@@ -381,6 +382,7 @@ export class UnitLayer implements Layer {
// otherwise the sprite of a unit can be drawn on top of another unit
this.clearUnitsCells(unitsToUpdate);
this.drawUnitsCells(unitsToUpdate);
this.flushTrailClears();
}
}
@@ -546,19 +548,33 @@ export class UnitLayer implements Layer {
}
}
private clearTrail(unit: UnitView) {
const trail = this.unitToTrail.get(unit) ?? [];
const rel = this.relationship(unit);
for (const t of trail) {
this.clearCell(this.game.x(t), this.game.y(t), this.unitTrailContext);
}
this.unitToTrail.delete(unit);
private flushTrailClears() {
if (this.pendingTrailClears.length === 0) return;
// Repaint overlapping trails
const trailSet = new Set(trail);
const clearedTiles = new Set<TileRef>();
for (const unit of this.pendingTrailClears) {
const trail = this.unitToTrail.get(unit);
if (trail) {
for (const t of trail) {
if (!clearedTiles.has(t)) {
this.clearCell(
this.game.x(t),
this.game.y(t),
this.unitTrailContext,
);
clearedTiles.add(t);
}
}
this.unitToTrail.delete(unit);
}
}
this.pendingTrailClears = [];
// Single repaint pass for all remaining units
for (const [other, trail] of this.unitToTrail) {
const rel = this.relationship(other);
for (const t of trail) {
if (trailSet.has(t)) {
if (clearedTiles.has(t)) {
this.paintCell(
this.game.x(t),
this.game.y(t),
@@ -609,7 +625,7 @@ export class UnitLayer implements Layer {
);
this.drawSprite(unit);
if (!unit.isActive()) {
this.clearTrail(unit);
this.pendingTrailClears.push(unit);
}
}
@@ -652,7 +668,7 @@ export class UnitLayer implements Layer {
this.drawSprite(unit);
if (!unit.isActive()) {
this.clearTrail(unit);
this.pendingTrailClears.push(unit);
}
}