diff --git a/src/client/graphics/layers/TerritoryLayer.ts b/src/client/graphics/layers/TerritoryLayer.ts index 802bc5b90..8cdd3461f 100644 --- a/src/client/graphics/layers/TerritoryLayer.ts +++ b/src/client/graphics/layers/TerritoryLayer.ts @@ -1,41 +1,21 @@ -import { PriorityQueue } from "@datastructures-js/priority-queue"; import { Colord } from "colord"; import { Theme } from "../../../core/configuration/Config"; import { EventBus } from "../../../core/EventBus"; -import { - ColoredTeams, - PlayerType, - Team, - UnitType, -} from "../../../core/game/Game"; +import { ColoredTeams, PlayerType, Team } from "../../../core/game/Game"; import { euclDistFN, TileRef } from "../../../core/game/GameMap"; import { GameUpdateType } from "../../../core/game/GameUpdates"; import { GameView, PlayerView } from "../../../core/game/GameView"; import { UserSettings } from "../../../core/game/UserSettings"; -import { PseudoRandom } from "../../../core/PseudoRandom"; import { AlternateViewEvent, ContextMenuEvent, - DragEvent, MouseOverEvent, } from "../../InputHandler"; import { FrameProfiler } from "../FrameProfiler"; import { TransformHandler } from "../TransformHandler"; import { Layer } from "./Layer"; -import { - CanvasTerritoryRenderer, - TerritoryRendererStrategy, - WebglTerritoryRenderer, -} from "./TerritoryRenderers"; import { TerritoryWebGLRenderer } from "./TerritoryWebGLRenderer"; -type TileTransition = { - startTime: number; - durationMs: number; - highlight: boolean; - lastProgressByte: number; -}; - export class TerritoryLayer implements Layer { profileName(): string { return "TerritoryLayer:renderLayer"; @@ -46,13 +26,6 @@ export class TerritoryLayer implements Layer { private cachedTerritoryPatternsEnabled: boolean | undefined; - private tileToRenderQueue: PriorityQueue<{ - tile: TileRef; - lastUpdate: number; - }> = new PriorityQueue((a, b) => { - return a.lastUpdate - b.lastUpdate; - }); - private random = new PseudoRandom(123); private theme: Theme; // Used for spawn highlighting @@ -60,22 +33,19 @@ export class TerritoryLayer implements Layer { private highlightContext: CanvasRenderingContext2D; private highlightedTerritory: PlayerView | null = null; - private territoryRenderer: TerritoryRendererStrategy | null = null; + private territoryRenderer: TerritoryWebGLRenderer | null = null; private alternativeView = false; - private lastDragTime = 0; - private nodrawDragDuration = 200; private lastMousePosition: { x: number; y: number } | null = null; - private refreshRate = 10; //refresh every 10ms - private lastRefresh = 0; - private lastFocusedPlayer: PlayerView | null = null; private lastMyPlayerSmallId: number | null = null; private lastPaletteSignature: string | null = null; - private tileTransitions: Map = new Map(); - private transitionHighlightTiles: TileRef[] = []; - private transitionHighlightAlphas: number[] = []; + private transitionActive = false; + private transitionEpoch = 1; + private transitionProgress = 1; + private transitionStartTime = 0; + private transitionDurationMs = 100; private lastGameTick = 0; private lastTickTime = 0; private lastTickDurationMs = 100; @@ -88,23 +58,16 @@ export class TerritoryLayer implements Layer { this.theme = game.config().theme(); this.cachedTerritoryPatternsEnabled = undefined; this.lastMyPlayerSmallId = game.myPlayer()?.smallID() ?? null; - this.lastTickTime = Date.now(); + this.lastTickTime = this.nowMs(); } shouldTransform(): boolean { return true; } - async paintPlayerBorder(player: PlayerView) { - const tiles = await player.borderTiles(); - tiles.borderTiles.forEach((tile: TileRef) => { - this.paintTerritory(tile, true); // Immediately paint the tile instead of enqueueing - }); - } - tick() { const tickProfile = FrameProfiler.start(); - const now = Date.now(); + const now = this.nowMs(); this.updateTickTiming(now); if (this.game.inSpawnPhase()) { this.spawnHighlight(); @@ -117,38 +80,9 @@ export class TerritoryLayer implements Layer { } this.refreshPaletteIfNeeded(); - this.game.recentlyUpdatedTiles().forEach((t) => { - this.enqueueTile(t); - // Immediately clear territory overlay for water tiles so old - // borders/territory don't persist visually (e.g. after nuke turns land to water) - if (this.game.isWater(t)) { - this.clearTile(t); - } - }); + this.game.recentlyUpdatedTiles().forEach((t) => this.markTile(t)); this.beginTileTransitions(this.game.recentlyUpdatedOwnerTiles(), now); const updates = this.game.updatesSinceLastTick(); - const unitUpdates = updates !== null ? updates[GameUpdateType.Unit] : []; - unitUpdates.forEach((update) => { - if (update.unitType === UnitType.DefensePost) { - // Only update borders if the defense post is not under construction - if (update.underConstruction) { - return; // Skip barrier creation while under construction - } - - const tile = update.pos; - this.game - .bfs(tile, euclDistFN(tile, this.game.config().defensePostRange())) - .forEach((t) => { - if ( - this.game.isBorder(t) && - (this.game.ownerID(t) === update.ownerID || - this.game.ownerID(t) === update.lastOwnerID) - ) { - this.enqueueTile(t); - } - }); - } - }); // Detect alliance mutations const myPlayer = this.game.myPlayer(); @@ -156,7 +90,7 @@ export class TerritoryLayer implements Layer { updates?.[GameUpdateType.BrokeAlliance]?.forEach((update) => { const territory = this.game.playerBySmallID(update.betrayedID); if (territory && territory instanceof PlayerView) { - this.redrawBorder(territory); + this.territoryRenderer?.refreshPalette(); } }); @@ -172,7 +106,7 @@ export class TerritoryLayer implements Layer { : update.request.requestorID; const territory = this.game.playerBySmallID(territoryId); if (territory && territory instanceof PlayerView) { - this.redrawBorder(territory); + this.territoryRenderer?.refreshPalette(); } } }); @@ -186,23 +120,14 @@ export class TerritoryLayer implements Layer { player.id() === myPlayer?.id() || embargoed.id() === myPlayer?.id() ) { - this.redrawBorder(player, embargoed); + this.territoryRenderer?.refreshPalette(); } }); } const focusedPlayer = this.game.focusedPlayer(); if (focusedPlayer !== this.lastFocusedPlayer) { - if (this.territoryRenderer?.isWebGL()) { - this.redraw(); - } else { - if (this.lastFocusedPlayer) { - this.paintPlayerBorder(this.lastFocusedPlayer); - } - if (focusedPlayer) { - this.paintPlayerBorder(focusedPlayer); - } - } + this.redraw(); this.lastFocusedPlayer = focusedPlayer; } @@ -374,10 +299,6 @@ export class TerritoryLayer implements Layer { this.hoverHighlightOptions(), ); }); - this.eventBus.on(DragEvent, () => { - // TODO: consider re-enabling this on mobile or low end devices for smoother dragging. - // this.lastDragTime = Date.now(); - }); this.redraw(); } @@ -387,13 +308,7 @@ export class TerritoryLayer implements Layer { } private updateHighlightedTerritory() { - const supportsHover = - this.alternativeView || this.territoryRenderer?.isWebGL() === true; - if (!supportsHover) { - return; - } - - if (!this.lastMousePosition) { + if (!this.lastMousePosition || !this.territoryRenderer) { return; } @@ -415,20 +330,9 @@ export class TerritoryLayer implements Layer { } if (previousTerritory?.id() !== this.highlightedTerritory?.id()) { - if (this.territoryRenderer?.isWebGL()) { - this.territoryRenderer.setHover( - this.highlightedTerritory?.smallID() ?? null, - ); - } else { - const territories: PlayerView[] = []; - if (previousTerritory) { - territories.push(previousTerritory); - } - if (this.highlightedTerritory) { - territories.push(this.highlightedTerritory); - } - this.redrawBorder(...territories); - } + this.territoryRenderer.setHoveredPlayerId( + this.highlightedTerritory?.smallID() ?? null, + ); } } @@ -445,14 +349,10 @@ export class TerritoryLayer implements Layer { } redraw() { - console.log("redrew territory layer"); this.lastMyPlayerSmallId = this.game.myPlayer()?.smallID() ?? null; this.cachedTerritoryPatternsEnabled = this.userSettings.territoryPatterns(); this.configureRenderers(); - this.tileTransitions.clear(); - this.transitionHighlightTiles.length = 0; - this.transitionHighlightAlphas.length = 0; - this.territoryRenderer?.redraw(); + this.transitionActive = false; // Add a second canvas for highlights this.highlightCanvas = document.createElement("canvas"); @@ -463,34 +363,28 @@ export class TerritoryLayer implements Layer { this.highlightContext = highlightContext; this.highlightCanvas.width = this.game.width(); this.highlightCanvas.height = this.game.height(); - - this.game.forEachTile((t) => { - this.paintTerritory(t); - }); } private configureRenderers() { - this.territoryRenderer = null; - - const { renderer } = TerritoryWebGLRenderer.create(this.game, this.theme); - 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.lastPaletteSignature = this.computePaletteSignature(); - return; + const { renderer, reason } = TerritoryWebGLRenderer.create( + this.game, + this.theme, + ); + if (!renderer) { + throw new Error(reason ?? "WebGL2 is required for territory rendering."); } - this.territoryRenderer = new CanvasTerritoryRenderer(this.game, this.theme); + this.territoryRenderer = renderer; this.territoryRenderer.setAlternativeView(this.alternativeView); + this.territoryRenderer.markAllDirty(); + this.territoryRenderer.refreshPalette(); this.territoryRenderer.setHoverHighlightOptions( this.hoverHighlightOptions(), ); - this.lastPaletteSignature = null; + this.territoryRenderer.setHoveredPlayerId( + this.highlightedTerritory?.smallID() ?? null, + ); + this.lastPaletteSignature = this.computePaletteSignature(); } private hoverHighlightOptions() { @@ -514,58 +408,26 @@ export class TerritoryLayer implements Layer { }; } - redrawBorder(...players: PlayerView[]) { - const shouldRefreshPalette = this.territoryRenderer?.isWebGL() ?? false; - return Promise.all( - players.map(async (player) => { - const tiles = await player.borderTiles(); - tiles.borderTiles.forEach((tile: TileRef) => { - this.paintTerritory(tile, true); - }); - }), - ).then(() => { - if (shouldRefreshPalette) { - this.territoryRenderer?.refreshPalette(); - } - }); - } - renderLayer(context: CanvasRenderingContext2D) { - const now = Date.now(); - const canRefresh = - now > this.lastDragTime + this.nodrawDragDuration && - now > this.lastRefresh + this.refreshRate; - if (canRefresh) { - this.lastRefresh = now; - const renderTerritoryStart = FrameProfiler.start(); - this.renderTerritory(); - FrameProfiler.end("TerritoryLayer:renderTerritory", renderTerritoryStart); + if (!this.territoryRenderer) { + return; } - + const now = this.nowMs(); this.updateTransitionProgress(now); - 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 renderTerritoryStart = FrameProfiler.start(); + this.territoryRenderer.render(); + FrameProfiler.end("TerritoryLayer:renderTerritory", renderTerritoryStart); - 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, - ); - } - - this.drawTransitionHighlights(context, now); + const drawTerritoryStart = FrameProfiler.start(); + context.drawImage( + this.territoryRenderer.canvas, + -this.game.width() / 2, + -this.game.height() / 2, + this.game.width(), + this.game.height(), + ); + FrameProfiler.end("TerritoryLayer:drawTerritoryCanvas", drawTerritoryStart); if (this.game.inSpawnPhase()) { const highlightDrawStart = FrameProfiler.start(); @@ -583,73 +445,11 @@ export class TerritoryLayer implements Layer { } } - renderTerritory() { - if (!this.territoryRenderer) { - return; - } - let numToRender = this.tileToRenderQueue.size(); - if (numToRender === 0) { - return; - } - - const useNeighborPaint = !(this.territoryRenderer?.isWebGL() ?? false); - const neighborsToPaint: TileRef[] = []; - const mainSpan = FrameProfiler.start(); - while (numToRender > 0) { - numToRender--; - - const entry = this.tileToRenderQueue.pop(); - if (!entry) { - break; - } - - const tile = entry.tile; - this.paintTerritory(tile); - - if (useNeighborPaint) { - for (const neighbor of this.game.neighbors(tile)) { - neighborsToPaint.push(neighbor); - } - } - } - FrameProfiler.end("TerritoryLayer:renderTerritory.mainPaint", mainSpan); - - if (useNeighborPaint && neighborsToPaint.length > 0) { - const neighborSpan = FrameProfiler.start(); - for (const neighbor of neighborsToPaint) { - this.paintTerritory(neighbor, true); - } - FrameProfiler.end( - "TerritoryLayer:renderTerritory.neighborPaint", - neighborSpan, - ); - } - } - - paintTerritory(tile: TileRef, _maybeStaleBorder: boolean = false) { - this.territoryRenderer?.paintTile(tile); - } - - clearTile(tile: TileRef) { - this.territoryRenderer?.clearTile(tile); - } - - enqueueTile(tile: TileRef) { - this.tileToRenderQueue.push({ - tile: tile, - lastUpdate: this.game.ticks() + this.random.nextFloat(0, 0.5), - }); - } - - async enqueuePlayerBorder(player: PlayerView) { - const playerBorderTiles = await player.borderTiles(); - playerBorderTiles.borderTiles.forEach((tile: TileRef) => { - this.enqueueTile(tile); - }); + private markTile(tile: TileRef) { + this.territoryRenderer?.markTile(tile); } paintHighlightTile(tile: TileRef, color: Colord, alpha: number) { - this.clearTile(tile); const x = this.game.x(tile); const y = this.game.y(tile); this.highlightContext.fillStyle = color.alpha(alpha / 255).toRgbString(); @@ -701,6 +501,10 @@ export class TerritoryLayer implements Layer { ctx.fill(); } + private nowMs(): number { + return typeof performance !== "undefined" ? performance.now() : Date.now(); + } + private updateTickTiming(now: number) { const currentTick = this.game.ticks(); if (currentTick === this.lastGameTick) { @@ -723,87 +527,46 @@ export class TerritoryLayer implements Layer { if (changes.length === 0) { return; } - const durationMs = this.lastTickDurationMs; + this.transitionEpoch = (this.transitionEpoch + 1) & 0xff; + if (this.transitionEpoch === 0) { + this.transitionEpoch = 1; + } + this.transitionStartTime = now; + this.transitionDurationMs = this.lastTickDurationMs; + this.transitionProgress = 0; + this.transitionActive = false; + for (const change of changes) { if (change.newOwner === change.previousOwner) { continue; } - if (change.newOwner === 0) { - this.tileTransitions.delete(change.tile); - this.territoryRenderer?.setTransitionProgress(change.tile, 1); - continue; + if (this.territoryRenderer) { + this.territoryRenderer.setTransitionEpoch( + change.tile, + this.transitionEpoch, + ); + this.transitionActive = true; } - this.tileTransitions.set(change.tile, { - startTime: now, - durationMs, - highlight: change.newOwner !== 0, - lastProgressByte: -1, - }); - this.territoryRenderer?.setTransitionProgress(change.tile, 0); } } private updateTransitionProgress(now: number) { - this.transitionHighlightTiles.length = 0; - this.transitionHighlightAlphas.length = 0; - if (!this.territoryRenderer || this.tileTransitions.size === 0) { + if (!this.territoryRenderer || !this.transitionActive) { return; } - - const toDelete: TileRef[] = []; - for (const [tile, transition] of this.tileTransitions) { - const elapsed = now - transition.startTime; - const duration = transition.durationMs > 0 ? transition.durationMs : 1; - const progress = Math.max(0, Math.min(1, elapsed / duration)); - const progressByte = Math.round(progress * 255); - if (progressByte !== transition.lastProgressByte) { - transition.lastProgressByte = progressByte; - this.territoryRenderer.setTransitionProgress(tile, progress); - } - if (transition.highlight && progress < 1) { - const alpha = (1 - progress) * 0.35; - if (alpha > 0.01) { - this.transitionHighlightTiles.push(tile); - this.transitionHighlightAlphas.push(alpha); - } - } - if (progress >= 1) { - toDelete.push(tile); - } + const elapsed = now - this.transitionStartTime; + const duration = + this.transitionDurationMs > 0 ? this.transitionDurationMs : 1; + const progress = Math.max(0, Math.min(1, elapsed / duration)); + const eased = progress * progress * (3 - 2 * progress); + this.transitionProgress = eased; + this.territoryRenderer.setTransitionProgress( + this.transitionProgress, + this.transitionEpoch, + ); + if (progress >= 1) { + this.transitionActive = false; } - for (const tile of toDelete) { - this.tileTransitions.delete(tile); - } - } - - private drawTransitionHighlights( - context: CanvasRenderingContext2D, - now: number, - ) { - if (this.transitionHighlightTiles.length === 0) { - return; - } - const pulse = 0.75 + 0.25 * Math.sin((now - this.lastTickTime) * 0.015); - const highlight = this.theme.spawnHighlightColor(); - const offsetX = -this.game.width() / 2; - const offsetY = -this.game.height() / 2; - context.save(); - context.fillStyle = highlight.toRgbString(); - for (let i = 0; i < this.transitionHighlightTiles.length; i++) { - const alpha = this.transitionHighlightAlphas[i] * pulse; - if (alpha <= 0) { - continue; - } - const tile = this.transitionHighlightTiles[i]; - context.globalAlpha = alpha; - context.fillRect( - this.game.x(tile) + offsetX, - this.game.y(tile) + offsetY, - 1, - 1, - ); - } - context.restore(); } private computePaletteSignature(): string { @@ -816,7 +579,7 @@ export class TerritoryLayer implements Layer { } private refreshPaletteIfNeeded() { - if (!this.territoryRenderer?.isWebGL()) { + if (!this.territoryRenderer) { return; } const signature = this.computePaletteSignature(); diff --git a/src/client/graphics/layers/TerritoryRenderers.ts b/src/client/graphics/layers/TerritoryRenderers.ts deleted file mode 100644 index 2a7d542cb..000000000 --- a/src/client/graphics/layers/TerritoryRenderers.ts +++ /dev/null @@ -1,324 +0,0 @@ -import { Colord } from "colord"; -import { Theme } from "../../../core/configuration/Config"; -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; - setTransitionProgress(tile: TileRef, progress: number): 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; - private transitionProgress: Map = new Map(); - - 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); - const isDefended = - owner && isBorderTile ? this.game.isDefended(tile) : false; - - const transitionFactor = this.transitionProgress.get(tile) ?? 1; - if (!owner) { - if (hasFallout) { - this.paintTileColor( - this.imageData, - tile, - this.theme.falloutColor(), - Math.round(150 * transitionFactor), - ); - this.paintTileColor( - this.alternativeImageData, - tile, - this.theme.falloutColor(), - Math.round(150 * transitionFactor), - ); - } else { - this.clearTile(tile); - } - FrameProfiler.end("CanvasTerritoryRenderer:paintTile", cpuStart); - return; - } - - const myPlayer = this.game.myPlayer(); - - if (isBorderTile) { - if (myPlayer) { - const alternativeColor = this.alternateViewColor(owner); - this.paintTileColor( - this.alternativeImageData, - tile, - alternativeColor, - Math.round(255 * transitionFactor), - ); - } - this.paintTileColor( - this.imageData, - tile, - owner.borderColor(tile, isDefended), - Math.round(255 * transitionFactor), - ); - } else { - // Alternative view only shows borders. - this.clearAlternativeTile(tile); - this.paintTileColor( - this.imageData, - tile, - owner.territoryColor(tile), - Math.round(150 * transitionFactor), - ); - } - FrameProfiler.end("CanvasTerritoryRenderer:paintTile", 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("CanvasTerritoryRenderer: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("CanvasTerritoryRenderer:drawCanvas", drawCanvasStart); - } - - setAlternativeView(enabled: boolean): void { - this.alternativeView = enabled; - } - - setTransitionProgress(tile: TileRef, progress: number): void { - const clamped = Math.max(0, Math.min(1, progress)); - if (clamped >= 1) { - if (this.transitionProgress.delete(tile)) { - this.paintTile(tile); - } - return; - } - const previous = this.transitionProgress.get(tile); - if (previous !== undefined && Math.abs(previous - clamped) < 1 / 255) { - return; - } - this.transitionProgress.set(tile, clamped); - this.paintTile(tile); - } - - 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 { - this.renderer.markTile(tile); - } - - setTransitionProgress(tile: TileRef, progress: number): void { - this.renderer.setTransitionProgress(tile, progress); - } - - render( - context: CanvasRenderingContext2D, - _viewport: { x: number; y: number; width: number; height: number }, - _shouldBlit: boolean, - ): void { - const webglRenderStart = FrameProfiler.start(); - this.renderer.render(); - FrameProfiler.end("WebglTerritoryRenderer: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("WebglTerritoryRenderer: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. - } -} diff --git a/src/client/graphics/layers/TerritoryWebGLRenderer.ts b/src/client/graphics/layers/TerritoryWebGLRenderer.ts index 5647877f8..ef3a11758 100644 --- a/src/client/graphics/layers/TerritoryWebGLRenderer.ts +++ b/src/client/graphics/layers/TerritoryWebGLRenderer.ts @@ -43,6 +43,10 @@ export class TerritoryWebGLRenderer { relations: WebGLUniformLocation | null; patterns: WebGLUniformLocation | null; transitions: WebGLUniformLocation | null; + transitionEpoch: WebGLUniformLocation | null; + transitionProgress: WebGLUniformLocation | null; + transitionHintColor: WebGLUniformLocation | null; + transitionHintStrength: WebGLUniformLocation | null; patternStride: WebGLUniformLocation | null; patternRows: WebGLUniformLocation | null; fallout: WebGLUniformLocation | null; @@ -62,7 +66,7 @@ export class TerritoryWebGLRenderer { }; private readonly state: Uint16Array; - private readonly transitionState: Uint8Array; + private readonly transitionEpochState: Uint8Array; private readonly dirtyRows: Map = new Map(); private readonly transitionDirtyRows: Map = new Map(); private needsFullUpload = true; @@ -75,6 +79,10 @@ export class TerritoryWebGLRenderer { private hoverPulseSpeed = Math.PI * 2; private hoveredPlayerId = -1; private animationStartTime = Date.now(); + private transitionEpoch = 1; + private transitionProgress = 1; + private transitionHintColor: [number, number, number] = [1, 1, 1]; + private transitionHintStrength = 0.25; private readonly userSettings = new UserSettings(); private readonly patternBytesCache = new Map(); @@ -88,8 +96,7 @@ export class TerritoryWebGLRenderer { this.canvas.height = game.height(); this.state = state; - this.transitionState = new Uint8Array(state.length); - this.transitionState.fill(255); + this.transitionEpochState = new Uint8Array(state.length); this.gl = this.canvas.getContext("webgl2", { premultipliedAlpha: true, @@ -113,6 +120,10 @@ export class TerritoryWebGLRenderer { relations: null, patterns: null, transitions: null, + transitionEpoch: null, + transitionProgress: null, + transitionHintColor: null, + transitionHintStrength: null, patternStride: null, patternRows: null, fallout: null, @@ -150,6 +161,10 @@ export class TerritoryWebGLRenderer { relations: null, patterns: null, transitions: null, + transitionEpoch: null, + transitionProgress: null, + transitionHintColor: null, + transitionHintStrength: null, patternStride: null, patternRows: null, fallout: null, @@ -177,6 +192,19 @@ export class TerritoryWebGLRenderer { relations: gl.getUniformLocation(this.program, "u_relations"), patterns: gl.getUniformLocation(this.program, "u_patterns"), transitions: gl.getUniformLocation(this.program, "u_transitions"), + transitionEpoch: gl.getUniformLocation(this.program, "u_transitionEpoch"), + transitionProgress: gl.getUniformLocation( + this.program, + "u_transitionProgress", + ), + transitionHintColor: gl.getUniformLocation( + this.program, + "u_transitionHintColor", + ), + transitionHintStrength: gl.getUniformLocation( + this.program, + "u_transitionHintStrength", + ), patternStride: gl.getUniformLocation(this.program, "u_patternStride"), patternRows: gl.getUniformLocation(this.program, "u_patternRows"), fallout: gl.getUniformLocation(this.program, "u_fallout"), @@ -274,7 +302,7 @@ export class TerritoryWebGLRenderer { 0, gl.RED_INTEGER, gl.UNSIGNED_BYTE, - this.transitionState, + this.transitionEpochState, ); gl.useProgram(this.program); @@ -370,6 +398,27 @@ export class TerritoryWebGLRenderer { if (this.uniforms.hoverPulseSpeed) { gl.uniform1f(this.uniforms.hoverPulseSpeed, this.hoverPulseSpeed); } + if (this.uniforms.transitionEpoch) { + gl.uniform1i(this.uniforms.transitionEpoch, this.transitionEpoch); + } + if (this.uniforms.transitionProgress) { + gl.uniform1f(this.uniforms.transitionProgress, this.transitionProgress); + } + if (this.uniforms.transitionHintColor) { + this.transitionHintColor = [1, 1, 1]; + gl.uniform3f( + this.uniforms.transitionHintColor, + this.transitionHintColor[0], + this.transitionHintColor[1], + this.transitionHintColor[2], + ); + } + if (this.uniforms.transitionHintStrength) { + gl.uniform1f( + this.uniforms.transitionHintStrength, + this.transitionHintStrength, + ); + } gl.enable(gl.BLEND); gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA); @@ -382,8 +431,7 @@ export class TerritoryWebGLRenderer { if (state.length !== expected) { return { renderer: null, - reason: - "Tile state buffer size mismatch; falling back to canvas territory draw.", + reason: "Tile state buffer size mismatch; WebGL renderer disabled.", }; } @@ -391,7 +439,7 @@ export class TerritoryWebGLRenderer { if (!renderer.isValid()) { return { renderer: null, - reason: "WebGL2 not available; falling back to canvas territory draw.", + reason: "WebGL2 not available; WebGL renderer disabled.", }; } return { renderer }; @@ -444,13 +492,12 @@ export class TerritoryWebGLRenderer { } } - setTransitionProgress(tile: TileRef, progress: number) { - const clamped = Math.max(0, Math.min(1, progress)); - const value = Math.round(clamped * 255); - if (this.transitionState[tile] === value) { + setTransitionEpoch(tile: TileRef, epoch: number) { + const value = epoch & 0xff; + if (this.transitionEpochState[tile] === value) { return; } - this.transitionState[tile] = value; + this.transitionEpochState[tile] = value; if (this.needsTransitionFullUpload) { return; } @@ -465,9 +512,16 @@ export class TerritoryWebGLRenderer { } } + setTransitionProgress(progress: number, epoch: number) { + this.transitionEpoch = epoch & 0xff; + this.transitionProgress = Math.max(0, Math.min(1, progress)); + } + markAllDirty() { this.needsFullUpload = true; this.dirtyRows.clear(); + this.needsTransitionFullUpload = true; + this.transitionDirtyRows.clear(); } refreshPalette() { @@ -528,6 +582,12 @@ export class TerritoryWebGLRenderer { const viewerId = this.game.myPlayer()?.smallID() ?? 0; gl.uniform1i(this.uniforms.viewerId, viewerId); } + if (this.uniforms.transitionEpoch) { + gl.uniform1i(this.uniforms.transitionEpoch, this.transitionEpoch); + } + if (this.uniforms.transitionProgress) { + gl.uniform1f(this.uniforms.transitionProgress, this.transitionProgress); + } gl.clearColor(0, 0, 0, 0); gl.clear(gl.COLOR_BUFFER_BIT); @@ -611,7 +671,7 @@ export class TerritoryWebGLRenderer { 0, gl.RED_INTEGER, gl.UNSIGNED_BYTE, - this.transitionState, + this.transitionEpochState, ); this.needsTransitionFullUpload = false; this.transitionDirtyRows.clear(); @@ -627,7 +687,10 @@ export class TerritoryWebGLRenderer { for (const [y, span] of this.transitionDirtyRows) { const width = span.maxX - span.minX + 1; const offset = y * this.canvas.width + span.minX; - const rowSlice = this.transitionState.subarray(offset, offset + width); + const rowSlice = this.transitionEpochState.subarray( + offset, + offset + width, + ); gl.texSubImage2D( gl.TEXTURE_2D, 0, @@ -834,6 +897,10 @@ export class TerritoryWebGLRenderer { uniform usampler2D u_relations; uniform usampler2D u_patterns; uniform usampler2D u_transitions; + uniform int u_transitionEpoch; + uniform float u_transitionProgress; + uniform vec3 u_transitionHintColor; + uniform float u_transitionHintStrength; uniform int u_patternStride; uniform int u_patternRows; uniform int u_viewerId; @@ -918,7 +985,11 @@ export class TerritoryWebGLRenderer { ivec2 texCoord = ivec2(fragCoord.x, int(u_resolution.y) - 1 - fragCoord.y); uint state = texelFetch(u_state, texCoord, 0).r; - float transition = float(texelFetch(u_transitions, texCoord, 0).r) / 255.0; + uint transitionEpoch = texelFetch(u_transitions, texCoord, 0).r; + bool inTransition = + (u_transitionEpoch != 0) && (transitionEpoch == uint(u_transitionEpoch)); + float transition = inTransition ? u_transitionProgress : 1.0; + float hintMix = inTransition ? u_transitionHintStrength * (1.0 - transition) : 0.0; uint owner = state & 0xFFFu; bool hasFallout = (state & 0x2000u) != 0u; bool isDefended = (state & 0x1000u) != 0u; @@ -926,7 +997,7 @@ export class TerritoryWebGLRenderer { if (owner == 0u) { if (hasFallout) { vec3 color = u_fallout.rgb; - float a = u_alpha * transition; + float a = u_alpha; outColor = vec4(color * a, a); } else { outColor = vec4(0.0); @@ -982,7 +1053,7 @@ export class TerritoryWebGLRenderer { float pulse = u_hoverPulseStrength > 0.0 ? (1.0 - u_hoverPulseStrength) + u_hoverPulseStrength * (0.5 + 0.5 * sin(u_time * u_hoverPulseSpeed)) - : 1.0; + : 1.0; color = mix(color, u_hoverHighlightColor, u_hoverHighlightStrength * pulse); } outColor = vec4(color * a, a); @@ -1018,13 +1089,17 @@ export class TerritoryWebGLRenderer { } color = borderColor; - a = baseBorder.a; + a = baseBorder.a * transition; } else { bool isPrimary = patternIsPrimary(owner, texCoord); color = isPrimary ? base.rgb : baseBorder.rgb; a = u_alpha; } + if (hintMix > 0.0) { + color = mix(color, u_transitionHintColor, hintMix); + } + if (u_hoveredPlayerId >= 0.0 && abs(float(owner) - u_hoveredPlayerId) < 0.5) { float pulse = u_hoverPulseStrength > 0.0 ? (1.0 - u_hoverPulseStrength) + @@ -1033,7 +1108,6 @@ export class TerritoryWebGLRenderer { color = mix(color, u_hoverHighlightColor, u_hoverHighlightStrength * pulse); } - a *= transition; outColor = vec4(color * a, a); } `;