mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-30 05:52:12 +00:00
c6c793f6b3
## Description:  The `RailroadLayer` simply displays tiles as instructed by the core worker. While it's practical for the layer to only care about the tiles, it also means it has no understanding of railroads as entities (their paths, connections, or identities). It also means that the core worker is responsible for rendering tasks such as tile orientation and construction animation, which is not expected. To support ID-based events and better separation of concerns, the rendering layer needs to be aware of complete railroads. With this change, the core worker can send the tiles once and subsequently reference railroads only by ID for all other events. #### Changes: - `RailroadLayer` now stores full railroad data instead of only individual tiles - `RailroadLayer` is responsible for animating newly built railroads - Add a new `RailroadSnapUpdate` sent when a new structure is built over an existing railroad. This event is used by `RailroadLayer` to keep railroad ID in sync. - When hovering over a railroad, the render worker is querying the core worker about overlapping railroads. Alternatively, RailroadLayer could compute overlaps itself now that it has full railroad knowledge, but this logic would need to be duplicated and kept in sync across workers. Keeping a single source of truth in the core worker is preferred. #### Edgecases: - When a structure snaps over a railroad, the original railroad is split into two new railroads. If the construction animation is still in progress, instead of resuming the animation at the correct point on the new railroads, all remaining tiles are rendered immediately - Previously, `RailroadUpdate` handled both construction and destruction. This no longer works with `RailroadSnapUpdate`, as event ordering is now pretty important and IDs may be lost before they are consumed. To address this, RailroadUpdate is split in two: `RailroadConstructionUpdate` and `RailroadDestructionUpdate`. ## 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: IngloriousTom --------- Co-authored-by: jrouillard <jon@rouillard.org>
465 lines
15 KiB
TypeScript
465 lines
15 KiB
TypeScript
import { EventBus } from "../../core/EventBus";
|
|
import { GameView } from "../../core/game/GameView";
|
|
import { UserSettings } from "../../core/game/UserSettings";
|
|
import { GameStartingModal } from "../GameStartingModal";
|
|
import { RefreshGraphicsEvent as RedrawGraphicsEvent } from "../InputHandler";
|
|
import { FrameProfiler } from "./FrameProfiler";
|
|
import { TransformHandler } from "./TransformHandler";
|
|
import { UIState } from "./UIState";
|
|
import { AlertFrame } from "./layers/AlertFrame";
|
|
import { BuildMenu } from "./layers/BuildMenu";
|
|
import { ChatDisplay } from "./layers/ChatDisplay";
|
|
import { ChatModal } from "./layers/ChatModal";
|
|
import { ControlPanel } from "./layers/ControlPanel";
|
|
import { DynamicUILayer } from "./layers/DynamicUILayer";
|
|
import { EmojiTable } from "./layers/EmojiTable";
|
|
import { EventsDisplay } from "./layers/EventsDisplay";
|
|
import { FxLayer } from "./layers/FxLayer";
|
|
import { GameLeftSidebar } from "./layers/GameLeftSidebar";
|
|
import { GameRightSidebar } from "./layers/GameRightSidebar";
|
|
import { HeadsUpMessage } from "./layers/HeadsUpMessage";
|
|
import { ImmunityTimer } from "./layers/ImmunityTimer";
|
|
import { InGameHeaderAd } from "./layers/InGameHeaderAd";
|
|
import { Layer } from "./layers/Layer";
|
|
import { Leaderboard } from "./layers/Leaderboard";
|
|
import { MainRadialMenu } from "./layers/MainRadialMenu";
|
|
import { MultiTabModal } from "./layers/MultiTabModal";
|
|
import { NameLayer } from "./layers/NameLayer";
|
|
import { NukeTrajectoryPreviewLayer } from "./layers/NukeTrajectoryPreviewLayer";
|
|
import { PerformanceOverlay } from "./layers/PerformanceOverlay";
|
|
import { PlayerInfoOverlay } from "./layers/PlayerInfoOverlay";
|
|
import { PlayerPanel } from "./layers/PlayerPanel";
|
|
import { RailroadLayer } from "./layers/RailroadLayer";
|
|
import { ReplayPanel } from "./layers/ReplayPanel";
|
|
import { SAMRadiusLayer } from "./layers/SAMRadiusLayer";
|
|
import { SettingsModal } from "./layers/SettingsModal";
|
|
import { SpawnTimer } from "./layers/SpawnTimer";
|
|
import { SpawnVideoAd } from "./layers/SpawnVideoReward";
|
|
import { StructureIconsLayer } from "./layers/StructureIconsLayer";
|
|
import { StructureLayer } from "./layers/StructureLayer";
|
|
import { TeamStats } from "./layers/TeamStats";
|
|
import { TerrainLayer } from "./layers/TerrainLayer";
|
|
import { TerritoryLayer } from "./layers/TerritoryLayer";
|
|
import { UILayer } from "./layers/UILayer";
|
|
import { UnitDisplay } from "./layers/UnitDisplay";
|
|
import { UnitLayer } from "./layers/UnitLayer";
|
|
import { WinModal } from "./layers/WinModal";
|
|
|
|
export function createRenderer(
|
|
canvas: HTMLCanvasElement,
|
|
game: GameView,
|
|
eventBus: EventBus,
|
|
): GameRenderer {
|
|
const transformHandler = new TransformHandler(game, eventBus, canvas);
|
|
const userSettings = new UserSettings();
|
|
|
|
const uiState = {
|
|
attackRatio: 20,
|
|
ghostStructure: null,
|
|
rocketDirectionUp: true,
|
|
} as UIState;
|
|
|
|
//hide when the game renders
|
|
const startingModal = document.querySelector(
|
|
"game-starting-modal",
|
|
) as GameStartingModal;
|
|
startingModal.hide();
|
|
|
|
// TODO maybe append this to document instead of querying for them?
|
|
const emojiTable = document.querySelector("emoji-table") as EmojiTable;
|
|
if (!emojiTable || !(emojiTable instanceof EmojiTable)) {
|
|
console.error("EmojiTable element not found in the DOM");
|
|
}
|
|
emojiTable.transformHandler = transformHandler;
|
|
emojiTable.game = game;
|
|
emojiTable.initEventBus(eventBus);
|
|
|
|
const buildMenu = document.querySelector("build-menu") as BuildMenu;
|
|
if (!buildMenu || !(buildMenu instanceof BuildMenu)) {
|
|
console.error("BuildMenu element not found in the DOM");
|
|
}
|
|
buildMenu.game = game;
|
|
buildMenu.eventBus = eventBus;
|
|
buildMenu.uiState = uiState;
|
|
buildMenu.transformHandler = transformHandler;
|
|
|
|
const leaderboard = document.querySelector("leader-board") as Leaderboard;
|
|
if (!leaderboard || !(leaderboard instanceof Leaderboard)) {
|
|
console.error("LeaderBoard element not found in the DOM");
|
|
}
|
|
leaderboard.eventBus = eventBus;
|
|
leaderboard.game = game;
|
|
|
|
const gameLeftSidebar = document.querySelector(
|
|
"game-left-sidebar",
|
|
) as GameLeftSidebar;
|
|
if (!gameLeftSidebar || !(gameLeftSidebar instanceof GameLeftSidebar)) {
|
|
console.error("GameLeftSidebar element not found in the DOM");
|
|
}
|
|
gameLeftSidebar.game = game;
|
|
|
|
const teamStats = document.querySelector("team-stats") as TeamStats;
|
|
if (!teamStats || !(teamStats instanceof TeamStats)) {
|
|
console.error("TeamStats element not found in the DOM");
|
|
}
|
|
teamStats.eventBus = eventBus;
|
|
teamStats.game = game;
|
|
|
|
const controlPanel = document.querySelector("control-panel") as ControlPanel;
|
|
if (!(controlPanel instanceof ControlPanel)) {
|
|
console.error("ControlPanel element not found in the DOM");
|
|
}
|
|
controlPanel.eventBus = eventBus;
|
|
controlPanel.uiState = uiState;
|
|
controlPanel.game = game;
|
|
|
|
const eventsDisplay = document.querySelector(
|
|
"events-display",
|
|
) as EventsDisplay;
|
|
if (!(eventsDisplay instanceof EventsDisplay)) {
|
|
console.error("events display not found");
|
|
}
|
|
eventsDisplay.eventBus = eventBus;
|
|
eventsDisplay.game = game;
|
|
eventsDisplay.uiState = uiState;
|
|
|
|
const chatDisplay = document.querySelector("chat-display") as ChatDisplay;
|
|
if (!(chatDisplay instanceof ChatDisplay)) {
|
|
console.error("chat display not found");
|
|
}
|
|
chatDisplay.eventBus = eventBus;
|
|
chatDisplay.game = game;
|
|
|
|
const playerInfo = document.querySelector(
|
|
"player-info-overlay",
|
|
) as PlayerInfoOverlay;
|
|
if (!(playerInfo instanceof PlayerInfoOverlay)) {
|
|
console.error("player info overlay not found");
|
|
}
|
|
playerInfo.eventBus = eventBus;
|
|
playerInfo.transform = transformHandler;
|
|
playerInfo.game = game;
|
|
|
|
const winModal = document.querySelector("win-modal") as WinModal;
|
|
if (!(winModal instanceof WinModal)) {
|
|
console.error("win modal not found");
|
|
}
|
|
winModal.eventBus = eventBus;
|
|
winModal.game = game;
|
|
|
|
const replayPanel = document.querySelector("replay-panel") as ReplayPanel;
|
|
if (!(replayPanel instanceof ReplayPanel)) {
|
|
console.error("replay panel not found");
|
|
}
|
|
replayPanel.eventBus = eventBus;
|
|
replayPanel.game = game;
|
|
|
|
const gameRightSidebar = document.querySelector(
|
|
"game-right-sidebar",
|
|
) as GameRightSidebar;
|
|
if (!(gameRightSidebar instanceof GameRightSidebar)) {
|
|
console.error("Game Right bar not found");
|
|
}
|
|
gameRightSidebar.game = game;
|
|
gameRightSidebar.eventBus = eventBus;
|
|
|
|
const settingsModal = document.querySelector(
|
|
"settings-modal",
|
|
) as SettingsModal;
|
|
if (!(settingsModal instanceof SettingsModal)) {
|
|
console.error("settings modal not found");
|
|
}
|
|
settingsModal.userSettings = userSettings;
|
|
settingsModal.eventBus = eventBus;
|
|
|
|
const unitDisplay = document.querySelector("unit-display") as UnitDisplay;
|
|
if (!(unitDisplay instanceof UnitDisplay)) {
|
|
console.error("unit display not found");
|
|
}
|
|
unitDisplay.game = game;
|
|
unitDisplay.eventBus = eventBus;
|
|
unitDisplay.uiState = uiState;
|
|
|
|
const playerPanel = document.querySelector("player-panel") as PlayerPanel;
|
|
if (!(playerPanel instanceof PlayerPanel)) {
|
|
console.error("player panel not found");
|
|
}
|
|
playerPanel.g = game;
|
|
playerPanel.initEventBus(eventBus);
|
|
playerPanel.emojiTable = emojiTable;
|
|
playerPanel.uiState = uiState;
|
|
|
|
const chatModal = document.querySelector("chat-modal") as ChatModal;
|
|
if (!(chatModal instanceof ChatModal)) {
|
|
console.error("chat modal not found");
|
|
}
|
|
chatModal.g = game;
|
|
chatModal.initEventBus(eventBus);
|
|
|
|
const multiTabModal = document.querySelector(
|
|
"multi-tab-modal",
|
|
) as MultiTabModal;
|
|
if (!(multiTabModal instanceof MultiTabModal)) {
|
|
console.error("multi-tab modal not found");
|
|
}
|
|
multiTabModal.game = game;
|
|
|
|
const headsUpMessage = document.querySelector(
|
|
"heads-up-message",
|
|
) as HeadsUpMessage;
|
|
if (!(headsUpMessage instanceof HeadsUpMessage)) {
|
|
console.error("heads-up message not found");
|
|
}
|
|
headsUpMessage.game = game;
|
|
|
|
const structureLayer = new StructureLayer(game, eventBus, transformHandler);
|
|
const samRadiusLayer = new SAMRadiusLayer(game, eventBus, uiState);
|
|
|
|
const performanceOverlay = document.querySelector(
|
|
"performance-overlay",
|
|
) as PerformanceOverlay;
|
|
if (!(performanceOverlay instanceof PerformanceOverlay)) {
|
|
console.error("performance overlay not found");
|
|
}
|
|
performanceOverlay.eventBus = eventBus;
|
|
performanceOverlay.userSettings = userSettings;
|
|
|
|
const alertFrame = document.querySelector("alert-frame") as AlertFrame;
|
|
if (!(alertFrame instanceof AlertFrame)) {
|
|
console.error("alert frame not found");
|
|
}
|
|
alertFrame.game = game;
|
|
|
|
const spawnTimer = document.querySelector("spawn-timer") as SpawnTimer;
|
|
if (!(spawnTimer instanceof SpawnTimer)) {
|
|
console.error("spawn timer not found");
|
|
}
|
|
spawnTimer.game = game;
|
|
spawnTimer.transformHandler = transformHandler;
|
|
|
|
const immunityTimer = document.querySelector(
|
|
"immunity-timer",
|
|
) as ImmunityTimer;
|
|
if (!(immunityTimer instanceof ImmunityTimer)) {
|
|
console.error("immunity timer not found");
|
|
}
|
|
immunityTimer.game = game;
|
|
|
|
const inGameHeaderAd = document.querySelector(
|
|
"in-game-header-ad",
|
|
) as InGameHeaderAd;
|
|
if (!(inGameHeaderAd instanceof InGameHeaderAd)) {
|
|
console.error("in-game header ad not found");
|
|
}
|
|
inGameHeaderAd.game = game;
|
|
|
|
const spawnVideoAd = document.querySelector("spawn-video-ad") as SpawnVideoAd;
|
|
if (!(spawnVideoAd instanceof SpawnVideoAd)) {
|
|
console.error("spawn video ad not found");
|
|
}
|
|
spawnVideoAd.game = game;
|
|
|
|
// When updating these layers please be mindful of the order.
|
|
// Try to group layers by the return value of shouldTransform.
|
|
// Not grouping the layers may cause excessive calls to context.save() and context.restore().
|
|
const layers: Layer[] = [
|
|
new TerrainLayer(game, transformHandler),
|
|
new TerritoryLayer(game, eventBus, transformHandler, userSettings),
|
|
new RailroadLayer(game, eventBus, transformHandler, uiState),
|
|
structureLayer,
|
|
samRadiusLayer,
|
|
new UnitLayer(game, eventBus, transformHandler),
|
|
new FxLayer(game, eventBus, transformHandler),
|
|
new UILayer(game, eventBus, transformHandler),
|
|
new NukeTrajectoryPreviewLayer(game, eventBus, transformHandler, uiState),
|
|
new StructureIconsLayer(game, eventBus, uiState, transformHandler),
|
|
new DynamicUILayer(game, transformHandler, eventBus),
|
|
new NameLayer(game, transformHandler, eventBus),
|
|
eventsDisplay,
|
|
chatDisplay,
|
|
buildMenu,
|
|
new MainRadialMenu(
|
|
eventBus,
|
|
game,
|
|
transformHandler,
|
|
emojiTable as EmojiTable,
|
|
buildMenu,
|
|
uiState,
|
|
playerPanel,
|
|
),
|
|
spawnTimer,
|
|
immunityTimer,
|
|
leaderboard,
|
|
gameLeftSidebar,
|
|
unitDisplay,
|
|
gameRightSidebar,
|
|
controlPanel,
|
|
playerInfo,
|
|
winModal,
|
|
replayPanel,
|
|
settingsModal,
|
|
teamStats,
|
|
playerPanel,
|
|
headsUpMessage,
|
|
multiTabModal,
|
|
inGameHeaderAd,
|
|
spawnVideoAd,
|
|
alertFrame,
|
|
performanceOverlay,
|
|
];
|
|
|
|
return new GameRenderer(
|
|
game,
|
|
eventBus,
|
|
canvas,
|
|
transformHandler,
|
|
uiState,
|
|
layers,
|
|
performanceOverlay,
|
|
);
|
|
}
|
|
|
|
export class GameRenderer {
|
|
private context: CanvasRenderingContext2D;
|
|
private layerTickState = new Map<Layer, { lastTickAtMs: number }>();
|
|
|
|
constructor(
|
|
private game: GameView,
|
|
private eventBus: EventBus,
|
|
private canvas: HTMLCanvasElement,
|
|
public transformHandler: TransformHandler,
|
|
public uiState: UIState,
|
|
private layers: Layer[],
|
|
private performanceOverlay: PerformanceOverlay,
|
|
) {
|
|
const context = canvas.getContext("2d", { alpha: false });
|
|
if (context === null) throw new Error("2d context not supported");
|
|
this.context = context;
|
|
}
|
|
|
|
initialize() {
|
|
this.eventBus.on(RedrawGraphicsEvent, () => this.redraw());
|
|
this.layers.forEach((l) => l.init?.());
|
|
|
|
// only append the canvas if it's not already in the document to avoid reparenting side-effects
|
|
if (!document.body.contains(this.canvas)) {
|
|
document.body.appendChild(this.canvas);
|
|
}
|
|
|
|
window.addEventListener("resize", () => this.resizeCanvas());
|
|
this.resizeCanvas();
|
|
|
|
//show whole map on startup
|
|
this.transformHandler.centerAll(0.9);
|
|
|
|
let rafId = requestAnimationFrame(() => this.renderGame());
|
|
this.canvas.addEventListener("contextlost", () => {
|
|
cancelAnimationFrame(rafId);
|
|
});
|
|
this.canvas.addEventListener("contextrestored", () => {
|
|
this.redraw();
|
|
rafId = requestAnimationFrame(() => this.renderGame());
|
|
});
|
|
}
|
|
|
|
resizeCanvas() {
|
|
this.canvas.width = window.innerWidth;
|
|
this.canvas.height = window.innerHeight;
|
|
this.transformHandler.updateCanvasBoundingRect();
|
|
//this.redraw()
|
|
}
|
|
|
|
redraw() {
|
|
this.layers.forEach((l) => {
|
|
if (l.redraw) {
|
|
l.redraw();
|
|
}
|
|
});
|
|
}
|
|
|
|
renderGame() {
|
|
FrameProfiler.clear();
|
|
const start = performance.now();
|
|
// Set background
|
|
this.context.fillStyle = this.game
|
|
.config()
|
|
.theme()
|
|
.backgroundColor()
|
|
.toHex();
|
|
this.context.fillRect(0, 0, this.canvas.width, this.canvas.height);
|
|
|
|
const handleTransformState = (
|
|
needsTransform: boolean,
|
|
active: boolean,
|
|
): boolean => {
|
|
if (needsTransform && !active) {
|
|
this.context.save();
|
|
this.transformHandler.handleTransform(this.context);
|
|
return true;
|
|
} else if (!needsTransform && active) {
|
|
this.context.restore();
|
|
return false;
|
|
}
|
|
return active;
|
|
};
|
|
|
|
let isTransformActive = false;
|
|
|
|
for (const layer of this.layers) {
|
|
const needsTransform = layer.shouldTransform?.() ?? false;
|
|
isTransformActive = handleTransformState(
|
|
needsTransform,
|
|
isTransformActive,
|
|
);
|
|
|
|
const layerStart = FrameProfiler.start();
|
|
layer.renderLayer?.(this.context);
|
|
FrameProfiler.end(layer.constructor?.name ?? "UnknownLayer", layerStart);
|
|
}
|
|
handleTransformState(false, isTransformActive); // Ensure context is clean after rendering
|
|
this.transformHandler.resetChanged();
|
|
|
|
requestAnimationFrame(() => this.renderGame());
|
|
const duration = performance.now() - start;
|
|
|
|
const layerDurations = FrameProfiler.consume();
|
|
this.performanceOverlay.updateFrameMetrics(duration, layerDurations);
|
|
|
|
if (duration > 50) {
|
|
console.warn(
|
|
`tick ${this.game.ticks()} took ${duration}ms to render frame`,
|
|
);
|
|
}
|
|
}
|
|
|
|
tick() {
|
|
const nowMs = performance.now();
|
|
|
|
for (const layer of this.layers) {
|
|
if (!layer.tick) {
|
|
continue;
|
|
}
|
|
|
|
const state = this.layerTickState.get(layer) ?? {
|
|
lastTickAtMs: -Infinity,
|
|
};
|
|
|
|
const intervalMs = layer.getTickIntervalMs?.() ?? 0;
|
|
if (intervalMs > 0 && nowMs - state.lastTickAtMs < intervalMs) {
|
|
this.layerTickState.set(layer, state);
|
|
continue;
|
|
}
|
|
|
|
state.lastTickAtMs = nowMs;
|
|
this.layerTickState.set(layer, state);
|
|
|
|
layer.tick();
|
|
}
|
|
}
|
|
|
|
resize(width: number, height: number): void {
|
|
this.canvas.width = Math.ceil(width / window.devicePixelRatio);
|
|
this.canvas.height = Math.ceil(height / window.devicePixelRatio);
|
|
}
|
|
}
|