Files
OpenFrontIO/src/client/graphics/layers/RailroadLayer.ts
T
Mattia Migliorini ba2a947061 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
2026-02-18 21:44:08 +00:00

481 lines
14 KiB
TypeScript

import { colord } from "colord";
import { EventBus, GameEvent } from "../../../core/EventBus";
import { PlayerID, UnitType } from "../../../core/game/Game";
import { TileRef } from "../../../core/game/GameMap";
import {
GameUpdateType,
RailroadConstructionUpdate,
RailroadDestructionUpdate,
RailroadSnapUpdate,
} from "../../../core/game/GameUpdates";
import { GameView } from "../../../core/game/GameView";
import { AlternateViewEvent } from "../../InputHandler";
import { TransformHandler } from "../TransformHandler";
import { UIState } from "../UIState";
import { Layer } from "./Layer";
import { getBridgeRects, getRailroadRects } from "./RailroadSprites";
import {
computeRailTiles,
RailroadView,
RailTile,
RailType,
} from "./RailroadView";
type RailRef = {
tile: RailTile;
numOccurence: number;
lastOwnerId: PlayerID | null;
};
const SNAPPABLE_STRUCTURES: UnitType[] = [
UnitType.Port,
UnitType.City,
UnitType.Factory,
];
export class RailTileChangedEvent implements GameEvent {
constructor(public tile: TileRef) {}
}
export class RailroadLayer implements Layer {
private canvas: HTMLCanvasElement;
private context: CanvasRenderingContext2D;
private alternativeView = false;
// Save the number of railroads per tiles. Delete when it reaches 0
private existingRailroads = new Map<TileRef, RailRef>();
private railroads = new Map<number, RailroadView>();
// Railroads under construction
private pendingRailroads = new Set<number>();
private nextRailIndexToCheck = 0;
private railTileList: TileRef[] = [];
private railTileIndex = new Map<TileRef, number>();
private lastRailColorUpdate = 0;
private readonly railColorIntervalMs = 50;
constructor(
private game: GameView,
private eventBus: EventBus,
private transformHandler: TransformHandler,
private uiState: UIState,
) {}
shouldTransform(): boolean {
return true;
}
tick() {
this.updatePendingRailroads();
const updates = this.game.updatesSinceLastTick();
if (!updates) return;
// The event has to be handled in this specific order: construction / snap / destruction
// Otherwise some ID may not be available yet/anymore
updates[GameUpdateType.RailroadConstructionEvent]?.forEach((update) => {
if (update === undefined) return;
this.onRailroadConstruction(update);
});
updates[GameUpdateType.RailroadSnapEvent]?.forEach((update) => {
if (update === undefined) return;
this.onRailroadSnapEvent(update);
});
updates[GameUpdateType.RailroadDestructionEvent]?.forEach((update) => {
if (update === undefined) return;
this.onRailroadDestruction(update);
});
}
updatePendingRailroads() {
for (const id of this.pendingRailroads) {
const pending = this.railroads.get(id);
if (pending === undefined) {
// Rail deleted or snapped before the end of the animation
this.pendingRailroads.delete(id);
continue;
}
const newTiles = pending.tick();
if (newTiles.length === 0) {
// Animation complete
this.pendingRailroads.delete(id);
continue;
}
for (const railTile of newTiles) {
this.paintRailTile(railTile);
this.eventBus.emit(new RailTileChangedEvent(railTile.tile));
}
}
}
updateRailColors() {
if (this.railTileList.length === 0) {
return;
}
// Throttle color checks so we do not re-evaluate on every frame
const now = performance.now();
if (now - this.lastRailColorUpdate < this.railColorIntervalMs) {
return;
}
this.lastRailColorUpdate = now;
// Spread work over multiple frames to avoid large bursts when many rails exist
const maxTilesPerFrame = Math.max(
1,
Math.ceil(this.railTileList.length / 120),
);
let checked = 0;
while (checked < maxTilesPerFrame && this.railTileList.length > 0) {
const tile = this.railTileList[this.nextRailIndexToCheck];
const railRef = this.existingRailroads.get(tile);
if (railRef) {
const currentOwner = this.game.owner(tile)?.id() ?? null;
if (railRef.lastOwnerId !== currentOwner) {
// Repaint only when the owner changed to keep colors in sync
railRef.lastOwnerId = currentOwner;
this.paintRail(railRef.tile);
}
}
this.nextRailIndexToCheck =
(this.nextRailIndexToCheck + 1) % this.railTileList.length;
checked++;
}
}
init() {
this.eventBus.on(AlternateViewEvent, (e) => {
this.alternativeView = e.alternateView;
for (const { tile } of this.existingRailroads.values()) {
this.paintRail(tile);
}
});
this.redraw();
}
redraw() {
this.canvas = document.createElement("canvas");
const context = this.canvas.getContext("2d", { alpha: true });
if (context === null) throw new Error("2d context not supported");
this.context = context;
// Enable smooth scaling
this.context.imageSmoothingEnabled = true;
this.context.imageSmoothingQuality = "high";
this.canvas.width = this.game.width() * 2;
this.canvas.height = this.game.height() * 2;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
for (const [_, rail] of this.existingRailroads) {
this.paintRail(rail.tile);
}
}
private highlightOverlappingRailroads(context: CanvasRenderingContext2D) {
if (
this.uiState.ghostStructure === null ||
!SNAPPABLE_STRUCTURES.includes(this.uiState.ghostStructure)
)
return;
if (
this.uiState.overlappingRailroads === undefined ||
this.uiState.overlappingRailroads.length === 0
)
return;
const offsetX = -this.game.width() / 2;
const offsetY = -this.game.height() / 2;
context.fillStyle = "rgba(0, 255, 0, 0.4)";
for (const id of this.uiState.overlappingRailroads) {
const rail = this.railroads.get(id);
if (rail) {
for (const railTile of rail.drawnTiles()) {
const x = this.game.x(railTile.tile);
const y = this.game.y(railTile.tile);
context.fillRect(x + offsetX - 1, y + offsetY - 1, 2.5, 2.5);
}
}
}
}
renderLayer(context: CanvasRenderingContext2D) {
const scale = this.transformHandler.scale;
if (scale <= 1) {
return;
}
this.updateRailColors();
const rawAlpha = (scale - 1) / (2 - 1); // maps 1->0, 2->1
const alpha = Math.max(0, Math.min(1, rawAlpha));
const [topLeft, bottomRight] = this.transformHandler.screenBoundingRect();
const padding = 2; // small margin so edges do not pop
const visLeft = Math.max(0, topLeft.x - padding);
const visTop = Math.max(0, topLeft.y - padding);
const visRight = Math.min(this.game.width(), bottomRight.x + padding);
const visBottom = Math.min(this.game.height(), bottomRight.y + padding);
const visWidth = Math.max(0, visRight - visLeft);
const visHeight = Math.max(0, visBottom - visTop);
if (visWidth === 0 || visHeight === 0) {
return;
}
const srcX = visLeft * 2;
const srcY = visTop * 2;
const srcW = visWidth * 2;
const srcH = visHeight * 2;
const dstX = -this.game.width() / 2 + visLeft;
const dstY = -this.game.height() / 2 + visTop;
context.save();
context.globalAlpha = alpha;
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) {
console.warn("Could not snap railroad: ", update.originalId);
return;
}
if (!original.isComplete()) {
// The animation is not complete but we don't want to compute where the animation should resume
// Just draw every remaining rails at once
this.drawRemainingTiles(original);
}
// No need to compute the directions here, the rails are already painted
const directions1: RailTile[] = update.tiles1.map((tile) => ({
tile,
type: RailType.HORIZONTAL,
}));
const directions2: RailTile[] = update.tiles2.map((tile) => ({
tile,
type: RailType.HORIZONTAL,
}));
// The rails are already painted, consider them complete
this.railroads.set(
update.newId1,
new RailroadView(update.newId1, directions1, true),
);
this.railroads.set(
update.newId2,
new RailroadView(update.newId2, directions2, true),
);
this.railroads.delete(update.originalId);
}
private drawRemainingTiles(railroad: RailroadView) {
for (const tile of railroad.remainingTiles()) {
this.paintRail(tile);
}
this.pendingRailroads.delete(railroad.id);
}
private onRailroadConstruction(railUpdate: RailroadConstructionUpdate) {
const railTiles = computeRailTiles(this.game, railUpdate.tiles);
const rail = new RailroadView(railUpdate.id, railTiles);
this.addRailroad(rail);
}
private onRailroadDestruction(railUpdate: RailroadDestructionUpdate) {
const railroad = this.railroads.get(railUpdate.id);
if (!railroad) {
console.warn("Can't remove unexisting railroad: ", railUpdate.id);
return;
}
this.removeRailroad(railroad);
}
private addRailroad(railroad: RailroadView) {
this.railroads.set(railroad.id, railroad);
this.pendingRailroads.add(railroad.id);
}
private removeRailroad(railroad: RailroadView) {
this.pendingRailroads.delete(railroad.id);
for (const railTile of railroad.drawnTiles()) {
this.clearRailroad(railTile.tile);
this.eventBus.emit(new RailTileChangedEvent(railTile.tile));
}
this.railroads.delete(railroad.id);
}
private paintRailTile(railTile: RailTile) {
const currentOwner = this.game.owner(railTile.tile)?.id() ?? null;
const railRef = this.existingRailroads.get(railTile.tile);
if (railRef) {
railRef.numOccurence++;
railRef.tile = railTile;
railRef.lastOwnerId = currentOwner;
} else {
this.existingRailroads.set(railTile.tile, {
tile: railTile,
numOccurence: 1,
lastOwnerId: currentOwner,
});
this.railTileIndex.set(railTile.tile, this.railTileList.length);
this.railTileList.push(railTile.tile);
this.paintRail(railTile);
}
}
private clearRailroad(railroad: TileRef) {
const ref = this.existingRailroads.get(railroad);
if (ref) ref.numOccurence--;
if (!ref || ref.numOccurence <= 0) {
this.existingRailroads.delete(railroad);
this.removeRailTile(railroad);
if (this.context === undefined) throw new Error("Not initialized");
if (this.game.isWater(railroad)) {
this.context.clearRect(
this.game.x(railroad) * 2 - 2,
this.game.y(railroad) * 2 - 2,
5,
6,
);
} else {
this.context.clearRect(
this.game.x(railroad) * 2 - 1,
this.game.y(railroad) * 2 - 1,
3,
3,
);
}
}
}
private removeRailTile(tile: TileRef) {
const idx = this.railTileIndex.get(tile);
if (idx === undefined) return;
const lastIndex = this.railTileList.length - 1;
const lastTile = this.railTileList[lastIndex];
this.railTileList[idx] = lastTile;
this.railTileIndex.set(lastTile, idx);
this.railTileList.pop();
this.railTileIndex.delete(tile);
if (this.nextRailIndexToCheck >= this.railTileList.length) {
this.nextRailIndexToCheck = 0;
}
}
paintRail(railTile: RailTile) {
if (this.context === undefined) throw new Error("Not initialized");
const { tile } = railTile;
const { type } = railTile;
const x = this.game.x(tile);
const y = this.game.y(tile);
// If rail tile is over water, paint a bridge underlay first
if (this.game.isWater(tile)) {
this.paintBridge(this.context, x, y, type);
}
const owner = this.game.owner(tile);
const recipient = owner.isPlayer() ? owner : null;
let color = recipient
? recipient.borderColor()
: colord("rgba(255,255,255,1)");
if (this.alternativeView && recipient?.isMe()) {
color = colord("#00ff00");
}
this.context.fillStyle = color.toRgbString();
this.paintRailRects(this.context, x, y, type);
}
private paintRailRects(
context: CanvasRenderingContext2D,
x: number,
y: number,
direction: RailType,
) {
const railRects = getRailroadRects(direction);
for (const [dx, dy, w, h] of railRects) {
context.fillRect(x * 2 + dx, y * 2 + dy, w, h);
}
}
private paintBridge(
context: CanvasRenderingContext2D,
x: number,
y: number,
direction: RailType,
) {
context.save();
context.fillStyle = "rgb(197,69,72)";
const bridgeRects = getBridgeRects(direction);
for (const [dx, dy, w, h] of bridgeRects) {
context.fillRect(x * 2 + dx, y * 2 + dy, w, h);
}
context.restore();
}
}