mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 09:50:43 +00:00
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user