From f1f63ec9b485140a5e2221969d1f050f8827afd0 Mon Sep 17 00:00:00 2001 From: FloPinguin <25036848+FloPinguin@users.noreply.github.com> Date: Thu, 16 Apr 2026 01:17:40 +0200 Subject: [PATCH] =?UTF-8?q?Partial=20Pathfinding=20Rebuild=20=F0=9F=92=A7?= =?UTF-8?q?=20(#3689)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description: Another big water nukes performance improvement. Performance measurements (water-nuke-detonation on GWM): image 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 --- src/core/game/WaterManager.ts | 12 +- .../pathfinding/algorithms/AbstractGraph.ts | 115 ++++++++++++++++++ 2 files changed, 126 insertions(+), 1 deletion(-) diff --git a/src/core/game/WaterManager.ts b/src/core/game/WaterManager.ts index 9508a40ba..4af979fc6 100644 --- a/src/core/game/WaterManager.ts +++ b/src/core/game/WaterManager.ts @@ -16,6 +16,7 @@ export class WaterManager { private _waterGraphLastRebuildTick: number = 0; private _pendingWaterTiles: Set = new Set(); + private _dirtyMiniTiles: Set = 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 diff --git a/src/core/pathfinding/algorithms/AbstractGraph.ts b/src/core/pathfinding/algorithms/AbstractGraph.ts index 3ab83b5ce..e1cd806c5 100644 --- a/src/core/pathfinding/algorithms/AbstractGraph.ts +++ b/src/core/pathfinding/algorithms/AbstractGraph.ts @@ -217,9 +217,15 @@ export class AbstractGraphBuilder { private nextEdgeId = 0; private edgeBetween = new Map>(); + // Partial rebuild state + private cleanClusters: Set | null = null; + private oldEdgeCosts: Map> | null = null; + constructor( private readonly map: GameMap, private readonly clusterSize: number = AbstractGraphBuilder.CLUSTER_SIZE, + private readonly oldGraph?: AbstractGraph, + private readonly dirtyMiniTiles?: Set, ) { 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(); + 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(); + 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); + } + } + } + } }