From ba2a947061ab63523946b2900a74885edccc6f07 Mon Sep 17 00:00:00 2001 From: Mattia Migliorini Date: Wed, 18 Feb 2026 22:44:08 +0100 Subject: [PATCH] Feat: Display ghost railways when building cities and ports (#3202) ## Description: Based on [this suggestion on Discord](https://discord.com/channels/1284581928254701718/1447110257196138577) and feedback gathered in [this thread](https://discord.com/channels/1359946986937258015/1469598906173227184). Supersedes #3143 This PR introduces "ghost railways": when you are going to place a city or port, previews railway connections that will be made when actually building the structure. Ghost railways are skipped if the structure is going to be snapped to existing railways (as in railway snapping functionality introduced in #3156 ). ### Video https://github.com/user-attachments/assets/ff8cf325-6501-4df8-801d-c8ae3ced3d0e ### Ghost rails color revisited black with 40% opacity image ## 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: deshack_82603 --- src/client/graphics/GameRenderer.ts | 6 +- src/client/graphics/UIState.ts | 2 + src/client/graphics/layers/RailroadLayer.ts | 80 +++++++-- .../graphics/layers/StructureIconsLayer.ts | 5 + src/core/game/Game.ts | 1 + src/core/game/PlayerImpl.ts | 4 + src/core/game/RailNetwork.ts | 3 +- src/core/game/RailNetworkImpl.ts | 61 +++++++ tests/InputHandler.test.ts | 1 + tests/core/game/RailNetwork.test.ts | 164 ++++++++++++++++++ 10 files changed, 309 insertions(+), 18 deletions(-) diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts index 5f4ef4a37..d1e191162 100644 --- a/src/client/graphics/GameRenderer.ts +++ b/src/client/graphics/GameRenderer.ts @@ -54,11 +54,13 @@ export function createRenderer( const transformHandler = new TransformHandler(game, eventBus, canvas); const userSettings = new UserSettings(); - const uiState = { + const uiState: UIState = { attackRatio: 20, ghostStructure: null, + overlappingRailroads: [], + ghostRailPaths: [], rocketDirectionUp: true, - } as UIState; + }; //hide when the game renders const startingModal = document.querySelector( diff --git a/src/client/graphics/UIState.ts b/src/client/graphics/UIState.ts index f10094e0a..277c91d42 100644 --- a/src/client/graphics/UIState.ts +++ b/src/client/graphics/UIState.ts @@ -1,8 +1,10 @@ import { UnitType } from "../../core/game/Game"; +import { TileRef } from "../../core/game/GameMap"; export interface UIState { attackRatio: number; ghostStructure: UnitType | null; overlappingRailroads: number[]; + ghostRailPaths: TileRef[][]; rocketDirectionUp: boolean; } diff --git a/src/client/graphics/layers/RailroadLayer.ts b/src/client/graphics/layers/RailroadLayer.ts index f0c2f073d..1b3815ac6 100644 --- a/src/client/graphics/layers/RailroadLayer.ts +++ b/src/client/graphics/layers/RailroadLayer.ts @@ -199,9 +199,6 @@ export class RailroadLayer implements Layer { if (scale <= 1) { return; } - if (this.existingRailroads.size === 0) { - return; - } this.updateRailColors(); const rawAlpha = (scale - 1) / (2 - 1); // maps 1->0, 2->1 const alpha = Math.max(0, Math.min(1, rawAlpha)); @@ -228,21 +225,74 @@ export class RailroadLayer implements Layer { context.save(); context.globalAlpha = alpha; - this.highlightOverlappingRailroads(context); - context.drawImage( - this.canvas, - srcX, - srcY, - srcW, - srcH, - dstX, - dstY, - visWidth, - visHeight, - ); + + this.renderGhostRailroads(context); + + if (this.existingRailroads.size > 0) { + this.highlightOverlappingRailroads(context); + + context.drawImage( + this.canvas, + srcX, + srcY, + srcW, + srcH, + dstX, + dstY, + visWidth, + visHeight, + ); + } + context.restore(); } + private renderGhostRailroads(context: CanvasRenderingContext2D) { + if ( + this.uiState.ghostStructure !== UnitType.City && + this.uiState.ghostStructure !== UnitType.Port + ) + return; + if (this.uiState.ghostRailPaths.length === 0) return; + + const offsetX = -this.game.width() / 2; + const offsetY = -this.game.height() / 2; + context.fillStyle = "rgba(0, 0, 0, 0.4)"; + + for (const path of this.uiState.ghostRailPaths) { + const railTiles = computeRailTiles(this.game, path); + for (const railTile of railTiles) { + const x = this.game.x(railTile.tile); + const y = this.game.y(railTile.tile); + + if (this.game.isWater(railTile.tile)) { + context.save(); + context.fillStyle = "rgba(197, 69, 72, 0.4)"; + const bridgeRects = getBridgeRects(railTile.type); + for (const [dx, dy, w, h] of bridgeRects) { + context.fillRect( + x + offsetX + dx / 2, + y + offsetY + dy / 2, + w / 2, + h / 2, + ); + } + context.restore(); + } + + const railRects = getRailroadRects(railTile.type); + for (const [dx, dy, w, h] of railRects) { + context.fillRect( + x + offsetX + dx / 2, + y + offsetY + dy / 2, + w / 2, + h / 2, + ); + } + } + } + } + private onRailroadSnapEvent(update: RailroadSnapUpdate) { const original = this.railroads.get(update.originalId); if (!original) { diff --git a/src/client/graphics/layers/StructureIconsLayer.ts b/src/client/graphics/layers/StructureIconsLayer.ts index 44c8085c6..9055e47e3 100644 --- a/src/client/graphics/layers/StructureIconsLayer.ts +++ b/src/client/graphics/layers/StructureIconsLayer.ts @@ -333,13 +333,16 @@ export class StructureIconsLayer implements Layer { } // No overlapping when a structure is upgradable this.uiState.overlappingRailroads = []; + this.uiState.ghostRailPaths = []; } else if (unit.canBuild === false) { this.ghostUnit.container.filters = [ new OutlineFilter({ thickness: 2, color: "rgba(255, 0, 0, 1)" }), ]; this.uiState.overlappingRailroads = []; + this.uiState.ghostRailPaths = []; } else { this.uiState.overlappingRailroads = unit.overlappingRailroads; + this.uiState.ghostRailPaths = unit.ghostRailPaths; } const scale = this.transformHandler.scale; @@ -459,6 +462,7 @@ export class StructureIconsLayer implements Layer { canUpgrade: false, cost: 0n, overlappingRailroads: [], + ghostRailPaths: [], }, }; const showPrice = this.game.config().userSettings().cursorCostLabel(); @@ -478,6 +482,7 @@ export class StructureIconsLayer implements Layer { this.potentialUpgrade.dotContainer.filters = []; this.potentialUpgrade = undefined; } + this.uiState.ghostRailPaths = []; } private removeGhostStructure() { diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index a1c779e02..5f17f2b01 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -852,6 +852,7 @@ export interface BuildableUnit { type: UnitType; cost: Gold; overlappingRailroads: number[]; + ghostRailPaths: TileRef[][]; } export interface PlayerProfile { diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts index 361c33135..4d6fabc56 100644 --- a/src/core/game/PlayerImpl.ts +++ b/src/core/game/PlayerImpl.ts @@ -993,6 +993,10 @@ export class PlayerImpl implements Player { canBuild !== false ? this.mg.railNetwork().overlappingRailroads(canBuild) : [], + ghostRailPaths: + canBuild !== false + ? this.mg.railNetwork().computeGhostRailPaths(u, canBuild) + : [], } as BuildableUnit; }); } diff --git a/src/core/game/RailNetwork.ts b/src/core/game/RailNetwork.ts index 7ad57c610..c80880783 100644 --- a/src/core/game/RailNetwork.ts +++ b/src/core/game/RailNetwork.ts @@ -1,4 +1,4 @@ -import { Unit } from "./Game"; +import { Unit, UnitType } from "./Game"; import { TileRef } from "./GameMap"; import { StationManager } from "./RailNetworkImpl"; import { TrainStation } from "./TrainStation"; @@ -9,5 +9,6 @@ export interface RailNetwork { findStationsPath(from: TrainStation, to: TrainStation): TrainStation[]; stationManager(): StationManager; overlappingRailroads(tile: TileRef): number[]; + computeGhostRailPaths(unitType: UnitType, tile: TileRef): TileRef[][]; recomputeClusters(): void; } diff --git a/src/core/game/RailNetworkImpl.ts b/src/core/game/RailNetworkImpl.ts index b35fb80e8..813098401 100644 --- a/src/core/game/RailNetworkImpl.ts +++ b/src/core/game/RailNetworkImpl.ts @@ -228,6 +228,67 @@ export class RailNetworkImpl implements RailNetwork { ); } + private canSnapToExistingRailway(tile: TileRef): boolean { + return this.railGrid.query(tile, this.stationRadius).size > 0; + } + + computeGhostRailPaths(unitType: UnitType, tile: TileRef): TileRef[][] { + // Factories already show their radius, so we'll exclude from ghost rails + // in order not to clutter the interface too much. + if (![UnitType.City, UnitType.Port].includes(unitType)) { + return []; + } + + if (this.canSnapToExistingRailway(tile)) { + return []; + } + + const maxRange = this.game.config().trainStationMaxRange(); + const minRangeSquared = this.game.config().trainStationMinRange() ** 2; + const maxPathSize = this.game.config().railroadMaxSize(); + + // Cannot connect if outside the max range of a factory + if (!this.game.hasUnitNearby(tile, maxRange, UnitType.Factory)) { + return []; + } + + const neighbors = this.game.nearbyUnits(tile, maxRange, [ + UnitType.City, + UnitType.Factory, + UnitType.Port, + ]); + neighbors.sort((a, b) => a.distSquared - b.distSquared); + + const paths: TileRef[][] = []; + const connectedStations: TrainStation[] = []; + for (const neighbor of neighbors) { + // Limit to the closest 5 stations to avoid running too many pathfinding calls. + if (paths.length >= 5) break; + if (neighbor.distSquared <= minRangeSquared) continue; + + const neighborStation = this._stationManager.findStation(neighbor.unit); + if (!neighborStation) continue; + + const alreadyReachable = connectedStations.some( + (s) => + this.distanceFrom( + neighborStation, + s, + this.maxConnectionDistance - 1, + ) !== -1, + ); + if (alreadyReachable) continue; + + const path = this.pathService.findTilePath(tile, neighborStation.tile()); + if (path.length > 0 && path.length < maxPathSize) { + paths.push(path); + connectedStations.push(neighborStation); + } + } + + return paths; + } + private connectToNearbyStations(station: TrainStation) { const neighbors = this.game.nearbyUnits( station.tile(), diff --git a/tests/InputHandler.test.ts b/tests/InputHandler.test.ts index 6176d2771..a49144d6a 100644 --- a/tests/InputHandler.test.ts +++ b/tests/InputHandler.test.ts @@ -39,6 +39,7 @@ describe("InputHandler AutoUpgrade", () => { ghostStructure: null, rocketDirectionUp: true, overlappingRailroads: [], + ghostRailPaths: [], }, mockCanvas, eventBus, diff --git a/tests/core/game/RailNetwork.test.ts b/tests/core/game/RailNetwork.test.ts index 70be4febd..a2efcca17 100644 --- a/tests/core/game/RailNetwork.test.ts +++ b/tests/core/game/RailNetwork.test.ts @@ -65,6 +65,7 @@ describe("RailNetworkImpl", () => { findStationsPath: vi.fn(() => [0]), }; game = { + hasUnitNearby: vi.fn(() => true), nearbyUnits: vi.fn(() => []), addExecution: vi.fn(), config: () => ({ @@ -166,4 +167,167 @@ describe("RailNetworkImpl", () => { expect(station.setCluster).toHaveBeenCalled(); expect(neighborStation.setCluster).toHaveBeenCalled(); }); + + describe("computeGhostRailPaths", () => { + test("returns empty when snappable rails exist nearby", () => { + const tile = 42 as any; + // Accessing private railGrid via any to set up mock + const railGridMock = { query: vi.fn(() => new Set([{}])) }; + (network as any).railGrid = railGridMock; + + const result = network.computeGhostRailPaths(UnitType.City, tile); + expect(result).toEqual([]); + expect(railGridMock.query).toHaveBeenCalledWith(tile, 3); + }); + + test("returns empty when no nearby stations found", () => { + const tile = 42 as any; + const railGridMock = { query: vi.fn(() => new Set()) }; + (network as any).railGrid = railGridMock; + game.nearbyUnits.mockReturnValue([]); + + const result = network.computeGhostRailPaths(UnitType.City, tile); + expect(result).toEqual([]); + }); + + test("returns paths to nearby stations within range", () => { + const tile = 42 as any; + const railGridMock = { query: vi.fn(() => new Set()) }; + (network as any).railGrid = railGridMock; + + const neighborStation = createMockStation(1); + neighborStation.tile.mockReturnValue(100); + stationManager.findStation.mockReturnValue(neighborStation); + + const mockPath = [42, 50, 60, 100]; + pathService.findTilePath.mockReturnValue(mockPath); + + game.nearbyUnits.mockReturnValue([ + { unit: neighborStation.unit, distSquared: 400 }, + ]); + + const result = network.computeGhostRailPaths(UnitType.City, tile); + expect(result).toEqual([mockPath]); + expect(pathService.findTilePath).toHaveBeenCalledWith(tile, 100); + }); + + test("skips neighbors within min range", () => { + const tile = 42 as any; + const railGridMock = { query: vi.fn(() => new Set()) }; + (network as any).railGrid = railGridMock; + + const neighborStation = createMockStation(1); + neighborStation.tile.mockReturnValue(43); + stationManager.findStation.mockReturnValue(neighborStation); + + // distSquared = 50 <= minRange^2 (10^2 = 100) + game.nearbyUnits.mockReturnValue([ + { unit: neighborStation.unit, distSquared: 50 }, + ]); + + const result = network.computeGhostRailPaths(UnitType.City, tile); + expect(result).toEqual([]); + }); + + test("skips neighbors without train stations", () => { + const tile = 42 as any; + const railGridMock = { query: vi.fn(() => new Set()) }; + (network as any).railGrid = railGridMock; + + stationManager.findStation.mockReturnValue(null); + + game.nearbyUnits.mockReturnValue([{ unit: { id: 1 }, distSquared: 400 }]); + + const result = network.computeGhostRailPaths(UnitType.City, tile); + expect(result).toEqual([]); + }); + + test("skips paths that exceed max railroad size", () => { + const tile = 42 as any; + const railGridMock = { query: vi.fn(() => new Set()) }; + (network as any).railGrid = railGridMock; + + const neighborStation = createMockStation(1); + neighborStation.tile.mockReturnValue(100); + stationManager.findStation.mockReturnValue(neighborStation); + + // Path length >= railroadMaxSize (100) + pathService.findTilePath.mockReturnValue(new Array(100)); + + game.nearbyUnits.mockReturnValue([ + { unit: neighborStation.unit, distSquared: 400 }, + ]); + + const result = network.computeGhostRailPaths(UnitType.City, tile); + expect(result).toEqual([]); + }); + + test("limits to at most 5 paths", () => { + const tile = 42 as any; + const railGridMock = { query: vi.fn(() => new Set()) }; + (network as any).railGrid = railGridMock; + + const neighbors: Array<{ unit: any; distSquared: number }> = []; + for (let i = 0; i < 7; i++) { + const station = createMockStation(i); + station.tile.mockReturnValue(100 + i); + neighbors.push({ unit: station.unit, distSquared: 400 + i }); + } + + stationManager.findStation.mockImplementation((unit: any) => { + const station = createMockStation(unit.id); + station.tile.mockReturnValue(100 + unit.id); + return station; + }); + + pathService.findTilePath.mockImplementation((_from: any, to: any) => [ + _from, + to, + ]); + + game.nearbyUnits.mockReturnValue(neighbors); + + const result = network.computeGhostRailPaths(UnitType.City, tile); + expect(result.length).toBe(5); + }); + + test("skips stations reachable through already-connected stations", () => { + const tile = 42 as any; + const railGridMock = { query: vi.fn(() => new Set()) }; + (network as any).railGrid = railGridMock; + + // Create two neighbor stations where B is reachable from A + const stationA = createMockStation(1); + stationA.tile.mockReturnValue(100); + const stationB = createMockStation(2); + stationB.tile.mockReturnValue(200); + + // Make A and B neighbors of each other (1 hop apart) + stationA.neighbors.mockReturnValue([stationB]); + stationB.neighbors.mockReturnValue([stationA]); + + stationManager.findStation.mockImplementation((unit: any) => { + if (unit.id === 1) return stationA; + if (unit.id === 2) return stationB; + return null; + }); + + pathService.findTilePath.mockImplementation((_from: any, to: any) => [ + _from, + to, + ]); + + // Station A is closer, station B is farther + game.nearbyUnits.mockReturnValue([ + { unit: stationA.unit, distSquared: 400 }, + { unit: stationB.unit, distSquared: 900 }, + ]); + + const result = network.computeGhostRailPaths(UnitType.City, tile); + // Only station A should get a path; B is reachable from A within maxConnectionDistance - 1 + expect(result.length).toBe(1); + expect(pathService.findTilePath).toHaveBeenCalledTimes(1); + expect(pathService.findTilePath).toHaveBeenCalledWith(tile, 100); + }); + }); });