Files
OpenFrontIO/src/core/game/WaterManager.ts
T
FloPinguin 7f7cbba12f Water-Nukes 💧 (#3604)
## Description:

Adds a new `waterNukes` game config option that causes nuclear
detonations to convert land tiles into water instead of just leaving
fallout. When enabled, nuked land tiles are batched and converted to
water each tick, with full terrain metadata updates including:

- Ocean bit propagation from adjacent ocean tiles (BFS flood fill)
- Magnitude recomputation via BFS from remaining coastlines
- Shoreline bit fix-up in a 2-ring neighborhood around converted tiles
- Minimap terrain sync (majority-rule downsampling)
- Throttled water navigation graph rebuild (every 20 ticks) for ship
pathfinding
- Ship executions detect graph rebuilds and refresh their pathfinders
- TransportShips auto-retreat if their destination becomes water
- Water nuke craters use a smoothed angular noise ring with a
bounding-box scan instead of the regular per-tile random coin flip with
BFS, producing clean blob-shaped craters without scattered land pixels
that players would have to boat to individually

The `TerrainLayer` now incrementally repaints tiles that changed terrain
type, and tile update packets encode the terrain byte alongside tile
state so clients can reflect water conversions in real time.

When `waterNukes` is disabled, behavior is unchanged (fallout only).

Includes a new test suite (WaterNukes.test.ts) covering the conversion
pipeline, ocean propagation, magnitude recalculation, shoreline updates,
and minimap sync.

Also adds a new public game modifier for the special rotation.

### The only problem
A bit of lag on impact. But otherwise it works great and is fun. Maybe
needs some followup improvements if it gets merged.
I think its very cool in baikal / four islands team games. Chip away the
territory of your opponents.
Its also fun to turn The Box / Alps into a water map (its actually
possible to boat-trade then)

### Media

Video does not show the updated craters


https://github.com/user-attachments/assets/aed8bf08-0e94-4484-b997-4de11ae313d9

Updated craters (no tiny islands after impact):

<img width="1920" height="1080" alt="image"
src="https://github.com/user-attachments/assets/e896870b-bc9d-493d-8bc8-b3a5427d69d3"
/>

<img width="1472" height="920" alt="image"
src="https://github.com/user-attachments/assets/677065aa-0159-48cd-af44-a91b0f57adfc"
/>

<img width="1296" height="892" alt="image"
src="https://github.com/user-attachments/assets/886ffaba-541f-4e46-97c6-ce963f632fe0"
/>

## 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
2026-04-08 20:56:02 -07:00

429 lines
14 KiB
TypeScript

import {
AbstractGraph,
AbstractGraphBuilder,
} from "../pathfinding/algorithms/AbstractGraph";
import { AStarWaterHierarchical } from "../pathfinding/algorithms/AStar.WaterHierarchical";
import { PathFinder } from "../pathfinding/types";
import { GameMap, TileRef } from "./GameMap";
const WATER_GRAPH_REBUILD_INTERVAL = 20;
export class WaterManager {
private _miniWaterGraph: AbstractGraph | null = null;
private _miniWaterHPA: AStarWaterHierarchical | null = null;
private _waterGraphVersion: number = 0;
private _waterGraphDirty: boolean = false;
private _waterGraphLastRebuildTick: number = 0;
private _pendingWaterTiles: Set<TileRef> = new Set();
// Reusable stamp-based distance tracking for magnitude BFS (avoids allocation per nuke)
private _waterDistArr: Uint16Array | null = null;
private _waterStampArr: Uint16Array | null = null;
private _waterStamp: number = 0;
constructor(
private map: GameMap,
private miniMap: GameMap,
private disableNavMesh: boolean,
) {
if (!disableNavMesh) {
const graphBuilder = new AbstractGraphBuilder(miniMap);
this._miniWaterGraph = graphBuilder.build();
this._miniWaterHPA = new AStarWaterHierarchical(
miniMap,
this._miniWaterGraph,
{ cachePaths: true },
);
}
}
queueTile(tile: TileRef): void {
this._pendingWaterTiles.add(tile);
}
/**
* Flush pending water conversions, run terrain fixup (ocean/magnitude/shoreline/minimap),
* and throttled graph rebuild. Returns tiles whose terrain changed (for recording).
*/
tick(currentTick: number): TileRef[] {
const changedTiles: TileRef[] = [];
if (this._pendingWaterTiles.size > 0) {
const converted: TileRef[] = [];
for (const tile of this._pendingWaterTiles) {
// Tile may have been conquered between queueing and flushing
if (this.map.isLand(tile) && !this.map.hasOwner(tile)) {
if (this.map.hasFallout(tile)) {
this.map.setFallout(tile, false);
}
this.map.setWater(tile);
converted.push(tile);
}
}
this._pendingWaterTiles.clear();
if (converted.length > 0) {
this.finalizeWaterChanges(converted, changedTiles);
}
}
// Throttled water graph rebuild: at most once every 20 ticks
if (
this._waterGraphDirty &&
!this.disableNavMesh &&
currentTick - this._waterGraphLastRebuildTick >=
WATER_GRAPH_REBUILD_INTERVAL
) {
this._waterGraphDirty = false;
this._waterGraphLastRebuildTick = currentTick;
const graphBuilder = new AbstractGraphBuilder(this.miniMap);
this._miniWaterGraph = graphBuilder.build();
this._miniWaterHPA = new AStarWaterHierarchical(
this.miniMap,
this._miniWaterGraph,
{ cachePaths: true },
);
this._waterGraphVersion++;
}
return changedTiles;
}
waterGraphVersion(): number {
return this._waterGraphVersion;
}
miniWaterHPA(): PathFinder<number> | null {
return this._miniWaterHPA;
}
miniWaterGraph(): AbstractGraph | null {
return this._miniWaterGraph;
}
getWaterComponent(tile: TileRef): number | null {
// Permissive fallback for tests with disableNavMesh
if (!this._miniWaterGraph) return 0;
const miniX = Math.floor(this.map.x(tile) / 2);
const miniY = Math.floor(this.map.y(tile) / 2);
const miniTile = this.miniMap.ref(miniX, miniY);
if (this.miniMap.isWater(miniTile)) {
return this._miniWaterGraph.getComponentId(miniTile);
}
// Shore tile: find water neighbor (expand search for minimap resolution loss)
for (const n of this.miniMap.neighbors(miniTile)) {
if (this.miniMap.isWater(n)) {
return this._miniWaterGraph.getComponentId(n);
}
}
// Extended search: check 2-hop neighbors for narrow straits
for (const n of this.miniMap.neighbors(miniTile)) {
for (const n2 of this.miniMap.neighbors(n)) {
if (this.miniMap.isWater(n2)) {
return this._miniWaterGraph.getComponentId(n2);
}
}
}
return null;
}
hasWaterComponent(tile: TileRef, component: number): boolean {
// Permissive fallback for tests with disableNavMesh
if (!this._miniWaterGraph) return true;
const miniX = Math.floor(this.map.x(tile) / 2);
const miniY = Math.floor(this.map.y(tile) / 2);
const miniTile = this.miniMap.ref(miniX, miniY);
// Check miniTile itself (shore in full map may be water in minimap)
if (
this.miniMap.isWater(miniTile) &&
this._miniWaterGraph.getComponentId(miniTile) === component
) {
return true;
}
// Check neighbors
for (const n of this.miniMap.neighbors(miniTile)) {
if (
this.miniMap.isWater(n) &&
this._miniWaterGraph.getComponentId(n) === component
) {
return true;
}
}
// Extended search: check 2-hop neighbors for narrow straits
for (const n of this.miniMap.neighbors(miniTile)) {
for (const n2 of this.miniMap.neighbors(n)) {
if (
this.miniMap.isWater(n2) &&
this._miniWaterGraph.getComponentId(n2) === component
) {
return true;
}
}
}
return false;
}
private finalizeWaterChanges(
convertedTiles: TileRef[],
changedTiles: TileRef[],
): void {
const converted = new Set<TileRef>(convertedTiles);
if (converted.size === 0) return;
const map = this.map;
const w = map.width();
const totalTiles = w * map.height();
// Track changed tiles in a set for dedup, drain into output at end
const changed = new Set<TileRef>();
// All converted tiles definitely changed (they just became water).
for (const tile of converted) changed.add(tile);
// Inline neighbor helper (no allocation, cardinal only)
const pushNeighbors = (
tile: TileRef,
out: TileRef[],
start: number,
): number => {
if (tile >= w) out[start++] = (tile - w) as TileRef;
if (tile < totalTiles - w) out[start++] = (tile + w) as TileRef;
const x = tile % w;
if (x > 0) out[start++] = (tile - 1) as TileRef;
if (x < w - 1) out[start++] = (tile + 1) as TileRef;
return start;
};
// Reusable scratch buffer for neighbors.
const nb: TileRef[] = new Array(8);
// ── 1. Propagate ocean bit ─────────────────────────────────────
const oceanQueue: TileRef[] = [];
for (const tile of converted) {
const end = pushNeighbors(tile, nb, 0);
for (let i = 0; i < end; i++) {
if (!converted.has(nb[i]) && map.isOcean(nb[i])) {
map.setOcean(tile);
oceanQueue.push(tile);
break;
}
}
}
// If no converted tile is adjacent to existing ocean (e.g. all-land map),
// mark all converted tiles as ocean so they're navigable for ports/boats.
if (oceanQueue.length === 0) {
for (const tile of converted) {
map.setOcean(tile);
oceanQueue.push(tile);
}
}
let oHead = 0;
while (oHead < oceanQueue.length) {
const tile = oceanQueue[oHead++];
const end = pushNeighbors(tile, nb, 0);
for (let i = 0; i < end; i++) {
if (map.isWater(nb[i]) && !map.isOcean(nb[i])) {
map.setOcean(nb[i]);
changed.add(nb[i]);
oceanQueue.push(nb[i]);
}
}
}
// ── 2. Recompute magnitude via BFS from remaining land outward ─
if (!this._waterDistArr || this._waterDistArr.length !== totalTiles) {
this._waterDistArr = new Uint16Array(totalTiles);
this._waterStampArr = new Uint16Array(totalTiles);
this._waterStamp = 0;
}
this._waterStamp++;
if (this._waterStamp >= 0xffff) {
this._waterStampArr!.fill(0);
this._waterStamp = 1;
}
const stamp = this._waterStamp;
const stampArr = this._waterStampArr!;
const distArr = this._waterDistArr;
const magQueue: TileRef[] = [];
// Seed candidates: converted tiles + their immediate water neighbors
const seedCandidates = new Set<TileRef>(converted);
for (const tile of converted) {
const end = pushNeighbors(tile, nb, 0);
for (let i = 0; i < end; i++) {
if (map.isWater(nb[i]) && !converted.has(nb[i])) {
seedCandidates.add(nb[i]);
}
}
}
// Seed: water tiles adjacent to remaining land get distance 0
for (const tile of seedCandidates) {
const end = pushNeighbors(tile, nb, 0);
for (let i = 0; i < end; i++) {
if (map.isLand(nb[i])) {
if (stampArr[tile] !== stamp) {
stampArr[tile] = stamp;
distArr[tile] = 0;
if (map.magnitude(tile) !== 0) {
map.setMagnitude(tile, 0);
changed.add(tile);
}
magQueue.push(tile);
}
break;
}
}
}
// BFS outward through water, stopping at convergence.
let magHead = 0;
while (magHead < magQueue.length) {
const tile = magQueue[magHead++];
const dist = distArr[tile];
const nextDist = dist + 1;
const nextMag = Math.min(Math.ceil(nextDist / 2), 31);
const end = pushNeighbors(tile, nb, 0);
for (let i = 0; i < end; i++) {
const n = nb[i];
if (!map.isWater(n) || stampArr[n] === stamp) continue;
const oldMag = map.magnitude(n);
if (oldMag === nextMag && !seedCandidates.has(n)) continue;
stampArr[n] = stamp;
distArr[n] = nextDist;
magQueue.push(n);
if (oldMag !== nextMag) {
map.setMagnitude(n, nextMag);
changed.add(n);
}
}
}
// Phase 2: unreached seed candidates (fully destroyed island)
const MAX_DEEP_DIST = 30;
const DEEP_OCEAN_MAGNITUDE = 20;
const deepQueue: TileRef[] = [];
for (const tile of seedCandidates) {
if (stampArr[tile] !== stamp && map.isWater(tile)) {
stampArr[tile] = stamp;
distArr[tile] = 0;
if (map.magnitude(tile) !== DEEP_OCEAN_MAGNITUDE) {
map.setMagnitude(tile, DEEP_OCEAN_MAGNITUDE);
changed.add(tile);
}
deepQueue.push(tile);
}
}
let deepHead = 0;
while (deepHead < deepQueue.length) {
const tile = deepQueue[deepHead++];
const dist = distArr[tile];
if (dist >= MAX_DEEP_DIST) continue;
const end = pushNeighbors(tile, nb, 0);
for (let i = 0; i < end; i++) {
const n = nb[i];
if (!map.isWater(n) || stampArr[n] === stamp) continue;
const oldMag = map.magnitude(n);
if (oldMag >= DEEP_OCEAN_MAGNITUDE) continue;
stampArr[n] = stamp;
distArr[n] = dist + 1;
map.setMagnitude(n, DEEP_OCEAN_MAGNITUDE);
changed.add(n);
deepQueue.push(n);
}
}
// ── 3. Fix shoreline bits ──────────────────────────────────────
const tilesToCheck = new Set<TileRef>();
for (const tile of converted) {
tilesToCheck.add(tile);
const end = pushNeighbors(tile, nb, 0);
for (let i = 0; i < end; i++) {
tilesToCheck.add(nb[i]);
const end2 = pushNeighbors(nb[i], nb, end);
for (let j = end; j < end2; j++) {
tilesToCheck.add(nb[j]);
}
}
}
for (let i = 0; i < magQueue.length; i++) {
const tile = magQueue[i];
tilesToCheck.add(tile);
const end = pushNeighbors(tile, nb, 0);
for (let j = 0; j < end; j++) {
tilesToCheck.add(nb[j]);
}
}
for (const tile of tilesToCheck) {
const tileIsLand = map.isLand(tile);
let hasOpposite = false;
const end = pushNeighbors(tile, nb, 0);
for (let i = 0; i < end; i++) {
if (map.isLand(nb[i]) !== tileIsLand) {
hasOpposite = true;
break;
}
}
const oldShoreline = map.isShoreline(tile);
if (hasOpposite) {
if (!oldShoreline) {
map.setShorelineBit(tile);
changed.add(tile);
}
} else {
if (oldShoreline) {
map.clearShorelineBit(tile);
changed.add(tile);
}
}
}
// ── 4. Update minimap terrain ──────────────────────────────────
const miniTilesToCheck = new Set<TileRef>();
const convertedMiniTiles = new Set<TileRef>();
for (const tile of converted) {
const miniX = Math.floor(map.x(tile) / 2);
const miniY = Math.floor(map.y(tile) / 2);
if (this.miniMap.isValidCoord(miniX, miniY)) {
miniTilesToCheck.add(this.miniMap.ref(miniX, miniY));
}
}
for (const miniTile of miniTilesToCheck) {
if (!this.miniMap.isLand(miniTile)) continue;
const fx = this.miniMap.x(miniTile) * 2;
const fy = this.miniMap.y(miniTile) * 2;
let waterCount = 0;
let totalCount = 0;
for (let dy = 0; dy < 2; dy++) {
for (let dx = 0; dx < 2; dx++) {
if (map.isValidCoord(fx + dx, fy + dy)) {
totalCount++;
if (map.isWater(map.ref(fx + dx, fy + dy))) {
waterCount++;
}
}
}
}
if (waterCount >= Math.min(3, totalCount)) {
this.miniMap.setWater(miniTile);
convertedMiniTiles.add(miniTile);
}
}
// ── 5. Mark water graph dirty (rebuilt lazily, throttled) ─────
if (convertedMiniTiles.size > 0) {
this._waterGraphDirty = true;
}
// Drain changed set into output array
for (const tile of changed) {
changedTiles.push(tile);
}
}
}