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(); private railroads = new Map(); // Railroads under construction private pendingRailroads = new Set(); private nextRailIndexToCheck = 0; private railTileList: TileRef[] = []; private railTileIndex = new Map(); 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; // Firefox's GPU limit is 8192, only known browser issue const maxTextureSize = 8192; const scaleX = maxTextureSize / this.game.width(); const scaleY = maxTextureSize / this.game.height(); const targetScale = Math.min(2, scaleX, scaleY); this.canvas.width = Math.max( 1, Math.floor(this.game.width() * targetScale), ); this.canvas.height = Math.max( 1, Math.floor(this.game.height() * targetScale), ); // Enable smooth scaling this.context.imageSmoothingEnabled = true; this.context.imageSmoothingQuality = "high"; // Scale context so existing *2 rendering math continues to work automatically this.context.scale( 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 actualScaleX = this.canvas.width / this.game.width(); const actualScaleY = this.canvas.height / this.game.height(); const srcX = visLeft * actualScaleX; const srcY = visTop * actualScaleY; const srcW = visWidth * actualScaleX; const srcH = visHeight * actualScaleY; 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(); } }