From 46e713baf657d4e9f087fa8d9eb851ce385d9e27 Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Fri, 9 Jan 2026 18:00:28 +0100 Subject: [PATCH] v1-dev --- src/client/graphics/layers/TerritoryLayer.ts | 366 +++---- .../graphics/layers/TerritoryRenderers.ts | 301 ++++++ .../graphics/layers/TerritoryWebGLRenderer.ts | 971 ++++++++++++++++++ src/core/game/GameImpl.ts | 84 ++ src/core/game/GameMap.ts | 20 + src/core/game/GameView.ts | 9 + src/core/game/UnitImpl.ts | 3 + 7 files changed, 1555 insertions(+), 199 deletions(-) create mode 100644 src/client/graphics/layers/TerritoryRenderers.ts create mode 100644 src/client/graphics/layers/TerritoryWebGLRenderer.ts diff --git a/src/client/graphics/layers/TerritoryLayer.ts b/src/client/graphics/layers/TerritoryLayer.ts index 08d3f5a9c..7d406bfa2 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, @@ -16,19 +15,26 @@ 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"; export class TerritoryLayer implements Layer { + profileName(): string { + return "TerritoryLayer:renderLayer"; + } + private userSettings: UserSettings; - private canvas: HTMLCanvasElement; - private context: CanvasRenderingContext2D; - private imageData: ImageData; - private alternativeImageData: ImageData; private borderAnimTime = 0; private cachedTerritoryPatternsEnabled: boolean | undefined; @@ -47,6 +53,7 @@ export class TerritoryLayer implements Layer { private highlightContext: CanvasRenderingContext2D; private highlightedTerritory: PlayerView | null = null; + private territoryRenderer: TerritoryRendererStrategy | null = null; private alternativeView = false; private lastDragTime = 0; @@ -57,6 +64,7 @@ export class TerritoryLayer implements Layer { private lastRefresh = 0; private lastFocusedPlayer: PlayerView | null = null; + private lastMyPlayerSmallId: number | null = null; constructor( private game: GameView, @@ -67,6 +75,7 @@ export class TerritoryLayer implements Layer { this.userSettings = userSettings; this.theme = game.config().theme(); this.cachedTerritoryPatternsEnabled = undefined; + this.lastMyPlayerSmallId = game.myPlayer()?.smallID() ?? null; } shouldTransform(): boolean { @@ -81,10 +90,17 @@ export class TerritoryLayer implements Layer { } tick() { + const tickProfile = FrameProfiler.start(); if (this.game.inSpawnPhase()) { this.spawnHighlight(); } + const patternsEnabled = this.userSettings.territoryPatterns(); + if (this.cachedTerritoryPatternsEnabled !== patternsEnabled) { + this.cachedTerritoryPatternsEnabled = patternsEnabled; + this.redraw(); + } + this.game.recentlyUpdatedTiles().forEach((t) => this.enqueueTile(t)); const updates = this.game.updatesSinceLastTick(); const unitUpdates = updates !== null ? updates[GameUpdateType.Unit] : []; @@ -153,14 +169,24 @@ export class TerritoryLayer implements Layer { const focusedPlayer = this.game.focusedPlayer(); if (focusedPlayer !== this.lastFocusedPlayer) { - if (this.lastFocusedPlayer) { - this.paintPlayerBorder(this.lastFocusedPlayer); - } - if (focusedPlayer) { - this.paintPlayerBorder(focusedPlayer); + if (this.territoryRenderer?.isWebGL()) { + this.redraw(); + } else { + if (this.lastFocusedPlayer) { + this.paintPlayerBorder(this.lastFocusedPlayer); + } + if (focusedPlayer) { + this.paintPlayerBorder(focusedPlayer); + } } this.lastFocusedPlayer = focusedPlayer; } + + const currentMyPlayer = this.game.myPlayer()?.smallID() ?? null; + if (currentMyPlayer !== this.lastMyPlayerSmallId) { + this.redraw(); + } + FrameProfiler.end("TerritoryLayer:tick", tickProfile); } private spawnHighlight() { @@ -202,7 +228,6 @@ export class TerritoryLayer implements Layer { color = this.theme.spawnHighlightColor(); } else if (myPlayer !== null && myPlayer !== human) { // In Team games, the spawn highlight color becomes that player's team color - // Optionally, this could be broken down to teammate or enemy and simplified to green and red, respectively const team = human.team(); if (team !== null && teamColors.includes(team)) { color = this.theme.teamColor(team); @@ -240,11 +265,10 @@ export class TerritoryLayer implements Layer { this.borderAnimTime += 0.5; const minRad = 8; const maxRad = 24; - // Range: [minPadding..maxPadding] const radius = minRad + (maxRad - minRad) * (0.5 + 0.5 * Math.sin(this.borderAnimTime)); - const baseColor = this.theme.spawnHighlightSelfColor(); //white + const baseColor = this.theme.spawnHighlightSelfColor(); let teamColor: Colord | null = null; const team: Team | null = focusedPlayer.team(); @@ -260,11 +284,10 @@ export class TerritoryLayer implements Layer { minRad, maxRad, radius, - baseColor, // Always draw white static semi-transparent ring - teamColor, // Pass the breathing ring color. White for FFA, Duos, Trios, Quads. Transparent team color for TEAM games. + baseColor, + teamColor, ); - // Draw breathing rings for teammates in team games (helps colorblind players identify teammates) this.drawTeammateHighlights(minRad, maxRad, radius); } @@ -282,7 +305,6 @@ export class TerritoryLayer implements Layer { .playerViews() .filter((p) => p !== myPlayer && myPlayer.isOnSameTeam(p)); - // Smaller radius for teammates (more subtle than self highlight) const teammateMinRad = 5; const teammateMaxRad = 14; const teammateRadius = @@ -323,10 +345,16 @@ export class TerritoryLayer implements Layer { init() { this.eventBus.on(MouseOverEvent, (e) => this.onMouseOver(e)); + this.eventBus.on(ContextMenuEvent, (e) => this.onMouseOver(e)); this.eventBus.on(AlternateViewEvent, (e) => { this.alternativeView = e.alternateView; + this.territoryRenderer?.setAlternativeView(this.alternativeView); + this.territoryRenderer?.markAllDirty(); + this.territoryRenderer?.setHoverHighlightOptions( + this.hoverHighlightOptions(), + ); }); - this.eventBus.on(DragEvent, (e) => { + this.eventBus.on(DragEvent, () => { // TODO: consider re-enabling this on mobile or low end devices for smoother dragging. // this.lastDragTime = Date.now(); }); @@ -339,7 +367,9 @@ export class TerritoryLayer implements Layer { } private updateHighlightedTerritory() { - if (!this.alternativeView) { + const supportsHover = + this.alternativeView || this.territoryRenderer?.isWebGL() === true; + if (!supportsHover) { return; } @@ -365,14 +395,20 @@ export class TerritoryLayer implements Layer { } if (previousTerritory?.id() !== this.highlightedTerritory?.id()) { - const territories: PlayerView[] = []; - if (previousTerritory) { - territories.push(previousTerritory); + 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); } - if (this.highlightedTerritory) { - territories.push(this.highlightedTerritory); - } - this.redrawBorder(...territories); } } @@ -381,7 +417,6 @@ export class TerritoryLayer implements Layer { if (!tile) { return null; } - // If the tile has no owner, it is either a fallout tile or a terra nullius tile. if (!this.game.hasOwner(tile)) { return null; } @@ -391,32 +426,10 @@ export class TerritoryLayer implements Layer { redraw() { console.log("redrew territory layer"); - 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(); - - this.context.putImageData( - this.alternativeView ? this.alternativeImageData : this.imageData, - 0, - 0, - ); + this.lastMyPlayerSmallId = this.game.myPlayer()?.smallID() ?? null; + this.cachedTerritoryPatternsEnabled = this.userSettings.territoryPatterns(); + this.configureRenderers(); + this.territoryRenderer?.redraw(); // Add a second canvas for highlights this.highlightCanvas = document.createElement("canvas"); @@ -433,7 +446,51 @@ export class TerritoryLayer implements Layer { }); } + 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; + return; + } + + this.territoryRenderer = new CanvasTerritoryRenderer(this.game, this.theme); + this.territoryRenderer.setAlternativeView(this.alternativeView); + this.territoryRenderer.setHoverHighlightOptions( + this.hoverHighlightOptions(), + ); + } + + private hoverHighlightOptions() { + const baseColor = this.theme.spawnHighlightSelfColor(); + const rgba = baseColor.rgba; + + if (this.alternativeView) { + return { + color: { r: rgba.r, g: rgba.g, b: rgba.b }, + strength: 0.8, + pulseStrength: 0.45, + pulseSpeed: Math.PI * 2, + }; + } + + return { + color: { r: rgba.r, g: rgba.g, b: rgba.b }, + strength: 0.6, + pulseStrength: 0.35, + pulseSpeed: Math.PI * 2, + }; + } + redrawBorder(...players: PlayerView[]) { + const shouldRefreshPalette = this.territoryRenderer?.isWebGL() ?? false; return Promise.all( players.map(async (player) => { const tiles = await player.borderTiles(); @@ -441,63 +498,46 @@ export class TerritoryLayer implements Layer { this.paintTerritory(tile, true); }); }), - ); - } - - 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; + ).then(() => { + if (shouldRefreshPalette) { + this.territoryRenderer?.refreshPalette(); + } }); } renderLayer(context: CanvasRenderingContext2D) { const now = Date.now(); - 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; - - if (w > 0 && h > 0) { - const putImageStart = FrameProfiler.start(); - this.context.putImageData( - this.alternativeView ? this.alternativeImageData : this.imageData, - 0, - 0, - vx0, - vy0, - w, - h, - ); - 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); + 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, + ); + } + if (this.game.inSpawnPhase()) { const highlightDrawStart = FrameProfiler.start(); context.drawImage( @@ -515,11 +555,21 @@ export class TerritoryLayer implements Layer { } renderTerritory() { + if (!this.territoryRenderer) { + return; + } let numToRender = Math.floor(this.tileToRenderQueue.size() / 10); - if (numToRender === 0 || this.game.inSpawnPhase()) { + if ( + numToRender === 0 || + this.game.inSpawnPhase() || + this.territoryRenderer.isWebGL() + ) { numToRender = this.tileToRenderQueue.size(); } + const useNeighborPaint = !(this.territoryRenderer?.isWebGL() ?? false); + const neighborsToPaint: TileRef[] = []; + const mainSpan = FrameProfiler.start(); while (numToRender > 0) { numToRender--; @@ -530,105 +580,33 @@ export class TerritoryLayer implements Layer { const tile = entry.tile; this.paintTerritory(tile); - for (const neighbor of this.game.neighbors(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); } - } - } - - paintTerritory(tile: TileRef, isBorder: boolean = false) { - if (isBorder && !this.game.hasOwner(tile)) { - return; - } - - if (!this.game.hasOwner(tile)) { - if (this.game.hasFallout(tile)) { - this.paintTile(this.imageData, tile, this.theme.falloutColor(), 150); - this.paintTile( - this.alternativeImageData, - tile, - this.theme.falloutColor(), - 150, - ); - return; - } - this.clearTile(tile); - return; - } - const owner = this.game.owner(tile) as PlayerView; - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const isHighlighted = - this.highlightedTerritory && - this.highlightedTerritory.id() === owner.id(); - const myPlayer = this.game.myPlayer(); - - if (this.game.isBorder(tile)) { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const playerIsFocused = owner && this.game.focusedPlayer() === owner; - if (myPlayer) { - const alternativeColor = this.alternateViewColor(owner); - this.paintTile(this.alternativeImageData, tile, alternativeColor, 255); - } - const isDefended = this.game.hasUnitNearby( - tile, - this.game.config().defensePostRange(), - UnitType.DefensePost, - owner.id(), + FrameProfiler.end( + "TerritoryLayer:renderTerritory.neighborPaint", + neighborSpan, ); - - 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); } } - 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; + paintTerritory(tile: TileRef, _maybeStaleBorder: boolean = false) { + 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) { @@ -673,36 +651,26 @@ export class TerritoryLayer implements Layer { // Draw a semi-transparent ring around the starting location ctx.beginPath(); - // Transparency matches the highlight color provided const transparent = transparentColor.alpha(0); const radGrad = ctx.createRadialGradient(cx, cy, minRad, cx, cy, maxRad); - // Pixels with radius < minRad are transparent radGrad.addColorStop(0, transparent.toRgbString()); - // The ring then starts with solid highlight color radGrad.addColorStop(0.01, transparentColor.toRgbString()); radGrad.addColorStop(0.1, transparentColor.toRgbString()); - // The outer edge of the ring is transparent radGrad.addColorStop(1, transparent.toRgbString()); - // Draw an arc at the max radius and fill with the created radial gradient ctx.arc(cx, cy, maxRad, 0, Math.PI * 2); ctx.fillStyle = radGrad; ctx.closePath(); ctx.fill(); const breatheInner = breathingColor.alpha(0); - // Draw a solid ring around the starting location with outer radius = the breathing radius ctx.beginPath(); const radGrad2 = ctx.createRadialGradient(cx, cy, minRad, cx, cy, radius); - // Pixels with radius < minRad are transparent radGrad2.addColorStop(0, breatheInner.toRgbString()); - // The ring then starts with solid highlight color radGrad2.addColorStop(0.01, breathingColor.toRgbString()); - // The ring is solid throughout radGrad2.addColorStop(1, breathingColor.toRgbString()); - // Draw an arc at the current breathing radius and fill with the created "gradient" ctx.arc(cx, cy, radius, 0, Math.PI * 2); ctx.fillStyle = radGrad2; ctx.fill(); diff --git a/src/client/graphics/layers/TerritoryRenderers.ts b/src/client/graphics/layers/TerritoryRenderers.ts new file mode 100644 index 000000000..3d453d539 --- /dev/null +++ b/src/client/graphics/layers/TerritoryRenderers.ts @@ -0,0 +1,301 @@ +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; + 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); + const isDefended = + owner && isBorderTile ? this.game.isDefended(tile) : false; + + 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("CanvasTerritoryRenderer:paintTile", 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("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; + } + + 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); + } + + 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 new file mode 100644 index 000000000..9a3cca48c --- /dev/null +++ b/src/client/graphics/layers/TerritoryWebGLRenderer.ts @@ -0,0 +1,971 @@ +import { base64url } from "jose"; +import { DefaultPattern } from "../../../core/CosmeticSchemas"; +import { Theme } from "../../../core/configuration/Config"; +import { TileRef } from "../../../core/game/GameMap"; +import { GameView, PlayerView } from "../../../core/game/GameView"; +import { UserSettings } from "../../../core/game/UserSettings"; +import { FrameProfiler } from "../FrameProfiler"; + +type DirtySpan = { minX: number; maxX: number }; + +export interface TerritoryWebGLCreateResult { + renderer: TerritoryWebGLRenderer | null; + reason?: string; +} + +export interface HoverHighlightOptions { + color?: { r: number; g: number; b: number }; + strength?: number; + pulseStrength?: number; + pulseSpeed?: number; +} + +const PATTERN_STRIDE_BYTES = 1052; + +// WebGL2 territory renderer that shades tiles from packed tile state +// (Uint16Array) using palette, relation, and pattern textures. +export class TerritoryWebGLRenderer { + public readonly canvas: HTMLCanvasElement; + + private readonly gl: WebGL2RenderingContext | null; + private readonly program: WebGLProgram | null; + private readonly vao: WebGLVertexArrayObject | null; + private readonly vertexBuffer: WebGLBuffer | null; + private readonly stateTexture: WebGLTexture | null; + private readonly paletteTexture: WebGLTexture | null; + private readonly relationTexture: WebGLTexture | null; + private readonly patternTexture: WebGLTexture | null; + private readonly uniforms: { + resolution: WebGLUniformLocation | null; + state: WebGLUniformLocation | null; + palette: WebGLUniformLocation | null; + relations: WebGLUniformLocation | null; + patterns: WebGLUniformLocation | null; + patternStride: WebGLUniformLocation | null; + patternRows: WebGLUniformLocation | null; + fallout: WebGLUniformLocation | null; + altSelf: WebGLUniformLocation | null; + altAlly: WebGLUniformLocation | null; + altNeutral: WebGLUniformLocation | null; + altEnemy: WebGLUniformLocation | null; + alpha: WebGLUniformLocation | null; + alternativeView: WebGLUniformLocation | null; + hoveredPlayerId: WebGLUniformLocation | null; + hoverHighlightStrength: WebGLUniformLocation | null; + hoverHighlightColor: WebGLUniformLocation | null; + hoverPulseStrength: WebGLUniformLocation | null; + hoverPulseSpeed: WebGLUniformLocation | null; + time: WebGLUniformLocation | null; + viewerId: WebGLUniformLocation | null; + }; + + private readonly state: Uint16Array; + private readonly dirtyRows: Map = new Map(); + private needsFullUpload = true; + private alternativeView = false; + private paletteWidth = 0; + private hoverHighlightStrength = 0.7; + private hoverHighlightColor: [number, number, number] = [1, 1, 1]; + private hoverPulseStrength = 0.25; + private hoverPulseSpeed = Math.PI * 2; + private hoveredPlayerId = -1; + private animationStartTime = Date.now(); + private readonly userSettings = new UserSettings(); + private readonly patternBytesCache = new Map(); + + private constructor( + private readonly game: GameView, + private readonly theme: Theme, + state: Uint16Array, + ) { + this.canvas = document.createElement("canvas"); + this.canvas.width = game.width(); + this.canvas.height = game.height(); + + this.state = state; + + this.gl = this.canvas.getContext("webgl2", { + premultipliedAlpha: true, + antialias: false, + preserveDrawingBuffer: true, + }); + + if (!this.gl) { + this.program = null; + this.vao = null; + this.vertexBuffer = null; + this.stateTexture = null; + this.paletteTexture = null; + this.relationTexture = null; + this.patternTexture = null; + this.uniforms = { + resolution: null, + state: null, + palette: null, + relations: null, + patterns: null, + patternStride: null, + patternRows: null, + fallout: null, + altSelf: null, + altAlly: null, + altNeutral: null, + altEnemy: null, + alpha: null, + alternativeView: null, + hoveredPlayerId: null, + hoverHighlightStrength: null, + hoverHighlightColor: null, + hoverPulseStrength: null, + hoverPulseSpeed: null, + time: null, + viewerId: null, + }; + return; + } + + const gl = this.gl; + this.program = this.createProgram(gl); + if (!this.program) { + this.vao = null; + this.vertexBuffer = null; + this.stateTexture = null; + this.paletteTexture = null; + this.relationTexture = null; + this.patternTexture = null; + this.uniforms = { + resolution: null, + state: null, + palette: null, + relations: null, + patterns: null, + patternStride: null, + patternRows: null, + fallout: null, + altSelf: null, + altAlly: null, + altNeutral: null, + altEnemy: null, + alpha: null, + alternativeView: null, + hoveredPlayerId: null, + hoverHighlightStrength: null, + hoverHighlightColor: null, + hoverPulseStrength: null, + hoverPulseSpeed: null, + time: null, + viewerId: null, + }; + return; + } + + this.uniforms = { + resolution: gl.getUniformLocation(this.program, "u_resolution"), + state: gl.getUniformLocation(this.program, "u_state"), + palette: gl.getUniformLocation(this.program, "u_palette"), + relations: gl.getUniformLocation(this.program, "u_relations"), + patterns: gl.getUniformLocation(this.program, "u_patterns"), + patternStride: gl.getUniformLocation(this.program, "u_patternStride"), + patternRows: gl.getUniformLocation(this.program, "u_patternRows"), + fallout: gl.getUniformLocation(this.program, "u_fallout"), + altSelf: gl.getUniformLocation(this.program, "u_altSelf"), + altAlly: gl.getUniformLocation(this.program, "u_altAlly"), + altNeutral: gl.getUniformLocation(this.program, "u_altNeutral"), + altEnemy: gl.getUniformLocation(this.program, "u_altEnemy"), + alpha: gl.getUniformLocation(this.program, "u_alpha"), + alternativeView: gl.getUniformLocation(this.program, "u_alternativeView"), + hoveredPlayerId: gl.getUniformLocation(this.program, "u_hoveredPlayerId"), + hoverHighlightStrength: gl.getUniformLocation( + this.program, + "u_hoverHighlightStrength", + ), + hoverHighlightColor: gl.getUniformLocation( + this.program, + "u_hoverHighlightColor", + ), + hoverPulseStrength: gl.getUniformLocation( + this.program, + "u_hoverPulseStrength", + ), + hoverPulseSpeed: gl.getUniformLocation(this.program, "u_hoverPulseSpeed"), + time: gl.getUniformLocation(this.program, "u_time"), + viewerId: gl.getUniformLocation(this.program, "u_viewerId"), + }; + + // Vertex data: two triangles covering the full map (pixel-perfect). + const vertices = new Float32Array([ + 0, + 0, + this.canvas.width, + 0, + 0, + this.canvas.height, + 0, + this.canvas.height, + this.canvas.width, + 0, + this.canvas.width, + this.canvas.height, + ]); + + this.vao = gl.createVertexArray(); + this.vertexBuffer = gl.createBuffer(); + gl.bindVertexArray(this.vao); + gl.bindBuffer(gl.ARRAY_BUFFER, this.vertexBuffer); + gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW); + + const posLoc = gl.getAttribLocation(this.program, "a_position"); + gl.enableVertexAttribArray(posLoc); + gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 2 * 4, 0); + gl.bindVertexArray(null); + + this.stateTexture = gl.createTexture(); + this.paletteTexture = gl.createTexture(); + this.relationTexture = gl.createTexture(); + this.patternTexture = gl.createTexture(); + + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, this.stateTexture); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1); + gl.texImage2D( + gl.TEXTURE_2D, + 0, + gl.R16UI, + this.canvas.width, + this.canvas.height, + 0, + gl.RED_INTEGER, + gl.UNSIGNED_SHORT, + this.state, + ); + + this.uploadPalette(); + + gl.useProgram(this.program); + gl.uniform1i(this.uniforms.state, 0); + gl.uniform1i(this.uniforms.palette, 1); + gl.uniform1i(this.uniforms.relations, 2); + gl.uniform1i(this.uniforms.patterns, 3); + + if (this.uniforms.resolution) { + gl.uniform2f( + this.uniforms.resolution, + this.canvas.width, + this.canvas.height, + ); + } + if (this.uniforms.alpha) { + gl.uniform1f(this.uniforms.alpha, 150 / 255); + } + if (this.uniforms.fallout) { + const f = this.theme.falloutColor().rgba; + gl.uniform4f( + this.uniforms.fallout, + f.r / 255, + f.g / 255, + f.b / 255, + f.a ?? 1, + ); + } + if (this.uniforms.altSelf) { + const c = this.theme.selfColor().rgba; + gl.uniform4f( + this.uniforms.altSelf, + c.r / 255, + c.g / 255, + c.b / 255, + c.a ?? 1, + ); + } + if (this.uniforms.altAlly) { + const c = this.theme.allyColor().rgba; + gl.uniform4f( + this.uniforms.altAlly, + c.r / 255, + c.g / 255, + c.b / 255, + c.a ?? 1, + ); + } + if (this.uniforms.altNeutral) { + const c = this.theme.neutralColor().rgba; + gl.uniform4f( + this.uniforms.altNeutral, + c.r / 255, + c.g / 255, + c.b / 255, + c.a ?? 1, + ); + } + if (this.uniforms.altEnemy) { + const c = this.theme.enemyColor().rgba; + gl.uniform4f( + this.uniforms.altEnemy, + c.r / 255, + c.g / 255, + c.b / 255, + c.a ?? 1, + ); + } + if (this.uniforms.viewerId) { + const viewerId = this.game.myPlayer()?.smallID() ?? 0; + gl.uniform1i(this.uniforms.viewerId, viewerId); + } + if (this.uniforms.alternativeView) { + gl.uniform1i(this.uniforms.alternativeView, 0); + } + if (this.uniforms.hoveredPlayerId) { + gl.uniform1f(this.uniforms.hoveredPlayerId, -1); + } + if (this.uniforms.hoverHighlightStrength) { + gl.uniform1f( + this.uniforms.hoverHighlightStrength, + this.hoverHighlightStrength, + ); + } + if (this.uniforms.hoverHighlightColor) { + const [r, g, b] = this.hoverHighlightColor; + gl.uniform3f(this.uniforms.hoverHighlightColor, r, g, b); + } + if (this.uniforms.hoverPulseStrength) { + gl.uniform1f(this.uniforms.hoverPulseStrength, this.hoverPulseStrength); + } + if (this.uniforms.hoverPulseSpeed) { + gl.uniform1f(this.uniforms.hoverPulseSpeed, this.hoverPulseSpeed); + } + + gl.enable(gl.BLEND); + gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA); + gl.viewport(0, 0, this.canvas.width, this.canvas.height); + } + + static create(game: GameView, theme: Theme): TerritoryWebGLCreateResult { + const state = game.tileStateView(); + const expected = game.width() * game.height(); + if (state.length !== expected) { + return { + renderer: null, + reason: + "Tile state buffer size mismatch; falling back to canvas territory draw.", + }; + } + + const renderer = new TerritoryWebGLRenderer(game, theme, state); + if (!renderer.isValid()) { + return { + renderer: null, + reason: "WebGL2 not available; falling back to canvas territory draw.", + }; + } + return { renderer }; + } + + isValid(): boolean { + return !!this.gl && !!this.program && !!this.vao; + } + + setAlternativeView(enabled: boolean) { + this.alternativeView = enabled; + } + + setHoveredPlayerId(playerSmallId: number | null) { + const encoded = playerSmallId ?? -1; + this.hoveredPlayerId = encoded; + } + + setHoverHighlightOptions(options: HoverHighlightOptions) { + if (options.strength !== undefined) { + this.hoverHighlightStrength = Math.max(0, Math.min(1, options.strength)); + } + if (options.color) { + this.hoverHighlightColor = [ + options.color.r / 255, + options.color.g / 255, + options.color.b / 255, + ]; + } + if (options.pulseStrength !== undefined) { + this.hoverPulseStrength = Math.max(0, Math.min(1, options.pulseStrength)); + } + if (options.pulseSpeed !== undefined) { + this.hoverPulseSpeed = Math.max(0, options.pulseSpeed); + } + } + + markTile(tile: TileRef) { + if (this.needsFullUpload) { + return; + } + const x = tile % this.canvas.width; + const y = Math.floor(tile / this.canvas.width); + const span = this.dirtyRows.get(y); + if (span === undefined) { + this.dirtyRows.set(y, { minX: x, maxX: x }); + } else { + span.minX = Math.min(span.minX, x); + span.maxX = Math.max(span.maxX, x); + } + } + + markAllDirty() { + this.needsFullUpload = true; + this.dirtyRows.clear(); + } + + refreshPalette() { + if (!this.gl || !this.paletteTexture || !this.relationTexture) { + return; + } + this.uploadPalette(); + } + + render() { + if (!this.gl || !this.program || !this.vao) { + return; + } + const gl = this.gl; + + const uploadStateSpan = FrameProfiler.start(); + this.uploadStateTexture(); + FrameProfiler.end("TerritoryWebGLRenderer:uploadState", uploadStateSpan); + + const renderSpan = FrameProfiler.start(); + gl.viewport(0, 0, this.canvas.width, this.canvas.height); + gl.useProgram(this.program); + gl.bindVertexArray(this.vao); + if (this.uniforms.alternativeView) { + gl.uniform1i(this.uniforms.alternativeView, this.alternativeView ? 1 : 0); + } + if (this.uniforms.hoveredPlayerId) { + gl.uniform1f(this.uniforms.hoveredPlayerId, this.hoveredPlayerId); + } + if (this.uniforms.hoverHighlightStrength) { + gl.uniform1f( + this.uniforms.hoverHighlightStrength, + this.hoverHighlightStrength, + ); + } + if (this.uniforms.hoverHighlightColor) { + const [r, g, b] = this.hoverHighlightColor; + gl.uniform3f(this.uniforms.hoverHighlightColor, r, g, b); + } + if (this.uniforms.hoverPulseStrength) { + gl.uniform1f(this.uniforms.hoverPulseStrength, this.hoverPulseStrength); + } + if (this.uniforms.hoverPulseSpeed) { + gl.uniform1f(this.uniforms.hoverPulseSpeed, this.hoverPulseSpeed); + } + if (this.uniforms.time) { + const currentTime = (Date.now() - this.animationStartTime) / 1000.0; + gl.uniform1f(this.uniforms.time, currentTime); + } + if (this.uniforms.viewerId) { + const viewerId = this.game.myPlayer()?.smallID() ?? 0; + gl.uniform1i(this.uniforms.viewerId, viewerId); + } + + gl.clearColor(0, 0, 0, 0); + gl.clear(gl.COLOR_BUFFER_BIT); + gl.drawArrays(gl.TRIANGLES, 0, 6); + gl.bindVertexArray(null); + FrameProfiler.end("TerritoryWebGLRenderer:draw", renderSpan); + } + + private uploadStateTexture(): { rows: number; bytes: number } { + if (!this.gl || !this.stateTexture) return { rows: 0, bytes: 0 }; + const gl = this.gl; + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, this.stateTexture); + + const bytesPerPixel = Uint16Array.BYTES_PER_ELEMENT; + let rowsUploaded = 0; + let bytesUploaded = 0; + + if (this.needsFullUpload) { + gl.texImage2D( + gl.TEXTURE_2D, + 0, + gl.R16UI, + this.canvas.width, + this.canvas.height, + 0, + gl.RED_INTEGER, + gl.UNSIGNED_SHORT, + this.state, + ); + this.needsFullUpload = false; + this.dirtyRows.clear(); + rowsUploaded = this.canvas.height; + bytesUploaded = this.canvas.width * this.canvas.height * bytesPerPixel; + return { rows: rowsUploaded, bytes: bytesUploaded }; + } + + if (this.dirtyRows.size === 0) { + return { rows: 0, bytes: 0 }; + } + + for (const [y, span] of this.dirtyRows) { + const width = span.maxX - span.minX + 1; + const offset = y * this.canvas.width + span.minX; + const rowSlice = this.state.subarray(offset, offset + width); + gl.texSubImage2D( + gl.TEXTURE_2D, + 0, + span.minX, + y, + width, + 1, + gl.RED_INTEGER, + gl.UNSIGNED_SHORT, + rowSlice, + ); + rowsUploaded++; + bytesUploaded += width * bytesPerPixel; + } + this.dirtyRows.clear(); + return { rows: rowsUploaded, bytes: bytesUploaded }; + } + + private uploadPalette() { + if ( + !this.gl || + !this.paletteTexture || + !this.relationTexture || + !this.patternTexture || + !this.program + ) + return; + const gl = this.gl; + const players = this.game.playerViews().filter((p) => p.isPlayer()); + + const maxId = players.reduce((max, p) => Math.max(max, p.smallID()), 0) + 1; + this.paletteWidth = Math.max(maxId, 1); + + const paletteData = new Uint8Array(this.paletteWidth * 8); + const relationData = new Uint8Array(this.paletteWidth * this.paletteWidth); + const patternData = new Uint8Array( + this.paletteWidth * PATTERN_STRIDE_BYTES, + ); + + const patternsEnabled = this.userSettings.territoryPatterns(); + const defaultPatternBytes = this.getPatternBytes( + DefaultPattern.patternData, + ); + + for (const p of players) { + const id = p.smallID(); + const territoryRgba = p.territoryColor().rgba; + paletteData[id * 8] = territoryRgba.r; + paletteData[id * 8 + 1] = territoryRgba.g; + paletteData[id * 8 + 2] = territoryRgba.b; + paletteData[id * 8 + 3] = Math.round((territoryRgba.a ?? 1) * 255); + + const borderRgba = p.borderColor().rgba; + paletteData[id * 8 + 4] = borderRgba.r; + paletteData[id * 8 + 5] = borderRgba.g; + paletteData[id * 8 + 6] = borderRgba.b; + paletteData[id * 8 + 7] = Math.round((borderRgba.a ?? 1) * 255); + + const patternBytes = + patternsEnabled && p.cosmetics.pattern + ? this.getPatternBytes(p.cosmetics.pattern.patternData) + : defaultPatternBytes; + const offset = id * PATTERN_STRIDE_BYTES; + patternData.set(patternBytes.slice(0, PATTERN_STRIDE_BYTES), offset); + } + + for (let ownerId = 0; ownerId < this.paletteWidth; ownerId++) { + const owner = this.safePlayerBySmallId(ownerId); + for (let otherId = 0; otherId < this.paletteWidth; otherId++) { + const other = this.safePlayerBySmallId(otherId); + relationData[ownerId * this.paletteWidth + otherId] = + this.resolveRelationCode(owner, other); + } + } + + gl.useProgram(this.program); + + gl.activeTexture(gl.TEXTURE1); + gl.bindTexture(gl.TEXTURE_2D, this.paletteTexture); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1); + gl.texImage2D( + gl.TEXTURE_2D, + 0, + gl.RGBA8, + this.paletteWidth * 2, + 1, + 0, + gl.RGBA, + gl.UNSIGNED_BYTE, + paletteData, + ); + + gl.activeTexture(gl.TEXTURE2); + gl.bindTexture(gl.TEXTURE_2D, this.relationTexture); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1); + gl.texImage2D( + gl.TEXTURE_2D, + 0, + gl.R8UI, + this.paletteWidth, + this.paletteWidth, + 0, + gl.RED_INTEGER, + gl.UNSIGNED_BYTE, + relationData, + ); + + gl.activeTexture(gl.TEXTURE3); + gl.bindTexture(gl.TEXTURE_2D, this.patternTexture); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1); + gl.texImage2D( + gl.TEXTURE_2D, + 0, + gl.R8UI, + PATTERN_STRIDE_BYTES, + this.paletteWidth, + 0, + gl.RED_INTEGER, + gl.UNSIGNED_BYTE, + patternData, + ); + + if (this.uniforms.patternStride) { + gl.uniform1i(this.uniforms.patternStride, PATTERN_STRIDE_BYTES); + } + if (this.uniforms.patternRows) { + gl.uniform1i(this.uniforms.patternRows, this.paletteWidth); + } + } + + private resolveRelationCode( + owner: PlayerView | null, + other: PlayerView | null, + ): number { + if (!owner || !other || !owner.isPlayer() || !other.isPlayer()) { + return 0; + } + + let code = 0; + if (owner.smallID() === other.smallID()) { + code |= 4; + } + if (owner.isFriendly(other) || other.isFriendly(owner)) { + code |= 1; + } + if (owner.hasEmbargo(other)) { + code |= 2; + } + return code; + } + + private safePlayerBySmallId(id: number): PlayerView | null { + const player = this.game.playerBySmallID(id); + return player instanceof PlayerView ? player : null; + } + + private getPatternBytes(patternData: string): Uint8Array { + const cached = this.patternBytesCache.get(patternData); + if (cached) { + return cached; + } + try { + const bytes = base64url.decode(patternData); + this.patternBytesCache.set(patternData, bytes); + return bytes; + } catch (error) { + const fallback = base64url.decode(DefaultPattern.patternData); + this.patternBytesCache.set(patternData, fallback); + return fallback; + } + } + + private createProgram(gl: WebGL2RenderingContext): WebGLProgram | null { + const vertexShaderSource = `#version 300 es + precision mediump float; + in vec2 a_position; + uniform vec2 u_resolution; + void main() { + vec2 zeroToOne = a_position / u_resolution; + vec2 clipSpace = zeroToOne * 2.0 - 1.0; + clipSpace.y = -clipSpace.y; + gl_Position = vec4(clipSpace, 0.0, 1.0); + } + `; + + const fragmentShaderSource = `#version 300 es + precision mediump float; + precision highp usampler2D; + + uniform usampler2D u_state; + uniform sampler2D u_palette; + uniform usampler2D u_relations; + uniform usampler2D u_patterns; + uniform int u_patternStride; + uniform int u_patternRows; + uniform int u_viewerId; + uniform vec2 u_resolution; + uniform vec4 u_fallout; + uniform vec4 u_altSelf; + uniform vec4 u_altAlly; + uniform vec4 u_altNeutral; + uniform vec4 u_altEnemy; + uniform float u_alpha; + uniform bool u_alternativeView; + uniform float u_hoveredPlayerId; + uniform vec3 u_hoverHighlightColor; + uniform float u_hoverHighlightStrength; + uniform float u_hoverPulseStrength; + uniform float u_hoverPulseSpeed; + uniform float u_time; + + out vec4 outColor; + + uint ownerAtTex(ivec2 texCoord) { + ivec2 clamped = clamp( + texCoord, + ivec2(0, 0), + ivec2(int(u_resolution.x) - 1, int(u_resolution.y) - 1) + ); + return texelFetch(u_state, clamped, 0).r & 0xFFFu; + } + + uint relationCode(uint owner, uint other) { + if (owner == 0u || other == 0u) { + return 0u; + } + return texelFetch(u_relations, ivec2(int(owner), int(other)), 0).r; + } + + bool isFriendly(uint code) { + return (code & 1u) != 0u; + } + + bool isEmbargo(uint code) { + return (code & 2u) != 0u; + } + + bool isSelf(uint code) { + return (code & 4u) != 0u; + } + + uint patternByte(uint owner, uint offset) { + int x = int(offset); + int y = int(owner); + if (x < 0 || x >= u_patternStride || y < 0 || y >= u_patternRows) { + return 0u; + } + return texelFetch(u_patterns, ivec2(x, y), 0).r; + } + + bool patternIsPrimary(uint owner, ivec2 texCoord) { + uint version = patternByte(owner, 0u); + if (version != 0u) { + return true; + } + uint b1 = patternByte(owner, 1u); + uint b2 = patternByte(owner, 2u); + uint scale = b1 & 7u; + uint width = (((b2 & 3u) << 5) | ((b1 >> 3) & 31u)) + 2u; + uint height = ((b2 >> 2) & 63u) + 2u; + if (width == 0u || height == 0u) { + return true; + } + uint px = (uint(texCoord.x) >> scale) % width; + uint py = (uint(texCoord.y) >> scale) % height; + uint idx = py * width + px; + uint byteIndex = idx >> 3; + uint bitIndex = idx & 7u; + uint byteVal = patternByte(owner, 3u + byteIndex); + return (byteVal & (1u << bitIndex)) == 0u; + } + + void main() { + ivec2 fragCoord = ivec2(gl_FragCoord.xy); + ivec2 texCoord = ivec2(fragCoord.x, int(u_resolution.y) - 1 - fragCoord.y); + + uint state = texelFetch(u_state, texCoord, 0).r; + uint owner = state & 0xFFFu; + bool hasFallout = (state & 0x2000u) != 0u; + bool isDefended = (state & 0x1000u) != 0u; + + if (owner == 0u) { + if (hasFallout) { + vec3 color = u_fallout.rgb; + float a = u_alpha; + outColor = vec4(color * a, a); + } else { + outColor = vec4(0.0); + } + return; + } + + bool isBorder = false; + bool hasFriendlyRelation = false; + bool hasEmbargoRelation = false; + uint nOwner = ownerAtTex(texCoord + ivec2(1, 0)); + isBorder = isBorder || (nOwner != owner); + if (nOwner != owner && nOwner != 0u) { + uint rel = relationCode(owner, nOwner); + hasEmbargoRelation = hasEmbargoRelation || isEmbargo(rel); + hasFriendlyRelation = hasFriendlyRelation || isFriendly(rel); + } + nOwner = ownerAtTex(texCoord + ivec2(-1, 0)); + isBorder = isBorder || (nOwner != owner); + if (nOwner != owner && nOwner != 0u) { + uint rel = relationCode(owner, nOwner); + hasEmbargoRelation = hasEmbargoRelation || isEmbargo(rel); + hasFriendlyRelation = hasFriendlyRelation || isFriendly(rel); + } + nOwner = ownerAtTex(texCoord + ivec2(0, 1)); + isBorder = isBorder || (nOwner != owner); + if (nOwner != owner && nOwner != 0u) { + uint rel = relationCode(owner, nOwner); + hasEmbargoRelation = hasEmbargoRelation || isEmbargo(rel); + hasFriendlyRelation = hasFriendlyRelation || isFriendly(rel); + } + nOwner = ownerAtTex(texCoord + ivec2(0, -1)); + isBorder = isBorder || (nOwner != owner); + if (nOwner != owner && nOwner != 0u) { + uint rel = relationCode(owner, nOwner); + hasEmbargoRelation = hasEmbargoRelation || isEmbargo(rel); + hasFriendlyRelation = hasFriendlyRelation || isFriendly(rel); + } + + if (u_alternativeView) { + uint relationAlt = relationCode(owner, uint(u_viewerId)); + vec4 altColor = u_altNeutral; + if (isSelf(relationAlt)) { + altColor = u_altSelf; + } else if (isFriendly(relationAlt)) { + altColor = u_altAlly; + } else if (isEmbargo(relationAlt)) { + altColor = u_altEnemy; + } + float a = isBorder ? 1.0 : 0.0; + vec3 color = altColor.rgb; + if (u_hoveredPlayerId >= 0.0 && abs(float(owner) - u_hoveredPlayerId) < 0.5) { + float pulse = u_hoverPulseStrength > 0.0 + ? (1.0 - u_hoverPulseStrength) + + u_hoverPulseStrength * (0.5 + 0.5 * sin(u_time * u_hoverPulseSpeed)) + : 1.0; + color = mix(color, u_hoverHighlightColor, u_hoverHighlightStrength * pulse); + } + outColor = vec4(color * a, a); + return; + } + + vec4 base = texelFetch(u_palette, ivec2(int(owner) * 2, 0), 0); + vec4 baseBorder = texelFetch(u_palette, ivec2(int(owner) * 2 + 1, 0), 0); + vec3 color = base.rgb; + float a = u_alpha; + + if (isBorder) { + vec3 borderColor = baseBorder.rgb; + + const float BORDER_TINT_RATIO = 0.35; + const vec3 FRIENDLY_TINT_TARGET = vec3(0.0, 1.0, 0.0); + const vec3 EMBARGO_TINT_TARGET = vec3(1.0, 0.0, 0.0); + + if (hasFriendlyRelation) { + borderColor = borderColor * (1.0 - BORDER_TINT_RATIO) + + FRIENDLY_TINT_TARGET * BORDER_TINT_RATIO; + } + if (hasEmbargoRelation) { + borderColor = borderColor * (1.0 - BORDER_TINT_RATIO) + + EMBARGO_TINT_TARGET * BORDER_TINT_RATIO; + } + + if (isDefended) { + bool isLightTile = ((texCoord.x % 2) == (texCoord.y % 2)); + const float LIGHT_FACTOR = 1.2; + const float DARK_FACTOR = 0.8; + borderColor *= isLightTile ? LIGHT_FACTOR : DARK_FACTOR; + } + + color = borderColor; + a = baseBorder.a; + } else { + bool isPrimary = patternIsPrimary(owner, texCoord); + color = isPrimary ? base.rgb : baseBorder.rgb; + a = u_alpha; + } + + if (u_hoveredPlayerId >= 0.0 && abs(float(owner) - u_hoveredPlayerId) < 0.5) { + float pulse = u_hoverPulseStrength > 0.0 + ? (1.0 - u_hoverPulseStrength) + + u_hoverPulseStrength * (0.5 + 0.5 * sin(u_time * u_hoverPulseSpeed)) + : 1.0; + color = mix(color, u_hoverHighlightColor, u_hoverHighlightStrength * pulse); + } + + outColor = vec4(color * a, a); + } + `; + + const vertexShader = this.compileShader( + gl, + gl.VERTEX_SHADER, + vertexShaderSource, + ); + const fragmentShader = this.compileShader( + gl, + gl.FRAGMENT_SHADER, + fragmentShaderSource, + ); + if (!vertexShader || !fragmentShader) { + return null; + } + + const program = gl.createProgram(); + if (!program) return null; + gl.attachShader(program, vertexShader); + gl.attachShader(program, fragmentShader); + gl.linkProgram(program); + if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { + console.error( + "[TerritoryWebGLRenderer] link error", + gl.getProgramInfoLog(program), + ); + gl.deleteProgram(program); + return null; + } + return program; + } + + private compileShader( + gl: WebGL2RenderingContext, + type: number, + source: string, + ): WebGLShader | null { + const shader = gl.createShader(type); + if (!shader) return null; + gl.shaderSource(shader, source); + gl.compileShader(shader); + if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { + console.error( + "[TerritoryWebGLRenderer] shader error", + gl.getShaderInfoLog(shader), + ); + gl.deleteShader(shader); + return null; + } + return shader; + } +} diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts index 47c0cc46c..b8305c4e3 100644 --- a/src/core/game/GameImpl.ts +++ b/src/core/game/GameImpl.ts @@ -574,6 +574,7 @@ export class GameImpl implements Game { owner._lastTileChange = this._ticks; this.updateBorders(tile); this._map.setFallout(tile, false); + this.updateDefendedStateForTileChange(tile, owner); this.addUpdate({ type: GameUpdateType.Tile, update: this.toTileUpdate(tile), @@ -595,6 +596,9 @@ export class GameImpl implements Game { this._map.setOwnerID(tile, 0); this.updateBorders(tile); + if (this._map.isDefended(tile)) { + this._map.setDefended(tile, false); + } this.addUpdate({ type: GameUpdateType.Tile, update: this.toTileUpdate(tile), @@ -799,16 +803,30 @@ export class GameImpl implements Game { addUnit(u: Unit) { this.unitGrid.addUnit(u); + if (u.type() === UnitType.DefensePost) { + this.updateDefendedStateForDefensePost(u.tile(), u.owner() as PlayerImpl); + } } removeUnit(u: Unit) { this.unitGrid.removeUnit(u); + if (u.type() === UnitType.DefensePost) { + this.updateDefendedStateForDefensePost(u.tile(), u.owner() as PlayerImpl); + } if (u.hasTrainStation()) { this._railNetwork.removeStation(u); } } updateUnitTile(u: Unit) { + if (u.type() === UnitType.DefensePost) { + this.updateDefendedStateForDefensePost(u.tile(), u.owner() as PlayerImpl); + } this.unitGrid.updateUnitCell(u); } + refreshDefensePostDefendedState(u: Unit) { + if (u.type() === UnitType.DefensePost) { + this.updateDefendedStateForDefensePost(u.tile(), u.owner() as PlayerImpl); + } + } hasUnitNearby( tile: TileRef, @@ -899,6 +917,12 @@ export class GameImpl implements Game { hasFallout(ref: TileRef): boolean { return this._map.hasFallout(ref); } + isDefended(ref: TileRef): boolean { + return this._map.isDefended(ref); + } + setDefended(ref: TileRef, value: boolean): void { + this._map.setDefended(ref, value); + } isBorder(ref: TileRef): boolean { return this._map.isBorder(ref); } @@ -957,6 +981,9 @@ export class GameImpl implements Game { updateTile(tu: TileUpdate): TileRef { return this._map.updateTile(tu); } + tileStateView(): Uint16Array { + return this._map.tileStateView(); + } numTilesWithFallout(): number { return this._map.numTilesWithFallout(); } @@ -1005,6 +1032,63 @@ export class GameImpl implements Game { // Record stats this.stats().goldWar(conqueror, conquered, gold); } + + /** + * Update defended state for border tiles within range of a defense post. + */ + private updateDefendedStateForDefensePost( + center: TileRef, + owner: PlayerImpl, + ) { + const range = this.config().defensePostRange(); + const rangeSq = range * range; + + for (const tile of owner._borderTiles) { + if (this._map.euclideanDistSquared(center, tile) <= rangeSq) { + const wasDefended = this._map.isDefended(tile); + const isDefended = this.unitGrid.hasUnitNearby( + tile, + range, + UnitType.DefensePost, + owner.id(), + ); + if (wasDefended !== isDefended) { + this._map.setDefended(tile, isDefended); + this.addUpdate({ + type: GameUpdateType.Tile, + update: this.toTileUpdate(tile), + }); + } + } + } + } + + /** + * Update defended state when a tile changes ownership. + */ + private updateDefendedStateForTileChange(tile: TileRef, owner: PlayerImpl) { + const wasDefended = this._map.isDefended(tile); + const isDefended = this.unitGrid.hasUnitNearby( + tile, + this.config().defensePostRange(), + UnitType.DefensePost, + owner.id(), + ); + if (wasDefended !== isDefended) { + this._map.setDefended(tile, isDefended); + this.addUpdate({ + type: GameUpdateType.Tile, + update: this.toTileUpdate(tile), + }); + } + + // If the conquered tile has a defense post, update nearby border tiles + if ( + this.unitGrid.hasUnitNearby(tile, 0, UnitType.DefensePost, owner.id()) + ) { + this.updateDefendedStateForDefensePost(tile, owner); + } + } } // Or a more dynamic approach that will catch new enum values: diff --git a/src/core/game/GameMap.ts b/src/core/game/GameMap.ts index 136fdf1d9..dd00f3b22 100644 --- a/src/core/game/GameMap.ts +++ b/src/core/game/GameMap.ts @@ -27,6 +27,9 @@ export interface GameMap { setOwnerID(ref: TileRef, playerId: number): void; hasFallout(ref: TileRef): boolean; setFallout(ref: TileRef, value: boolean): void; + isDefended(ref: TileRef): boolean; + setDefended(ref: TileRef, value: boolean): void; + tileStateView(): Uint16Array; isOnEdgeOfMap(ref: TileRef): boolean; isBorder(ref: TileRef): boolean; neighbors(ref: TileRef): TileRef[]; @@ -76,6 +79,7 @@ export class GameMapImpl implements GameMap { // State bits (Uint16Array) private static readonly PLAYER_ID_MASK = 0xfff; + private static readonly DEFENDED_BIT = 12; private static readonly FALLOUT_BIT = 13; private static readonly DEFENSE_BONUS_BIT = 14; // Bit 15 still reserved @@ -211,6 +215,22 @@ export class GameMapImpl implements GameMap { } } + isDefended(ref: TileRef): boolean { + return Boolean(this.state[ref] & (1 << GameMapImpl.DEFENDED_BIT)); + } + + setDefended(ref: TileRef, value: boolean): void { + if (value) { + this.state[ref] |= 1 << GameMapImpl.DEFENDED_BIT; + } else { + this.state[ref] &= ~(1 << GameMapImpl.DEFENDED_BIT); + } + } + + tileStateView(): Uint16Array { + return this.state; + } + isOnEdgeOfMap(ref: TileRef): boolean { const x = this.x(ref); const y = this.y(ref); diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts index 04bf79d52..78225d927 100644 --- a/src/core/game/GameView.ts +++ b/src/core/game/GameView.ts @@ -855,6 +855,15 @@ export class GameView implements GameMap { setFallout(ref: TileRef, value: boolean): void { return this._map.setFallout(ref, value); } + isDefended(ref: TileRef): boolean { + return this._map.isDefended(ref); + } + setDefended(ref: TileRef, value: boolean): void { + return this._map.setDefended(ref, value); + } + tileStateView(): Uint16Array { + return this._map.tileStateView(); + } isBorder(ref: TileRef): boolean { return this._map.isBorder(ref); } diff --git a/src/core/game/UnitImpl.ts b/src/core/game/UnitImpl.ts index fad1f02f0..4b7cb3464 100644 --- a/src/core/game/UnitImpl.ts +++ b/src/core/game/UnitImpl.ts @@ -340,6 +340,9 @@ export class UnitImpl implements Unit { setUnderConstruction(underConstruction: boolean): void { if (this._underConstruction !== underConstruction) { this._underConstruction = underConstruction; + if (this._type === UnitType.DefensePost) { + this.mg.refreshDefensePostDefendedState(this); + } this.mg.addUpdate(this.toUpdate()); } }