Split railroads when placing overlapping structures (#3003)

## Description:
Players wrongly assume that building a structure over an existing
railroad will connect it properly. What actually happens is that the
structure will connect on the network with its own railroad, even if the
new railroads are overlapping over the existing network.

To address this issue, this PR splits the overlapping railroad into two
segments when a structure is built over it, and inserts the structure as
a new node in the rail graph. It does not alter the rail network
visually because the same railroad tiles are used for the new segments.

Railroad tiles are not stored directly in the map, they exist only as
edges in the rail graph, so looking for nearby rails would be terribly
inefficient. To address that, this PR introduces a new `RailSpatialGrid`
class which indexes rails on a 4×4 grid, allowing fast spatial queries.

Alternative considered: removing overlapping rails and rebuilding them
from the new structure. It would visually modify the rail network, which
may be unexpected for the player.

It's still missing a visual indicator so the player knows that the
structures has been connected properly.

### Line placement:


![snap_line](https://github.com/user-attachments/assets/f24ddd36-1594-4316-91ff-093a5cebd576)

### Multi-railroad overlap:


![snap_cross](https://github.com/user-attachments/assets/b2cc962e-6dce-4444-b689-7e04a09de603)


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

IngloriousTom
This commit is contained in:
DevelopingTom
2026-01-23 04:19:51 +01:00
committed by GitHub
parent 20c9335d47
commit 9415162f51
4 changed files with 184 additions and 2 deletions
+65 -2
View File
@@ -4,6 +4,7 @@ import { Game, Unit, UnitType } from "./Game";
import { TileRef } from "./GameMap";
import { RailNetwork } from "./RailNetwork";
import { Railroad } from "./Railroad";
import { RailSpatialGrid } from "./RailroadSpatialGrid";
import { Cluster, TrainStation } from "./TrainStation";
/**
@@ -81,12 +82,17 @@ export function createRailNetwork(game: Game): RailNetwork {
export class RailNetworkImpl implements RailNetwork {
private maxConnectionDistance: number = 4;
private stationRadius: number = 3;
private gridCellSize: number = 4;
private railGrid: RailSpatialGrid;
constructor(
private game: Game,
private _stationManager: StationManager,
private pathService: RailPathFinderService,
) {}
) {
this.railGrid = new RailSpatialGrid(game, this.gridCellSize); // 4x4 tiles spatial grid
}
stationManager(): StationManager {
return this._stationManager;
@@ -94,7 +100,9 @@ export class RailNetworkImpl implements RailNetwork {
connectStation(station: TrainStation) {
this._stationManager.addStation(station);
this.connectToNearbyStations(station);
if (!this.connectToExistingRails(station)) {
this.connectToNearbyStations(station);
}
}
removeStation(unit: Unit): void {
@@ -126,6 +134,59 @@ export class RailNetworkImpl implements RailNetwork {
return this.pathService.findStationsPath(from, to);
}
private connectToExistingRails(station: TrainStation): boolean {
const rails = this.railGrid.query(station.tile(), this.stationRadius);
const editedClusters = new Set<Cluster>();
for (const rail of rails) {
const from = rail.from;
const to = rail.to;
const closestRailIndex = rail.getClosestTileIndex(
this.game,
station.tile(),
);
if (closestRailIndex === 0 || closestRailIndex >= rail.tiles.length) {
continue;
}
// Disconnect current rail as it will become invalid
from.removeRailroad(rail);
to.removeRailroad(rail);
this.railGrid.unregister(rail);
const newRailFrom = new Railroad(
from,
station,
rail.tiles.slice(0, closestRailIndex),
);
const newRailTo = new Railroad(
station,
to,
rail.tiles.slice(closestRailIndex),
);
// New station is connected to both new rails
station.addRailroad(newRailFrom);
station.addRailroad(newRailTo);
// From and to are connected to the new segments
from.addRailroad(newRailFrom);
to.addRailroad(newRailTo);
this.railGrid.register(newRailTo);
this.railGrid.register(newRailFrom);
const cluster = from.getCluster();
if (cluster) {
cluster.addStation(station);
editedClusters.add(cluster);
}
}
// If multiple clusters own the new station, merge them into a single cluster
if (editedClusters.size > 1) {
this.mergeClusters(editedClusters);
}
return editedClusters.size !== 0;
}
private connectToNearbyStations(station: TrainStation) {
const neighbors = this.game.nearbyUnits(
station.tile(),
@@ -176,6 +237,7 @@ export class RailNetworkImpl implements RailNetwork {
private disconnectFromNetwork(station: TrainStation) {
for (const rail of station.getRailroads()) {
rail.delete(this.game);
this.railGrid.unregister(rail);
}
station.clearRailroads();
const cluster = station.getCluster();
@@ -198,6 +260,7 @@ export class RailNetworkImpl implements RailNetwork {
this.game.addExecution(new RailroadExecution(railRoad));
from.addRailroad(railRoad);
to.addRailroad(railRoad);
this.railGrid.register(railRoad);
return true;
}
return false;
+20
View File
@@ -23,6 +23,26 @@ export class Railroad {
this.from.removeRailroad(this);
this.to.removeRailroad(this);
}
getClosestTileIndex(game: Game, to: TileRef): number {
if (this.tiles.length === 0) return -1;
const toX = game.x(to);
const toY = game.y(to);
let closestIndex = 0;
let minDistSquared = Infinity;
for (let i = 0; i < this.tiles.length; i++) {
const tile = this.tiles[i];
const dx = game.x(tile) - toX;
const dy = game.y(tile) - toY;
const distSquared = dx * dx + dy * dy;
if (distSquared < minDistSquared) {
minDistSquared = distSquared;
closestIndex = i;
}
}
return closestIndex;
}
}
export function getOrientedRailroad(
+97
View File
@@ -0,0 +1,97 @@
import { GameMap, TileRef } from "./GameMap";
import { Railroad } from "./Railroad";
export class RailSpatialGrid {
private cells = new Map<string, Set<Railroad>>();
// Quick access to avoid iterating over the cells
private railToCells = new Map<Railroad, Set<string>>();
constructor(
private game: GameMap,
private cellSize: number,
) {
if (cellSize <= 0) {
throw new Error("cellSize must be > 0");
}
}
register(rail: Railroad) {
// Defensive: avoid double-registration but it should never happen
this.unregister(rail);
const railCells = new Set<string>();
for (const tile of rail.tiles) {
const { cx, cy } = this.cellOf(this.game.x(tile), this.game.y(tile));
const k = this.key(cx, cy);
if (railCells.has(k)) continue;
let set = this.cells.get(k);
if (!set) {
set = new Set();
this.cells.set(k, set);
}
railCells.add(k);
set.add(rail);
}
if (railCells.size > 0) {
this.railToCells.set(rail, railCells);
}
}
unregister(rail: Railroad) {
const keys = this.railToCells.get(rail);
if (!keys) return;
for (const k of keys) {
const set = this.cells.get(k);
if (!set) continue;
set.delete(rail);
if (set.size === 0) {
this.cells.delete(k);
}
}
this.railToCells.delete(rail);
}
query(tile: TileRef, radius: number): Set<Railroad> {
const x = this.game.x(tile);
const y = this.game.y(tile);
const minX = x - radius;
const minY = y - radius;
const maxX = x + radius;
const maxY = y + radius;
const c0 = this.cellOf(minX, minY);
const c1 = this.cellOf(maxX, maxY);
const result = new Set<Railroad>();
for (let cx = c0.cx; cx <= c1.cx; cx++) {
for (let cy = c0.cy; cy <= c1.cy; cy++) {
const set = this.cells.get(this.key(cx, cy));
if (!set) continue;
for (const rail of set) {
result.add(rail);
}
}
}
return result;
}
private key(cx: number, cy: number): string {
return `${cx}:${cy}`;
}
private cellOf(x: number, y: number): { cx: number; cy: number } {
return {
cx: Math.floor(x / this.cellSize),
cy: Math.floor(y / this.cellSize),
};
}
}
+2
View File
@@ -71,6 +71,8 @@ describe("RailNetworkImpl", () => {
trainStationMinRange: () => 10,
railroadMaxSize: () => 100,
}),
x: vi.fn(() => 0),
y: vi.fn(() => 0),
};
network = new RailNetworkImpl(game, stationManager, pathService);