mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-25 09:34:36 +00:00
236f611f61
Resolves #3582 ## Description: Almost exactly the same fix as #3574 , just to RailroadLayer instead of StuctureLayer. While browsers like Firefox will report their maximum texture size of 16384, going over 8192 causes extreme VRAM usage and massive FPS drops. This issue is slightly more elusive as the RailroadLayer texture is not rendered until the first railroad is created, meaning FPS will suddenly drop mid-game. This PR sets the RailroadLayer texture size to cap at 8192, while keeping near-exact scales. The result is increased performance, reduced VRAM Usage, (especially in larger maps), and the resolution of the unplayable performance issues when RailroadLayer is present, with zero noticeable degradation. All tested on Giant World, where the issues were first spotted, but applies to all maps. ## 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 Discord: @EnderBoy9217
502 lines
15 KiB
TypeScript
502 lines
15 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;
|
|
|
|
// 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();
|
|
}
|
|
}
|