From 2ee2fb97e397b1b4bc1ed5e18d65cef34cc3ec12 Mon Sep 17 00:00:00 2001 From: VariableVince <24507472+VariableVince@users.noreply.github.com> Date: Fri, 22 May 2026 11:33:09 +0200 Subject: [PATCH] WebGL: return of factory/defence post radii, and railroad highlighting when placing city/port right on top (#3981) ## Description: Show factory and defence post radius for ghost structure when placing structures from build bar (unitdisplay). Show when city/port is placed directly over existing railroad, by highlighting the railroad green. The railroad is not highlighted when instead a city/port nearby the ghost structure will be upgraded instead of placing it on the railroad. This works with the existing code in buildableUnits in PlayerImpl: it would already return an empty array [] for overlappingRailroads and for ghostRailPaths when canUpgrade is false. So the old checks for uiState for Canvas2D in BuildPreviewController weren't even needed per se, they followed the same logic as buildableUnits in PlayerImpl already did. Both changes emulate how it worked before the move to WebGL. - OverlappingRailroads now returns TileRefs instead of a railroad ID, and it does so with less allocations than the previous code. It's a determistic outcome, sorted and deduplicated. In doubt about this a bit, because it's better also in case we ever do desync checks using this data, but for the rendering it isn't needed per se and could be more performant without allocations. - Also: Cleanup obsolete Canvas2D rail highlighting state (UIState) that was superseded by GhostPreviewData. ## 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: tryout33 --- src/client/UIState.ts | 3 -- .../controllers/BuildPreviewController.ts | 34 +++++++++---------- src/client/hud/GameRenderer.ts | 2 -- src/client/render/types/Renderer.ts | 6 ++-- src/core/game/Game.ts | 2 +- src/core/game/RailNetwork.ts | 2 +- src/core/game/RailNetworkImpl.ts | 12 ++++--- tests/InputHandler.test.ts | 18 ---------- tests/core/game/RailNetwork.test.ts | 27 +++++++++++++++ 9 files changed, 57 insertions(+), 49 deletions(-) diff --git a/src/client/UIState.ts b/src/client/UIState.ts index c4f529615..90b8194fd 100644 --- a/src/client/UIState.ts +++ b/src/client/UIState.ts @@ -1,10 +1,7 @@ import { PlayerBuildableUnitType } from "../core/game/Game"; -import { TileRef } from "../core/game/GameMap"; export interface UIState { attackRatio: number; ghostStructure: PlayerBuildableUnitType | null; - overlappingRailroads: number[]; - ghostRailPaths: TileRef[][]; rocketDirectionUp: boolean; } diff --git a/src/client/controllers/BuildPreviewController.ts b/src/client/controllers/BuildPreviewController.ts index 661a65805..14b5ad3ab 100644 --- a/src/client/controllers/BuildPreviewController.ts +++ b/src/client/controllers/BuildPreviewController.ts @@ -191,15 +191,6 @@ export class BuildPreviewController implements Controller { this.ghostUnit.buildableUnit = unit; - if (unit.canUpgrade || unit.canBuild === false) { - // No rail-snap overlap for upgrades or invalid placements. - this.uiState.overlappingRailroads = []; - this.uiState.ghostRailPaths = []; - } else { - this.uiState.overlappingRailroads = unit.overlappingRailroads; - this.uiState.ghostRailPaths = unit.ghostRailPaths; - } - if (this.pendingConfirm !== null) { const ev = this.pendingConfirm; this.pendingConfirm = null; @@ -326,14 +317,22 @@ export class BuildPreviewController implements Controller { // Range circle: SAM placement preview shows targetable radius; nuke // previews show the outer blast radius at the target tile. let rangeRadius = 0; - if (u.type === UnitType.SAMLauncher) { - const level = this.resolveGhostRangeLevel(u) ?? 1; - rangeRadius = this.game.config().samRange(level); - } else if ( - u.type === UnitType.AtomBomb || - u.type === UnitType.HydrogenBomb - ) { - rangeRadius = this.game.config().nukeMagnitudes(u.type).outer; + switch (u.type) { + case UnitType.SAMLauncher: { + const level = this.resolveGhostRangeLevel(u) ?? 1; + rangeRadius = this.game.config().samRange(level); + break; + } + case UnitType.AtomBomb: + case UnitType.HydrogenBomb: + rangeRadius = this.game.config().nukeMagnitudes(u.type).outer; + break; + case UnitType.Factory: + rangeRadius = this.game.config().trainStationMaxRange(); + break; + case UnitType.DefensePost: + rangeRadius = this.game.config().defensePostRange(); + break; } return { @@ -428,7 +427,6 @@ export class BuildPreviewController implements Controller { private clearGhostStructure() { this.pendingConfirm = null; this.ghostUnit = null; - this.uiState.ghostRailPaths = []; this.lastGhostData = null; this.view.updateGhostPreview(null); this.view.updateNukeTrajectory(null); diff --git a/src/client/hud/GameRenderer.ts b/src/client/hud/GameRenderer.ts index 5ad90783a..4ef7fbfc4 100644 --- a/src/client/hud/GameRenderer.ts +++ b/src/client/hud/GameRenderer.ts @@ -51,8 +51,6 @@ export function createRenderer( const uiState: UIState = { attackRatio: 20, ghostStructure: null, - overlappingRailroads: [], - ghostRailPaths: [], rocketDirectionUp: true, }; diff --git a/src/client/render/types/Renderer.ts b/src/client/render/types/Renderer.ts index 0c99d958d..ef53602a8 100644 --- a/src/client/render/types/Renderer.ts +++ b/src/client/render/types/Renderer.ts @@ -1,3 +1,5 @@ +import type { TileRef } from "../../../core/game/GameMap"; + /** TrainType enum — numeric values matching UnitState.trainType. */ export enum TrainType { Engine = 0, @@ -152,8 +154,8 @@ export interface GhostPreviewData { canBuild: boolean; // Valid placement? canUpgrade: boolean; // Upgrading existing structure? cost: number; // Gold cost - ghostRailPaths: number[][]; // TileRef paths (City/Port only) - overlappingRailroads: number[]; // Rail IDs in snap zone + ghostRailPaths: TileRef[][]; // TileRef paths (City/Port only) + overlappingRailroads: TileRef[]; // TileRefs containing rails in snap zone ownerID: number; // Player's smallID (for color) /** Tile position of existing structure being upgraded (null if fresh build). */ upgradeTargetTile: number | null; diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index 02b0b6d38..3bf5a080b 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -985,7 +985,7 @@ export interface BuildableUnit { canUpgrade: number | false; type: PlayerBuildableUnitType; cost: Gold; - overlappingRailroads: number[]; + overlappingRailroads: TileRef[]; ghostRailPaths: TileRef[][]; } diff --git a/src/core/game/RailNetwork.ts b/src/core/game/RailNetwork.ts index c80880783..420e59c29 100644 --- a/src/core/game/RailNetwork.ts +++ b/src/core/game/RailNetwork.ts @@ -8,7 +8,7 @@ export interface RailNetwork { removeStation(unit: Unit): void; findStationsPath(from: TrainStation, to: TrainStation): TrainStation[]; stationManager(): StationManager; - overlappingRailroads(tile: TileRef): number[]; + overlappingRailroads(tile: TileRef): TileRef[]; computeGhostRailPaths(unitType: UnitType, tile: TileRef): TileRef[][]; recomputeClusters(): void; } diff --git a/src/core/game/RailNetworkImpl.ts b/src/core/game/RailNetworkImpl.ts index 43bb8f1d8..7692b9ba2 100644 --- a/src/core/game/RailNetworkImpl.ts +++ b/src/core/game/RailNetworkImpl.ts @@ -223,10 +223,14 @@ export class RailNetworkImpl implements RailNetwork { return editedClusters.size !== 0; } - overlappingRailroads(tile: TileRef): number[] { - return [...this.railGrid.query(tile, this.stationRadius)].map( - (railroad: Railroad) => railroad.id, - ); + overlappingRailroads(tile: TileRef): TileRef[] { + const tiles = new Set(); + for (const railroad of this.railGrid.query(tile, this.stationRadius)) { + for (const t of railroad.tiles) { + tiles.add(t); + } + } + return Array.from(tiles).sort((a, b) => a - b); } private canSnapToExistingRailway(tile: TileRef): boolean { diff --git a/tests/InputHandler.test.ts b/tests/InputHandler.test.ts index f0a8f2e04..b89e2f46d 100644 --- a/tests/InputHandler.test.ts +++ b/tests/InputHandler.test.ts @@ -65,8 +65,6 @@ describe("InputHandler AutoUpgrade", () => { attackRatio: 20, ghostStructure: null, rocketDirectionUp: true, - overlappingRailroads: [], - ghostRailPaths: [], }, mockCanvas, eventBus, @@ -541,8 +539,6 @@ describe("InputHandler AutoUpgrade", () => { attackRatio: 20, ghostStructure: null, rocketDirectionUp: true, - overlappingRailroads: [], - ghostRailPaths: [], } as UIState; inputHandler = new InputHandler( mockGameView, @@ -597,8 +593,6 @@ describe("InputHandler AutoUpgrade", () => { attackRatio: 20, ghostStructure: null, rocketDirectionUp: true, - overlappingRailroads: [], - ghostRailPaths: [], } as UIState; inputHandler = new InputHandler( mockGameView, @@ -649,8 +643,6 @@ describe("InputHandler AutoUpgrade", () => { attackRatio: 20, ghostStructure: null, rocketDirectionUp: true, - overlappingRailroads: [], - ghostRailPaths: [], } as UIState; inputHandler = new InputHandler( mockGameView, @@ -671,8 +663,6 @@ describe("InputHandler AutoUpgrade", () => { attackRatio: 20, ghostStructure: null, rocketDirectionUp: true, - overlappingRailroads: [], - ghostRailPaths: [], } as UIState; inputHandler = new InputHandler( mockGameView, @@ -699,8 +689,6 @@ describe("InputHandler AutoUpgrade", () => { attackRatio: 20, ghostStructure: null, rocketDirectionUp: true, - overlappingRailroads: [], - ghostRailPaths: [], } as UIState; inputHandler = new InputHandler( mockGameView, @@ -724,8 +712,6 @@ describe("InputHandler AutoUpgrade", () => { attackRatio: 20, ghostStructure: null, rocketDirectionUp: true, - overlappingRailroads: [], - ghostRailPaths: [], } as UIState; inputHandler = new InputHandler( mockGameView, @@ -752,8 +738,6 @@ describe("InputHandler AutoUpgrade", () => { attackRatio: 20, ghostStructure: null, rocketDirectionUp: true, - overlappingRailroads: [], - ghostRailPaths: [], } as UIState; }); @@ -892,8 +876,6 @@ describe("Warship box selection (Shift+drag)", () => { attackRatio: 20, ghostStructure: null, rocketDirectionUp: true, - overlappingRailroads: [], - ghostRailPaths: [], } as UIState; inputHandler = new InputHandler( mockGameView, diff --git a/tests/core/game/RailNetwork.test.ts b/tests/core/game/RailNetwork.test.ts index a2efcca17..e63fadef2 100644 --- a/tests/core/game/RailNetwork.test.ts +++ b/tests/core/game/RailNetwork.test.ts @@ -168,6 +168,33 @@ describe("RailNetworkImpl", () => { expect(neighborStation.setCluster).toHaveBeenCalled(); }); + describe("overlappingRailroads", () => { + test("returns deterministic deduplicated TileRef array", () => { + const tile = 42 as any; + const railGridMock = { + query: vi.fn( + () => new Set([{ tiles: [50, 42, 60] }, { tiles: [60, 45, 42] }]), + ), + }; + (network as any).railGrid = railGridMock; + + const result = network.overlappingRailroads(tile); + + expect(railGridMock.query).toHaveBeenCalledWith(tile, 3); + expect(result).toEqual([42, 45, 50, 60]); // Deduplicated and sorted + }); + + test("returns empty array when no railroads overlap", () => { + const tile = 42 as any; + const railGridMock = { query: vi.fn(() => new Set()) }; + (network as any).railGrid = railGridMock; + + const result = network.overlappingRailroads(tile); + + expect(result).toEqual([]); + }); + }); + describe("computeGhostRailPaths", () => { test("returns empty when snappable rails exist nearby", () => { const tile = 42 as any;