diff --git a/src/client/InputHandler.ts b/src/client/InputHandler.ts index 329e9344d..459e4c860 100644 --- a/src/client/InputHandler.ts +++ b/src/client/InputHandler.ts @@ -92,10 +92,6 @@ export class TerritoryWebGLStatusEvent implements GameEvent { ) {} } -export class ToggleTerritoryWebGLDebugBordersEvent implements GameEvent { - constructor(public readonly enabled: boolean) {} -} - export class ToggleStructureEvent implements GameEvent { constructor(public readonly structureTypes: UnitType[] | null) {} } diff --git a/src/client/graphics/layers/BorderRenderer.ts b/src/client/graphics/layers/BorderRenderer.ts deleted file mode 100644 index a2da016bb..000000000 --- a/src/client/graphics/layers/BorderRenderer.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { TileRef } from "../../../core/game/GameMap"; -import { PlayerView } from "../../../core/game/GameView"; - -export interface BorderRenderer { - setAlternativeView(enabled: boolean): void; - setHoveredPlayerId(playerSmallId: number | null): void; - drawsOwnBorders(): boolean; - - updateBorder( - tile: TileRef, - owner: PlayerView | null, - isBorder: boolean, - isDefended: boolean, - hasFallout: boolean, - ): void; - - clearTile(tile: TileRef): void; - - render(context: CanvasRenderingContext2D): void; -} - -export class NullBorderRenderer implements BorderRenderer { - drawsOwnBorders(): boolean { - return false; - } - - setAlternativeView() {} - - setHoveredPlayerId() {} - - updateBorder() {} - - clearTile() {} - - render() {} -} diff --git a/src/client/graphics/layers/TerritoryLayer.ts b/src/client/graphics/layers/TerritoryLayer.ts index 65f872341..9de266173 100644 --- a/src/client/graphics/layers/TerritoryLayer.ts +++ b/src/client/graphics/layers/TerritoryLayer.ts @@ -3,7 +3,6 @@ import { Colord } from "colord"; import { Theme } from "../../../core/configuration/Config"; import { EventBus } from "../../../core/EventBus"; import { - Cell, ColoredTeams, PlayerType, Team, @@ -26,14 +25,15 @@ import { FrameProfiler } from "../FrameProfiler"; import { resolveHoverTarget } from "../HoverTarget"; import { TransformHandler } from "../TransformHandler"; import { Layer } from "./Layer"; +import { + CanvasTerritoryRenderer, + TerritoryRendererStrategy, + WebglTerritoryRenderer, +} from "./TerritoryRenderers"; import { TerritoryWebGLRenderer } from "./TerritoryWebGLRenderer"; export class TerritoryLayer implements Layer { private userSettings: UserSettings; - private canvas: HTMLCanvasElement; - private context: CanvasRenderingContext2D; - private imageData: ImageData; - private alternativeImageData: ImageData; private borderAnimTime = 0; private cachedTerritoryPatternsEnabled: boolean | undefined; @@ -52,7 +52,7 @@ export class TerritoryLayer implements Layer { private highlightContext: CanvasRenderingContext2D; private highlightedTerritory: PlayerView | null = null; - private territoryRenderer: TerritoryWebGLRenderer | null = null; + private territoryRenderer: TerritoryRendererStrategy | null = null; private alternativeView = false; private lastDragTime = 0; @@ -345,8 +345,8 @@ export class TerritoryLayer implements Layer { } if (previousTerritory?.id() !== this.highlightedTerritory?.id()) { - if (this.territoryRenderer) { - this.territoryRenderer.setHoveredPlayerId( + if (this.territoryRenderer?.isWebGL()) { + this.territoryRenderer.setHover( this.highlightedTerritory?.smallID() ?? null, ); } else { @@ -365,38 +365,8 @@ export class TerritoryLayer implements Layer { redraw() { console.log("redrew territory layer"); this.lastMyPlayerSmallId = this.game.myPlayer()?.smallID() ?? null; - this.canvas = document.createElement("canvas"); - const context = this.canvas.getContext("2d"); - if (context === null) throw new Error("2d context not supported"); - this.context = context; - this.canvas.width = this.game.width(); - this.canvas.height = this.game.height(); - - this.imageData = this.context.getImageData( - 0, - 0, - this.canvas.width, - this.canvas.height, - ); - this.alternativeImageData = this.context.getImageData( - 0, - 0, - this.canvas.width, - this.canvas.height, - ); - this.initImageData(); - - if (!this.territoryRenderer) { - this.context.putImageData( - this.alternativeView ? this.alternativeImageData : this.imageData, - 0, - 0, - ); - } else { - this.context.clearRect(0, 0, this.canvas.width, this.canvas.height); - } - this.configureRenderers(); + this.territoryRenderer?.redraw(); // Add a second canvas for highlights this.highlightCanvas = document.createElement("canvas"); @@ -417,6 +387,14 @@ export class TerritoryLayer implements Layer { this.territoryRenderer = null; if (!this.useWebGL) { + this.territoryRenderer = new CanvasTerritoryRenderer( + this.game, + this.theme, + ); + this.territoryRenderer.setAlternativeView(this.alternativeView); + this.territoryRenderer.setHoverHighlightOptions( + this.hoverHighlightOptions(), + ); this.webglSupported = true; this.emitWebGLStatus( false, @@ -431,32 +409,29 @@ export class TerritoryLayer implements Layer { this.game, this.theme, ); - this.territoryRenderer = renderer; - if (this.territoryRenderer) { - this.territoryRenderer.setAlternativeView(this.alternativeView); - this.territoryRenderer.markAllDirty(); - this.territoryRenderer.refreshPalette(); - this.territoryRenderer.setHoverHighlightOptions( - this.hoverHighlightOptions(), - ); - this.territoryRenderer.setHoveredPlayerId( - this.highlightedTerritory?.smallID() ?? null, - ); + if (renderer) { + const strategy = new WebglTerritoryRenderer(renderer, this.game); + strategy.setAlternativeView(this.alternativeView); + strategy.markAllDirty(); + strategy.refreshPalette(); + strategy.setHoverHighlightOptions(this.hoverHighlightOptions()); + strategy.setHover(this.highlightedTerritory?.smallID() ?? null); + this.territoryRenderer = strategy; + this.webglSupported = true; + this.emitWebGLStatus(true, true, true, undefined); + return; } - const supported = this.territoryRenderer !== null; - const active = this.territoryRenderer !== null; const fallbackReason = reason ?? "WebGL not available. Using canvas fallback for borders and fill."; - - this.webglSupported = supported; - this.emitWebGLStatus( - true, - active, - supported, - active ? undefined : fallbackReason, + this.territoryRenderer = new CanvasTerritoryRenderer(this.game, this.theme); + this.territoryRenderer.setAlternativeView(this.alternativeView); + this.territoryRenderer.setHoverHighlightOptions( + this.hoverHighlightOptions(), ); + this.webglSupported = false; + this.emitWebGLStatus(true, false, false, fallbackReason); } /** @@ -508,90 +483,37 @@ export class TerritoryLayer implements Layer { ); } - initImageData() { - this.game.forEachTile((tile) => { - const cell = new Cell(this.game.x(tile), this.game.y(tile)); - const index = cell.y * this.game.width() + cell.x; - const offset = index * 4; - this.imageData.data[offset + 3] = 0; - this.alternativeImageData.data[offset + 3] = 0; - }); - } - renderLayer(context: CanvasRenderingContext2D) { const now = Date.now(); - // When WebGL is available, rely entirely on the GPU renderer (even in alt view). - const gpuTerritoryActive = this.territoryRenderer !== null; - const skipTerritoryCanvas = gpuTerritoryActive; - - if ( + const canRefresh = now > this.lastDragTime + this.nodrawDragDuration && - now > this.lastRefresh + this.refreshRate - ) { + now > this.lastRefresh + this.refreshRate; + if (canRefresh) { this.lastRefresh = now; const renderTerritoryStart = FrameProfiler.start(); this.renderTerritory(); FrameProfiler.end("TerritoryLayer:renderTerritory", renderTerritoryStart); - - const [topLeft, bottomRight] = this.transformHandler.screenBoundingRect(); - const vx0 = Math.max(0, topLeft.x); - const vy0 = Math.max(0, topLeft.y); - const vx1 = Math.min(this.game.width() - 1, bottomRight.x); - const vy1 = Math.min(this.game.height() - 1, bottomRight.y); - - const w = vx1 - vx0 + 1; - const h = vy1 - vy0 + 1; - - // When WebGL borders are active and we're in alternative view, the 2D - // territory buffer (alternativeImageData) is effectively transparent and - // all visible work is done by the WebGL layer. Skip putImageData in that - // case to avoid unnecessary CPU work each frame. - const shouldBlitTerritories = !gpuTerritoryActive && !skipTerritoryCanvas; - - if (w > 0 && h > 0 && shouldBlitTerritories) { - const putImageStart = FrameProfiler.start(); - this.context.putImageData( - this.alternativeView ? this.alternativeImageData : this.imageData, - 0, - 0, - vx0, - vy0, - w, - h, - ); - FrameProfiler.end("TerritoryLayer:putImageData", putImageStart); - } } - if (gpuTerritoryActive) { - const webglRenderStart = FrameProfiler.start(); - this.territoryRenderer?.render(); - FrameProfiler.end( - "TerritoryLayer:territoryWebGL.render", - webglRenderStart, + const [topLeft, bottomRight] = this.transformHandler.screenBoundingRect(); + const vx0 = Math.max(0, topLeft.x); + const vy0 = Math.max(0, topLeft.y); + const vx1 = Math.min(this.game.width() - 1, bottomRight.x); + const vy1 = Math.min(this.game.height() - 1, bottomRight.y); + + const w = vx1 - vx0 + 1; + const h = vy1 - vy0 + 1; + if (this.territoryRenderer) { + this.territoryRenderer.render( + context, + { + x: vx0, + y: vy0, + width: w, + height: h, + }, + canRefresh, ); - const drawCanvasStart = FrameProfiler.start(); - context.drawImage( - this.territoryRenderer!.canvas, - -this.game.width() / 2, - -this.game.height() / 2, - this.game.width(), - this.game.height(), - ); - FrameProfiler.end( - "TerritoryLayer:territoryWebGL.drawImage", - drawCanvasStart, - ); - } else if (!skipTerritoryCanvas) { - const drawCanvasStart = FrameProfiler.start(); - context.drawImage( - this.canvas, - -this.game.width() / 2, - -this.game.height() / 2, - this.game.width(), - this.game.height(), - ); - FrameProfiler.end("TerritoryLayer:drawCanvas", drawCanvasStart); } if (this.game.inSpawnPhase()) { @@ -611,6 +533,9 @@ export class TerritoryLayer implements Layer { } renderTerritory() { + if (!this.territoryRenderer) { + return; + } let numToRender = Math.floor(this.tileToRenderQueue.size() / 10); if (numToRender === 0 || this.game.inSpawnPhase()) { numToRender = this.tileToRenderQueue.size(); @@ -633,123 +558,11 @@ export class TerritoryLayer implements Layer { } paintTerritory(tile: TileRef, _maybeStaleBorder: boolean = false) { - const cpuStart = FrameProfiler.start(); - const useGpuTerritory = this.territoryRenderer !== null; - const hasOwner = this.game.hasOwner(tile); - const rawOwner = hasOwner ? this.game.owner(tile) : null; - const owner = - rawOwner && - typeof (rawOwner as any).isPlayer === "function" && - (rawOwner as any).isPlayer() - ? (rawOwner as PlayerView) - : null; - const isBorderTile = this.game.isBorder(tile); - const hasFallout = this.game.hasFallout(tile); - let isDefended = false; - if (owner && isBorderTile) { - isDefended = this.game.hasUnitNearby( - tile, - this.game.config().defensePostRange(), - UnitType.DefensePost, - owner.id(), - ); - } - - if (useGpuTerritory) { - this.territoryRenderer?.markTile(tile); - if (!owner || !isBorderTile) { - this.territoryRenderer?.clearBorderColor(tile); - } else { - const borderCol = owner.borderColor(tile, isDefended).rgba; - this.territoryRenderer?.setBorderColor(tile, { - r: borderCol.r, - g: borderCol.g, - b: borderCol.b, - a: Math.round((borderCol.a ?? 1) * 255), - }); - } - } else { - if (!owner) { - if (hasFallout) { - this.paintTile(this.imageData, tile, this.theme.falloutColor(), 150); - this.paintTile( - this.alternativeImageData, - tile, - this.theme.falloutColor(), - 150, - ); - } else { - this.clearTile(tile); - } - } else { - const myPlayer = this.game.myPlayer(); - - if (isBorderTile) { - if (myPlayer) { - const alternativeColor = this.alternateViewColor(owner); - this.paintTile( - this.alternativeImageData, - tile, - alternativeColor, - 255, - ); - } - this.paintTile( - this.imageData, - tile, - owner.borderColor(tile, isDefended), - 255, - ); - } else { - // Alternative view only shows borders. - this.clearAlternativeTile(tile); - - this.paintTile(this.imageData, tile, owner.territoryColor(tile), 150); - } - } - } - FrameProfiler.end("TerritoryLayer:paintTerritory.cpu", cpuStart); - } - - alternateViewColor(other: PlayerView): Colord { - const myPlayer = this.game.myPlayer(); - if (!myPlayer) { - return this.theme.neutralColor(); - } - if (other.smallID() === myPlayer.smallID()) { - return this.theme.selfColor(); - } - if (other.isFriendly(myPlayer)) { - return this.theme.allyColor(); - } - if (!other.hasEmbargo(myPlayer)) { - return this.theme.neutralColor(); - } - return this.theme.enemyColor(); - } - - paintAlternateViewTile(tile: TileRef, other: PlayerView) { - const color = this.alternateViewColor(other); - this.paintTile(this.alternativeImageData, tile, color, 255); - } - - paintTile(imageData: ImageData, tile: TileRef, color: Colord, alpha: number) { - const offset = tile * 4; - imageData.data[offset] = color.rgba.r; - imageData.data[offset + 1] = color.rgba.g; - imageData.data[offset + 2] = color.rgba.b; - imageData.data[offset + 3] = alpha; + this.territoryRenderer?.paintTile(tile); } clearTile(tile: TileRef) { - const offset = tile * 4; - this.imageData.data[offset + 3] = 0; // Set alpha to 0 (fully transparent) - this.alternativeImageData.data[offset + 3] = 0; // Set alpha to 0 (fully transparent) - } - - clearAlternativeTile(tile: TileRef) { - const offset = tile * 4; - this.alternativeImageData.data[offset + 3] = 0; // Set alpha to 0 (fully transparent) + this.territoryRenderer?.clearTile(tile); } enqueueTile(tile: TileRef) { diff --git a/src/client/graphics/layers/TerritoryRenderers.ts b/src/client/graphics/layers/TerritoryRenderers.ts new file mode 100644 index 000000000..99276b3a4 --- /dev/null +++ b/src/client/graphics/layers/TerritoryRenderers.ts @@ -0,0 +1,342 @@ +import { Colord } from "colord"; +import { Theme } from "../../../core/configuration/Config"; +import { UnitType } from "../../../core/game/Game"; +import { TileRef } from "../../../core/game/GameMap"; +import { GameView, PlayerView } from "../../../core/game/GameView"; +import { FrameProfiler } from "../FrameProfiler"; +import { + HoverHighlightOptions, + TerritoryWebGLRenderer, +} from "./TerritoryWebGLRenderer"; + +export interface TerritoryRendererStrategy { + isWebGL(): boolean; + redraw(): void; + markAllDirty(): void; + paintTile(tile: TileRef): void; + render( + context: CanvasRenderingContext2D, + viewport: { x: number; y: number; width: number; height: number }, + shouldBlit: boolean, + ): void; + setAlternativeView(enabled: boolean): void; + setHover(playerSmallId: number | null): void; + setHoverHighlightOptions(options: HoverHighlightOptions): void; + refreshPalette(): void; + clearTile(tile: TileRef): void; +} + +export class CanvasTerritoryRenderer implements TerritoryRendererStrategy { + private canvas: HTMLCanvasElement; + private context: CanvasRenderingContext2D; + private imageData: ImageData; + private alternativeImageData: ImageData; + private alternativeView = false; + + constructor( + private readonly game: GameView, + private readonly theme: Theme, + ) { + this.canvas = document.createElement("canvas"); + const context = this.canvas.getContext("2d"); + if (!context) throw new Error("2d context not supported"); + this.context = context; + this.imageData = context.createImageData(1, 1); + this.alternativeImageData = context.createImageData(1, 1); + } + + isWebGL(): boolean { + return false; + } + + redraw() { + this.canvas.width = this.game.width(); + this.canvas.height = this.game.height(); + this.imageData = this.context.getImageData( + 0, + 0, + this.canvas.width, + this.canvas.height, + ); + this.alternativeImageData = this.context.getImageData( + 0, + 0, + this.canvas.width, + this.canvas.height, + ); + this.initImageData(); + } + + markAllDirty(): void { + // No special handling needed for canvas path. + } + + paintTile(tile: TileRef) { + const cpuStart = FrameProfiler.start(); + const hasOwner = this.game.hasOwner(tile); + const rawOwner = hasOwner ? this.game.owner(tile) : null; + const owner = + rawOwner && + typeof (rawOwner as any).isPlayer === "function" && + (rawOwner as any).isPlayer() + ? (rawOwner as PlayerView) + : null; + const isBorderTile = this.game.isBorder(tile); + const hasFallout = this.game.hasFallout(tile); + let isDefended = false; + if (owner && isBorderTile) { + isDefended = this.game.hasUnitNearby( + tile, + this.game.config().defensePostRange(), + UnitType.DefensePost, + owner.id(), + ); + } + + if (!owner) { + if (hasFallout) { + this.paintTileColor( + this.imageData, + tile, + this.theme.falloutColor(), + 150, + ); + this.paintTileColor( + this.alternativeImageData, + tile, + this.theme.falloutColor(), + 150, + ); + } else { + this.clearTile(tile); + } + FrameProfiler.end("TerritoryLayer:paintTerritory.cpu", cpuStart); + return; + } + + const myPlayer = this.game.myPlayer(); + + if (isBorderTile) { + if (myPlayer) { + const alternativeColor = this.alternateViewColor(owner); + this.paintTileColor( + this.alternativeImageData, + tile, + alternativeColor, + 255, + ); + } + this.paintTileColor( + this.imageData, + tile, + owner.borderColor(tile, isDefended), + 255, + ); + } else { + // Alternative view only shows borders. + this.clearAlternativeTile(tile); + this.paintTileColor( + this.imageData, + tile, + owner.territoryColor(tile), + 150, + ); + } + FrameProfiler.end("TerritoryLayer:paintTerritory.cpu", cpuStart); + } + + render( + context: CanvasRenderingContext2D, + viewport: { x: number; y: number; width: number; height: number }, + shouldBlit: boolean, + ) { + const { x, y, width, height } = viewport; + if (width <= 0 || height <= 0) { + return; + } + if (shouldBlit) { + const putImageStart = FrameProfiler.start(); + this.context.putImageData( + this.alternativeView ? this.alternativeImageData : this.imageData, + 0, + 0, + x, + y, + width, + height, + ); + FrameProfiler.end("TerritoryLayer:putImageData", putImageStart); + } + + const drawCanvasStart = FrameProfiler.start(); + context.drawImage( + this.canvas, + -this.game.width() / 2, + -this.game.height() / 2, + this.game.width(), + this.game.height(), + ); + FrameProfiler.end("TerritoryLayer:drawCanvas", drawCanvasStart); + } + + setAlternativeView(enabled: boolean): void { + this.alternativeView = enabled; + } + + setHover(): void { + // Canvas path relies on CPU highlight redraw in TerritoryLayer. + } + + setHoverHighlightOptions(): void { + // Not used in canvas mode. + } + + refreshPalette(): void { + // Nothing to refresh for canvas path. + } + + clearTile(tile: TileRef) { + const offset = tile * 4; + this.imageData.data[offset + 3] = 0; + this.alternativeImageData.data[offset + 3] = 0; + } + + private alternateViewColor(other: PlayerView): Colord { + const myPlayer = this.game.myPlayer(); + if (!myPlayer) { + return this.theme.neutralColor(); + } + if (other.smallID() === myPlayer.smallID()) { + return this.theme.selfColor(); + } + if (other.isFriendly(myPlayer)) { + return this.theme.allyColor(); + } + if (!other.hasEmbargo(myPlayer)) { + return this.theme.neutralColor(); + } + return this.theme.enemyColor(); + } + + private paintTileColor( + imageData: ImageData, + tile: TileRef, + color: Colord, + alpha: number, + ) { + const offset = tile * 4; + imageData.data[offset] = color.rgba.r; + imageData.data[offset + 1] = color.rgba.g; + imageData.data[offset + 2] = color.rgba.b; + imageData.data[offset + 3] = alpha; + } + + private clearAlternativeTile(tile: TileRef) { + const offset = tile * 4; + this.alternativeImageData.data[offset + 3] = 0; + } + + private initImageData() { + this.game.forEachTile((tile) => { + const offset = tile * 4; + this.imageData.data[offset + 3] = 0; + this.alternativeImageData.data[offset + 3] = 0; + }); + } +} + +export class WebglTerritoryRenderer implements TerritoryRendererStrategy { + constructor( + private readonly renderer: TerritoryWebGLRenderer, + private readonly game: GameView, + ) {} + + isWebGL(): boolean { + return true; + } + + redraw(): void { + this.markAllDirty(); + } + + markAllDirty(): void { + this.renderer.markAllDirty(); + } + + paintTile(tile: TileRef): void { + const hasOwner = this.game.hasOwner(tile); + const rawOwner = hasOwner ? this.game.owner(tile) : null; + const owner = + rawOwner && + typeof (rawOwner as any).isPlayer === "function" && + (rawOwner as any).isPlayer() + ? (rawOwner as PlayerView) + : null; + const isBorderTile = this.game.isBorder(tile); + let isDefended = false; + if (owner && isBorderTile) { + isDefended = this.game.hasUnitNearby( + tile, + this.game.config().defensePostRange(), + UnitType.DefensePost, + owner.id(), + ); + } + + this.renderer.markTile(tile); + if (!owner || !isBorderTile) { + this.renderer.clearBorderColor(tile); + } else { + const borderCol = owner.borderColor(tile, isDefended).rgba; + this.renderer.setBorderColor(tile, { + r: borderCol.r, + g: borderCol.g, + b: borderCol.b, + a: Math.round((borderCol.a ?? 1) * 255), + }); + } + } + + render( + context: CanvasRenderingContext2D, + _viewport: { x: number; y: number; width: number; height: number }, + _shouldBlit: boolean, + ): void { + const webglRenderStart = FrameProfiler.start(); + this.renderer.render(); + FrameProfiler.end("TerritoryLayer:territoryWebGL.render", webglRenderStart); + + const drawCanvasStart = FrameProfiler.start(); + context.drawImage( + this.renderer.canvas, + -this.game.width() / 2, + -this.game.height() / 2, + this.game.width(), + this.game.height(), + ); + FrameProfiler.end( + "TerritoryLayer:territoryWebGL.drawImage", + drawCanvasStart, + ); + } + + setAlternativeView(enabled: boolean): void { + this.renderer.setAlternativeView(enabled); + } + + setHover(playerSmallId: number | null): void { + this.renderer.setHoveredPlayerId(playerSmallId ?? null); + } + + setHoverHighlightOptions(options: HoverHighlightOptions): void { + this.renderer.setHoverHighlightOptions(options); + } + + refreshPalette(): void { + this.renderer.refreshPalette(); + } + + clearTile(): void { + // No-op for WebGL; canvas alpha clearing is not used. + } +}