diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts index 5f4ef4a37d..d1e1911628 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 f10094e0ac..277c91d424 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 f0c2f073db..1b3815ac6d 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 72d7ef7f91..ca627d1f0b 100644 --- a/src/client/graphics/layers/StructureIconsLayer.ts +++ b/src/client/graphics/layers/StructureIconsLayer.ts @@ -335,13 +335,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; @@ -461,6 +464,7 @@ export class StructureIconsLayer implements Layer { canUpgrade: false, cost: 0n, overlappingRailroads: [], + ghostRailPaths: [], }, }; const showPrice = this.game.config().userSettings().cursorCostLabel(); @@ -480,6 +484,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 06f9e280d4..f3b07ae896 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -854,6 +854,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 361c331356..4d6fabc563 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 7ad57c6106..c808807838 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 b35fb80e8f..813098401f 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 6176d27715..a49144d6ab 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 70be4febd7..a2efcca172 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); + }); + }); });