mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-26 10:04:36 +00:00
ba2a947061
## 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
481 lines
14 KiB
TypeScript
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();
|
|
}
|
|
}
|