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

<img width="695" height="430" alt="image"
src="https://github.com/user-attachments/assets/272efbcc-4185-426a-921c-7fae61f6c462"
/>


## 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
This commit is contained in:
Mattia Migliorini
2026-02-18 22:44:08 +01:00
committed by GitHub
parent f7ebde1c7f
commit ba2a947061
10 changed files with 309 additions and 18 deletions
+4 -2
View File
@@ -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(
+2
View File
@@ -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;
}
+65 -15
View File
@@ -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) {
@@ -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() {