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
This commit is contained in:
VariableVince
2026-05-22 11:33:09 +02:00
committed by GitHub
parent ff11d6b8d1
commit 2ee2fb97e3
9 changed files with 57 additions and 49 deletions
-3
View File
@@ -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;
}
@@ -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);
-2
View File
@@ -51,8 +51,6 @@ export function createRenderer(
const uiState: UIState = {
attackRatio: 20,
ghostStructure: null,
overlappingRailroads: [],
ghostRailPaths: [],
rocketDirectionUp: true,
};
+4 -2
View File
@@ -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;
+1 -1
View File
@@ -985,7 +985,7 @@ export interface BuildableUnit {
canUpgrade: number | false;
type: PlayerBuildableUnitType;
cost: Gold;
overlappingRailroads: number[];
overlappingRailroads: TileRef[];
ghostRailPaths: TileRef[][];
}
+1 -1
View File
@@ -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;
}
+8 -4
View File
@@ -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<TileRef>();
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 {
-18
View File
@@ -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,
+27
View File
@@ -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;