mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 09:40:44 +00:00
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:  ### Multi-railroad overlap:  ## 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:
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -71,6 +71,8 @@ describe("RailNetworkImpl", () => {
|
||||
trainStationMinRange: () => 10,
|
||||
railroadMaxSize: () => 100,
|
||||
}),
|
||||
x: vi.fn(() => 0),
|
||||
y: vi.fn(() => 0),
|
||||
};
|
||||
|
||||
network = new RailNetworkImpl(game, stationManager, pathService);
|
||||
|
||||
Reference in New Issue
Block a user