From 9415162f51e8ca08f6405937d07f95c24bac15be Mon Sep 17 00:00:00 2001 From: DevelopingTom Date: Fri, 23 Jan 2026 04:19:51 +0100 Subject: [PATCH] Split railroads when placing overlapping structures (#3003) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- src/core/game/RailNetworkImpl.ts | 67 ++++++++++++++++++- src/core/game/Railroad.ts | 20 ++++++ src/core/game/RailroadSpatialGrid.ts | 97 ++++++++++++++++++++++++++++ tests/core/game/RailNetwork.test.ts | 2 + 4 files changed, 184 insertions(+), 2 deletions(-) create mode 100644 src/core/game/RailroadSpatialGrid.ts diff --git a/src/core/game/RailNetworkImpl.ts b/src/core/game/RailNetworkImpl.ts index d3aef952b..b56599beb 100644 --- a/src/core/game/RailNetworkImpl.ts +++ b/src/core/game/RailNetworkImpl.ts @@ -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(); + 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; diff --git a/src/core/game/Railroad.ts b/src/core/game/Railroad.ts index 8b0f3086a..00b5d9c37 100644 --- a/src/core/game/Railroad.ts +++ b/src/core/game/Railroad.ts @@ -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( diff --git a/src/core/game/RailroadSpatialGrid.ts b/src/core/game/RailroadSpatialGrid.ts new file mode 100644 index 000000000..27e57fcae --- /dev/null +++ b/src/core/game/RailroadSpatialGrid.ts @@ -0,0 +1,97 @@ +import { GameMap, TileRef } from "./GameMap"; +import { Railroad } from "./Railroad"; + +export class RailSpatialGrid { + private cells = new Map>(); + // Quick access to avoid iterating over the cells + private railToCells = new Map>(); + + 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(); + + 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 { + 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(); + + 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), + }; + } +} diff --git a/tests/core/game/RailNetwork.test.ts b/tests/core/game/RailNetwork.test.ts index d77ca10dd..fc39db81d 100644 --- a/tests/core/game/RailNetwork.test.ts +++ b/tests/core/game/RailNetwork.test.ts @@ -71,6 +71,8 @@ describe("RailNetworkImpl", () => { trainStationMinRange: () => 10, railroadMaxSize: () => 100, }), + x: vi.fn(() => 0), + y: vi.fn(() => 0), }; network = new RailNetworkImpl(game, stationManager, pathService);