Partial Pathfinding Rebuild 💧 (#3689)

## Description:

Another big water nukes performance improvement.

Performance measurements (water-nuke-detonation on GWM):

<img width="203" height="103" alt="image"
src="https://github.com/user-attachments/assets/b3d62575-d4bd-43c9-a5af-af127d73a9c5"
/>

I did a lot of testing with throwing water nukes and sending boats in
the area, paths are looking clean & correct!

## 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:

FloPinguin
This commit is contained in:
FloPinguin
2026-04-16 01:17:40 +02:00
committed by GitHub
parent a12cb56192
commit f1f63ec9b4
2 changed files with 126 additions and 1 deletions
+11 -1
View File
@@ -16,6 +16,7 @@ export class WaterManager {
private _waterGraphLastRebuildTick: number = 0;
private _pendingWaterTiles: Set<TileRef> = new Set();
private _dirtyMiniTiles: Set<TileRef> = new Set();
// Reusable stamp-based distance tracking for magnitude BFS (avoids allocation per nuke)
private _waterDistArr: Uint16Array | null = null;
@@ -76,8 +77,14 @@ export class WaterManager {
) {
this._waterGraphDirty = false;
this._waterGraphLastRebuildTick = currentTick;
const graphBuilder = new AbstractGraphBuilder(this.miniMap);
const graphBuilder = new AbstractGraphBuilder(
this.miniMap,
AbstractGraphBuilder.CLUSTER_SIZE,
this._miniWaterGraph ?? undefined,
this._dirtyMiniTiles.size > 0 ? this._dirtyMiniTiles : undefined,
);
this._miniWaterGraph = graphBuilder.build();
this._dirtyMiniTiles.clear();
this._miniWaterHPA = new AStarWaterHierarchical(
this.miniMap,
this._miniWaterGraph,
@@ -424,6 +431,9 @@ export class WaterManager {
// ── 5. Mark water graph dirty (rebuilt lazily, throttled) ─────
if (convertedMiniTiles.size > 0) {
this._waterGraphDirty = true;
for (const mt of convertedMiniTiles) {
this._dirtyMiniTiles.add(mt);
}
}
// Drain changed set into output array
@@ -217,9 +217,15 @@ export class AbstractGraphBuilder {
private nextEdgeId = 0;
private edgeBetween = new Map<number, Map<number, AbstractEdge>>();
// Partial rebuild state
private cleanClusters: Set<number> | null = null;
private oldEdgeCosts: Map<number, Map<number, number>> | null = null;
constructor(
private readonly map: GameMap,
private readonly clusterSize: number = AbstractGraphBuilder.CLUSTER_SIZE,
private readonly oldGraph?: AbstractGraph,
private readonly dirtyMiniTiles?: Set<TileRef>,
) {
this.width = map.width();
this.height = map.height();
@@ -241,6 +247,11 @@ export class AbstractGraphBuilder {
// Initialize water components
this.waterComponents.initialize();
// Compute partial rebuild info (which clusters can skip BFS)
if (this.oldGraph && this.dirtyMiniTiles && this.dirtyMiniTiles.size > 0) {
this.computePartialRebuildInfo();
}
// Pre-create all clusters
for (let cy = 0; cy < this.clustersY; cy++) {
for (let cx = 0; cx < this.clustersX; cx++) {
@@ -421,6 +432,14 @@ export class AbstractGraphBuilder {
}
private buildClusterConnections(cx: number, cy: number): void {
const clusterKey = cy * this.clustersX + cx;
// For clean clusters, copy edge costs from old graph instead of BFS
if (this.cleanClusters?.has(clusterKey)) {
this.buildClusterConnectionsFromCache(cx, cy);
return;
}
const cluster = this.graph.getCluster(cx, cy);
if (!cluster) return;
@@ -582,4 +601,100 @@ export class AbstractGraphBuilder {
return reachable;
}
/**
* Compute which clusters are "clean" (unaffected by water changes) and
* build a lookup of old edge costs by tile-pair for fast edge recreation.
*/
private computePartialRebuildInfo(): void {
const dirtyMiniTiles = this.dirtyMiniTiles!;
const oldGraph = this.oldGraph!;
// Map dirty minimap tiles to their cluster indices
const primaryDirty = new Set<number>();
for (const tile of dirtyMiniTiles) {
const x = this.map.x(tile);
const y = this.map.y(tile);
const cx = Math.floor(x / this.clusterSize);
const cy = Math.floor(y / this.clusterSize);
primaryDirty.add(cy * this.clustersX + cx);
}
// Expand by 1-ring neighbors (gateway nodes sit on cluster boundaries
// and belong to both adjacent clusters)
const expandedDirty = new Set<number>();
for (const key of primaryDirty) {
const cy = Math.floor(key / this.clustersX);
const cx = key - cy * this.clustersX;
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
const nx = cx + dx;
const ny = cy + dy;
if (
nx >= 0 &&
nx < this.clustersX &&
ny >= 0 &&
ny < this.clustersY
) {
expandedDirty.add(ny * this.clustersX + nx);
}
}
}
}
// Everything not in the expanded dirty set is clean
this.cleanClusters = new Set();
const totalClusters = this.clustersX * this.clustersY;
for (let k = 0; k < totalClusters; k++) {
if (!expandedDirty.has(k)) this.cleanClusters.add(k);
}
// Build old edge cost lookup: (minTile, maxTile) → cost
this.oldEdgeCosts = new Map();
for (const edge of oldGraph.getAllEdges()) {
const nodeA = oldGraph.getNode(edge.nodeA);
const nodeB = oldGraph.getNode(edge.nodeB);
if (!nodeA || !nodeB) continue;
const tileMin = Math.min(nodeA.tile, nodeB.tile);
const tileMax = Math.max(nodeA.tile, nodeB.tile);
let inner = this.oldEdgeCosts.get(tileMin);
if (!inner) {
inner = new Map();
this.oldEdgeCosts.set(tileMin, inner);
}
const existing = inner.get(tileMax);
if (existing === undefined || edge.cost < existing) {
inner.set(tileMax, edge.cost);
}
}
}
/**
* For clean clusters: recreate edges by looking up costs from the old graph
* instead of running expensive BFS. The gateway nodes are at the same positions
* and the intra-cluster water topology hasn't changed.
*/
private buildClusterConnectionsFromCache(cx: number, cy: number): void {
const cluster = this.graph.getCluster(cx, cy);
if (!cluster) return;
const nodeIds = cluster.nodeIds;
const nodes = nodeIds.map((id) => this.graph.getNode(id)!);
const oldEdgeCosts = this.oldEdgeCosts!;
for (let i = 0; i < nodes.length; i++) {
for (let j = i + 1; j < nodes.length; j++) {
// Skip nodes in different water components
if (nodes[i].componentId !== nodes[j].componentId) continue;
const tileMin = Math.min(nodes[i].tile, nodes[j].tile);
const tileMax = Math.max(nodes[i].tile, nodes[j].tile);
const cost = oldEdgeCosts.get(tileMin)?.get(tileMax);
if (cost !== undefined) {
this.addOrUpdateEdge(nodes[i].id, nodes[j].id, cost, cx, cy);
}
}
}
}
}