diff --git a/resources/lang/en.json b/resources/lang/en.json index e119dae09..887fb7cd6 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -705,6 +705,8 @@ "attacking_troops_overlay_desc": "Show attacker vs defender troop counts on active front lines.", "territory_border_mode_label": "Territory Borders", "territory_border_mode_desc": "Select border rendering style (visual only)", + "renderer_label": "Renderer", + "renderer_desc": "Choose territory rendering backend. Auto uses WebGPU, then WebGL, then Classic.", "performance_overlay_label": "Performance Overlay", "performance_overlay_desc": "Toggle the performance overlay. When enabled, the performance overlay will be displayed. Press shift-D during game to toggle.", "easter_writing_speed_label": "Writing Speed Multiplier", diff --git a/src/client/UserSettingModal.ts b/src/client/UserSettingModal.ts index 6f6ddb698..fafb8e0e2 100644 --- a/src/client/UserSettingModal.ts +++ b/src/client/UserSettingModal.ts @@ -300,7 +300,9 @@ export class UserSettingModal extends BaseModal { this.requestUpdate(); } - private changeTerritoryBorderMode(e: CustomEvent<{ value: number | string }>) { + private changeTerritoryBorderMode( + e: CustomEvent<{ value: number | string }>, + ) { const rawValue = e.detail?.value; const value = typeof rawValue === "number" ? rawValue : parseInt(String(rawValue), 10); @@ -310,6 +312,12 @@ export class UserSettingModal extends BaseModal { this.requestUpdate(); } + private changeTerritoryRenderer(e: CustomEvent<{ value: number | string }>) { + const value = String(e.detail?.value ?? "auto"); + this.userSettings.setTerritoryRenderer(value); + this.requestUpdate(); + } + private toggleTerritoryPatterns() { this.userSettings.toggleTerritoryPatterns(); @@ -777,6 +785,20 @@ export class UserSettingModal extends BaseModal { @change=${this.changeTerritoryBorderMode} > + + = new PriorityQueue((a, b) => { + return a.lastUpdate - b.lastUpdate; + }); + private random = new PseudoRandom(123); + private theme: Theme; + + // Used for spawn highlighting + private highlightCanvas: HTMLCanvasElement; + private highlightContext: CanvasRenderingContext2D; + + private highlightedTerritory: PlayerView | 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; + + constructor( + private game: GameView, + private eventBus: EventBus, + private transformHandler: TransformHandler, + ) { + this.theme = game.config().theme(); + this.cachedTerritoryPatternsEnabled = undefined; + } + + 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() { + if (this.game.inSpawnPhase()) { + this.spawnHighlight(); + } + + 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); + } + }); + 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(); + if (myPlayer) { + updates?.[GameUpdateType.BrokeAlliance]?.forEach((update) => { + const territory = this.game.playerBySmallID(update.betrayedID); + if (territory && territory instanceof PlayerView) { + this.redrawBorder(territory); + } + }); + + updates?.[GameUpdateType.AllianceRequestReply]?.forEach((update) => { + if ( + update.accepted && + (update.request.requestorID === myPlayer.smallID() || + update.request.recipientID === myPlayer.smallID()) + ) { + const territoryId = + update.request.requestorID === myPlayer.smallID() + ? update.request.recipientID + : update.request.requestorID; + const territory = this.game.playerBySmallID(territoryId); + if (territory && territory instanceof PlayerView) { + this.redrawBorder(territory); + } + } + }); + updates?.[GameUpdateType.EmbargoEvent]?.forEach((update) => { + const player = this.game.playerBySmallID(update.playerID) as PlayerView; + const embargoed = this.game.playerBySmallID( + update.embargoedID, + ) as PlayerView; + + if ( + player.id() === myPlayer?.id() || + embargoed.id() === myPlayer?.id() + ) { + this.redrawBorder(player, embargoed); + } + }); + } + + const focusedPlayer = this.game.focusedPlayer(); + if (focusedPlayer !== this.lastFocusedPlayer) { + if (this.lastFocusedPlayer) { + this.paintPlayerBorder(this.lastFocusedPlayer); + } + if (focusedPlayer) { + this.paintPlayerBorder(focusedPlayer); + } + this.lastFocusedPlayer = focusedPlayer; + } + } + + private spawnHighlight() { + this.highlightContext.clearRect( + 0, + 0, + this.game.width(), + this.game.height(), + ); + + this.drawFocusedPlayerHighlight(); + + const humans = this.game + .playerViews() + .filter((p) => p.type() === PlayerType.Human); + + const focusedPlayer = this.game.focusedPlayer(); + const teamColors = Object.values(ColoredTeams); + for (const human of humans) { + if (human === focusedPlayer) { + continue; + } + const center = human.nameLocation(); + if (!center) { + continue; + } + const centerTile = this.game.ref(center.x, center.y); + if (!centerTile) { + continue; + } + let color = this.theme.spawnHighlightColor(); + const myPlayer = this.game.myPlayer(); + if (myPlayer !== null && myPlayer !== human && myPlayer.team() === null) { + // In FFA games (when team === null), use default yellow spawn highlight color + 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); + } else { + if (myPlayer.isFriendly(human)) { + color = this.theme.spawnHighlightTeamColor(); + } else { + color = this.theme.spawnHighlightColor(); + } + } + } + + for (const tile of this.game.bfs( + centerTile, + euclDistFN(centerTile, 9, true), + )) { + if (!this.game.hasOwner(tile)) { + this.paintHighlightTile(tile, color, 255); + } + } + } + } + + private drawFocusedPlayerHighlight() { + const focusedPlayer = this.game.focusedPlayer(); + + if (!focusedPlayer) { + return; + } + const center = focusedPlayer.nameLocation(); + if (!center) { + return; + } + // Breathing border animation + 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 + let teamColor: Colord | null = null; + + const team: Team | null = focusedPlayer.team(); + if (team !== null && Object.values(ColoredTeams).includes(team)) { + teamColor = this.theme.teamColor(team).alpha(0.5); + } else { + teamColor = baseColor; + } + + this.drawBreathingRing( + center.x, + center.y, + 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. + ); + + // Draw breathing rings for teammates in team games (helps colorblind players identify teammates) + this.drawTeammateHighlights(minRad, maxRad, radius); + } + + private drawTeammateHighlights( + minRad: number, + maxRad: number, + radius: number, + ) { + const myPlayer = this.game.myPlayer(); + if (myPlayer === null || myPlayer.team() === null) { + return; + } + + const teammates = this.game + .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 = + teammateMinRad + + (teammateMaxRad - teammateMinRad) * + ((radius - minRad) / (maxRad - minRad)); + + const teamColors = Object.values(ColoredTeams); + for (const teammate of teammates) { + const center = teammate.nameLocation(); + if (!center) { + continue; + } + + const team = teammate.team(); + let baseColor: Colord; + let breathingColor: Colord; + + if (team !== null && teamColors.includes(team)) { + baseColor = this.theme.teamColor(team).alpha(0.5); + breathingColor = this.theme.teamColor(team).alpha(0.5); + } else { + baseColor = this.theme.spawnHighlightTeamColor(); + breathingColor = this.theme.spawnHighlightTeamColor(); + } + + this.drawBreathingRing( + center.x, + center.y, + teammateMinRad, + teammateMaxRad, + teammateRadius, + baseColor, + breathingColor, + ); + } + } + + init() { + this.eventBus.on(MouseOverEvent, (e) => this.onMouseOver(e)); + this.eventBus.on(AlternateViewEvent, (e) => { + this.alternativeView = e.alternateView; + }); + this.eventBus.on(DragEvent, (e) => { + // TODO: consider re-enabling this on mobile or low end devices for smoother dragging. + // this.lastDragTime = Date.now(); + }); + this.redraw(); + } + + onMouseOver(event: MouseOverEvent) { + this.lastMousePosition = { x: event.x, y: event.y }; + this.updateHighlightedTerritory(); + } + + private updateHighlightedTerritory() { + if (!this.alternativeView) { + return; + } + + if (!this.lastMousePosition) { + return; + } + + const cell = this.transformHandler.screenToWorldCoordinates( + this.lastMousePosition.x, + this.lastMousePosition.y, + ); + if (!this.game.isValidCoord(cell.x, cell.y)) { + return; + } + + const previousTerritory = this.highlightedTerritory; + const territory = this.getTerritoryAtCell(cell); + + if (territory) { + this.highlightedTerritory = territory; + } else { + this.highlightedTerritory = null; + } + + if (previousTerritory?.id() !== this.highlightedTerritory?.id()) { + const territories: PlayerView[] = []; + if (previousTerritory) { + territories.push(previousTerritory); + } + if (this.highlightedTerritory) { + territories.push(this.highlightedTerritory); + } + this.redrawBorder(...territories); + } + } + + private getTerritoryAtCell(cell: { x: number; y: number }) { + const tile = this.game.ref(cell.x, cell.y); + 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; + } + const owner = this.game.owner(tile); + return owner instanceof PlayerView ? owner : null; + } + + 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, + ); + + // Add a second canvas for highlights + this.highlightCanvas = document.createElement("canvas"); + const highlightContext = this.highlightCanvas.getContext("2d", { + alpha: true, + }); + if (highlightContext === null) throw new Error("2d context not supported"); + this.highlightContext = highlightContext; + this.highlightCanvas.width = this.game.width(); + this.highlightCanvas.height = this.game.height(); + + this.game.forEachTile((t) => { + this.paintTerritory(t); + }); + } + + redrawBorder(...players: PlayerView[]) { + return Promise.all( + players.map(async (player) => { + const tiles = await player.borderTiles(); + tiles.borderTiles.forEach((tile: TileRef) => { + 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; + }); + } + + renderLayer(context: CanvasRenderingContext2D) { + const now = Date.now(); + if ( + now > this.lastDragTime + this.nodrawDragDuration && + now > this.lastRefresh + this.refreshRate + ) { + 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); + if (this.game.inSpawnPhase()) { + const highlightDrawStart = FrameProfiler.start(); + context.drawImage( + this.highlightCanvas, + -this.game.width() / 2, + -this.game.height() / 2, + this.game.width(), + this.game.height(), + ); + FrameProfiler.end( + "TerritoryLayer:drawHighlightCanvas", + highlightDrawStart, + ); + } + } + + renderTerritory() { + let numToRender = Math.floor(this.tileToRenderQueue.size() / 10); + if (numToRender === 0 || this.game.inSpawnPhase()) { + numToRender = this.tileToRenderQueue.size(); + } + + while (numToRender > 0) { + numToRender--; + + const entry = this.tileToRenderQueue.pop(); + if (!entry) { + break; + } + + const tile = entry.tile; + this.paintTerritory(tile); + for (const neighbor of this.game.neighbors(tile)) { + 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(), + ); + + 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; + } + + 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) + } + + 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); + }); + } + + 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(); + this.highlightContext.fillRect(x, y, 1, 1); + } + + clearHighlightTile(tile: TileRef) { + const x = this.game.x(tile); + const y = this.game.y(tile); + this.highlightContext.clearRect(x, y, 1, 1); + } + + private drawBreathingRing( + cx: number, + cy: number, + minRad: number, + maxRad: number, + radius: number, + transparentColor: Colord, + breathingColor: Colord, + ) { + const ctx = this.highlightContext; + if (!ctx) return; + + // 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/ClassicTerritoryBackend.ts b/src/client/graphics/layers/ClassicTerritoryBackend.ts new file mode 100644 index 000000000..1a1431f69 --- /dev/null +++ b/src/client/graphics/layers/ClassicTerritoryBackend.ts @@ -0,0 +1,59 @@ +import { EventBus } from "../../../core/EventBus"; +import { GameView } from "../../../core/game/GameView"; +import { TransformHandler } from "../TransformHandler"; +import { ClassicCanvasTerritoryLayer } from "./ClassicCanvasTerritoryLayer"; +import { TerrainLayer } from "./TerrainLayer"; +import { TerritoryBackend } from "./TerritoryBackend"; + +export class ClassicTerritoryBackend implements TerritoryBackend { + readonly id = "classic"; + + private readonly terrainLayer: TerrainLayer; + private readonly territoryLayer: ClassicCanvasTerritoryLayer; + + constructor( + game: GameView, + eventBus: EventBus, + transformHandler: TransformHandler, + ) { + this.terrainLayer = new TerrainLayer(game, transformHandler); + this.territoryLayer = new ClassicCanvasTerritoryLayer( + game, + eventBus, + transformHandler, + ); + } + + profileName(): string { + return "ClassicTerritoryBackend:renderLayer"; + } + + shouldTransform(): boolean { + return true; + } + + init() { + this.terrainLayer.init?.(); + this.territoryLayer.init?.(); + } + + tick() { + this.terrainLayer.tick?.(); + this.territoryLayer.tick?.(); + } + + redraw() { + this.terrainLayer.redraw?.(); + this.territoryLayer.redraw?.(); + } + + renderLayer(context: CanvasRenderingContext2D) { + this.terrainLayer.renderLayer?.(context); + this.territoryLayer.renderLayer?.(context); + } + + dispose() { + // Classic layers own only offscreen canvases and event-bus listeners. + // The event bus does not currently expose unsubscribe hooks. + } +} diff --git a/src/client/graphics/layers/TerrainLayer.ts b/src/client/graphics/layers/TerrainLayer.ts new file mode 100644 index 000000000..353555912 --- /dev/null +++ b/src/client/graphics/layers/TerrainLayer.ts @@ -0,0 +1,107 @@ +import { Config, Theme } from "../../../core/configuration/Config"; +import { GameView } from "../../../core/game/GameView"; +import { TransformHandler } from "../TransformHandler"; +import { Layer } from "./Layer"; + +export class TerrainLayer implements Layer { + private canvas: HTMLCanvasElement; + private context: CanvasRenderingContext2D; + private imageData: ImageData; + private theme: Theme; + private config: Config; + + constructor( + private game: GameView, + private transformHandler: TransformHandler, + ) { + this.config = this.game.config(); + } + shouldTransform(): boolean { + return true; + } + tick() { + if (this.config.theme() !== this.theme) { + this.redraw(); + return; + } + // Repaint terrain for tiles whose terrain changed (e.g. nuke + // turning land to water). + const updatedTiles = this.game.recentlyUpdatedTerrainTiles(); + if (updatedTiles.length > 0) { + let dirty = false; + for (const tile of updatedTiles) { + const terrainColor = this.theme.terrainColor(this.game, tile); + const offset = tile * 4; + const r = terrainColor.rgba.r; + const g = terrainColor.rgba.g; + const b = terrainColor.rgba.b; + if ( + this.imageData.data[offset] !== r || + this.imageData.data[offset + 1] !== g || + this.imageData.data[offset + 2] !== b + ) { + this.imageData.data[offset] = r; + this.imageData.data[offset + 1] = g; + this.imageData.data[offset + 2] = b; + dirty = true; + } + } + if (dirty) { + this.context.putImageData(this.imageData, 0, 0); + } + } + } + + init() { + console.log("redrew terrain layer"); + this.redraw(); + } + + redraw(): void { + this.canvas = document.createElement("canvas"); + this.canvas.width = this.game.width(); + this.canvas.height = this.game.height(); + + const context = this.canvas.getContext("2d", { alpha: false }); + if (context === null) throw new Error("2d context not supported"); + this.context = context; + + this.imageData = this.context.createImageData( + this.canvas.width, + this.canvas.height, + ); + + this.initImageData(); + this.context.putImageData(this.imageData, 0, 0); + } + + initImageData() { + this.theme = this.config.theme(); + this.game.forEachTile((tile) => { + const terrainColor = this.theme.terrainColor(this.game, tile); + // TODO: isn't tileref and index the same? + const index = this.game.y(tile) * this.game.width() + this.game.x(tile); + const offset = index * 4; + this.imageData.data[offset] = terrainColor.rgba.r; + this.imageData.data[offset + 1] = terrainColor.rgba.g; + this.imageData.data[offset + 2] = terrainColor.rgba.b; + this.imageData.data[offset + 3] = 255; + }); + } + + renderLayer(context: CanvasRenderingContext2D) { + if (this.transformHandler.scale < 1) { + context.imageSmoothingEnabled = true; + context.imageSmoothingQuality = "low"; + } else { + context.imageSmoothingEnabled = false; + } + context.drawImage( + this.canvas, + -this.game.width() / 2, + -this.game.height() / 2, + this.game.width(), + this.game.height(), + ); + } +} diff --git a/src/client/graphics/layers/TerritoryBackend.ts b/src/client/graphics/layers/TerritoryBackend.ts new file mode 100644 index 000000000..7b02694ca --- /dev/null +++ b/src/client/graphics/layers/TerritoryBackend.ts @@ -0,0 +1,134 @@ +import { Layer } from "./Layer"; + +export type TerritoryRendererId = "classic" | "webgl" | "webgpu"; +export type TerritoryRendererPreference = "auto" | TerritoryRendererId; + +export const TERRITORY_RENDERER_OPTIONS: TerritoryRendererPreference[] = [ + "auto", + "classic", + "webgl", + "webgpu", +]; + +export interface TerritoryBackend extends Layer { + readonly id: TerritoryRendererId; + dispose?: () => void; + getFailureReason?: () => string | null; + whenReady?: () => Promise; +} + +export interface TerritoryBackendCandidate { + readonly id: TerritoryRendererId; + init?: () => void | Promise; + dispose?: () => void; + getFailureReason?: () => string | null; + whenReady?: () => Promise; +} + +export interface TerritoryBackendSelectionFailure { + id: TerritoryRendererId; + reason: string; + error?: unknown; +} + +export interface TerritoryBackendSelection< + T extends TerritoryBackendCandidate, +> { + backend: T | null; + failures: TerritoryBackendSelectionFailure[]; + cancelled: boolean; +} + +export function normalizeTerritoryRendererPreference( + value: string | null | undefined, +): TerritoryRendererPreference { + if ( + value === "classic" || + value === "webgl" || + value === "webgpu" || + value === "auto" + ) { + return value; + } + return "auto"; +} + +export function territoryRendererOrder( + preference: TerritoryRendererPreference, + failedBackends: ReadonlySet = new Set(), +): TerritoryRendererId[] { + const preferredOrder: TerritoryRendererId[] = + preference === "classic" + ? ["classic"] + : preference === "webgl" + ? ["webgl", "classic"] + : ["webgpu", "webgl", "classic"]; + + return preferredOrder.filter( + (id) => id === "classic" || !failedBackends.has(id), + ); +} + +export async function selectTerritoryBackend< + T extends TerritoryBackendCandidate, +>( + preference: TerritoryRendererPreference, + failedBackends: ReadonlySet, + createBackend: (id: TerritoryRendererId) => T, + shouldContinue: () => boolean = () => true, +): Promise> { + const failures: TerritoryBackendSelectionFailure[] = []; + + for (const id of territoryRendererOrder(preference, failedBackends)) { + if (!shouldContinue()) { + return { backend: null, failures, cancelled: true }; + } + + const backend = createBackend(id); + try { + await backend.init?.(); + + if (!shouldContinue()) { + backend.dispose?.(); + return { backend: null, failures, cancelled: true }; + } + + let reason = backend.getFailureReason?.() ?? null; + if (reason !== null) { + backend.dispose?.(); + failures.push({ id, reason }); + continue; + } + + if (backend.whenReady) { + const ready = await backend.whenReady(); + + if (!shouldContinue()) { + backend.dispose?.(); + return { backend: null, failures, cancelled: true }; + } + + reason = backend.getFailureReason?.() ?? null; + if (!ready || reason !== null) { + backend.dispose?.(); + failures.push({ + id, + reason: reason ?? "initialization failed", + }); + continue; + } + } + + return { backend, failures, cancelled: false }; + } catch (error) { + backend.dispose?.(); + failures.push({ + id, + reason: error instanceof Error ? error.message : String(error), + error, + }); + } + } + + return { backend: null, failures, cancelled: false }; +} diff --git a/src/client/graphics/layers/TerritoryLayer.ts b/src/client/graphics/layers/TerritoryLayer.ts index 94b27d886..60833078b 100644 --- a/src/client/graphics/layers/TerritoryLayer.ts +++ b/src/client/graphics/layers/TerritoryLayer.ts @@ -1,68 +1,42 @@ -import { Theme } from "../../../core/configuration/Config"; import { EventBus } from "../../../core/EventBus"; -import { UnitType } from "../../../core/game/Game"; -import { TileRef } from "../../../core/game/GameMap"; import { GameView } from "../../../core/game/GameView"; -import { UserSettings } from "../../../core/game/UserSettings"; import { - AlternateViewEvent, - MouseOverEvent, - WebGPUComputeMetricsEvent, -} from "../../InputHandler"; -import { FrameProfiler } from "../FrameProfiler"; + TERRITORY_RENDERER_KEY, + USER_SETTINGS_CHANGED_EVENT, + UserSettings, +} from "../../../core/game/UserSettings"; import { TransformHandler } from "../TransformHandler"; +import { ClassicTerritoryBackend } from "./ClassicTerritoryBackend"; import { - buildTerrainShaderParams, - readTerrainShaderId, -} from "../webgpu/render/TerrainShaderRegistry"; -import { - buildTerritoryPostSmoothingParams, - readTerritoryPostSmoothingId, -} from "../webgpu/render/TerritoryPostSmoothingRegistry"; -import { - buildTerritoryPreSmoothingParams, - readTerritoryPreSmoothingId, -} from "../webgpu/render/TerritoryPreSmoothingRegistry"; -import { - buildTerritoryShaderParams, - readTerritoryShaderId, -} from "../webgpu/render/TerritoryShaderRegistry"; -import { TerritoryRenderer } from "../webgpu/TerritoryRenderer"; -import { Layer } from "./Layer"; + TerritoryBackend, + TerritoryRendererId, + selectTerritoryBackend, + territoryRendererOrder, +} from "./TerritoryBackend"; +import { WebGLTerritoryBackend } from "./WebGLTerritoryBackend"; +import { WebGPUTerritoryBackend } from "./WebGPUTerritoryBackend"; -export class TerritoryLayer implements Layer { - profileName(): string { - return "TerritoryLayer:renderLayer"; - } +export class TerritoryLayer implements TerritoryBackend { + readonly id = "classic"; - private attachedTerritoryCanvas: HTMLCanvasElement | null = null; - - private overlayWrapper: HTMLElement | null = null; - private overlayResizeObserver: ResizeObserver | null = null; - - private theme: Theme; - - private territoryRenderer: TerritoryRenderer | null = null; - private alternativeView = false; - - private lastPaletteSignature: string | null = null; - private lastDefensePostsSignature: string | null = null; - private lastTerrainShaderSignature: string | null = null; - private lastTerritoryShaderSignature: string | null = null; - private lastPreSmoothingSignature: string | null = null; - private lastPostSmoothingSignature: string | null = null; - - private lastMousePosition: { x: number; y: number } | null = null; - private hoveredOwnerSmallId: number | null = null; - private lastHoverUpdateMs = 0; + private activeBackend: TerritoryBackend | null = null; + private failedBackends = new Set(); + private selectionToken = 0; + private initialized = false; + private readonly settingsChanged = () => { + this.failedBackends.clear(); + void this.selectConfiguredBackend(); + }; constructor( private game: GameView, private eventBus: EventBus, private transformHandler: TransformHandler, private userSettings: UserSettings, - ) { - this.theme = game.config().theme(); + ) {} + + profileName(): string { + return "TerritoryLayer:renderLayer"; } shouldTransform(): boolean { @@ -70,355 +44,201 @@ export class TerritoryLayer implements Layer { } init() { - this.eventBus.on(AlternateViewEvent, (e) => { - this.alternativeView = e.alternateView; - this.territoryRenderer?.setAlternativeView(this.alternativeView); - }); - this.eventBus.on(MouseOverEvent, (e) => { - this.lastMousePosition = { x: e.x, y: e.y }; - }); - this.redraw(); + this.initialized = true; + globalThis.addEventListener?.( + `${USER_SETTINGS_CHANGED_EVENT}:${TERRITORY_RENDERER_KEY}`, + this.settingsChanged, + ); + + // Keep the map visible while accelerated renderers initialize. + this.activateBackend(this.createBackend("classic")); + void this.selectConfiguredBackend(); } tick() { - const tickProfile = FrameProfiler.start(); - - const currentTheme = this.game.config().theme(); - if (currentTheme !== this.theme) { - this.theme = currentTheme; - this.territoryRenderer?.refreshTerrain(); - this.redraw(); - } - - this.refreshPaletteIfNeeded(); - this.refreshDefensePostsIfNeeded(); - this.applyTerrainShaderSettings(); - this.applyTerritoryShaderSettings(); - this.applyTerritorySmoothingSettings(); - - const updatedTiles = this.game.recentlyUpdatedTiles(); - for (let i = 0; i < updatedTiles.length; i++) { - this.markTile(updatedTiles[i]); - } - - // After collecting pending updates and handling palette/theme changes, - // invoke the renderer's tick() to process compute passes. This ensures - // compute shaders run at the simulation rate rather than every frame. - if (this.territoryRenderer) { - const start = performance.now(); - this.territoryRenderer.tick(); - const computeMs = performance.now() - start; - this.eventBus.emit(new WebGPUComputeMetricsEvent(computeMs)); - } - - FrameProfiler.end("TerritoryLayer:tick", tickProfile); + this.runActive("tick", (backend) => backend.tick?.()); } redraw() { - this.configureRenderer(); - } - - private configureRenderer() { - const { renderer, reason } = TerritoryRenderer.create( - this.game, - this.theme, - ); - if (!renderer) { - throw new Error(reason ?? "WebGPU is required for territory rendering."); + if (!this.initialized) { + return; } - - this.territoryRenderer = renderer; - this.territoryRenderer.setAlternativeView(this.alternativeView); - this.territoryRenderer.setHighlightedOwnerId(this.hoveredOwnerSmallId); - this.applyTerrainShaderSettings(true); - this.applyTerritoryShaderSettings(true); - this.applyTerritorySmoothingSettings(true); - this.territoryRenderer.markAllDirty(); - this.territoryRenderer.refreshPalette(); - this.lastPaletteSignature = this.computePaletteSignature(); - - this.lastDefensePostsSignature = this.computeDefensePostsSignature(); - // Ensure defense posts buffer is uploaded on first tick. - this.territoryRenderer.markDefensePostsDirty(); - - // Run an initial tick to upload state and build the colour texture. Without - // this, the first render call may occur before the initial compute pass - // has been executed, resulting in undefined colours. - this.territoryRenderer.tick(); + this.runActive("redraw", (backend) => backend.redraw?.()); + void this.selectConfiguredBackend(); } renderLayer(context: CanvasRenderingContext2D) { - if (!this.territoryRenderer) { + if (!this.activeBackend) { return; } - // Check for theme changes in renderLayer too (for when game is paused) - const currentTheme = this.game.config().theme(); - if (currentTheme !== this.theme) { - this.theme = currentTheme; - this.territoryRenderer.refreshTerrain(); - this.redraw(); + if (this.activeBackend.id !== "webgpu") { + this.fillBackground(context); } - // Apply user settings even while the game is paused (settings modal). - this.applyTerritoryShaderSettings(); - this.applyTerritorySmoothingSettings(); + this.runActive("renderLayer", (backend) => backend.renderLayer?.(context)); + } - this.ensureTerritoryCanvasAttached(context.canvas); - this.updateHoverHighlight(); - - const renderTerritoryStart = FrameProfiler.start(); - this.territoryRenderer.setViewSize( - context.canvas.width, - context.canvas.height, + dispose() { + globalThis.removeEventListener?.( + `${USER_SETTINGS_CHANGED_EVENT}:${TERRITORY_RENDERER_KEY}`, + this.settingsChanged, ); - const viewOffset = this.transformHandler.viewOffset(); - this.territoryRenderer.setViewTransform( - this.transformHandler.scale, - viewOffset.x, - viewOffset.y, + this.activeBackend?.dispose?.(); + this.activeBackend = null; + } + + private async selectConfiguredBackend() { + const token = ++this.selectionToken; + const preference = this.userSettings.territoryRenderer(); + const order = territoryRendererOrder(preference, this.failedBackends); + if ( + this.activeBackend?.id === order[0] && + !this.activeBackend.getFailureReason?.() + ) { + return; + } + + const selection = await selectTerritoryBackend( + preference, + this.failedBackends, + (id) => this.createBackend(id), + () => token === this.selectionToken, ); - this.territoryRenderer.render(); - FrameProfiler.end("TerritoryLayer:renderTerritory", renderTerritoryStart); - } - private ensureTerritoryCanvasAttached(mainCanvas: HTMLCanvasElement) { - if (!this.territoryRenderer) { + if (selection.cancelled) { return; } - const canvas = this.territoryRenderer.canvas; - - // If the renderer recreated its canvas, detach the old one. - if (this.attachedTerritoryCanvas !== canvas) { - this.attachedTerritoryCanvas?.remove(); - this.attachedTerritoryCanvas = canvas; - - // Configure overlay canvas styles once. Avoid per-frame style reads/writes. - canvas.style.pointerEvents = "none"; - canvas.style.position = "absolute"; - canvas.style.inset = "0"; - canvas.style.width = "100%"; - canvas.style.height = "100%"; - canvas.style.display = "block"; - } - - const parent = mainCanvas.parentElement; - if (!parent) { - // Fallback: if the canvas isn't in the DOM yet, append to body. - if (!canvas.isConnected) { - document.body.appendChild(canvas); - } - return; - } - - // Ensure the main canvas is wrapped in a positioned container so the - // territory canvas can overlay it without mirroring computed styles. - let wrapper: HTMLElement; - const currentParent = mainCanvas.parentElement; - if (currentParent && currentParent.dataset.territoryOverlay === "1") { - wrapper = currentParent; - } else { - wrapper = document.createElement("div"); - wrapper.dataset.territoryOverlay = "1"; - wrapper.style.position = "relative"; - wrapper.style.display = "inline-block"; - wrapper.style.lineHeight = "0"; - - // Replace mainCanvas with wrapper, then re-insert mainCanvas inside wrapper. - parent.replaceChild(wrapper, mainCanvas); - wrapper.appendChild(mainCanvas); - } - - if (this.overlayWrapper !== wrapper) { - this.overlayWrapper = wrapper; - this.overlayResizeObserver?.disconnect(); - this.overlayResizeObserver = new ResizeObserver(() => { - this.syncOverlayWrapperSize(mainCanvas, wrapper); - }); - this.overlayResizeObserver.observe(mainCanvas); - // Kick an initial size update; further updates are handled by ResizeObserver. - this.syncOverlayWrapperSize(mainCanvas, wrapper); - } - - // Ensure territory canvas is the first child so it's the lowest layer. - if (canvas.parentElement !== wrapper) { - canvas.remove(); - wrapper.insertBefore(canvas, mainCanvas); - } else if (canvas !== wrapper.firstElementChild) { - wrapper.insertBefore(canvas, mainCanvas); - } - } - - private syncOverlayWrapperSize( - mainCanvas: HTMLCanvasElement, - wrapper: HTMLElement, - ) { - // Ensure the wrapper has real layout size so the absolutely-positioned - // territory canvas (100% width/height) is non-zero even if the main canvas - // is positioned absolutely. - const rect = mainCanvas.getBoundingClientRect(); - const w = rect.width > 0 ? rect.width : mainCanvas.clientWidth; - const h = rect.height > 0 ? rect.height : mainCanvas.clientHeight; - if (w > 0) wrapper.style.width = `${w}px`; - if (h > 0) wrapper.style.height = `${h}px`; - } - - private markTile(tile: TileRef) { - this.territoryRenderer?.markTile(tile); - } - - private updateHoverHighlight() { - if (!this.territoryRenderer) { - return; - } - - const now = performance.now(); - if (now - this.lastHoverUpdateMs < 100) { - return; - } - this.lastHoverUpdateMs = now; - - let nextOwnerSmallId: number | null = null; - if (this.lastMousePosition) { - const cell = this.transformHandler.screenToWorldCoordinates( - this.lastMousePosition.x, - this.lastMousePosition.y, + for (const failure of selection.failures) { + console.warn( + `[TerritoryLayer] ${failure.id} renderer unavailable: ${failure.reason}`, + failure.error ?? "", ); - if (this.game.isValidCoord(cell.x, cell.y)) { - const tile = this.game.ref(cell.x, cell.y); - const owner = this.game.owner(tile); - if (owner && owner.isPlayer()) { - nextOwnerSmallId = owner.smallID(); + if (failure.id !== "classic") { + this.failedBackends.add(failure.id); + } + } + + if (selection.backend !== null) { + this.activateBackend(selection.backend); + } + } + + private async initializeCandidate( + backend: TerritoryBackend, + token: number, + ): Promise { + try { + await backend.init?.(); + if (token !== this.selectionToken) { + return false; + } + if (backend.getFailureReason?.()) { + console.warn( + `[TerritoryLayer] ${backend.id} renderer unavailable: ${backend.getFailureReason()}`, + ); + return false; + } + if (backend.whenReady) { + const ready = await backend.whenReady(); + if (!ready || backend.getFailureReason?.()) { + console.warn( + `[TerritoryLayer] ${backend.id} renderer unavailable: ${ + backend.getFailureReason?.() ?? "initialization failed" + }`, + ); + return false; } } - } - - if (nextOwnerSmallId === this.hoveredOwnerSmallId) { - return; - } - this.hoveredOwnerSmallId = nextOwnerSmallId; - this.territoryRenderer.setHighlightedOwnerId(nextOwnerSmallId); - } - - private computePaletteSignature(): string { - let maxSmallId = 0; - for (const player of this.game.playerViews()) { - maxSmallId = Math.max(maxSmallId, player.smallID()); - } - const patternsEnabled = this.userSettings.territoryPatterns(); - return `${this.game.playerViews().length}:${maxSmallId}:${patternsEnabled ? 1 : 0}`; - } - - private refreshPaletteIfNeeded() { - if (!this.territoryRenderer) { - return; - } - const signature = this.computePaletteSignature(); - if (signature !== this.lastPaletteSignature) { - this.lastPaletteSignature = signature; - this.territoryRenderer.refreshPalette(); - } - } - - private applyTerritoryShaderSettings(force: boolean = false) { - if (!this.territoryRenderer) { - return; - } - - const shaderId = readTerritoryShaderId(this.userSettings); - const { shaderPath, params0, params1 } = buildTerritoryShaderParams( - this.userSettings, - shaderId, - ); - - const signature = `${shaderPath}:${Array.from(params0).join(",")}:${Array.from(params1).join(",")}`; - if (!force && signature === this.lastTerritoryShaderSignature) { - return; - } - this.lastTerritoryShaderSignature = signature; - - this.territoryRenderer.setTerritoryShader(shaderPath); - this.territoryRenderer.setTerritoryShaderParams(params0, params1); - } - - private applyTerrainShaderSettings(force: boolean = false) { - if (!this.territoryRenderer) { - return; - } - - const terrainId = readTerrainShaderId(this.userSettings); - const { shaderPath, params0, params1 } = buildTerrainShaderParams( - this.userSettings, - terrainId, - ); - const signature = `${shaderPath}:${Array.from(params0).join(",")}:${Array.from(params1).join(",")}`; - if (!force && signature === this.lastTerrainShaderSignature) { - return; - } - this.lastTerrainShaderSignature = signature; - this.territoryRenderer.setTerrainShader(shaderPath); - this.territoryRenderer.setTerrainShaderParams(params0, params1); - } - - private applyTerritorySmoothingSettings(force: boolean = false) { - if (!this.territoryRenderer) { - return; - } - - const preId = readTerritoryPreSmoothingId(this.userSettings); - const preParams = buildTerritoryPreSmoothingParams( - this.userSettings, - preId, - ); - const preSignature = `${preId}:${Array.from(preParams.params0).join(",")}`; - if (force || preSignature !== this.lastPreSmoothingSignature) { - this.lastPreSmoothingSignature = preSignature; - this.territoryRenderer.setPreSmoothing( - preParams.enabled, - preParams.shaderPath, - preParams.params0, + return true; + } catch (error) { + console.warn( + `[TerritoryLayer] ${backend.id} renderer failed init`, + error, ); + return false; + } + } + + private activateBackend(backend: TerritoryBackend) { + if (this.activeBackend === backend) { + return; + } + const previous = this.activeBackend; + this.activeBackend = backend; + previous?.dispose?.(); + console.info(`[TerritoryLayer] active renderer: ${backend.id}`); + } + + private runActive( + operation: "tick" | "redraw" | "renderLayer", + run: (backend: TerritoryBackend) => void, + ) { + const backend = this.activeBackend; + if (!backend) { + return; } - const postId = readTerritoryPostSmoothingId(this.userSettings); - const postParams = buildTerritoryPostSmoothingParams( - this.userSettings, - postId, - ); - const postSignature = `${postId}:${Array.from(postParams.params0).join(",")}`; - if (force || postSignature !== this.lastPostSmoothingSignature) { - this.lastPostSmoothingSignature = postSignature; - this.territoryRenderer.setPostSmoothing( - postParams.enabled, - postParams.shaderPath, - postParams.params0, + try { + run(backend); + const reason = backend.getFailureReason?.(); + if (reason) { + this.handleBackendFailure(backend, `${operation}: ${reason}`); + } + } catch (error) { + this.handleBackendFailure(backend, `${operation}: ${String(error)}`); + } + } + + private handleBackendFailure(backend: TerritoryBackend, reason: string) { + console.warn(`[TerritoryLayer] ${backend.id} renderer failed: ${reason}`); + if (backend.id !== "classic") { + this.failedBackends.add(backend.id); + } + if (this.activeBackend === backend) { + this.activeBackend = null; + backend.dispose?.(); + const classic = this.createBackend("classic"); + void this.initializeCandidate(classic, ++this.selectionToken).then( + (ready) => { + if (ready) { + this.activateBackend(classic); + void this.selectConfiguredBackend(); + } + }, ); } } - private computeDefensePostsSignature(): string { - // Active + completed posts only. - const parts: string[] = []; - for (const u of this.game.units(UnitType.DefensePost)) { - if (!u.isActive() || u.isUnderConstruction()) continue; - const tile = u.tile(); - parts.push( - `${u.owner().smallID()},${this.game.x(tile)},${this.game.y(tile)}`, + private createBackend(id: TerritoryRendererId): TerritoryBackend { + if (id === "webgpu") { + return new WebGPUTerritoryBackend( + this.game, + this.eventBus, + this.transformHandler, + this.userSettings, ); } - parts.sort(); - return parts.join("|"); + if (id === "webgl") { + return new WebGLTerritoryBackend( + this.game, + this.eventBus, + this.transformHandler, + ); + } + return new ClassicTerritoryBackend( + this.game, + this.eventBus, + this.transformHandler, + ); } - private refreshDefensePostsIfNeeded() { - if (!this.territoryRenderer) { - return; - } - const signature = this.computeDefensePostsSignature(); - if (signature !== this.lastDefensePostsSignature) { - this.lastDefensePostsSignature = signature; - this.territoryRenderer.markDefensePostsDirty(); - } + private fillBackground(context: CanvasRenderingContext2D) { + context.save(); + context.setTransform(1, 0, 0, 1, 0, 0); + context.fillStyle = this.game.config().theme().backgroundColor().toHex(); + context.fillRect(0, 0, context.canvas.width, context.canvas.height); + context.restore(); } } diff --git a/src/client/graphics/layers/TerritoryWebGLRenderer.ts b/src/client/graphics/layers/TerritoryWebGLRenderer.ts new file mode 100644 index 000000000..f506f45aa --- /dev/null +++ b/src/client/graphics/layers/TerritoryWebGLRenderer.ts @@ -0,0 +1,3870 @@ +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 contestEnabled = false; + private contestPatternMode: 0 | 1 | 2 = 0; // 0=blueNoise(strength), 1=checkerboard(50/50), 2=bayer4x4(strength) + private debugDisableStaticBorders = false; + private debugDisableAllBorders = false; + private seedSamplingMode: 0 | 1 | 2 = 1; // 0=none(single texel), 1=2x2, 2=3x3 + private debugStripeFixedColors = false; // Use fixed debug colors for moving stripe + private motionMode: 0 | 1 | 2 | 3 = 0; // 0=euclidean, 1=axisSnap, 2=manhattan, 3=chebyshev + + private readonly gl: WebGL2RenderingContext | null; + private readonly program: WebGLProgram | null; + private readonly vao: WebGLVertexArrayObject | null; + private readonly vertexBuffer: WebGLBuffer | null; + private readonly jfaVao: WebGLVertexArrayObject | null; + private readonly jfaVertexBuffer: WebGLBuffer | null; + private readonly stateTexture: WebGLTexture | null; + private readonly terrainTexture: WebGLTexture | null; + private readonly paletteTexture: WebGLTexture | null; + private readonly relationTexture: WebGLTexture | null; + private readonly patternTexture: WebGLTexture | null; + private readonly contestOwnersTexture: WebGLTexture | null; + private readonly contestIdsTexture: WebGLTexture | null; + private readonly contestTimesTexture: WebGLTexture | null; + private readonly contestStrengthsTexture: WebGLTexture | null; + private readonly prevOwnerTexture: WebGLTexture | null; + private readonly olderOwnerTexture: WebGLTexture | null; + private readonly stateFramebuffer: WebGLFramebuffer | null; + private readonly prevStateFramebuffer: WebGLFramebuffer | null; + private readonly olderStateFramebuffer: WebGLFramebuffer | null; + private readonly jfaTextureA: WebGLTexture | null; + private readonly jfaTextureB: WebGLTexture | null; + private readonly jfaFramebufferA: WebGLFramebuffer | null; + private readonly jfaFramebufferB: WebGLFramebuffer | null; + private readonly jfaResultOlderTexture: WebGLTexture | null; + private readonly jfaResultOldTexture: WebGLTexture | null; + private readonly jfaResultNewTexture: WebGLTexture | null; + private readonly jfaResultOlderFramebuffer: WebGLFramebuffer | null; + private readonly jfaResultOldFramebuffer: WebGLFramebuffer | null; + private readonly jfaResultNewFramebuffer: WebGLFramebuffer | null; + private readonly jfaSeedProgram: WebGLProgram | null; + private readonly jfaProgram: WebGLProgram | null; + private readonly changeMaskProgram: WebGLProgram | null; + private readonly changeMaskTextureOlder: WebGLTexture | null; + private readonly changeMaskTextureOld: WebGLTexture | null; + private readonly changeMaskTextureNew: WebGLTexture | null; + private readonly changeMaskFramebufferOlder: WebGLFramebuffer | null; + private readonly changeMaskFramebufferOld: WebGLFramebuffer | null; + private readonly changeMaskFramebufferNew: WebGLFramebuffer | null; + private readonly jfaSeedUniforms: { + resolution: WebGLUniformLocation | null; + owner: WebGLUniformLocation | null; + }; + private readonly jfaUniforms: { + resolution: WebGLUniformLocation | null; + step: WebGLUniformLocation | null; + seeds: WebGLUniformLocation | null; + }; + private readonly changeMaskUniforms: { + resolution: WebGLUniformLocation | null; + oldTexture: WebGLUniformLocation | null; + newTexture: WebGLUniformLocation | null; + }; + private readonly uniforms: { + mapResolution: WebGLUniformLocation | null; + viewResolution: WebGLUniformLocation | null; + viewScale: WebGLUniformLocation | null; + viewOffset: WebGLUniformLocation | null; + state: WebGLUniformLocation | null; + terrain: WebGLUniformLocation | null; + latestState: WebGLUniformLocation | null; + palette: WebGLUniformLocation | null; + relations: WebGLUniformLocation | null; + patterns: WebGLUniformLocation | null; + contestEnabled: WebGLUniformLocation | null; + contestPatternMode: WebGLUniformLocation | null; + debugDisableStaticBorders: WebGLUniformLocation | null; + debugDisableAllBorders: WebGLUniformLocation | null; + seedSamplingMode: WebGLUniformLocation | null; + debugStripeFixedColors: WebGLUniformLocation | null; + motionMode: WebGLUniformLocation | null; + contestOwners: WebGLUniformLocation | null; + contestIds: WebGLUniformLocation | null; + contestTimes: WebGLUniformLocation | null; + contestStrengths: WebGLUniformLocation | null; + jfaAvailable: WebGLUniformLocation | null; + contestNow: WebGLUniformLocation | null; + contestDuration: WebGLUniformLocation | null; + prevOwner: WebGLUniformLocation | null; + jfaSeedsOld: WebGLUniformLocation | null; + jfaSeedsNew: WebGLUniformLocation | null; + smoothProgress: WebGLUniformLocation | null; + changeMask: WebGLUniformLocation | null; + smoothEnabled: 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; + darkMode: WebGLUniformLocation | null; + }; + + private readonly mapWidth: number; + private readonly mapHeight: number; + private viewWidth: number; + private viewHeight: number; + private viewScale = 1; + private viewOffsetX = 0; + private viewOffsetY = 0; + + private readonly state: Uint16Array; + private contestOwnersState: Uint16Array; + private contestIdsState: Uint16Array; + private contestTimesState: Uint16Array; + private contestStrengthsState: Uint16Array; + private readonly dirtyRows: Map = new Map(); + private readonly contestDirtyRows: Map = new Map(); + private needsFullUpload = true; + private needsContestFullUpload = true; + private needsContestTimesUpload = true; + private needsContestStrengthsUpload = true; + private alternativeView = false; + private paletteWidth = 0; + // Defaults are overridden by setHoverHighlightOptions() from TerritoryLayer. + private hoverHighlightStrength = 0.3; + // Defaults are overridden by setHoverHighlightOptions() from TerritoryLayer. + private hoverHighlightColor: [number, number, number] = [1, 1, 1]; + // Defaults are overridden by setHoverHighlightOptions() from TerritoryLayer. + private hoverPulseStrength = 0.25; + // Defaults are overridden by setHoverHighlightOptions() from TerritoryLayer. + private hoverPulseSpeed = Math.PI * 2; + private hoveredPlayerId = -1; + private hoverStartTime = 0; + private static readonly HOVER_DURATION_MS = 5000; + private animationStartTime = Date.now(); + private contestNow = 0; + private contestDurationTicks = 0; + private smoothProgress = 1; + private smoothEnabled = true; + private jfaSupported = false; + private jfaDisabledReason: string | null = null; + private jfaDirty = false; + private jfaHistoryInitialized = false; + private changeMaskDirty = false; + private changeMaskHistoryInitialized = false; + private prevStateCopySupported = false; + private jfaSteps: number[] = []; + private interpolationPair: "prevCurrent" | "olderPrev" = "prevCurrent"; + 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.mapWidth = game.width(); + this.mapHeight = game.height(); + this.viewWidth = this.mapWidth; + this.viewHeight = this.mapHeight; + this.canvas.width = this.viewWidth; + this.canvas.height = this.viewHeight; + + this.state = state; + this.contestOwnersState = new Uint16Array(state.length * 2); + this.contestIdsState = new Uint16Array(state.length); + this.contestTimesState = new Uint16Array(1); + this.contestStrengthsState = new Uint16Array(1); + + 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.jfaVao = null; + this.jfaVertexBuffer = null; + this.stateTexture = null; + this.terrainTexture = null; + this.paletteTexture = null; + this.relationTexture = null; + this.patternTexture = null; + this.contestOwnersTexture = null; + this.contestIdsTexture = null; + this.contestTimesTexture = null; + this.contestStrengthsTexture = null; + this.prevOwnerTexture = null; + this.olderOwnerTexture = null; + this.stateFramebuffer = null; + this.prevStateFramebuffer = null; + this.olderStateFramebuffer = null; + this.jfaTextureA = null; + this.jfaTextureB = null; + this.jfaFramebufferA = null; + this.jfaFramebufferB = null; + this.jfaResultOlderTexture = null; + this.jfaResultOldTexture = null; + this.jfaResultNewTexture = null; + this.jfaResultOlderFramebuffer = null; + this.jfaResultOldFramebuffer = null; + this.jfaResultNewFramebuffer = null; + this.jfaSeedProgram = null; + this.jfaProgram = null; + this.changeMaskProgram = null; + this.changeMaskTextureOlder = null; + this.changeMaskTextureOld = null; + this.changeMaskTextureNew = null; + this.changeMaskFramebufferOlder = null; + this.changeMaskFramebufferOld = null; + this.changeMaskFramebufferNew = null; + this.jfaSeedUniforms = { resolution: null, owner: null }; + this.jfaUniforms = { resolution: null, step: null, seeds: null }; + this.changeMaskUniforms = { + resolution: null, + oldTexture: null, + newTexture: null, + }; + this.uniforms = { + mapResolution: null, + viewResolution: null, + viewScale: null, + viewOffset: null, + state: null, + terrain: null, + latestState: null, + palette: null, + relations: null, + patterns: null, + contestEnabled: null, + contestPatternMode: null, + debugDisableStaticBorders: null, + debugDisableAllBorders: null, + seedSamplingMode: null, + debugStripeFixedColors: null, + motionMode: null, + contestOwners: null, + contestIds: null, + contestTimes: null, + contestStrengths: null, + jfaAvailable: null, + contestNow: null, + contestDuration: null, + prevOwner: null, + jfaSeedsOld: null, + jfaSeedsNew: null, + smoothProgress: null, + changeMask: null, + smoothEnabled: 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, + darkMode: null, + }; + return; + } + + const gl = this.gl; + this.program = this.createProgram(gl); + if (!this.program) { + this.vao = null; + this.vertexBuffer = null; + this.jfaVao = null; + this.jfaVertexBuffer = null; + this.stateTexture = null; + this.terrainTexture = null; + this.paletteTexture = null; + this.relationTexture = null; + this.patternTexture = null; + this.contestOwnersTexture = null; + this.contestIdsTexture = null; + this.contestTimesTexture = null; + this.contestStrengthsTexture = null; + this.prevOwnerTexture = null; + this.olderOwnerTexture = null; + this.stateFramebuffer = null; + this.prevStateFramebuffer = null; + this.olderStateFramebuffer = null; + this.jfaTextureA = null; + this.jfaTextureB = null; + this.jfaFramebufferA = null; + this.jfaFramebufferB = null; + this.jfaResultOlderTexture = null; + this.jfaResultOldTexture = null; + this.jfaResultNewTexture = null; + this.jfaResultOlderFramebuffer = null; + this.jfaResultOldFramebuffer = null; + this.jfaResultNewFramebuffer = null; + this.jfaSeedProgram = null; + this.jfaProgram = null; + this.changeMaskProgram = null; + this.changeMaskTextureOlder = null; + this.changeMaskTextureOld = null; + this.changeMaskTextureNew = null; + this.changeMaskFramebufferOlder = null; + this.changeMaskFramebufferOld = null; + this.changeMaskFramebufferNew = null; + this.jfaSeedUniforms = { resolution: null, owner: null }; + this.jfaUniforms = { resolution: null, step: null, seeds: null }; + this.changeMaskUniforms = { + resolution: null, + oldTexture: null, + newTexture: null, + }; + this.uniforms = { + mapResolution: null, + viewResolution: null, + viewScale: null, + viewOffset: null, + state: null, + terrain: null, + latestState: null, + palette: null, + relations: null, + patterns: null, + contestEnabled: null, + contestPatternMode: null, + debugDisableStaticBorders: null, + debugDisableAllBorders: null, + seedSamplingMode: null, + debugStripeFixedColors: null, + motionMode: null, + contestOwners: null, + contestIds: null, + contestTimes: null, + contestStrengths: null, + jfaAvailable: null, + contestNow: null, + contestDuration: null, + prevOwner: null, + jfaSeedsOld: null, + jfaSeedsNew: null, + smoothProgress: null, + changeMask: null, + smoothEnabled: 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, + darkMode: null, + }; + return; + } + + this.jfaSupported = !!gl.getExtension("EXT_color_buffer_float"); + if (!this.jfaSupported) { + this.jfaDisabledReason = "EXT_color_buffer_float unavailable"; + } + this.jfaSeedProgram = this.jfaSupported + ? this.createJfaSeedProgram(gl) + : null; + this.jfaProgram = this.jfaSupported ? this.createJfaProgram(gl) : null; + this.changeMaskProgram = this.jfaSupported + ? this.createChangeMaskProgram(gl) + : null; + if (!this.jfaSeedProgram || !this.jfaProgram) { + this.jfaSupported = false; + this.jfaDisabledReason ??= "JFA shaders unavailable"; + } + this.jfaSeedUniforms = this.jfaSeedProgram + ? { + resolution: gl.getUniformLocation( + this.jfaSeedProgram, + "u_resolution", + ), + owner: gl.getUniformLocation(this.jfaSeedProgram, "u_ownerTexture"), + } + : { resolution: null, owner: null }; + this.jfaUniforms = this.jfaProgram + ? { + resolution: gl.getUniformLocation(this.jfaProgram, "u_resolution"), + step: gl.getUniformLocation(this.jfaProgram, "u_step"), + seeds: gl.getUniformLocation(this.jfaProgram, "u_seeds"), + } + : { resolution: null, step: null, seeds: null }; + this.changeMaskUniforms = this.changeMaskProgram + ? { + resolution: gl.getUniformLocation( + this.changeMaskProgram, + "u_resolution", + ), + oldTexture: gl.getUniformLocation( + this.changeMaskProgram, + "u_oldTexture", + ), + newTexture: gl.getUniformLocation( + this.changeMaskProgram, + "u_newTexture", + ), + } + : { resolution: null, oldTexture: null, newTexture: null }; + + this.uniforms = { + mapResolution: gl.getUniformLocation(this.program, "u_mapResolution"), + viewResolution: gl.getUniformLocation(this.program, "u_viewResolution"), + viewScale: gl.getUniformLocation(this.program, "u_viewScale"), + viewOffset: gl.getUniformLocation(this.program, "u_viewOffset"), + state: gl.getUniformLocation(this.program, "u_state"), + terrain: gl.getUniformLocation(this.program, "u_terrain"), + latestState: gl.getUniformLocation(this.program, "u_latestState"), + palette: gl.getUniformLocation(this.program, "u_palette"), + relations: gl.getUniformLocation(this.program, "u_relations"), + patterns: gl.getUniformLocation(this.program, "u_patterns"), + contestEnabled: gl.getUniformLocation(this.program, "u_contestEnabled"), + contestPatternMode: gl.getUniformLocation( + this.program, + "u_contestPatternMode", + ), + debugDisableStaticBorders: gl.getUniformLocation( + this.program, + "u_debugDisableStaticBorders", + ), + debugDisableAllBorders: gl.getUniformLocation( + this.program, + "u_debugDisableAllBorders", + ), + seedSamplingMode: gl.getUniformLocation( + this.program, + "u_seedSamplingMode", + ), + debugStripeFixedColors: gl.getUniformLocation( + this.program, + "u_debugStripeFixedColors", + ), + motionMode: gl.getUniformLocation(this.program, "u_motionMode"), + contestOwners: gl.getUniformLocation(this.program, "u_contestOwners"), + contestIds: gl.getUniformLocation(this.program, "u_contestIds"), + contestTimes: gl.getUniformLocation(this.program, "u_contestTimes"), + contestStrengths: gl.getUniformLocation( + this.program, + "u_contestStrengths", + ), + jfaAvailable: gl.getUniformLocation(this.program, "u_jfaAvailable"), + contestNow: gl.getUniformLocation(this.program, "u_contestNow"), + contestDuration: gl.getUniformLocation( + this.program, + "u_contestDurationTicks", + ), + prevOwner: gl.getUniformLocation(this.program, "u_prevOwner"), + jfaSeedsOld: gl.getUniformLocation(this.program, "u_jfaSeedsOld"), + jfaSeedsNew: gl.getUniformLocation(this.program, "u_jfaSeedsNew"), + smoothProgress: gl.getUniformLocation(this.program, "u_smoothProgress"), + changeMask: gl.getUniformLocation(this.program, "u_changeMask"), + smoothEnabled: gl.getUniformLocation(this.program, "u_smoothEnabled"), + 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"), + darkMode: gl.getUniformLocation(this.program, "u_darkMode"), + }; + + // Vertex data: two triangles covering the full view (pixel-perfect). + const vertices = new Float32Array([ + 0, + 0, + this.viewWidth, + 0, + 0, + this.viewHeight, + 0, + this.viewHeight, + this.viewWidth, + 0, + this.viewWidth, + this.viewHeight, + ]); + + 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); + + const mapVertices = new Float32Array([ + 0, + 0, + this.mapWidth, + 0, + 0, + this.mapHeight, + 0, + this.mapHeight, + this.mapWidth, + 0, + this.mapWidth, + this.mapHeight, + ]); + this.jfaVao = gl.createVertexArray(); + this.jfaVertexBuffer = gl.createBuffer(); + gl.bindVertexArray(this.jfaVao); + gl.bindBuffer(gl.ARRAY_BUFFER, this.jfaVertexBuffer); + gl.bufferData(gl.ARRAY_BUFFER, mapVertices, gl.STATIC_DRAW); + gl.enableVertexAttribArray(posLoc); + gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 2 * 4, 0); + gl.bindVertexArray(null); + + this.stateTexture = gl.createTexture(); + this.terrainTexture = gl.createTexture(); + this.paletteTexture = gl.createTexture(); + this.relationTexture = gl.createTexture(); + this.patternTexture = gl.createTexture(); + this.contestOwnersTexture = gl.createTexture(); + this.contestIdsTexture = gl.createTexture(); + this.contestTimesTexture = gl.createTexture(); + this.contestStrengthsTexture = gl.createTexture(); + this.prevOwnerTexture = gl.createTexture(); + this.olderOwnerTexture = gl.createTexture(); + this.stateFramebuffer = gl.createFramebuffer(); + this.prevStateFramebuffer = gl.createFramebuffer(); + this.olderStateFramebuffer = gl.createFramebuffer(); + this.jfaTextureA = this.jfaSupported ? gl.createTexture() : null; + this.jfaTextureB = this.jfaSupported ? gl.createTexture() : null; + this.jfaFramebufferA = this.jfaSupported ? gl.createFramebuffer() : null; + this.jfaFramebufferB = this.jfaSupported ? gl.createFramebuffer() : null; + this.jfaResultOlderTexture = this.jfaSupported ? gl.createTexture() : null; + this.jfaResultOldTexture = this.jfaSupported ? gl.createTexture() : null; + this.jfaResultNewTexture = this.jfaSupported ? gl.createTexture() : null; + this.jfaResultOlderFramebuffer = this.jfaSupported + ? gl.createFramebuffer() + : null; + this.jfaResultOldFramebuffer = this.jfaSupported + ? gl.createFramebuffer() + : null; + this.jfaResultNewFramebuffer = this.jfaSupported + ? gl.createFramebuffer() + : null; + this.changeMaskTextureOlder = this.jfaSupported ? gl.createTexture() : null; + this.changeMaskTextureOld = this.jfaSupported ? gl.createTexture() : null; + this.changeMaskTextureNew = this.jfaSupported ? gl.createTexture() : null; + this.changeMaskFramebufferOlder = this.jfaSupported + ? gl.createFramebuffer() + : null; + this.changeMaskFramebufferOld = this.jfaSupported + ? gl.createFramebuffer() + : null; + this.changeMaskFramebufferNew = this.jfaSupported + ? gl.createFramebuffer() + : null; + + 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.mapWidth, + this.mapHeight, + 0, + gl.RED_INTEGER, + gl.UNSIGNED_SHORT, + this.state, + ); + + // Terrain texture (immutable, only uploaded once) + gl.activeTexture(gl.TEXTURE14); + gl.bindTexture(gl.TEXTURE_2D, this.terrainTexture); + 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.mapWidth, + this.mapHeight, + 0, + gl.RED_INTEGER, + gl.UNSIGNED_BYTE, + game.terrainDataView(), + ); + + this.uploadPalette(); + + gl.activeTexture(gl.TEXTURE4); + gl.bindTexture(gl.TEXTURE_2D, this.contestOwnersTexture); + 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.RG16UI, + this.mapWidth, + this.mapHeight, + 0, + gl.RG_INTEGER, + gl.UNSIGNED_SHORT, + this.contestOwnersState, + ); + + gl.activeTexture(gl.TEXTURE5); + gl.bindTexture(gl.TEXTURE_2D, this.contestIdsTexture); + 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.mapWidth, + this.mapHeight, + 0, + gl.RED_INTEGER, + gl.UNSIGNED_SHORT, + this.contestIdsState, + ); + + gl.activeTexture(gl.TEXTURE6); + gl.bindTexture(gl.TEXTURE_2D, this.contestTimesTexture); + 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.contestTimesState.length, + 1, + 0, + gl.RED_INTEGER, + gl.UNSIGNED_SHORT, + this.contestTimesState, + ); + + gl.activeTexture(gl.TEXTURE11); + gl.bindTexture(gl.TEXTURE_2D, this.contestStrengthsTexture); + 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.contestStrengthsState.length, + 1, + 0, + gl.RED_INTEGER, + gl.UNSIGNED_SHORT, + this.contestStrengthsState, + ); + + gl.activeTexture(gl.TEXTURE7); + gl.bindTexture(gl.TEXTURE_2D, this.prevOwnerTexture); + 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.mapWidth, + this.mapHeight, + 0, + gl.RED_INTEGER, + gl.UNSIGNED_SHORT, + this.state, + ); + + gl.activeTexture(gl.TEXTURE13); + gl.bindTexture(gl.TEXTURE_2D, this.olderOwnerTexture); + 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.mapWidth, + this.mapHeight, + 0, + gl.RED_INTEGER, + gl.UNSIGNED_SHORT, + this.state, + ); + + if ( + this.stateFramebuffer && + this.prevStateFramebuffer && + this.olderStateFramebuffer && + this.stateTexture && + this.prevOwnerTexture && + this.olderOwnerTexture + ) { + gl.bindFramebuffer(gl.FRAMEBUFFER, this.stateFramebuffer); + gl.framebufferTexture2D( + gl.FRAMEBUFFER, + gl.COLOR_ATTACHMENT0, + gl.TEXTURE_2D, + this.stateTexture, + 0, + ); + const stateStatus = gl.checkFramebufferStatus(gl.FRAMEBUFFER); + gl.bindFramebuffer(gl.FRAMEBUFFER, this.prevStateFramebuffer); + gl.framebufferTexture2D( + gl.FRAMEBUFFER, + gl.COLOR_ATTACHMENT0, + gl.TEXTURE_2D, + this.prevOwnerTexture, + 0, + ); + const prevStatus = gl.checkFramebufferStatus(gl.FRAMEBUFFER); + gl.bindFramebuffer(gl.FRAMEBUFFER, this.olderStateFramebuffer); + gl.framebufferTexture2D( + gl.FRAMEBUFFER, + gl.COLOR_ATTACHMENT0, + gl.TEXTURE_2D, + this.olderOwnerTexture, + 0, + ); + const olderStatus = gl.checkFramebufferStatus(gl.FRAMEBUFFER); + this.prevStateCopySupported = + stateStatus === gl.FRAMEBUFFER_COMPLETE && + prevStatus === gl.FRAMEBUFFER_COMPLETE && + olderStatus === gl.FRAMEBUFFER_COMPLETE; + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + } + + if ( + this.jfaSupported && + this.jfaTextureA && + this.jfaTextureB && + this.jfaFramebufferA && + this.jfaFramebufferB && + this.jfaResultOlderTexture && + this.jfaResultOldTexture && + this.jfaResultNewTexture && + this.jfaResultOlderFramebuffer && + this.jfaResultOldFramebuffer && + this.jfaResultNewFramebuffer + ) { + gl.activeTexture(gl.TEXTURE9); + gl.bindTexture(gl.TEXTURE_2D, this.jfaTextureA); + 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.texImage2D( + gl.TEXTURE_2D, + 0, + gl.RG16F, + this.mapWidth, + this.mapHeight, + 0, + gl.RG, + gl.HALF_FLOAT, + null, + ); + + gl.activeTexture(gl.TEXTURE10); + gl.bindTexture(gl.TEXTURE_2D, this.jfaTextureB); + 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.texImage2D( + gl.TEXTURE_2D, + 0, + gl.RG16F, + this.mapWidth, + this.mapHeight, + 0, + gl.RG, + gl.HALF_FLOAT, + null, + ); + + gl.bindFramebuffer(gl.FRAMEBUFFER, this.jfaFramebufferA); + gl.framebufferTexture2D( + gl.FRAMEBUFFER, + gl.COLOR_ATTACHMENT0, + gl.TEXTURE_2D, + this.jfaTextureA, + 0, + ); + gl.bindFramebuffer(gl.FRAMEBUFFER, this.jfaFramebufferB); + gl.framebufferTexture2D( + gl.FRAMEBUFFER, + gl.COLOR_ATTACHMENT0, + gl.TEXTURE_2D, + this.jfaTextureB, + 0, + ); + + gl.activeTexture(gl.TEXTURE12); + gl.bindTexture(gl.TEXTURE_2D, this.jfaResultOlderTexture); + 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.texImage2D( + gl.TEXTURE_2D, + 0, + gl.RG16F, + this.mapWidth, + this.mapHeight, + 0, + gl.RG, + gl.HALF_FLOAT, + null, + ); + + gl.activeTexture(gl.TEXTURE10); + gl.bindTexture(gl.TEXTURE_2D, this.jfaResultOldTexture); + 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.texImage2D( + gl.TEXTURE_2D, + 0, + gl.RG16F, + this.mapWidth, + this.mapHeight, + 0, + gl.RG, + gl.HALF_FLOAT, + null, + ); + + gl.activeTexture(gl.TEXTURE11); + gl.bindTexture(gl.TEXTURE_2D, this.jfaResultNewTexture); + 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.texImage2D( + gl.TEXTURE_2D, + 0, + gl.RG16F, + this.mapWidth, + this.mapHeight, + 0, + gl.RG, + gl.HALF_FLOAT, + null, + ); + + gl.bindFramebuffer(gl.FRAMEBUFFER, this.jfaResultOlderFramebuffer); + gl.framebufferTexture2D( + gl.FRAMEBUFFER, + gl.COLOR_ATTACHMENT0, + gl.TEXTURE_2D, + this.jfaResultOlderTexture, + 0, + ); + gl.bindFramebuffer(gl.FRAMEBUFFER, this.jfaResultOldFramebuffer); + gl.framebufferTexture2D( + gl.FRAMEBUFFER, + gl.COLOR_ATTACHMENT0, + gl.TEXTURE_2D, + this.jfaResultOldTexture, + 0, + ); + gl.bindFramebuffer(gl.FRAMEBUFFER, this.jfaResultNewFramebuffer); + gl.framebufferTexture2D( + gl.FRAMEBUFFER, + gl.COLOR_ATTACHMENT0, + gl.TEXTURE_2D, + this.jfaResultNewTexture, + 0, + ); + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + + this.jfaSteps = this.buildJfaSteps(this.mapWidth, this.mapHeight); + this.jfaDirty = true; + } + + if ( + this.jfaSupported && + this.changeMaskTextureOlder && + this.changeMaskTextureOld && + this.changeMaskTextureNew && + this.changeMaskFramebufferOlder && + this.changeMaskFramebufferOld && + this.changeMaskFramebufferNew + ) { + const initMaskTex = (tex: WebGLTexture) => { + gl.bindTexture(gl.TEXTURE_2D, tex); + 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.texImage2D( + gl.TEXTURE_2D, + 0, + gl.R8UI, + this.mapWidth, + this.mapHeight, + 0, + gl.RED_INTEGER, + gl.UNSIGNED_BYTE, + null, + ); + }; + + gl.activeTexture(gl.TEXTURE13); + initMaskTex(this.changeMaskTextureOlder); + initMaskTex(this.changeMaskTextureOld); + initMaskTex(this.changeMaskTextureNew); + + gl.bindFramebuffer(gl.FRAMEBUFFER, this.changeMaskFramebufferOlder); + gl.framebufferTexture2D( + gl.FRAMEBUFFER, + gl.COLOR_ATTACHMENT0, + gl.TEXTURE_2D, + this.changeMaskTextureOlder, + 0, + ); + gl.clearBufferuiv(gl.COLOR, 0, new Uint32Array([0, 0, 0, 0])); + gl.bindFramebuffer(gl.FRAMEBUFFER, this.changeMaskFramebufferOld); + gl.framebufferTexture2D( + gl.FRAMEBUFFER, + gl.COLOR_ATTACHMENT0, + gl.TEXTURE_2D, + this.changeMaskTextureOld, + 0, + ); + gl.clearBufferuiv(gl.COLOR, 0, new Uint32Array([0, 0, 0, 0])); + gl.bindFramebuffer(gl.FRAMEBUFFER, this.changeMaskFramebufferNew); + gl.framebufferTexture2D( + gl.FRAMEBUFFER, + gl.COLOR_ATTACHMENT0, + gl.TEXTURE_2D, + this.changeMaskTextureNew, + 0, + ); + gl.clearBufferuiv(gl.COLOR, 0, new Uint32Array([0, 0, 0, 0])); + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + + this.changeMaskDirty = true; + } + + gl.useProgram(this.program); + gl.uniform1i(this.uniforms.state, 0); + if (this.uniforms.terrain) { + gl.uniform1i(this.uniforms.terrain, 14); + } + if (this.uniforms.latestState) { + gl.uniform1i(this.uniforms.latestState, 12); + } + gl.uniform1i(this.uniforms.palette, 1); + gl.uniform1i(this.uniforms.relations, 2); + gl.uniform1i(this.uniforms.patterns, 3); + gl.uniform1i(this.uniforms.contestOwners, 4); + gl.uniform1i(this.uniforms.contestIds, 5); + gl.uniform1i(this.uniforms.contestTimes, 6); + gl.uniform1i(this.uniforms.contestStrengths, 11); + gl.uniform1i(this.uniforms.prevOwner, 7); + gl.uniform1i(this.uniforms.jfaSeedsOld, 8); + gl.uniform1i(this.uniforms.jfaSeedsNew, 9); + if (this.uniforms.changeMask) { + gl.uniform1i(this.uniforms.changeMask, 13); + } + + if (this.uniforms.mapResolution) { + gl.uniform2f(this.uniforms.mapResolution, this.mapWidth, this.mapHeight); + } + if (this.uniforms.viewResolution) { + gl.uniform2f( + this.uniforms.viewResolution, + this.viewWidth, + this.viewHeight, + ); + } + if (this.uniforms.viewScale) { + gl.uniform1f(this.uniforms.viewScale, this.viewScale); + } + if (this.uniforms.viewOffset) { + gl.uniform2f( + this.uniforms.viewOffset, + this.viewOffsetX, + this.viewOffsetY, + ); + } + 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.viewResolution) { + gl.uniform2f( + this.uniforms.viewResolution, + this.viewWidth, + this.viewHeight, + ); + } + if (this.uniforms.viewScale) { + gl.uniform1f(this.uniforms.viewScale, this.viewScale); + } + if (this.uniforms.viewOffset) { + gl.uniform2f( + this.uniforms.viewOffset, + this.viewOffsetX, + this.viewOffsetY, + ); + } + 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); + } + if (this.uniforms.jfaAvailable) { + gl.uniform1i(this.uniforms.jfaAvailable, this.jfaSupported ? 1 : 0); + } + if (this.uniforms.contestNow) { + gl.uniform1i(this.uniforms.contestNow, this.contestNow); + } + if (this.uniforms.contestDuration) { + gl.uniform1f(this.uniforms.contestDuration, this.contestDurationTicks); + } + if (this.uniforms.smoothProgress) { + gl.uniform1f(this.uniforms.smoothProgress, this.smoothProgress); + } + if (this.uniforms.smoothEnabled) { + gl.uniform1i(this.uniforms.smoothEnabled, this.smoothEnabled ? 1 : 0); + } + + if ( + this.jfaSupported && + this.jfaResultOldTexture && + this.jfaResultNewTexture + ) { + gl.activeTexture(gl.TEXTURE8); + gl.bindTexture(gl.TEXTURE_2D, this.jfaResultOldTexture); + gl.activeTexture(gl.TEXTURE9); + gl.bindTexture(gl.TEXTURE_2D, this.jfaResultNewTexture); + } + + gl.enable(gl.BLEND); + gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA); + gl.viewport(0, 0, this.viewWidth, this.viewHeight); + } + + 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; WebGL renderer disabled.", + }; + } + + const renderer = new TerritoryWebGLRenderer(game, theme, state); + if (!renderer.isValid()) { + return { + renderer: null, + reason: "WebGL2 not available; WebGL renderer disabled.", + }; + } + return { renderer }; + } + + isValid(): boolean { + return !!this.gl && !!this.program && !!this.vao; + } + + dispose(): void { + if (this.gl) { + this.gl.getExtension("WEBGL_lose_context")?.loseContext(); + } + this.canvas.remove(); + } + + setAlternativeView(enabled: boolean) { + this.alternativeView = enabled; + } + + setViewSize(width: number, height: number) { + const nextWidth = Math.max(1, Math.floor(width)); + const nextHeight = Math.max(1, Math.floor(height)); + if (nextWidth === this.viewWidth && nextHeight === this.viewHeight) { + return; + } + this.viewWidth = nextWidth; + this.viewHeight = nextHeight; + this.canvas.width = nextWidth; + this.canvas.height = nextHeight; + if (!this.gl || !this.vertexBuffer) { + return; + } + const gl = this.gl; + const vertices = new Float32Array([ + 0, + 0, + this.viewWidth, + 0, + 0, + this.viewHeight, + 0, + this.viewHeight, + this.viewWidth, + 0, + this.viewWidth, + this.viewHeight, + ]); + gl.bindBuffer(gl.ARRAY_BUFFER, this.vertexBuffer); + gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW); + if (this.program) { + gl.useProgram(this.program); + if (this.uniforms.viewResolution) { + gl.uniform2f( + this.uniforms.viewResolution, + this.viewWidth, + this.viewHeight, + ); + } + } + } + + setViewTransform(scale: number, offsetX: number, offsetY: number) { + this.viewScale = scale; + this.viewOffsetX = offsetX; + this.viewOffsetY = offsetY; + } + + setHoveredPlayerId(playerSmallId: number | null) { + const encoded = playerSmallId ?? -1; + if (encoded !== this.hoveredPlayerId) { + this.hoveredPlayerId = encoded; + this.hoverStartTime = encoded >= 0 ? Date.now() : 0; + } + } + + 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); + } + } + + setContestEnabled(enabled: boolean) { + if (this.contestEnabled === enabled) { + return; + } + this.contestEnabled = enabled; + if (this.contestEnabled) { + this.needsContestFullUpload = true; + this.needsContestTimesUpload = true; + this.needsContestStrengthsUpload = true; + } else { + this.contestDirtyRows.clear(); + } + } + + setContestPatternMode(mode: "blueNoise" | "checkerboard" | "bayer4x4") { + if (mode === "checkerboard") this.contestPatternMode = 1; + else if (mode === "bayer4x4") this.contestPatternMode = 2; + else this.contestPatternMode = 0; + } + + setDebugDisableStaticBorders(disabled: boolean) { + this.debugDisableStaticBorders = disabled; + } + + setDebugDisableAllBorders(disabled: boolean) { + this.debugDisableAllBorders = disabled; + } + + setSeedSamplingMode(mode: "none" | "2x2" | "3x3") { + this.seedSamplingMode = mode === "none" ? 0 : mode === "2x2" ? 1 : 2; + } + + setDebugStripeFixedColors(enabled: boolean) { + this.debugStripeFixedColors = enabled; + } + + setMotionMode(mode: "euclidean" | "axisSnap" | "manhattan" | "chebyshev") { + if (mode === "axisSnap") this.motionMode = 1; + else if (mode === "manhattan") this.motionMode = 2; + else if (mode === "chebyshev") this.motionMode = 3; + else this.motionMode = 0; + } + + markTile(tile: TileRef) { + if (this.needsFullUpload) { + return; + } + const x = tile % this.mapWidth; + const y = Math.floor(tile / this.mapWidth); + 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); + } + } + + setContestTile( + tile: TileRef, + defenderOwner: number, + attackerOwner: number, + componentId: number, + attackerEver: boolean, + ) { + if (!this.contestEnabled) { + return; + } + const offset = tile * 2; + const defenderValue = defenderOwner & 0xffff; + const attackerValue = attackerOwner & 0xffff; + const idValue = (componentId & 0x7fff) | (attackerEver ? 0x8000 : 0); + if ( + this.contestOwnersState[offset] === defenderValue && + this.contestOwnersState[offset + 1] === attackerValue && + this.contestIdsState[tile] === idValue + ) { + return; + } + this.contestOwnersState[offset] = defenderValue; + this.contestOwnersState[offset + 1] = attackerValue; + this.contestIdsState[tile] = idValue; + if (this.needsContestFullUpload) { + return; + } + const x = tile % this.mapWidth; + const y = Math.floor(tile / this.mapWidth); + const span = this.contestDirtyRows.get(y); + if (span === undefined) { + this.contestDirtyRows.set(y, { minX: x, maxX: x }); + } else { + span.minX = Math.min(span.minX, x); + span.maxX = Math.max(span.maxX, x); + } + } + + clearContestTile(tile: TileRef) { + this.setContestTile(tile, 0, 0, 0, false); + } + + setContestTime(componentId: number, nowPacked: number) { + if (!this.contestEnabled) { + return; + } + if (componentId <= 0) { + return; + } + this.ensureContestTimeCapacity(componentId); + const packed = nowPacked & 0xffff; + if (this.contestTimesState[componentId] === packed) { + return; + } + this.contestTimesState[componentId] = packed; + this.needsContestTimesUpload = true; + } + + ensureContestTimeCapacity(componentId: number) { + if (componentId < this.contestTimesState.length) { + return; + } + let nextLength = Math.max(1, this.contestTimesState.length); + while (nextLength <= componentId) { + nextLength *= 2; + } + const nextState = new Uint16Array(nextLength); + nextState.set(this.contestTimesState); + this.contestTimesState = nextState; + this.needsContestTimesUpload = true; + } + + setContestStrength(componentId: number, strength: number) { + if (!this.contestEnabled) { + return; + } + if (componentId <= 0) { + return; + } + this.ensureContestStrengthCapacity(componentId); + const clamped = Math.max(0, Math.min(1, strength)); + const packed = Math.round(clamped * 65535) & 0xffff; + if (this.contestStrengthsState[componentId] === packed) { + return; + } + this.contestStrengthsState[componentId] = packed; + this.needsContestStrengthsUpload = true; + } + + ensureContestStrengthCapacity(componentId: number) { + if (componentId < this.contestStrengthsState.length) { + return; + } + let nextLength = Math.max(1, this.contestStrengthsState.length); + while (nextLength <= componentId) { + nextLength *= 2; + } + const nextState = new Uint16Array(nextLength); + nextState.set(this.contestStrengthsState); + this.contestStrengthsState = nextState; + this.needsContestStrengthsUpload = true; + } + + setContestNow(nowPacked: number, durationTicks: number) { + if (!this.contestEnabled) { + return; + } + this.contestNow = nowPacked | 0; + this.contestDurationTicks = Math.max(0, durationTicks); + } + + snapshotStateForSmoothing() { + if ( + !this.gl || + !this.prevStateCopySupported || + !this.stateFramebuffer || + !this.prevStateFramebuffer || + !this.olderStateFramebuffer + ) { + return; + } + const gl = this.gl; + + gl.bindFramebuffer(gl.READ_FRAMEBUFFER, this.prevStateFramebuffer); + gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, this.olderStateFramebuffer); + gl.blitFramebuffer( + 0, + 0, + this.mapWidth, + this.mapHeight, + 0, + 0, + this.mapWidth, + this.mapHeight, + gl.COLOR_BUFFER_BIT, + gl.NEAREST, + ); + gl.bindFramebuffer(gl.READ_FRAMEBUFFER, this.stateFramebuffer); + gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, this.prevStateFramebuffer); + gl.blitFramebuffer( + 0, + 0, + this.mapWidth, + this.mapHeight, + 0, + 0, + this.mapWidth, + this.mapHeight, + gl.COLOR_BUFFER_BIT, + gl.NEAREST, + ); + gl.bindFramebuffer(gl.READ_FRAMEBUFFER, null); + gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, null); + + if ( + this.jfaSupported && + this.jfaResultOlderFramebuffer && + this.jfaResultOldFramebuffer && + this.jfaResultNewFramebuffer + ) { + gl.bindFramebuffer(gl.READ_FRAMEBUFFER, this.jfaResultOldFramebuffer); + gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, this.jfaResultOlderFramebuffer); + gl.blitFramebuffer( + 0, + 0, + this.mapWidth, + this.mapHeight, + 0, + 0, + this.mapWidth, + this.mapHeight, + gl.COLOR_BUFFER_BIT, + gl.NEAREST, + ); + gl.bindFramebuffer(gl.READ_FRAMEBUFFER, this.jfaResultNewFramebuffer); + gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, this.jfaResultOldFramebuffer); + gl.blitFramebuffer( + 0, + 0, + this.mapWidth, + this.mapHeight, + 0, + 0, + this.mapWidth, + this.mapHeight, + gl.COLOR_BUFFER_BIT, + gl.NEAREST, + ); + gl.bindFramebuffer(gl.READ_FRAMEBUFFER, null); + gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, null); + } + + if ( + this.jfaSupported && + this.changeMaskFramebufferOlder && + this.changeMaskFramebufferOld && + this.changeMaskFramebufferNew + ) { + gl.bindFramebuffer(gl.READ_FRAMEBUFFER, this.changeMaskFramebufferOld); + gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, this.changeMaskFramebufferOlder); + gl.blitFramebuffer( + 0, + 0, + this.mapWidth, + this.mapHeight, + 0, + 0, + this.mapWidth, + this.mapHeight, + gl.COLOR_BUFFER_BIT, + gl.NEAREST, + ); + gl.bindFramebuffer(gl.READ_FRAMEBUFFER, this.changeMaskFramebufferNew); + gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, this.changeMaskFramebufferOld); + gl.blitFramebuffer( + 0, + 0, + this.mapWidth, + this.mapHeight, + 0, + 0, + this.mapWidth, + this.mapHeight, + gl.COLOR_BUFFER_BIT, + gl.NEAREST, + ); + gl.bindFramebuffer(gl.READ_FRAMEBUFFER, null); + gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, null); + } + this.jfaDirty = true; + this.changeMaskDirty = true; + } + + setSmoothProgress(progress: number) { + this.smoothProgress = Math.max(0, Math.min(1, progress)); + } + + setSmoothEnabled(enabled: boolean) { + this.smoothEnabled = + enabled && + this.jfaSupported && + this.prevStateCopySupported && + !!this.changeMaskProgram && + !!this.changeMaskTextureOld && + !!this.changeMaskTextureNew && + !!this.jfaResultOldTexture && + !!this.jfaResultNewTexture; + } + + setInterpolationPair(pair: "prevCurrent" | "olderPrev") { + this.interpolationPair = pair; + } + + markAllDirty() { + this.needsFullUpload = true; + this.dirtyRows.clear(); + this.needsContestFullUpload = true; + this.needsContestTimesUpload = true; + this.needsContestStrengthsUpload = true; + this.contestDirtyRows.clear(); + this.jfaDirty = true; + this.changeMaskDirty = true; + } + + 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); + + if (this.contestEnabled) { + const uploadContestSpan = FrameProfiler.start(); + this.uploadContestTexture(); + FrameProfiler.end( + "TerritoryWebGLRenderer:uploadContests", + uploadContestSpan, + ); + + const uploadContestTimesSpan = FrameProfiler.start(); + this.uploadContestTimesTexture(); + FrameProfiler.end( + "TerritoryWebGLRenderer:uploadContestTimes", + uploadContestTimesSpan, + ); + + const uploadContestStrengthsSpan = FrameProfiler.start(); + this.uploadContestStrengthsTexture(); + FrameProfiler.end( + "TerritoryWebGLRenderer:uploadContestStrengths", + uploadContestStrengthsSpan, + ); + } + + if (this.jfaSupported) { + this.updateChangeMask(); + this.updateJfa(); + } + + const renderSpan = FrameProfiler.start(); + gl.viewport(0, 0, this.viewWidth, this.viewHeight); + gl.useProgram(this.program); + gl.bindVertexArray(this.vao); + + const canUseOlderPair = + this.interpolationPair === "olderPrev" && + !!this.prevOwnerTexture && + !!this.olderOwnerTexture && + !!this.jfaResultOldTexture && + !!this.jfaResultOlderTexture; + const renderPair = canUseOlderPair ? "olderPrev" : "prevCurrent"; + + const toStateTexture = + renderPair === "olderPrev" ? this.prevOwnerTexture : this.stateTexture; + const fromStateTexture = + renderPair === "olderPrev" + ? this.olderOwnerTexture + : this.prevOwnerTexture; + + if (toStateTexture) { + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, toStateTexture); + } + if (this.paletteTexture) { + gl.activeTexture(gl.TEXTURE1); + gl.bindTexture(gl.TEXTURE_2D, this.paletteTexture); + } + if (this.relationTexture) { + gl.activeTexture(gl.TEXTURE2); + gl.bindTexture(gl.TEXTURE_2D, this.relationTexture); + } + if (this.patternTexture) { + gl.activeTexture(gl.TEXTURE3); + gl.bindTexture(gl.TEXTURE_2D, this.patternTexture); + } + if (this.contestOwnersTexture) { + gl.activeTexture(gl.TEXTURE4); + gl.bindTexture(gl.TEXTURE_2D, this.contestOwnersTexture); + } + if (this.contestIdsTexture) { + gl.activeTexture(gl.TEXTURE5); + gl.bindTexture(gl.TEXTURE_2D, this.contestIdsTexture); + } + if (this.contestTimesTexture) { + gl.activeTexture(gl.TEXTURE6); + gl.bindTexture(gl.TEXTURE_2D, this.contestTimesTexture); + } + if (fromStateTexture) { + gl.activeTexture(gl.TEXTURE7); + gl.bindTexture(gl.TEXTURE_2D, fromStateTexture); + } + + const seedsOld = + renderPair === "olderPrev" + ? this.jfaResultOlderTexture + : this.jfaResultOldTexture; + const seedsNew = + renderPair === "olderPrev" + ? this.jfaResultOldTexture + : this.jfaResultNewTexture; + if (seedsOld) { + gl.activeTexture(gl.TEXTURE8); + gl.bindTexture(gl.TEXTURE_2D, seedsOld); + } + if (seedsNew) { + gl.activeTexture(gl.TEXTURE9); + gl.bindTexture(gl.TEXTURE_2D, seedsNew); + } + + if (this.stateTexture) { + gl.activeTexture(gl.TEXTURE12); + gl.bindTexture(gl.TEXTURE_2D, this.stateTexture); + } + if (this.terrainTexture) { + gl.activeTexture(gl.TEXTURE14); + gl.bindTexture(gl.TEXTURE_2D, this.terrainTexture); + } + + const changeMaskTexture = + renderPair === "olderPrev" + ? this.changeMaskTextureOld + : this.changeMaskTextureNew; + if (changeMaskTexture) { + gl.activeTexture(gl.TEXTURE13); + gl.bindTexture(gl.TEXTURE_2D, changeMaskTexture); + } + if (this.contestStrengthsTexture) { + gl.activeTexture(gl.TEXTURE11); + gl.bindTexture(gl.TEXTURE_2D, this.contestStrengthsTexture); + } + if (this.uniforms.viewResolution) { + gl.uniform2f( + this.uniforms.viewResolution, + this.viewWidth, + this.viewHeight, + ); + } + if (this.uniforms.viewScale) { + gl.uniform1f(this.uniforms.viewScale, this.viewScale); + } + if (this.uniforms.viewOffset) { + gl.uniform2f( + this.uniforms.viewOffset, + this.viewOffsetX, + this.viewOffsetY, + ); + } + if (this.uniforms.alternativeView) { + gl.uniform1i(this.uniforms.alternativeView, this.alternativeView ? 1 : 0); + } + if (this.uniforms.hoveredPlayerId) { + // Disable highlight after 5 seconds + const now = Date.now(); + const elapsed = now - this.hoverStartTime; + const activeHoverId = + this.hoveredPlayerId >= 0 && + elapsed < TerritoryWebGLRenderer.HOVER_DURATION_MS + ? this.hoveredPlayerId + : -1; + gl.uniform1f(this.uniforms.hoveredPlayerId, activeHoverId); + } + 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); + } + if (this.uniforms.contestEnabled) { + gl.uniform1i(this.uniforms.contestEnabled, this.contestEnabled ? 1 : 0); + } + if (this.uniforms.contestPatternMode) { + gl.uniform1i(this.uniforms.contestPatternMode, this.contestPatternMode); + } + if (this.uniforms.debugDisableStaticBorders) { + gl.uniform1i( + this.uniforms.debugDisableStaticBorders, + this.debugDisableStaticBorders ? 1 : 0, + ); + } + if (this.uniforms.debugDisableAllBorders) { + gl.uniform1i( + this.uniforms.debugDisableAllBorders, + this.debugDisableAllBorders ? 1 : 0, + ); + } + if (this.uniforms.seedSamplingMode) { + gl.uniform1i(this.uniforms.seedSamplingMode, this.seedSamplingMode); + } + if (this.uniforms.debugStripeFixedColors) { + gl.uniform1i( + this.uniforms.debugStripeFixedColors, + this.debugStripeFixedColors ? 1 : 0, + ); + } + if (this.uniforms.motionMode) { + gl.uniform1i(this.uniforms.motionMode, this.motionMode); + } + if (this.uniforms.contestNow) { + gl.uniform1i(this.uniforms.contestNow, this.contestNow); + } + if (this.uniforms.contestDuration) { + gl.uniform1f(this.uniforms.contestDuration, this.contestDurationTicks); + } + if (this.uniforms.smoothProgress) { + gl.uniform1f(this.uniforms.smoothProgress, this.smoothProgress); + } + if (this.uniforms.smoothEnabled) { + gl.uniform1i(this.uniforms.smoothEnabled, this.smoothEnabled ? 1 : 0); + } + if (this.uniforms.darkMode) { + gl.uniform1i( + this.uniforms.darkMode, + this.userSettings.darkMode() ? 1 : 0, + ); + } + + 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); + } + + getDebugStats() { + return { + mapWidth: this.mapWidth, + mapHeight: this.mapHeight, + viewWidth: this.viewWidth, + viewHeight: this.viewHeight, + viewScale: this.viewScale, + viewOffsetX: this.viewOffsetX, + viewOffsetY: this.viewOffsetY, + smoothEnabled: this.smoothEnabled, + smoothProgress: this.smoothProgress, + jfaSupported: this.jfaSupported, + jfaDisabledReason: this.jfaDisabledReason, + jfaDirty: this.jfaDirty, + prevStateCopySupported: this.prevStateCopySupported, + contestDurationTicks: this.contestDurationTicks, + contestNow: this.contestNow, + hoveredPlayerId: this.hoveredPlayerId, + }; + } + + 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.mapWidth, + this.mapHeight, + 0, + gl.RED_INTEGER, + gl.UNSIGNED_SHORT, + this.state, + ); + this.needsFullUpload = false; + this.dirtyRows.clear(); + rowsUploaded = this.mapHeight; + bytesUploaded = this.mapWidth * this.mapHeight * 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.mapWidth + 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 uploadContestTexture(): { rows: number; bytes: number } { + if (!this.gl || !this.contestOwnersTexture || !this.contestIdsTexture) { + return { rows: 0, bytes: 0 }; + } + const gl = this.gl; + gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1); + + const bytesPerOwnerPixel = Uint16Array.BYTES_PER_ELEMENT * 2; + const bytesPerIdPixel = Uint16Array.BYTES_PER_ELEMENT; + let rowsUploaded = 0; + let bytesUploaded = 0; + + if (this.needsContestFullUpload) { + gl.activeTexture(gl.TEXTURE4); + gl.bindTexture(gl.TEXTURE_2D, this.contestOwnersTexture); + gl.texImage2D( + gl.TEXTURE_2D, + 0, + gl.RG16UI, + this.mapWidth, + this.mapHeight, + 0, + gl.RG_INTEGER, + gl.UNSIGNED_SHORT, + this.contestOwnersState, + ); + + gl.activeTexture(gl.TEXTURE5); + gl.bindTexture(gl.TEXTURE_2D, this.contestIdsTexture); + gl.texImage2D( + gl.TEXTURE_2D, + 0, + gl.R16UI, + this.mapWidth, + this.mapHeight, + 0, + gl.RED_INTEGER, + gl.UNSIGNED_SHORT, + this.contestIdsState, + ); + + this.needsContestFullUpload = false; + this.contestDirtyRows.clear(); + rowsUploaded = this.mapHeight; + bytesUploaded = + this.mapWidth * this.mapHeight * (bytesPerOwnerPixel + bytesPerIdPixel); + return { rows: rowsUploaded, bytes: bytesUploaded }; + } + + if (this.contestDirtyRows.size === 0) { + return { rows: 0, bytes: 0 }; + } + + for (const [y, span] of this.contestDirtyRows) { + const width = span.maxX - span.minX + 1; + const ownerOffset = (y * this.mapWidth + span.minX) * 2; + const ownerSlice = this.contestOwnersState.subarray( + ownerOffset, + ownerOffset + width * 2, + ); + + gl.activeTexture(gl.TEXTURE4); + gl.bindTexture(gl.TEXTURE_2D, this.contestOwnersTexture); + gl.texSubImage2D( + gl.TEXTURE_2D, + 0, + span.minX, + y, + width, + 1, + gl.RG_INTEGER, + gl.UNSIGNED_SHORT, + ownerSlice, + ); + + const idOffset = y * this.mapWidth + span.minX; + const idSlice = this.contestIdsState.subarray(idOffset, idOffset + width); + gl.activeTexture(gl.TEXTURE5); + gl.bindTexture(gl.TEXTURE_2D, this.contestIdsTexture); + gl.texSubImage2D( + gl.TEXTURE_2D, + 0, + span.minX, + y, + width, + 1, + gl.RED_INTEGER, + gl.UNSIGNED_SHORT, + idSlice, + ); + + rowsUploaded++; + bytesUploaded += width * (bytesPerOwnerPixel + bytesPerIdPixel); + } + this.contestDirtyRows.clear(); + return { rows: rowsUploaded, bytes: bytesUploaded }; + } + + private uploadContestTimesTexture(): { rows: number; bytes: number } { + if (!this.gl || !this.contestTimesTexture) { + return { rows: 0, bytes: 0 }; + } + if (!this.needsContestTimesUpload) { + return { rows: 0, bytes: 0 }; + } + const gl = this.gl; + gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1); + gl.activeTexture(gl.TEXTURE6); + gl.bindTexture(gl.TEXTURE_2D, this.contestTimesTexture); + gl.texImage2D( + gl.TEXTURE_2D, + 0, + gl.R16UI, + this.contestTimesState.length, + 1, + 0, + gl.RED_INTEGER, + gl.UNSIGNED_SHORT, + this.contestTimesState, + ); + this.needsContestTimesUpload = false; + const bytes = this.contestTimesState.length * Uint16Array.BYTES_PER_ELEMENT; + return { rows: 1, bytes }; + } + + private uploadContestStrengthsTexture(): { rows: number; bytes: number } { + if (!this.gl || !this.contestStrengthsTexture) { + return { rows: 0, bytes: 0 }; + } + if (!this.needsContestStrengthsUpload) { + return { rows: 0, bytes: 0 }; + } + const gl = this.gl; + gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1); + gl.activeTexture(gl.TEXTURE11); + gl.bindTexture(gl.TEXTURE_2D, this.contestStrengthsTexture); + gl.texImage2D( + gl.TEXTURE_2D, + 0, + gl.R16UI, + this.contestStrengthsState.length, + 1, + 0, + gl.RED_INTEGER, + gl.UNSIGNED_SHORT, + this.contestStrengthsState, + ); + this.needsContestStrengthsUpload = false; + const bytes = + this.contestStrengthsState.length * Uint16Array.BYTES_PER_ELEMENT; + return { rows: 1, bytes }; + } + + private updateChangeMask() { + if ( + !this.gl || + !this.jfaSupported || + !this.changeMaskDirty || + !this.changeMaskProgram || + !this.changeMaskFramebufferNew || + !this.changeMaskFramebufferOld || + !this.changeMaskFramebufferOlder || + !this.prevOwnerTexture || + !this.stateTexture || + !this.jfaVao + ) { + return; + } + + const gl = this.gl; + const prevBlend = gl.isEnabled(gl.BLEND); + gl.disable(gl.BLEND); + gl.viewport(0, 0, this.mapWidth, this.mapHeight); + gl.bindVertexArray(this.jfaVao); + + gl.useProgram(this.changeMaskProgram); + if (this.changeMaskUniforms.resolution) { + gl.uniform2f( + this.changeMaskUniforms.resolution, + this.mapWidth, + this.mapHeight, + ); + } + if (this.changeMaskUniforms.oldTexture) { + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, this.prevOwnerTexture); + gl.uniform1i(this.changeMaskUniforms.oldTexture, 0); + } + if (this.changeMaskUniforms.newTexture) { + gl.activeTexture(gl.TEXTURE1); + gl.bindTexture(gl.TEXTURE_2D, this.stateTexture); + gl.uniform1i(this.changeMaskUniforms.newTexture, 1); + } + + gl.bindFramebuffer(gl.FRAMEBUFFER, this.changeMaskFramebufferNew); + gl.drawArrays(gl.TRIANGLES, 0, 6); + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + + if (!this.changeMaskHistoryInitialized) { + gl.bindFramebuffer(gl.READ_FRAMEBUFFER, this.changeMaskFramebufferNew); + gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, this.changeMaskFramebufferOld); + gl.blitFramebuffer( + 0, + 0, + this.mapWidth, + this.mapHeight, + 0, + 0, + this.mapWidth, + this.mapHeight, + gl.COLOR_BUFFER_BIT, + gl.NEAREST, + ); + gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, this.changeMaskFramebufferOlder); + gl.blitFramebuffer( + 0, + 0, + this.mapWidth, + this.mapHeight, + 0, + 0, + this.mapWidth, + this.mapHeight, + gl.COLOR_BUFFER_BIT, + gl.NEAREST, + ); + gl.bindFramebuffer(gl.READ_FRAMEBUFFER, null); + gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, null); + this.changeMaskHistoryInitialized = true; + } + + this.changeMaskDirty = false; + + if (prevBlend) { + gl.enable(gl.BLEND); + } + } + + private updateJfa() { + if ( + !this.gl || + !this.jfaSupported || + !this.jfaSeedProgram || + !this.jfaProgram || + !this.jfaFramebufferA || + !this.jfaFramebufferB || + !this.jfaTextureA || + !this.jfaTextureB || + !this.stateTexture || + !this.jfaResultNewFramebuffer || + !this.jfaResultNewTexture || + !this.jfaVao + ) { + return; + } + if (!this.jfaDirty) { + return; + } + const gl = this.gl; + const prevBlend = gl.isEnabled(gl.BLEND); + gl.disable(gl.BLEND); + gl.viewport(0, 0, this.mapWidth, this.mapHeight); + gl.bindVertexArray(this.jfaVao); + + const runJfa = ( + ownerTexture: WebGLTexture, + resultFramebuffer: WebGLFramebuffer, + ) => { + gl.useProgram(this.jfaSeedProgram); + if (this.jfaSeedUniforms.resolution) { + gl.uniform2f( + this.jfaSeedUniforms.resolution, + this.mapWidth, + this.mapHeight, + ); + } + if (this.jfaSeedUniforms.owner) { + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, ownerTexture); + gl.uniform1i(this.jfaSeedUniforms.owner, 0); + } + gl.bindFramebuffer(gl.FRAMEBUFFER, this.jfaFramebufferA); + gl.drawArrays(gl.TRIANGLES, 0, 6); + + let readTex = this.jfaTextureA; + let readFbo = this.jfaFramebufferA; + let writeFbo = this.jfaFramebufferB; + let writeTex = this.jfaTextureB; + for (const step of this.jfaSteps) { + gl.useProgram(this.jfaProgram); + if (this.jfaUniforms.resolution) { + gl.uniform2f( + this.jfaUniforms.resolution, + this.mapWidth, + this.mapHeight, + ); + } + if (this.jfaUniforms.step) { + gl.uniform1f(this.jfaUniforms.step, step); + } + if (this.jfaUniforms.seeds) { + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, readTex); + gl.uniform1i(this.jfaUniforms.seeds, 0); + } + gl.bindFramebuffer(gl.FRAMEBUFFER, writeFbo); + gl.drawArrays(gl.TRIANGLES, 0, 6); + + const tempTex = readTex; + readTex = writeTex; + writeTex = tempTex; + const tempFbo = readFbo; + readFbo = writeFbo; + writeFbo = tempFbo; + } + + gl.bindFramebuffer(gl.READ_FRAMEBUFFER, readFbo); + gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, resultFramebuffer); + gl.blitFramebuffer( + 0, + 0, + this.mapWidth, + this.mapHeight, + 0, + 0, + this.mapWidth, + this.mapHeight, + gl.COLOR_BUFFER_BIT, + gl.NEAREST, + ); + }; + + runJfa(this.stateTexture, this.jfaResultNewFramebuffer); + + this.jfaDirty = false; + + if ( + !this.jfaHistoryInitialized && + this.jfaResultOlderFramebuffer && + this.jfaResultOldFramebuffer + ) { + gl.bindFramebuffer(gl.READ_FRAMEBUFFER, this.jfaResultNewFramebuffer); + gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, this.jfaResultOldFramebuffer); + gl.blitFramebuffer( + 0, + 0, + this.mapWidth, + this.mapHeight, + 0, + 0, + this.mapWidth, + this.mapHeight, + gl.COLOR_BUFFER_BIT, + gl.NEAREST, + ); + gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, this.jfaResultOlderFramebuffer); + gl.blitFramebuffer( + 0, + 0, + this.mapWidth, + this.mapHeight, + 0, + 0, + this.mapWidth, + this.mapHeight, + gl.COLOR_BUFFER_BIT, + gl.NEAREST, + ); + gl.bindFramebuffer(gl.READ_FRAMEBUFFER, null); + gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, null); + this.jfaHistoryInitialized = true; + } + + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + if (prevBlend) { + gl.enable(gl.BLEND); + } + gl.bindVertexArray(null); + } + + private buildJfaSteps(width: number, height: number): number[] { + const maxDim = Math.max(width, height); + let step = 1; + while (step < maxDim) { + step <<= 1; + } + step >>= 1; + const steps: number[] = []; + while (step >= 1) { + steps.push(step); + step >>= 1; + } + return steps; + } + + 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 createJfaSeedProgram( + gl: WebGL2RenderingContext, + ): WebGLProgram | null { + const vertexShaderSource = `#version 300 es + precision highp float; + layout(location = 0) 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 highp float; + precision highp usampler2D; + + uniform usampler2D u_ownerTexture; + uniform vec2 u_resolution; + + out vec2 outSeed; + + uint ownerAt(ivec2 texCoord) { + ivec2 clamped = clamp( + texCoord, + ivec2(0, 0), + ivec2(int(u_resolution.x) - 1, int(u_resolution.y) - 1) + ); + return texelFetch(u_ownerTexture, clamped, 0).r & 0xFFFu; + } + + void main() { + ivec2 fragCoord = ivec2(gl_FragCoord.xy); + ivec2 texCoord = ivec2( + fragCoord.x, + int(u_resolution.y) - 1 - fragCoord.y + ); + + uint owner = ownerAt(texCoord); + bool isBorder = false; + vec2 edgeDir = vec2(0.0); + uint nOwner = ownerAt(texCoord + ivec2(1, 0)); + if (nOwner != owner) { isBorder = true; edgeDir += vec2(1.0, 0.0); } + nOwner = ownerAt(texCoord + ivec2(-1, 0)); + if (nOwner != owner) { isBorder = true; edgeDir += vec2(-1.0, 0.0); } + nOwner = ownerAt(texCoord + ivec2(0, 1)); + if (nOwner != owner) { isBorder = true; edgeDir += vec2(0.0, 1.0); } + nOwner = ownerAt(texCoord + ivec2(0, -1)); + if (nOwner != owner) { isBorder = true; edgeDir += vec2(0.0, -1.0); } + + vec2 edgeOffset = vec2( + edgeDir.x == 0.0 ? 0.0 : (edgeDir.x > 0.0 ? 0.5 : -0.5), + edgeDir.y == 0.0 ? 0.0 : (edgeDir.y > 0.0 ? 0.5 : -0.5) + ); + + // Seed at the border edge (tile center +/- 0.5) so the front can move + // even when the border tile itself stays the same. + outSeed = isBorder + ? (vec2(texCoord) + vec2(0.5) + edgeOffset) + : vec2(-1.0, -1.0); + } + `; + + 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] JFA seed link error", + gl.getProgramInfoLog(program), + ); + gl.deleteProgram(program); + return null; + } + return program; + } + + private createJfaProgram(gl: WebGL2RenderingContext): WebGLProgram | null { + const vertexShaderSource = `#version 300 es + precision highp float; + layout(location = 0) 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 highp float; + + uniform sampler2D u_seeds; + uniform vec2 u_resolution; + uniform float u_step; + + out vec2 outSeed; + + vec2 seedAt(ivec2 coord) { + // coord is in texCoord space (Y-flipped from fragCoord) + // JFA texture was written at fragCoord positions, so flip back + ivec2 jfaCoord = ivec2(coord.x, int(u_resolution.y) - 1 - coord.y); + ivec2 clamped = clamp( + jfaCoord, + ivec2(0, 0), + ivec2(int(u_resolution.x) - 1, int(u_resolution.y) - 1) + ); + return texelFetch(u_seeds, clamped, 0).rg; + } + + void considerSeed(ivec2 coord, ivec2 texCoord, inout vec2 bestSeed, inout float bestDist) { + vec2 seed = seedAt(coord); + if (seed.x < 0.0) { + return; + } + float dist = length(seed - (vec2(texCoord) + vec2(0.5))); + if (dist < bestDist) { + bestDist = dist; + bestSeed = seed; + } + } + + void main() { + ivec2 fragCoord = ivec2(gl_FragCoord.xy); + ivec2 texCoord = ivec2( + fragCoord.x, + int(u_resolution.y) - 1 - fragCoord.y + ); + int step = int(u_step + 0.5); + + vec2 bestSeed = seedAt(texCoord); + vec2 texPos = vec2(texCoord) + vec2(0.5); + float bestDist = bestSeed.x < 0.0 ? 1e20 : length(bestSeed - texPos); + + considerSeed(texCoord + ivec2(-step, -step), texCoord, bestSeed, bestDist); + considerSeed(texCoord + ivec2(0, -step), texCoord, bestSeed, bestDist); + considerSeed(texCoord + ivec2(step, -step), texCoord, bestSeed, bestDist); + considerSeed(texCoord + ivec2(-step, 0), texCoord, bestSeed, bestDist); + considerSeed(texCoord + ivec2(step, 0), texCoord, bestSeed, bestDist); + considerSeed(texCoord + ivec2(-step, step), texCoord, bestSeed, bestDist); + considerSeed(texCoord + ivec2(0, step), texCoord, bestSeed, bestDist); + considerSeed(texCoord + ivec2(step, step), texCoord, bestSeed, bestDist); + + outSeed = bestSeed; + } + `; + + 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] JFA link error", + gl.getProgramInfoLog(program), + ); + gl.deleteProgram(program); + return null; + } + return program; + } + + private createChangeMaskProgram( + gl: WebGL2RenderingContext, + ): WebGLProgram | null { + const vertexShaderSource = `#version 300 es + precision highp float; + layout(location = 0) 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 highp float; + precision highp usampler2D; + + uniform usampler2D u_oldTexture; + uniform usampler2D u_newTexture; + uniform vec2 u_resolution; + + layout(location = 0) out uint outMask; + + uint ownerAt(usampler2D tex, ivec2 texCoord) { + ivec2 clamped = clamp( + texCoord, + ivec2(0, 0), + ivec2(int(u_resolution.x) - 1, int(u_resolution.y) - 1) + ); + return texelFetch(tex, clamped, 0).r & 0xFFFu; + } + + void main() { + ivec2 fragCoord = ivec2(gl_FragCoord.xy); + ivec2 texCoord = ivec2( + fragCoord.x, + int(u_resolution.y) - 1 - fragCoord.y + ); + + bool changed = ownerAt(u_oldTexture, texCoord) != ownerAt(u_newTexture, texCoord); + changed = changed || (ownerAt(u_oldTexture, texCoord + ivec2(1, 0)) != ownerAt(u_newTexture, texCoord + ivec2(1, 0))); + changed = changed || (ownerAt(u_oldTexture, texCoord + ivec2(-1, 0)) != ownerAt(u_newTexture, texCoord + ivec2(-1, 0))); + changed = changed || (ownerAt(u_oldTexture, texCoord + ivec2(0, 1)) != ownerAt(u_newTexture, texCoord + ivec2(0, 1))); + changed = changed || (ownerAt(u_oldTexture, texCoord + ivec2(0, -1)) != ownerAt(u_newTexture, texCoord + ivec2(0, -1))); + + outMask = changed ? 1u : 0u; + } + `; + + 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] change mask link error", + gl.getProgramInfoLog(program), + ); + gl.deleteProgram(program); + return null; + } + return program; + } + + private createProgram(gl: WebGL2RenderingContext): WebGLProgram | null { + const vertexShaderSource = `#version 300 es + precision highp float; + layout(location = 0) in vec2 a_position; + uniform vec2 u_viewResolution; + void main() { + vec2 zeroToOne = a_position / u_viewResolution; + 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 highp float; + precision highp usampler2D; + + uniform usampler2D u_state; + uniform usampler2D u_terrain; + uniform usampler2D u_latestState; + uniform sampler2D u_palette; + uniform usampler2D u_relations; + uniform usampler2D u_patterns; + uniform bool u_contestEnabled; + uniform int u_contestPatternMode; // 0=blueNoise(strength), 1=checkerboard(50/50), 2=bayer4x4(strength) + uniform bool u_debugDisableStaticBorders; + uniform bool u_debugDisableAllBorders; + uniform int u_seedSamplingMode; // 0=none(single texel), 1=2x2, 2=3x3 + uniform bool u_debugStripeFixedColors; // Use fixed debug colors for moving stripe + uniform int u_motionMode; // 0=euclidean, 1=axisSnap, 2=manhattan, 3=chebyshev + uniform usampler2D u_contestOwners; + uniform usampler2D u_contestIds; + uniform usampler2D u_contestTimes; + uniform usampler2D u_contestStrengths; + uniform bool u_jfaAvailable; + uniform int u_contestNow; + uniform float u_contestDurationTicks; + uniform usampler2D u_prevOwner; + uniform usampler2D u_changeMask; + uniform sampler2D u_jfaSeedsOld; + uniform sampler2D u_jfaSeedsNew; + uniform float u_smoothProgress; + uniform bool u_smoothEnabled; + uniform int u_patternStride; + uniform int u_patternRows; + uniform int u_viewerId; + uniform vec2 u_mapResolution; + uniform vec2 u_viewResolution; + uniform float u_viewScale; + uniform vec2 u_viewOffset; + 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; + uniform bool u_darkMode; + + out vec4 outColor; + + uint stateAtTex(ivec2 texCoord) { + ivec2 clamped = clamp( + texCoord, + ivec2(0, 0), + ivec2(int(u_mapResolution.x) - 1, int(u_mapResolution.y) - 1) + ); + return texelFetch(u_state, clamped, 0).r; + } + + uint ownerAtTex(ivec2 texCoord) { + return stateAtTex(texCoord) & 0xFFFu; + } + + // Terrain bit layout: bit7=land, bit6=shoreline, bit5=ocean, bits0-4=magnitude + uint terrainAtTex(ivec2 texCoord) { + ivec2 clamped = clamp( + texCoord, + ivec2(0, 0), + ivec2(int(u_mapResolution.x) - 1, int(u_mapResolution.y) - 1) + ); + return texelFetch(u_terrain, clamped, 0).r; + } + + bool isLand(uint terrain) { + return (terrain & 0x80u) != 0u; // bit 7 + } + + bool isShoreline(uint terrain) { + return (terrain & 0x40u) != 0u; // bit 6 + } + + bool isOcean(uint terrain) { + return (terrain & 0x20u) != 0u; // bit 5 + } + + uint getMagnitude(uint terrain) { + return terrain & 0x1Fu; // bits 0-4 + } + + // Compute terrain color based on type, magnitude, and theme + // Colors match PastelTheme (light) and PastelThemeDark exactly + vec3 terrainColor(uint terrain) { + uint mag = getMagnitude(terrain); + float fmag = float(mag); + + if (isLand(terrain)) { + if (isShoreline(terrain)) { + // Shore/beach - land adjacent to water + // Light: rgb(204,203,158), Dark: rgb(134,133,88) + return u_darkMode + ? vec3(134.0/255.0, 133.0/255.0, 88.0/255.0) + : vec3(204.0/255.0, 203.0/255.0, 158.0/255.0); + } + if (mag < 10u) { + // Plains (mag 0-9) + // Light: rgb(190, 220-2*mag, 138), Dark: rgb(140, 170-2*mag, 88) + return u_darkMode + ? vec3(140.0/255.0, (170.0 - 2.0*fmag)/255.0, 88.0/255.0) + : vec3(190.0/255.0, (220.0 - 2.0*fmag)/255.0, 138.0/255.0); + } else if (mag < 20u) { + // Highland (mag 10-19) + // Light: rgb(200+2*mag, 183+2*mag, 138+2*mag) + // Dark: rgb(150+2*mag, 133+2*mag, 88+2*mag) + return u_darkMode + ? vec3((150.0 + 2.0*fmag)/255.0, (133.0 + 2.0*fmag)/255.0, (88.0 + 2.0*fmag)/255.0) + : vec3((200.0 + 2.0*fmag)/255.0, (183.0 + 2.0*fmag)/255.0, (138.0 + 2.0*fmag)/255.0); + } else { + // Mountain (mag 20-30) + // Light: rgb(230+mag/2, 230+mag/2, 230+mag/2) + // Dark: rgb(180+mag/2, 180+mag/2, 180+mag/2) + float base = u_darkMode ? 180.0 : 230.0; + float val = (base + fmag/2.0) / 255.0; + return vec3(val, val, val); + } + } else { + // Water + if (isShoreline(terrain)) { + // Shoreline water - lighter, adjacent to land + // Light: rgb(100,143,255), Dark: rgb(50,50,50) + return u_darkMode + ? vec3(50.0/255.0, 50.0/255.0, 50.0/255.0) + : vec3(100.0/255.0, 143.0/255.0, 255.0/255.0); + } + if (isOcean(terrain)) { + // Ocean - depth-adjusted + // Light base: rgb(70,132,180), adjusted by +1-min(mag,10) + // Dark base: rgb(14,11,30), adjusted by +9-mag for mag<10 + float depthAdj = float(min(mag, 10u)); + if (u_darkMode) { + // Dark: rgb(14+9-mag, 11+9-mag, 30+9-mag) for mag<10, else rgb(14,11,30) + if (mag < 10u) { + return vec3( + (14.0 + 9.0 - fmag)/255.0, + (11.0 + 9.0 - fmag)/255.0, + (30.0 + 9.0 - fmag)/255.0 + ); + } + return vec3(14.0/255.0, 11.0/255.0, 30.0/255.0); + } else { + // Light: rgb(70-10+11-min(mag,10), 132-10+11-min(mag,10), 180-10+11-min(mag,10)) + // = rgb(71-depthAdj, 133-depthAdj, 181-depthAdj) + return vec3( + (71.0 - depthAdj)/255.0, + (133.0 - depthAdj)/255.0, + (181.0 - depthAdj)/255.0 + ); + } + } else { + // Lake - use same as shoreline water for simplicity + // Light: rgb(100,143,255), Dark: rgb(50,50,50) + return u_darkMode + ? vec3(50.0/255.0, 50.0/255.0, 50.0/255.0) + : vec3(100.0/255.0, 143.0/255.0, 255.0/255.0); + } + } + } + + uint prevStateAtTex(ivec2 texCoord) { + ivec2 clamped = clamp( + texCoord, + ivec2(0, 0), + ivec2(int(u_mapResolution.x) - 1, int(u_mapResolution.y) - 1) + ); + return texelFetch(u_prevOwner, clamped, 0).r; + } + + uint prevOwnerAtTex(ivec2 texCoord) { + return prevStateAtTex(texCoord) & 0xFFFu; + } + + vec2 jfaSeedOldAtTex(ivec2 texCoord) { + // JFA texture was written with fragCoord (bottom-left origin), but we're reading with + // texCoord (top-left origin, same as state texture). Need to flip Y to match. + // JFA row 0 = fragCoord.y=0 = stateTexCoord.y=height-1 = bottom of map + // To read data for texCoord.y=0 (top), we need JFA row height-1 + ivec2 flipped = ivec2(texCoord.x, int(u_mapResolution.y) - 1 - texCoord.y); + ivec2 clamped = clamp( + flipped, + ivec2(0, 0), + ivec2(int(u_mapResolution.x) - 1, int(u_mapResolution.y) - 1) + ); + return texelFetch(u_jfaSeedsOld, clamped, 0).rg; + } + + vec2 jfaSeedNewAtTex(ivec2 texCoord) { + // JFA texture was written with fragCoord (bottom-left origin), but we're reading with + // texCoord (top-left origin, same as state texture). Need to flip Y to match. + ivec2 flipped = ivec2(texCoord.x, int(u_mapResolution.y) - 1 - texCoord.y); + ivec2 clamped = clamp( + flipped, + ivec2(0, 0), + ivec2(int(u_mapResolution.x) - 1, int(u_mapResolution.y) - 1) + ); + return texelFetch(u_jfaSeedsNew, clamped, 0).rg; + } + + // Best-of-NxN seed sampling to reduce tile-boundary discontinuities. + // Returns the seed (from OLD JFA) that is closest to mapCoord. + vec2 bestSeedOld(vec2 mapCoord) { + ivec2 base = ivec2(floor(mapCoord)); + float bestDist = 1e9; + vec2 bestSeed = vec2(-1.0); + + int radius = u_seedSamplingMode == 2 ? 1 : 0; // 3x3 vs 2x2 + int end = u_seedSamplingMode == 2 ? 2 : 2; // 3x3: -1..+1, 2x2: 0..+1 + int start = u_seedSamplingMode == 2 ? -1 : 0; + + for (int dy = start; dy < end; dy++) { + for (int dx = start; dx < end; dx++) { + ivec2 sampleTex = base + ivec2(dx, dy); + vec2 seed = jfaSeedOldAtTex(sampleTex); + if (seed.x >= 0.0) { + float d = distance(mapCoord, seed); + if (d < bestDist) { + bestDist = d; + bestSeed = seed; + } + } + } + } + return bestSeed; + } + + // Best-of-NxN seed sampling for NEW JFA. + vec2 bestSeedNew(vec2 mapCoord) { + ivec2 base = ivec2(floor(mapCoord)); + float bestDist = 1e9; + vec2 bestSeed = vec2(-1.0); + + int radius = u_seedSamplingMode == 2 ? 1 : 0; + int end = u_seedSamplingMode == 2 ? 2 : 2; + int start = u_seedSamplingMode == 2 ? -1 : 0; + + for (int dy = start; dy < end; dy++) { + for (int dx = start; dx < end; dx++) { + ivec2 sampleTex = base + ivec2(dx, dy); + vec2 seed = jfaSeedNewAtTex(sampleTex); + if (seed.x >= 0.0) { + float d = distance(mapCoord, seed); + if (d < bestDist) { + bestDist = d; + bestSeed = seed; + } + } + } + } + return bestSeed; + } + + uvec2 contestOwnersAtTex(ivec2 texCoord) { + ivec2 clamped = clamp( + texCoord, + ivec2(0, 0), + ivec2(int(u_mapResolution.x) - 1, int(u_mapResolution.y) - 1) + ); + return texelFetch(u_contestOwners, clamped, 0).rg; + } + + uint contestIdRawAtTex(ivec2 texCoord) { + ivec2 clamped = clamp( + texCoord, + ivec2(0, 0), + ivec2(int(u_mapResolution.x) - 1, int(u_mapResolution.y) - 1) + ); + return texelFetch(u_contestIds, clamped, 0).r; + } + + float contestStrength(uint contestId) { + if (contestId == 0u) { + return 0.5; + } + uint strengthRaw = texelFetch( + u_contestStrengths, + ivec2(int(contestId), 0), + 0 + ).r; + return clamp(float(strengthRaw) / 65535.0, 0.0, 1.0); + } + + float blueNoise(ivec2 texCoord) { + vec2 p = vec2(texCoord); + float x = fract(0.06711056 * p.x + 0.00583715 * p.y); + return fract(52.9829189 * x); + } + + float bayer4x4(ivec2 texCoord) { + // Classic 4x4 Bayer matrix values 0..15 mapped to (0.5/16 .. 15.5/16) + int x = texCoord.x & 3; + int y = texCoord.y & 3; + int idx = (y << 2) | x; + int v = 0; + // Row-major: + // 0 8 2 10 + // 12 4 14 6 + // 3 11 1 9 + // 15 7 13 5 + if (idx == 0) v = 0; + else if (idx == 1) v = 8; + else if (idx == 2) v = 2; + else if (idx == 3) v = 10; + else if (idx == 4) v = 12; + else if (idx == 5) v = 4; + else if (idx == 6) v = 14; + else if (idx == 7) v = 6; + else if (idx == 8) v = 3; + else if (idx == 9) v = 11; + else if (idx == 10) v = 1; + else if (idx == 11) v = 9; + else if (idx == 12) v = 15; + else if (idx == 13) v = 7; + else if (idx == 14) v = 13; + else v = 5; + return (float(v) + 0.5) / 16.0; + } + + bool contestPickAttacker(ivec2 texCoord, float strength) { + if (u_contestPatternMode == 1) { + // Checkerboard is always 50/50 (ignores strength) + return ((texCoord.x + texCoord.y) & 1) == 0; + } + if (u_contestPatternMode == 2) { + return bayer4x4(texCoord) < strength; + } + return blueNoise(texCoord) < strength; + } + + 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; + } + + vec3 applyDefended(vec3 color, bool defended, ivec2 texCoord) { + if (!defended) { + return color; + } + bool isLightTile = ((texCoord.x % 2) == (texCoord.y % 2)); + const float LIGHT_FACTOR = 1.2; + const float DARK_FACTOR = 0.8; + return color * (isLightTile ? LIGHT_FACTOR : DARK_FACTOR); + } + + vec3 applyBorderTint(vec3 color, bool hasFriendly, bool hasEmbargo) { + 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 (hasFriendly) { + color = color * (1.0 - BORDER_TINT_RATIO) + + FRIENDLY_TINT_TARGET * BORDER_TINT_RATIO; + } + if (hasEmbargo) { + color = color * (1.0 - BORDER_TINT_RATIO) + + EMBARGO_TINT_TARGET * BORDER_TINT_RATIO; + } + return color; + } + + void main() { + // gl_FragCoord.xy is already at pixel center (0.5, 0.5 ...). + // Use the pixel center to avoid half-pixel snapping/offset artifacts, + // especially noticeable on the interpolated JFA border/front. + vec2 viewCoord = vec2( + gl_FragCoord.x - 0.5, + u_viewResolution.y - gl_FragCoord.y - 0.5 + ); + vec2 mapHalf = u_mapResolution * 0.5; + vec2 mapCoord = (viewCoord - mapHalf) / u_viewScale + u_viewOffset + mapHalf; + if ( + mapCoord.x < 0.0 || + mapCoord.y < 0.0 || + mapCoord.x >= u_mapResolution.x || + mapCoord.y >= u_mapResolution.y + ) { + outColor = vec4(0.0); + return; + } + // Tile centers are at (0.5, 1.5, 2.5, ...). Floor gives the tile index. + // Original ivec2(mapCoord) is equivalent but less explicit. + ivec2 texCoord = ivec2(mapCoord); + + uint state = stateAtTex(texCoord); + uint owner = state & 0xFFFu; + bool hasFallout = (state & 0x2000u) != 0u; + bool isDefended = (state & 0x1000u) != 0u; + uint latestState = texelFetch(u_latestState, texCoord, 0).r; + uint latestOwner = latestState & 0xFFFu; + uint oldState = prevStateAtTex(texCoord); + uint oldOwner = oldState & 0xFFFu; + bool oldHasFallout = (oldState & 0x2000u) != 0u; + bool oldIsDefended = (oldState & 0x1000u) != 0u; + // ChangeMask was written with Y-flipped coords, so flip when reading + ivec2 changeMaskCoord = ivec2(texCoord.x, int(u_mapResolution.y) - 1 - texCoord.y); + uint changeMask = texelFetch(u_changeMask, changeMaskCoord, 0).r; + + // Expand the animation region by 1 tile (halo) so the *outer* border edge can move smoothly. + // If we only animate "changed" tiles, the leading edge stays pinned to tile coordinates because + // neighbor pixels are still rendered from the static FROM snapshot. + uint affectedMask = changeMask; + ivec2 cm; + cm = ivec2(clamp(texCoord.x + 1, 0, int(u_mapResolution.x) - 1), texCoord.y); + affectedMask |= texelFetch(u_changeMask, ivec2(cm.x, int(u_mapResolution.y) - 1 - cm.y), 0).r; + cm = ivec2(clamp(texCoord.x - 1, 0, int(u_mapResolution.x) - 1), texCoord.y); + affectedMask |= texelFetch(u_changeMask, ivec2(cm.x, int(u_mapResolution.y) - 1 - cm.y), 0).r; + cm = ivec2(texCoord.x, clamp(texCoord.y + 1, 0, int(u_mapResolution.y) - 1)); + affectedMask |= texelFetch(u_changeMask, ivec2(cm.x, int(u_mapResolution.y) - 1 - cm.y), 0).r; + cm = ivec2(texCoord.x, clamp(texCoord.y - 1, 0, int(u_mapResolution.y) - 1)); + affectedMask |= texelFetch(u_changeMask, ivec2(cm.x, int(u_mapResolution.y) - 1 - cm.y), 0).r; + bool smoothActive = u_smoothEnabled && + u_smoothProgress < 1.0 && + !u_alternativeView && + u_jfaAvailable && + affectedMask != 0u; + + uint contestIdRaw = 0u; + const uint CONTEST_ID_MASK = 0x7FFFu; + uint contestId = 0u; + uvec2 contestOwners = uvec2(0u); + uint defender = 0u; + bool contested = false; + if (u_contestEnabled) { + contestIdRaw = contestIdRawAtTex(texCoord); + contestId = contestIdRaw & CONTEST_ID_MASK; + contestOwners = contestOwnersAtTex(texCoord); + defender = contestOwners.r & 0xFFFu; + + if (contestId != 0u) { + uint lastTime = texelFetch(u_contestTimes, ivec2(int(contestId), 0), 0).r; + const uint CONTEST_WRAP = 32768u; + uint nowTime = uint(u_contestNow); + uint elapsed = nowTime >= lastTime + ? (nowTime - lastTime) + : (CONTEST_WRAP - lastTime + nowTime); + contested = float(elapsed) < u_contestDurationTicks; + } + } + + // Border detection: check if any neighbor has a different owner. + bool isBorder = false; + bool hasFriendlyRelation = false; + bool hasEmbargoRelation = false; + if (!smoothActive) { + 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); + } + } + + // Get terrain for background rendering (needed for both normal and alt view) + uint terrain = terrainAtTex(texCoord); + vec3 baseTerrainColor = terrainColor(terrain); + + if (u_alternativeView) { + // Alt view: terrain + borders only, no territory fill + vec3 color = baseTerrainColor; + if (!u_debugDisableAllBorders && !u_debugDisableStaticBorders && owner != 0u && isBorder) { + // Only draw borders, not territory fill + 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; + } + 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, 1.0); + return; + } + + // Normal view: blend territory on top of terrain + vec3 fillColor = baseTerrainColor; + vec3 borderColor = vec3(0.0); + float borderAlpha = 0.0; + vec3 ownerBase = vec3(0.0); + vec4 ownerBorder = vec4(0.0); + + if (owner == 0u) { + // Unowned tile - show terrain (or fallout if irradiated) + if (hasFallout) { + // Blend fallout on top of terrain + fillColor = mix(baseTerrainColor, u_fallout.rgb, u_alpha); + } + // Otherwise fillColor is already baseTerrainColor + } else { + vec4 base = texelFetch(u_palette, ivec2(int(owner) * 2, 0), 0); + vec4 baseBorder = texelFetch( + u_palette, + ivec2(int(owner) * 2 + 1, 0), + 0 + ); + ownerBase = base.rgb; + ownerBorder = baseBorder; + bool isPrimary = patternIsPrimary(owner, texCoord); + vec3 patternColor = isPrimary ? base.rgb : baseBorder.rgb; + // Blend territory fill on top of terrain + fillColor = mix(baseTerrainColor, patternColor, u_alpha); + + if (isBorder && !smoothActive) { + vec3 bColor = applyBorderTint( + baseBorder.rgb, + hasFriendlyRelation, + hasEmbargoRelation + ); + borderColor = applyDefended(bColor, isDefended, texCoord); + borderAlpha = baseBorder.a; + } + } + + vec3 color = fillColor; + bool useContestedFill = false; + if (contested && latestOwner != 0u) { + useContestedFill = true; + vec3 latestOwnerBase = texelFetch( + u_palette, + ivec2(int(latestOwner) * 2, 0), + 0 + ).rgb; + vec3 defenderBase = latestOwnerBase; + if (defender != 0u) { + vec4 defenderColor = texelFetch( + u_palette, + ivec2(int(defender) * 2, 0), + 0 + ); + defenderBase = defenderColor.rgb; + } + float strength = contestStrength(contestId); + bool pickAttacker = contestPickAttacker(texCoord, strength); + vec3 contestColor = pickAttacker ? latestOwnerBase : defenderBase; + // Blend contested fill on top of terrain + color = mix(baseTerrainColor, contestColor, u_alpha); + } + + if (!u_debugDisableAllBorders && !u_debugDisableStaticBorders && !smoothActive && isBorder && owner != 0u) { + // Blend border on top of the current fill + color = mix(color, borderColor, borderAlpha); + } + + if (smoothActive) { + // DEBUG: uncomment ONE line to visualize issues + // color = vec3(1.0, 0.0, 1.0); outColor = vec4(color, 1.0); return; // magenta = smoothActive tiles + // vec2 ds = jfaSeedOldAtTex(texCoord); color = vec3(ds.x >= 0.0 ? 0.0 : 1.0, jfaSeedNewAtTex(texCoord).x >= 0.0 ? 0.0 : 1.0, 0.0); outColor = vec4(color, 1.0); return; // seed validity + + // Compute old color blended on terrain + vec3 oldColor = baseTerrainColor; + if (oldOwner == 0u) { + if (oldHasFallout) { + oldColor = mix(baseTerrainColor, u_fallout.rgb, u_alpha); + } + // Otherwise oldColor is already baseTerrainColor + } else { + vec4 oldBase = texelFetch(u_palette, ivec2(int(oldOwner) * 2, 0), 0); + vec4 oldBorder = texelFetch( + u_palette, + ivec2(int(oldOwner) * 2 + 1, 0), + 0 + ); + bool oldPrimary = patternIsPrimary(oldOwner, texCoord); + vec3 oldPatternColor = oldPrimary ? oldBase.rgb : oldBorder.rgb; + oldColor = mix(baseTerrainColor, oldPatternColor, u_alpha); + } + + // JFA-based animation with tile-sized pixelated look + // Movement is pixel-smooth but edges remain hard/blocky like stable borders + // Use best-of-NxN seed sampling when enabled to reduce tile-boundary discontinuities. + // Use seeds picked at the TILE CENTER to avoid seed flipping inside a tile + // (which can cause direction/timing glitches). Distances still use mapCoord + // for smooth within-tile variation. + vec2 tileCenter = floor(mapCoord) + 0.5; + vec2 seedOld = u_seedSamplingMode == 0 + ? jfaSeedOldAtTex(texCoord) + : bestSeedOld(tileCenter); + vec2 seedNew = u_seedSamplingMode == 0 + ? jfaSeedNewAtTex(texCoord) + : bestSeedNew(tileCenter); + + bool hasOldSeed = seedOld.x >= 0.0; + bool hasNewSeed = seedNew.x >= 0.0; + + // CORRECT MODEL (no blending, no "future"): + // - We are interpolating between a *pair* of snapshots (from/to), selected by "renderPair" on CPU. + // - u_prevOwner is the FROM snapshot (texture unit 7). + // - u_state is the TO snapshot (texture unit 0). + // - u_jfaSeedsOld/u_jfaSeedsNew + u_changeMask also match that pair. + // + // We render: + // 1) Old snapshot at the true map coords (static). + // 2) New snapshot slid in from the old border position toward the new border position. + // No blending: the slid-in new snapshot overwrites old ONLY where changeMask indicates change. + + float t = clamp(u_smoothProgress, 0.0, 1.0); + + // --- Old layer (FROM snapshot), at texCoord --- + uint fromState = oldState; + uint fromOwner = oldOwner; + + // Fill for FROM owner + vec3 fromColor = baseTerrainColor; + if (fromOwner != 0u) { + vec4 fromBase = texelFetch(u_palette, ivec2(int(fromOwner) * 2, 0), 0); + vec4 fromBorderBase = texelFetch( + u_palette, + ivec2(int(fromOwner) * 2 + 1, 0), + 0 + ); + bool fromPrimary = patternIsPrimary(fromOwner, texCoord); + vec3 fromPatternColor = fromPrimary ? fromBase.rgb : fromBorderBase.rgb; + fromColor = mix(baseTerrainColor, fromPatternColor, u_alpha); + } else if (oldHasFallout) { + // preserve fallout tint when unowned + fromColor = mix(baseTerrainColor, u_fallout.rgb, u_alpha); + } + + // Border for FROM owner (tile-width, stable look) + bool fromIsBorder = false; + uint fromOther = 0u; + uint nFrom; + nFrom = texelFetch(u_prevOwner, texCoord + ivec2(1, 0), 0).r & 0xFFFu; + if (nFrom != fromOwner) { fromIsBorder = true; if (nFrom != 0u) fromOther = nFrom; } + nFrom = texelFetch(u_prevOwner, texCoord + ivec2(-1, 0), 0).r & 0xFFFu; + if (nFrom != fromOwner) { fromIsBorder = true; if (nFrom != 0u) fromOther = nFrom; } + nFrom = texelFetch(u_prevOwner, texCoord + ivec2(0, 1), 0).r & 0xFFFu; + if (nFrom != fromOwner) { fromIsBorder = true; if (nFrom != 0u) fromOther = nFrom; } + nFrom = texelFetch(u_prevOwner, texCoord + ivec2(0, -1), 0).r & 0xFFFu; + if (nFrom != fromOwner) { fromIsBorder = true; if (nFrom != 0u) fromOther = nFrom; } + + if (!u_debugDisableAllBorders && !u_debugDisableStaticBorders && fromIsBorder && fromOwner != 0u) { + vec4 borderBase = texelFetch(u_palette, ivec2(int(fromOwner) * 2 + 1, 0), 0); + bool fromFriendly = false; + bool fromEmbargo = false; + if (fromOther != 0u) { + uint rel = relationCode(fromOwner, fromOther); + fromFriendly = isFriendly(rel); + fromEmbargo = isEmbargo(rel); + } + vec3 bColor = applyBorderTint( + borderBase.rgb, + fromFriendly, + fromEmbargo + ); + bColor = applyDefended(bColor, oldIsDefended, texCoord); + fromColor = bColor; + } + + // Start with FROM layer + color = fromColor; + + // Draw a *constant-width* moving border stripe between the FROM and TO snapshots. + // Use a planar front (not radial) that moves coherently across tiles based on + // the displacement direction from old->new seeds. + if (affectedMask != 0u && hasOldSeed && hasNewSeed) { + vec2 disp = seedNew - seedOld; + vec2 absDisp = abs(disp); + vec2 dispSign = vec2(disp.x >= 0.0 ? 1.0 : -1.0, disp.y >= 0.0 ? 1.0 : -1.0); + float dispLen = length(disp); + if (dispLen > 1e-4) { + vec2 dir = vec2(1.0, 0.0); + vec2 frontOrigin = seedOld; + float frontPos = 0.0; + vec2 shift = vec2(0.0); + + if (u_motionMode == 1) { + bool xDom = absDisp.x >= absDisp.y; + dir = xDom ? vec2(dispSign.x, 0.0) : vec2(0.0, dispSign.y); + float len = xDom ? absDisp.x : absDisp.y; + frontOrigin = seedOld; + frontPos = t * len; + shift = dir * (len * (1.0 - t)); + } else if (u_motionMode == 2) { + bool xDom = absDisp.x >= absDisp.y; + vec2 axisX = vec2(dispSign.x, 0.0); + vec2 axisY = vec2(0.0, dispSign.y); + vec2 axis1 = xDom ? axisX : axisY; + vec2 axis2 = xDom ? axisY : axisX; + float len1 = xDom ? absDisp.x : absDisp.y; + float len2 = xDom ? absDisp.y : absDisp.x; + float total = len1 + len2; + float split = total > 1e-4 ? len1 / total : 0.5; + if (t <= split) { + float t1 = split > 1e-4 ? t / split : 1.0; + dir = axis1; + frontOrigin = seedOld; + frontPos = t1 * len1; + shift = axis1 * (len1 * (1.0 - t1)) + axis2 * len2; + } else { + float t2 = (t - split) / max(1.0 - split, 1e-4); + dir = axis2; + frontOrigin = seedOld + axis1 * len1; + frontPos = t2 * len2; + shift = axis2 * (len2 * (1.0 - t2)); + } + } else if (u_motionMode == 3) { + float maxAbs = max(absDisp.x, absDisp.y); + float p = t * maxAbs; + vec2 remaining = max(absDisp - vec2(p), vec2(0.0)); + shift = dispSign * remaining; + bool xDom = absDisp.x >= absDisp.y; + dir = xDom ? vec2(dispSign.x, 0.0) : vec2(0.0, dispSign.y); + frontOrigin = seedOld; + frontPos = t * maxAbs; + } else { + dir = disp / dispLen; + frontOrigin = seedOld; + frontPos = t * dispLen; + shift = disp * (1.0 - t); + } + + // Project mapCoord onto the displacement direction, measured from frontOrigin. + // This gives us a global coordinate along the motion axis. + // At t=0, front should be near frontOrigin (s ~ 0). + // At t=1, front should be near frontOrigin + dir * frontPos. + float s = dot(mapCoord - frontOrigin, dir); + + // Signed distance from the moving front plane. + // Positive means the front has passed this point (new territory side). + float frontDist = frontPos - s; + + // Compute the sliding position: sample owners at the position where the front currently is. + // This ensures owner checks happen at the sliding position, not static. + vec2 slideOffsetFront = (frontPos - s) * dir; // Offset from current position to front position + vec2 slideCoordFront = mapCoord + slideOffsetFront; + ivec2 slideTexFront = clamp(ivec2(slideCoordFront), ivec2(0), ivec2(int(u_mapResolution.x) - 1, int(u_mapResolution.y) - 1)); + + // Sample owners at the sliding position + uint slideState = texelFetch(u_state, slideTexFront, 0).r; + uint slideOwner = slideState & 0xFFFu; + bool slideHasFallout = (slideState & 0x2000u) != 0u; + bool slideIsDefended = (slideState & 0x1000u) != 0u; + + // Check if we're on a border at the sliding position (this is where the border currently is) + bool slideIsBorder = false; + bool slideHasFriendly = false; + bool slideHasEmbargo = false; + uint slideOther = 0u; + uint nSlide; + ivec2 nSlideTex; + nSlideTex = clamp(slideTexFront + ivec2(1, 0), ivec2(0), ivec2(int(u_mapResolution.x) - 1, int(u_mapResolution.y) - 1)); + nSlide = texelFetch(u_state, nSlideTex, 0).r & 0xFFFu; + if (nSlide != slideOwner) { slideIsBorder = true; if (nSlide != 0u) { slideOther = nSlide; uint rel = relationCode(slideOwner, nSlide); slideHasFriendly = slideHasFriendly || isFriendly(rel); slideHasEmbargo = slideHasEmbargo || isEmbargo(rel); } } + nSlideTex = clamp(slideTexFront + ivec2(-1, 0), ivec2(0), ivec2(int(u_mapResolution.x) - 1, int(u_mapResolution.y) - 1)); + nSlide = texelFetch(u_state, nSlideTex, 0).r & 0xFFFu; + if (nSlide != slideOwner) { slideIsBorder = true; if (nSlide != 0u) { slideOther = nSlide; uint rel = relationCode(slideOwner, nSlide); slideHasFriendly = slideHasFriendly || isFriendly(rel); slideHasEmbargo = slideHasEmbargo || isEmbargo(rel); } } + nSlideTex = clamp(slideTexFront + ivec2(0, 1), ivec2(0), ivec2(int(u_mapResolution.x) - 1, int(u_mapResolution.y) - 1)); + nSlide = texelFetch(u_state, nSlideTex, 0).r & 0xFFFu; + if (nSlide != slideOwner) { slideIsBorder = true; if (nSlide != 0u) { slideOther = nSlide; uint rel = relationCode(slideOwner, nSlide); slideHasFriendly = slideHasFriendly || isFriendly(rel); slideHasEmbargo = slideHasEmbargo || isEmbargo(rel); } } + nSlideTex = clamp(slideTexFront + ivec2(0, -1), ivec2(0), ivec2(int(u_mapResolution.x) - 1, int(u_mapResolution.y) - 1)); + nSlide = texelFetch(u_state, nSlideTex, 0).r & 0xFFFu; + if (nSlide != slideOwner) { slideIsBorder = true; if (nSlide != 0u) { slideOther = nSlide; uint rel = relationCode(slideOwner, nSlide); slideHasFriendly = slideHasFriendly || isFriendly(rel); slideHasEmbargo = slideHasEmbargo || isEmbargo(rel); } } + + // Check if we're on a border in the FROM state (retreating side) + uint fromSlideState = prevStateAtTex(slideTexFront); + uint fromSlideOwner = fromSlideState & 0xFFFu; + bool fromSlideDefended = (fromSlideState & 0x1000u) != 0u; + bool fromIsBorderAtSlide = false; + uint fromOtherAtSlide = 0u; + uint nFromSlide; + ivec2 nFromSlideTex; + nFromSlideTex = clamp(slideTexFront + ivec2(1, 0), ivec2(0), ivec2(int(u_mapResolution.x) - 1, int(u_mapResolution.y) - 1)); + nFromSlide = texelFetch(u_prevOwner, nFromSlideTex, 0).r & 0xFFFu; + if (nFromSlide != fromSlideOwner) { fromIsBorderAtSlide = true; if (nFromSlide != 0u) fromOtherAtSlide = nFromSlide; } + nFromSlideTex = clamp(slideTexFront + ivec2(-1, 0), ivec2(0), ivec2(int(u_mapResolution.x) - 1, int(u_mapResolution.y) - 1)); + nFromSlide = texelFetch(u_prevOwner, nFromSlideTex, 0).r & 0xFFFu; + if (nFromSlide != fromSlideOwner) { fromIsBorderAtSlide = true; if (nFromSlide != 0u) fromOtherAtSlide = nFromSlide; } + nFromSlideTex = clamp(slideTexFront + ivec2(0, 1), ivec2(0), ivec2(int(u_mapResolution.x) - 1, int(u_mapResolution.y) - 1)); + nFromSlide = texelFetch(u_prevOwner, nFromSlideTex, 0).r & 0xFFFu; + if (nFromSlide != fromSlideOwner) { fromIsBorderAtSlide = true; if (nFromSlide != 0u) fromOtherAtSlide = nFromSlide; } + nFromSlideTex = clamp(slideTexFront + ivec2(0, -1), ivec2(0), ivec2(int(u_mapResolution.x) - 1, int(u_mapResolution.y) - 1)); + nFromSlide = texelFetch(u_prevOwner, nFromSlideTex, 0).r & 0xFFFu; + if (nFromSlide != fromSlideOwner) { fromIsBorderAtSlide = true; if (nFromSlide != 0u) fromOtherAtSlide = nFromSlide; } + + // Draw border stripe: check both expanding (TO) and retreating (FROM) sides + float stripeWidth = u_debugDisableAllBorders ? 0.0 : 0.5; + bool isStripe = abs(frontDist) <= stripeWidth; + bool drawExpandingBorder = + isStripe && slideIsBorder && slideOwner != 0u && frontDist > 0.0; + bool drawRetreatingBorder = + isStripe && fromIsBorderAtSlide && fromSlideOwner != 0u && frontDist <= 0.0; + + if (!u_debugDisableAllBorders && (drawExpandingBorder || drawRetreatingBorder)) { + uint stripeOwner = drawExpandingBorder ? slideOwner : fromSlideOwner; + uint stripeOther = drawExpandingBorder ? slideOther : fromOtherAtSlide; + + if (u_debugStripeFixedColors) { + // Debug mode: Use fixed colors + if (drawExpandingBorder) { + // Expanding: bright red + color = vec3(1.0, float(stripeOwner) / 255.0, 0.0); + } else { + // Retreating: bright blue + color = vec3(0.0, float(stripeOwner) / 255.0, 1.0); + } + } else { + // Normal mode: Use actual border colors + if (stripeOwner != 0u) { + vec4 borderBase = texelFetch( + u_palette, + ivec2(int(stripeOwner) * 2 + 1, 0), + 0 + ); + bool stripeFriendly = false; + bool stripeEmbargo = false; + if (stripeOther != 0u) { + uint rel = relationCode(stripeOwner, stripeOther); + stripeFriendly = isFriendly(rel); + stripeEmbargo = isEmbargo(rel); + } + bool stripeDefended = drawExpandingBorder + ? slideIsDefended + : fromSlideDefended; + vec3 bColor = applyBorderTint( + borderBase.rgb, + stripeFriendly, + stripeEmbargo + ); + bColor = applyDefended(bColor, stripeDefended, slideTexFront); + color = bColor; + } + } + } else if (frontDist > stripeWidth) { + // Front has passed; show the new fill/border at the shifted position + vec2 slideCoordFill = mapCoord - shift; + ivec2 slideTexFill = clamp(ivec2(slideCoordFill), ivec2(0), ivec2(int(u_mapResolution.x) - 1, int(u_mapResolution.y) - 1)); + + uint fillState = texelFetch(u_state, slideTexFill, 0).r; + uint fillOwner = fillState & 0xFFFu; + bool fillHasFallout = (fillState & 0x2000u) != 0u; + bool fillIsDefended = (fillState & 0x1000u) != 0u; + + bool fillIsBorder = false; + bool fillHasFriendly = false; + bool fillHasEmbargo = false; + uint fillOther = 0u; + uint nFill; + ivec2 nFillTex; + nFillTex = clamp(slideTexFill + ivec2(1, 0), ivec2(0), ivec2(int(u_mapResolution.x) - 1, int(u_mapResolution.y) - 1)); + nFill = texelFetch(u_state, nFillTex, 0).r & 0xFFFu; + if (nFill != fillOwner) { fillIsBorder = true; if (nFill != 0u) { fillOther = nFill; uint rel = relationCode(fillOwner, nFill); fillHasFriendly = fillHasFriendly || isFriendly(rel); fillHasEmbargo = fillHasEmbargo || isEmbargo(rel); } } + nFillTex = clamp(slideTexFill + ivec2(-1, 0), ivec2(0), ivec2(int(u_mapResolution.x) - 1, int(u_mapResolution.y) - 1)); + nFill = texelFetch(u_state, nFillTex, 0).r & 0xFFFu; + if (nFill != fillOwner) { fillIsBorder = true; if (nFill != 0u) { fillOther = nFill; uint rel = relationCode(fillOwner, nFill); fillHasFriendly = fillHasFriendly || isFriendly(rel); fillHasEmbargo = fillHasEmbargo || isEmbargo(rel); } } + nFillTex = clamp(slideTexFill + ivec2(0, 1), ivec2(0), ivec2(int(u_mapResolution.x) - 1, int(u_mapResolution.y) - 1)); + nFill = texelFetch(u_state, nFillTex, 0).r & 0xFFFu; + if (nFill != fillOwner) { fillIsBorder = true; if (nFill != 0u) { fillOther = nFill; uint rel = relationCode(fillOwner, nFill); fillHasFriendly = fillHasFriendly || isFriendly(rel); fillHasEmbargo = fillHasEmbargo || isEmbargo(rel); } } + nFillTex = clamp(slideTexFill + ivec2(0, -1), ivec2(0), ivec2(int(u_mapResolution.x) - 1, int(u_mapResolution.y) - 1)); + nFill = texelFetch(u_state, nFillTex, 0).r & 0xFFFu; + if (nFill != fillOwner) { fillIsBorder = true; if (nFill != 0u) { fillOther = nFill; uint rel = relationCode(fillOwner, nFill); fillHasFriendly = fillHasFriendly || isFriendly(rel); fillHasEmbargo = fillHasEmbargo || isEmbargo(rel); } } + + vec3 toColor = baseTerrainColor; + if (fillOwner != 0u) { + vec4 toBase = texelFetch(u_palette, ivec2(int(fillOwner) * 2, 0), 0); + vec4 toBorderBase = texelFetch( + u_palette, + ivec2(int(fillOwner) * 2 + 1, 0), + 0 + ); + bool toPrimary = patternIsPrimary(fillOwner, slideTexFill); + vec3 toPatternColor = toPrimary ? toBase.rgb : toBorderBase.rgb; + toColor = mix(baseTerrainColor, toPatternColor, u_alpha); + if (!u_debugDisableAllBorders && !u_debugDisableStaticBorders && fillIsBorder) { + vec3 bColor = applyBorderTint( + toBorderBase.rgb, + fillHasFriendly, + fillHasEmbargo + ); + bColor = applyDefended(bColor, fillIsDefended, slideTexFill); + toColor = bColor; + } + } else if (fillHasFallout) { + toColor = mix(baseTerrainColor, u_fallout.rgb, u_alpha); + } + + color = toColor; + } + // If frontDist < -stripeWidth, we're ahead of the front, so keep fromColor (already set). + } + } + + } + + bool pendingOwnerChange = latestOwner != owner; + if (pendingOwnerChange && !useContestedFill && !u_alternativeView) { + vec3 hintColor = baseTerrainColor; + if (latestOwner != 0u) { + vec3 latestColor = texelFetch( + u_palette, + ivec2(int(latestOwner) * 2, 0), + 0 + ).rgb; + hintColor = mix(baseTerrainColor, latestColor, u_alpha * 0.12); + } + color = mix(color, hintColor, 0.5); + } + + 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); + } + + // Output fully opaque since we render terrain as background + outColor = vec4(color, 1.0); + } + `; + + 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/client/graphics/layers/WebGLTerritoryBackend.ts b/src/client/graphics/layers/WebGLTerritoryBackend.ts new file mode 100644 index 000000000..e683b2d61 --- /dev/null +++ b/src/client/graphics/layers/WebGLTerritoryBackend.ts @@ -0,0 +1,1669 @@ +import { Colord } from "colord"; +import { Theme } from "../../../core/configuration/Config"; +import { EventBus } from "../../../core/EventBus"; +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 { + AlternateViewEvent, + ContextMenuEvent, + MouseOverEvent, +} from "../../InputHandler"; +import { FrameProfiler } from "../FrameProfiler"; +import { getHoverInfo } from "../HoverInfo"; +import { TransformHandler } from "../TransformHandler"; +import { TerritoryBackend } from "./TerritoryBackend"; +import { TerritoryWebGLRenderer } from "./TerritoryWebGLRenderer"; + +const CONTEST_ID_MASK = 0x7fff; +const CONTEST_ATTACKER_EVER_BIT = 0x8000; +const CONTEST_TIME_WRAP = 32768; +const DEFAULT_CONTEST_DURATION_TICKS = 2; +const ENABLE_CONTEST_TRACKING = false; +const CONTEST_STRENGTH_EMA_ALPHA = 0.8; +const CONTEST_STRENGTH_MIN = 0.01; +const CONTEST_STRENGTH_MAX = 0.95; +const DEBUG_TERRITORY_OVERLAY = false; + +type ContestComponent = { + id: number; + attacker: number; + defender: number; + lastActivityPacked: number; + tiles: TileRef[]; + strength: number; +}; + +export class WebGLTerritoryBackend implements TerritoryBackend { + readonly id = "webgl"; + + profileName(): string { + return "WebGLTerritoryBackend:renderLayer"; + } + + private userSettings = new UserSettings(); + private borderAnimTime = 0; + + private cachedTerritoryPatternsEnabled: boolean | undefined; + + private theme: Theme; + + // Used for spawn highlighting + private highlightCanvas: HTMLCanvasElement; + private highlightContext: CanvasRenderingContext2D; + + private highlightedTerritory: PlayerView | null = null; + private territoryRenderer: TerritoryWebGLRenderer | null = null; + + private alternativeView = false; + private lastMousePosition: { x: number; y: number } | null = null; + + private lastFocusedPlayer: PlayerView | null = null; + private lastMyPlayerSmallId: number | null = null; + private lastPaletteSignature: string | null = null; + private contestDurationTicks = DEFAULT_CONTEST_DURATION_TICKS; + private contestActive = false; + private contestNextId = 1; + private contestFreeIds: number[] = []; + private contestComponentIds: Uint16Array | null = null; + private contestPrevOwners: Uint16Array | null = null; + private contestAttackers: Uint16Array | null = null; + private contestTileIndices: Int32Array | null = null; + private contestComponents = new Map(); + private contestTileCount = 0; + private contestEnabled = ENABLE_CONTEST_TRACKING; + private tickSnapshotPending = false; + private tickTimeMsCurrent = 0; + private tickTimeMsPrev = 0; + private tickTimeMsOlder = 0; + private tickNumberCurrent: number | null = null; + private tickNumberPrev: number | null = null; + private tickNumberOlder: number | null = null; + private interpolationDelayMs = 100; + private lastInterpolationPair: "prevCurrent" | "olderPrev" = "prevCurrent"; + + // Runtime debug controls (UI) + private tripleBufferEnabled = true; + private interpolationDelayMode: "ema" | "fixed50" | "fixed100" | "fixed200" = + "ema"; + private tickIntervalEmaMs = 0; + private readonly TICK_INTERVAL_EMA_ALPHA = 0.2; + private smoothingDebugUi: HTMLDivElement | null = null; + private contestedPatternMode: "blueNoise" | "checkerboard" | "bayer4x4" = + "blueNoise"; + private debugDisableStaticBorders = false; + private debugDisableAllBorders = false; + private motionMode: "euclidean" | "axisSnap" | "manhattan" | "chebyshev" = + "euclidean"; + private seedSamplingMode: "none" | "2x2" | "3x3" = "2x2"; + private debugStripeFixedColors = false; + private failureReason: string | null = null; + private readonly contextLostHandler = (event: Event) => { + event.preventDefault(); + this.failureReason = "WebGL context lost."; + }; + + constructor( + private game: GameView, + private eventBus: EventBus, + private transformHandler: TransformHandler, + ) { + this.theme = game.config().theme(); + this.cachedTerritoryPatternsEnabled = undefined; + this.lastMyPlayerSmallId = game.myPlayer()?.smallID() ?? null; + } + + shouldTransform(): boolean { + return true; + } + + tick() { + const tickProfile = FrameProfiler.start(); + const now = this.nowMs(); + const currentTheme = this.game.config().theme(); + if (currentTheme !== this.theme) { + this.theme = currentTheme; + this.redraw(); + } + if (this.game.inSpawnPhase()) { + this.spawnHighlight(); + } + + const patternsEnabled = this.userSettings.territoryPatterns(); + if (this.cachedTerritoryPatternsEnabled !== patternsEnabled) { + this.cachedTerritoryPatternsEnabled = patternsEnabled; + this.redraw(); + } + this.refreshPaletteIfNeeded(); + + const tickNumber = this.game.ticks(); + if (this.tickNumberCurrent !== tickNumber) { + this.tickNumberOlder = this.tickNumberPrev; + this.tickNumberPrev = this.tickNumberCurrent; + this.tickNumberCurrent = tickNumber; + + this.tickTimeMsOlder = this.tickTimeMsPrev; + this.tickTimeMsPrev = this.tickTimeMsCurrent; + this.tickTimeMsCurrent = now; + + const lastInterval = this.tickTimeMsCurrent - this.tickTimeMsPrev; + if (lastInterval > 0) { + // Track tick interval EMA for stable delay at variable speeds. + this.tickIntervalEmaMs = + this.tickIntervalEmaMs <= 0 + ? lastInterval + : this.tickIntervalEmaMs * (1 - this.TICK_INTERVAL_EMA_ALPHA) + + lastInterval * this.TICK_INTERVAL_EMA_ALPHA; + + // Choose delay mode. + if (this.interpolationDelayMode === "fixed50") { + this.interpolationDelayMs = 50; + } else if (this.interpolationDelayMode === "fixed100") { + this.interpolationDelayMs = 100; + } else if (this.interpolationDelayMode === "fixed200") { + this.interpolationDelayMs = 200; + } else { + // "ema": render roughly one tick behind using the raw EMA interval. + // Do not clamp in EMA mode (debug requested). + this.interpolationDelayMs = this.tickIntervalEmaMs; + } + } + + if (this.territoryRenderer) { + this.tickSnapshotPending = true; + } + } + + this.game.recentlyUpdatedTiles().forEach((t) => this.markTile(t)); + if (this.contestEnabled) { + const ownerUpdates = this.game.recentlyUpdatedOwnerTiles(); + const nowTickPacked = this.packContestTick(this.game.ticks()); + this.applyContestChanges(ownerUpdates, nowTickPacked); + this.updateContestState(nowTickPacked); + this.updateContestStrengths(); + let tileCount = 0; + for (const component of this.contestComponents.values()) { + tileCount += component.tiles.length; + } + this.contestTileCount = tileCount; + } else { + this.contestTileCount = 0; + this.contestActive = false; + } + const updates = this.game.updatesSinceLastTick(); + + // Detect alliance mutations + const myPlayer = this.game.myPlayer(); + if (myPlayer) { + updates?.[GameUpdateType.BrokeAlliance]?.forEach((update) => { + const territory = this.game.playerBySmallID(update.betrayedID); + if (territory && territory instanceof PlayerView) { + this.territoryRenderer?.refreshPalette(); + } + }); + + updates?.[GameUpdateType.AllianceRequestReply]?.forEach((update) => { + if ( + update.accepted && + (update.request.requestorID === myPlayer.smallID() || + update.request.recipientID === myPlayer.smallID()) + ) { + const territoryId = + update.request.requestorID === myPlayer.smallID() + ? update.request.recipientID + : update.request.requestorID; + const territory = this.game.playerBySmallID(territoryId); + if (territory && territory instanceof PlayerView) { + this.territoryRenderer?.refreshPalette(); + } + } + }); + updates?.[GameUpdateType.EmbargoEvent]?.forEach((update) => { + const player = this.game.playerBySmallID(update.playerID) as PlayerView; + const embargoed = this.game.playerBySmallID( + update.embargoedID, + ) as PlayerView; + + if ( + player.id() === myPlayer?.id() || + embargoed.id() === myPlayer?.id() + ) { + this.territoryRenderer?.refreshPalette(); + } + }); + } + + const focusedPlayer = this.game.focusedPlayer(); + if (focusedPlayer !== this.lastFocusedPlayer) { + this.redraw(); + this.lastFocusedPlayer = focusedPlayer; + } + + const currentMyPlayer = this.game.myPlayer()?.smallID() ?? null; + if (currentMyPlayer !== this.lastMyPlayerSmallId) { + this.redraw(); + } + FrameProfiler.end("TerritoryLayer:tick", tickProfile); + } + + private spawnHighlight() { + this.highlightContext.clearRect( + 0, + 0, + this.game.width(), + this.game.height(), + ); + + this.drawFocusedPlayerHighlight(); + + const humans = this.game + .playerViews() + .filter((p) => p.type() === PlayerType.Human); + + const focusedPlayer = this.game.focusedPlayer(); + const teamColors = Object.values(ColoredTeams); + for (const human of humans) { + if (human === focusedPlayer) { + continue; + } + const center = human.nameLocation(); + if (!center) { + continue; + } + const centerTile = this.game.ref(center.x, center.y); + if (!centerTile) { + continue; + } + let color = this.theme.spawnHighlightColor(); + const myPlayer = this.game.myPlayer(); + if (myPlayer !== null && myPlayer !== human && myPlayer.team() === null) { + // In FFA games (when team === null), use default yellow spawn highlight color + color = this.theme.spawnHighlightColor(); + } else if (myPlayer !== null && myPlayer !== human) { + // In Team games, the spawn highlight color becomes that player's team color + const team = human.team(); + if (team !== null && teamColors.includes(team)) { + color = this.theme.teamColor(team); + } else { + if (myPlayer.isFriendly(human)) { + color = this.theme.spawnHighlightTeamColor(); + } else { + color = this.theme.spawnHighlightColor(); + } + } + } + + for (const tile of this.game.bfs( + centerTile, + euclDistFN(centerTile, 9, true), + )) { + if (!this.game.hasOwner(tile)) { + this.paintHighlightTile(tile, color, 255); + } + } + } + } + + private drawFocusedPlayerHighlight() { + const focusedPlayer = this.game.focusedPlayer(); + + if (!focusedPlayer) { + return; + } + const center = focusedPlayer.nameLocation(); + if (!center) { + return; + } + // Breathing border animation + this.borderAnimTime += 0.5; + const minRad = 8; + const maxRad = 24; + const radius = + minRad + (maxRad - minRad) * (0.5 + 0.5 * Math.sin(this.borderAnimTime)); + + const baseColor = this.theme.spawnHighlightSelfColor(); + let teamColor: Colord | null = null; + + const team: Team | null = focusedPlayer.team(); + if (team !== null && Object.values(ColoredTeams).includes(team)) { + teamColor = this.theme.teamColor(team).alpha(0.5); + } else { + teamColor = baseColor; + } + + this.drawBreathingRing( + center.x, + center.y, + minRad, + maxRad, + radius, + baseColor, + teamColor, + ); + + this.drawTeammateHighlights(minRad, maxRad, radius); + } + + private drawTeammateHighlights( + minRad: number, + maxRad: number, + radius: number, + ) { + const myPlayer = this.game.myPlayer(); + if (myPlayer === null || myPlayer.team() === null) { + return; + } + + const teammates = this.game + .playerViews() + .filter((p) => p !== myPlayer && myPlayer.isOnSameTeam(p)); + + const teammateMinRad = 5; + const teammateMaxRad = 14; + const teammateRadius = + teammateMinRad + + (teammateMaxRad - teammateMinRad) * + ((radius - minRad) / (maxRad - minRad)); + + const teamColors = Object.values(ColoredTeams); + for (const teammate of teammates) { + const center = teammate.nameLocation(); + if (!center) { + continue; + } + + const team = teammate.team(); + let baseColor: Colord; + let breathingColor: Colord; + + if (team !== null && teamColors.includes(team)) { + baseColor = this.theme.teamColor(team).alpha(0.5); + breathingColor = this.theme.teamColor(team).alpha(0.5); + } else { + baseColor = this.theme.spawnHighlightTeamColor(); + breathingColor = this.theme.spawnHighlightTeamColor(); + } + + this.drawBreathingRing( + center.x, + center.y, + teammateMinRad, + teammateMaxRad, + teammateRadius, + baseColor, + breathingColor, + ); + } + } + + 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.redraw(); + this.ensureSmoothingDebugUi(); + } + + getFailureReason(): string | null { + return this.failureReason; + } + + dispose() { + this.smoothingDebugUi?.remove(); + this.smoothingDebugUi = null; + this.territoryRenderer?.canvas.removeEventListener( + "webglcontextlost", + this.contextLostHandler, + ); + this.territoryRenderer?.dispose(); + this.territoryRenderer = null; + } + + private ensureSmoothingDebugUi() { + if (!DEBUG_TERRITORY_OVERLAY) return; + if (this.smoothingDebugUi) return; + + const root = document.createElement("div"); + root.style.position = "fixed"; + root.style.right = "10px"; + root.style.top = "10px"; + root.style.zIndex = "9999"; + root.style.background = "rgba(0, 0, 0, 0.6)"; + root.style.color = "rgba(255, 255, 255, 0.92)"; + root.style.padding = "8px 10px"; + root.style.borderRadius = "8px"; + root.style.font = "12px monospace"; + root.style.userSelect = "none"; + root.style.touchAction = "none"; + + const title = document.createElement("div"); + title.textContent = "Territory smoothing"; + title.style.fontWeight = "700"; + title.style.marginBottom = "6px"; + title.style.cursor = "move"; + root.appendChild(title); + + // Restore last position (if any) + const POS_KEY = "debug.territorySmoothingPanelPos.v1"; + try { + const raw = localStorage.getItem(POS_KEY); + if (raw) { + const parsed = JSON.parse(raw) as { left: number; top: number }; + if ( + typeof parsed?.left === "number" && + typeof parsed?.top === "number" && + Number.isFinite(parsed.left) && + Number.isFinite(parsed.top) + ) { + root.style.left = `${parsed.left}px`; + root.style.top = `${parsed.top}px`; + root.style.right = "auto"; + } + } + } catch { + // ignore + } + + // Make draggable via title bar + let dragging = false; + let dragDx = 0; + let dragDy = 0; + const clampPos = (left: number, top: number) => { + const maxLeft = Math.max(0, window.innerWidth - root.offsetWidth); + const maxTop = Math.max(0, window.innerHeight - root.offsetHeight); + return { + left: Math.max(0, Math.min(maxLeft, left)), + top: Math.max(0, Math.min(maxTop, top)), + }; + }; + + title.addEventListener("pointerdown", (e) => { + e.preventDefault(); + e.stopPropagation(); + dragging = true; + title.setPointerCapture(e.pointerId); + const rect = root.getBoundingClientRect(); + dragDx = e.clientX - rect.left; + dragDy = e.clientY - rect.top; + // Switch to explicit left/top positioning + root.style.left = `${rect.left}px`; + root.style.top = `${rect.top}px`; + root.style.right = "auto"; + }); + + title.addEventListener("pointermove", (e) => { + if (!dragging) return; + e.preventDefault(); + e.stopPropagation(); + const next = clampPos(e.clientX - dragDx, e.clientY - dragDy); + root.style.left = `${next.left}px`; + root.style.top = `${next.top}px`; + try { + localStorage.setItem(POS_KEY, JSON.stringify(next)); + } catch { + // ignore + } + }); + + const endDrag = (e: PointerEvent) => { + if (!dragging) return; + e.preventDefault(); + e.stopPropagation(); + dragging = false; + try { + title.releasePointerCapture(e.pointerId); + } catch { + // ignore + } + }; + title.addEventListener("pointerup", endDrag); + title.addEventListener("pointercancel", endDrag); + + const tripleRow = document.createElement("label"); + tripleRow.style.display = "flex"; + tripleRow.style.alignItems = "center"; + tripleRow.style.gap = "6px"; + tripleRow.style.marginBottom = "6px"; + + const tripleCheckbox = document.createElement("input"); + tripleCheckbox.type = "checkbox"; + tripleCheckbox.checked = this.tripleBufferEnabled; + tripleCheckbox.addEventListener("change", () => { + this.tripleBufferEnabled = tripleCheckbox.checked; + }); + + const tripleText = document.createElement("span"); + tripleText.textContent = "triple buffer (olderPrev)"; + tripleRow.appendChild(tripleCheckbox); + tripleRow.appendChild(tripleText); + root.appendChild(tripleRow); + + const modeRow = document.createElement("label"); + modeRow.style.display = "flex"; + modeRow.style.alignItems = "center"; + modeRow.style.gap = "6px"; + modeRow.style.marginBottom = "6px"; + + const modeText = document.createElement("span"); + modeText.textContent = "delay mode:"; + + const modeSelect = document.createElement("select"); + modeSelect.style.font = "12px monospace"; + modeSelect.style.background = "rgba(0,0,0,0.35)"; + modeSelect.style.color = "rgba(255,255,255,0.92)"; + modeSelect.style.border = "1px solid rgba(255,255,255,0.2)"; + modeSelect.style.borderRadius = "4px"; + modeSelect.style.padding = "2px 4px"; + + const modes: Array<"ema" | "fixed200" | "fixed100" | "fixed50"> = [ + "ema", + "fixed200", + "fixed100", + "fixed50", + ]; + for (const m of modes) { + const opt = document.createElement("option"); + opt.value = m; + opt.textContent = m; + modeSelect.appendChild(opt); + } + modeSelect.value = this.interpolationDelayMode; + modeSelect.addEventListener("change", () => { + const v = modeSelect.value as typeof this.interpolationDelayMode; + this.interpolationDelayMode = v; + // Apply immediately using current EMA if available, otherwise fall back to existing delay. + if (v === "fixed50") this.interpolationDelayMs = 50; + else if (v === "fixed100") this.interpolationDelayMs = 100; + else if (v === "fixed200") this.interpolationDelayMs = 200; + else if (this.tickIntervalEmaMs > 0) { + // "ema": do not clamp (debug requested) + this.interpolationDelayMs = this.tickIntervalEmaMs; + } + }); + + modeRow.appendChild(modeText); + modeRow.appendChild(modeSelect); + root.appendChild(modeRow); + + // Contested drawing controls + const contestedRow = document.createElement("label"); + contestedRow.style.display = "flex"; + contestedRow.style.alignItems = "center"; + contestedRow.style.gap = "6px"; + contestedRow.style.marginBottom = "6px"; + + const contestedCheckbox = document.createElement("input"); + contestedCheckbox.type = "checkbox"; + contestedCheckbox.checked = this.contestEnabled; + contestedCheckbox.addEventListener("change", () => { + const enabled = contestedCheckbox.checked; + this.contestEnabled = enabled; + this.contestTileCount = 0; + this.contestActive = false; + if (enabled) { + this.ensureContestScratch(); + this.syncContestStateToRenderer(); + } else { + this.contestComponents.clear(); + } + this.territoryRenderer?.setContestEnabled(enabled); + this.territoryRenderer?.markAllDirty(); + }); + + const contestedText = document.createElement("span"); + contestedText.textContent = "contested draw"; + contestedRow.appendChild(contestedCheckbox); + contestedRow.appendChild(contestedText); + root.appendChild(contestedRow); + + const contestedModeRow = document.createElement("label"); + contestedModeRow.style.display = "flex"; + contestedModeRow.style.alignItems = "center"; + contestedModeRow.style.gap = "6px"; + contestedModeRow.style.marginBottom = "0px"; + + const contestedModeText = document.createElement("span"); + contestedModeText.textContent = "contested pattern:"; + + const contestedModeSelect = document.createElement("select"); + contestedModeSelect.style.font = "12px monospace"; + contestedModeSelect.style.background = "rgba(0,0,0,0.35)"; + contestedModeSelect.style.color = "rgba(255,255,255,0.92)"; + contestedModeSelect.style.border = "1px solid rgba(255,255,255,0.2)"; + contestedModeSelect.style.borderRadius = "4px"; + contestedModeSelect.style.padding = "2px 4px"; + + const contestedModes: Array<"blueNoise" | "checkerboard" | "bayer4x4"> = [ + "blueNoise", + "checkerboard", + "bayer4x4", + ]; + for (const m of contestedModes) { + const opt = document.createElement("option"); + opt.value = m; + opt.textContent = m; + contestedModeSelect.appendChild(opt); + } + contestedModeSelect.value = this.contestedPatternMode; + contestedModeSelect.addEventListener("change", () => { + const v = contestedModeSelect.value as + | "blueNoise" + | "checkerboard" + | "bayer4x4"; + this.contestedPatternMode = v; + this.territoryRenderer?.setContestPatternMode(v); + this.territoryRenderer?.markAllDirty(); + }); + + contestedModeRow.appendChild(contestedModeText); + contestedModeRow.appendChild(contestedModeSelect); + root.appendChild(contestedModeRow); + + // Debug: hide all borders + const allBordersRow = document.createElement("label"); + allBordersRow.style.display = "flex"; + allBordersRow.style.alignItems = "center"; + allBordersRow.style.gap = "6px"; + allBordersRow.style.marginTop = "6px"; + + const allBordersCheckbox = document.createElement("input"); + allBordersCheckbox.type = "checkbox"; + allBordersCheckbox.checked = this.debugDisableAllBorders; + allBordersCheckbox.addEventListener("change", () => { + const disabled = allBordersCheckbox.checked; + this.debugDisableAllBorders = disabled; + this.territoryRenderer?.setDebugDisableAllBorders(disabled); + this.territoryRenderer?.markAllDirty(); + }); + + const allBordersText = document.createElement("span"); + allBordersText.textContent = "hide all borders"; + allBordersRow.appendChild(allBordersCheckbox); + allBordersRow.appendChild(allBordersText); + root.appendChild(allBordersRow); + + // Debug: hide non-smoothed (static) borders + const staticBordersRow = document.createElement("label"); + staticBordersRow.style.display = "flex"; + staticBordersRow.style.alignItems = "center"; + staticBordersRow.style.gap = "6px"; + staticBordersRow.style.marginTop = "6px"; + + const staticBordersCheckbox = document.createElement("input"); + staticBordersCheckbox.type = "checkbox"; + staticBordersCheckbox.checked = this.debugDisableStaticBorders; + staticBordersCheckbox.addEventListener("change", () => { + const disabled = staticBordersCheckbox.checked; + this.debugDisableStaticBorders = disabled; + this.territoryRenderer?.setDebugDisableStaticBorders(disabled); + this.territoryRenderer?.markAllDirty(); + }); + + const staticBordersText = document.createElement("span"); + staticBordersText.textContent = "hide static borders"; + staticBordersRow.appendChild(staticBordersCheckbox); + staticBordersRow.appendChild(staticBordersText); + root.appendChild(staticBordersRow); + + // Seed sampling mode dropdown (none / 2x2 / 3x3) + const seedSamplingRow = document.createElement("label"); + seedSamplingRow.style.display = "flex"; + seedSamplingRow.style.alignItems = "center"; + seedSamplingRow.style.gap = "6px"; + seedSamplingRow.style.marginTop = "6px"; + + const seedSamplingText = document.createElement("span"); + seedSamplingText.textContent = "seed sampling"; + + const seedSamplingSelect = document.createElement("select"); + seedSamplingSelect.style.background = "rgba(0,0,0,0.5)"; + seedSamplingSelect.style.color = "#fff"; + seedSamplingSelect.style.border = "1px solid rgba(255,255,255,0.2)"; + seedSamplingSelect.style.borderRadius = "4px"; + seedSamplingSelect.style.padding = "2px 4px"; + + const seedModes: Array<"none" | "2x2" | "3x3"> = ["none", "2x2", "3x3"]; + for (const m of seedModes) { + const opt = document.createElement("option"); + opt.value = m; + opt.textContent = m; + seedSamplingSelect.appendChild(opt); + } + seedSamplingSelect.value = this.seedSamplingMode; + seedSamplingSelect.addEventListener("change", () => { + const v = seedSamplingSelect.value as "none" | "2x2" | "3x3"; + this.seedSamplingMode = v; + this.territoryRenderer?.setSeedSamplingMode(v); + this.territoryRenderer?.markAllDirty(); + }); + + seedSamplingRow.appendChild(seedSamplingText); + seedSamplingRow.appendChild(seedSamplingSelect); + root.appendChild(seedSamplingRow); + + // Motion mode dropdown + const motionModeRow = document.createElement("label"); + motionModeRow.style.display = "flex"; + motionModeRow.style.alignItems = "center"; + motionModeRow.style.gap = "6px"; + motionModeRow.style.marginTop = "6px"; + + const motionModeText = document.createElement("span"); + motionModeText.textContent = "motion mode"; + + const motionModeSelect = document.createElement("select"); + motionModeSelect.style.background = "rgba(0,0,0,0.5)"; + motionModeSelect.style.color = "#fff"; + motionModeSelect.style.border = "1px solid rgba(255,255,255,0.2)"; + motionModeSelect.style.borderRadius = "4px"; + motionModeSelect.style.padding = "2px 4px"; + + const motionModes: Array< + "euclidean" | "axisSnap" | "manhattan" | "chebyshev" + > = ["euclidean", "axisSnap", "manhattan", "chebyshev"]; + for (const m of motionModes) { + const opt = document.createElement("option"); + opt.value = m; + opt.textContent = m; + motionModeSelect.appendChild(opt); + } + motionModeSelect.value = this.motionMode; + motionModeSelect.addEventListener("change", () => { + const v = motionModeSelect.value as + | "euclidean" + | "axisSnap" + | "manhattan" + | "chebyshev"; + this.motionMode = v; + this.territoryRenderer?.setMotionMode(v); + this.territoryRenderer?.markAllDirty(); + }); + + motionModeRow.appendChild(motionModeText); + motionModeRow.appendChild(motionModeSelect); + root.appendChild(motionModeRow); + + // Debug: fixed stripe colors + const stripeColorsRow = document.createElement("label"); + stripeColorsRow.style.display = "flex"; + stripeColorsRow.style.alignItems = "center"; + stripeColorsRow.style.gap = "6px"; + stripeColorsRow.style.marginTop = "6px"; + + const stripeColorsCheckbox = document.createElement("input"); + stripeColorsCheckbox.type = "checkbox"; + stripeColorsCheckbox.checked = this.debugStripeFixedColors; + stripeColorsCheckbox.addEventListener("change", () => { + const enabled = stripeColorsCheckbox.checked; + this.debugStripeFixedColors = enabled; + this.territoryRenderer?.setDebugStripeFixedColors(enabled); + this.territoryRenderer?.markAllDirty(); + }); + + const stripeColorsText = document.createElement("span"); + stripeColorsText.textContent = + "fixed stripe colors (red=expand, blue=retreat, green=owner)"; + stripeColorsRow.appendChild(stripeColorsCheckbox); + stripeColorsRow.appendChild(stripeColorsText); + root.appendChild(stripeColorsRow); + + document.body.appendChild(root); + this.smoothingDebugUi = root; + } + + onMouseOver(event: MouseOverEvent) { + this.lastMousePosition = { x: event.x, y: event.y }; + this.updateHighlightedTerritory(); + } + + private updateHighlightedTerritory() { + if (!this.lastMousePosition || !this.territoryRenderer) { + return; + } + + const cell = this.transformHandler.screenToWorldCoordinates( + this.lastMousePosition.x, + this.lastMousePosition.y, + ); + const previousTerritory = this.highlightedTerritory; + const info = getHoverInfo(this.game, cell); + let territory: PlayerView | null = null; + if (info.player) { + territory = info.player; + } else if (info.unit) { + territory = info.unit.owner(); + } + + if (territory) { + this.highlightedTerritory = territory; + } else { + this.highlightedTerritory = null; + } + + if (previousTerritory?.id() !== this.highlightedTerritory?.id()) { + this.territoryRenderer.setHoveredPlayerId( + this.highlightedTerritory?.smallID() ?? null, + ); + } + } + + redraw() { + this.lastMyPlayerSmallId = this.game.myPlayer()?.smallID() ?? null; + this.cachedTerritoryPatternsEnabled = this.userSettings.territoryPatterns(); + this.configureRenderers(); + if (this.contestEnabled) { + this.ensureContestScratch(); + this.syncContestStateToRenderer(); + } else { + this.contestActive = false; + this.contestComponents.clear(); + this.contestFreeIds = []; + this.contestNextId = 1; + } + + // Add a second canvas for highlights + this.highlightCanvas = document.createElement("canvas"); + const highlightContext = this.highlightCanvas.getContext("2d", { + alpha: true, + }); + if (highlightContext === null) throw new Error("2d context not supported"); + this.highlightContext = highlightContext; + this.highlightCanvas.width = this.game.width(); + this.highlightCanvas.height = this.game.height(); + } + + private configureRenderers() { + this.territoryRenderer?.canvas.removeEventListener( + "webglcontextlost", + this.contextLostHandler, + ); + this.territoryRenderer?.dispose(); + + const { renderer, reason } = TerritoryWebGLRenderer.create( + this.game, + this.theme, + ); + if (!renderer) { + throw new Error(reason ?? "WebGL2 is required for territory rendering."); + } + + this.territoryRenderer = renderer; + this.territoryRenderer.canvas.addEventListener( + "webglcontextlost", + this.contextLostHandler, + ); + this.territoryRenderer.setContestEnabled(this.contestEnabled); + this.territoryRenderer.setContestPatternMode(this.contestedPatternMode); + this.territoryRenderer.setDebugDisableStaticBorders( + this.debugDisableStaticBorders, + ); + this.territoryRenderer.setDebugDisableAllBorders( + this.debugDisableAllBorders, + ); + this.territoryRenderer.setSeedSamplingMode(this.seedSamplingMode); + this.territoryRenderer.setMotionMode(this.motionMode); + this.territoryRenderer.setDebugStripeFixedColors( + this.debugStripeFixedColors, + ); + this.territoryRenderer.setAlternativeView(this.alternativeView); + this.territoryRenderer.markAllDirty(); + this.territoryRenderer.refreshPalette(); + this.territoryRenderer.setHoverHighlightOptions( + this.hoverHighlightOptions(), + ); + this.territoryRenderer.setHoveredPlayerId( + this.highlightedTerritory?.smallID() ?? null, + ); + this.lastPaletteSignature = this.computePaletteSignature(); + } + + private hoverHighlightOptions() { + const baseColor = this.theme.playerHighlightColor(); + 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, + }; + } + + renderLayer(context: CanvasRenderingContext2D) { + if (!this.territoryRenderer) { + return; + } + const now = this.nowMs(); + if (this.tickSnapshotPending) { + this.territoryRenderer.snapshotStateForSmoothing(); + this.tickSnapshotPending = false; + } + this.updateInterpolationState(now); + + const renderTerritoryStart = FrameProfiler.start(); + this.territoryRenderer.setViewSize( + context.canvas.width, + context.canvas.height, + ); + const viewOffset = this.transformHandler.viewOffset(); + this.territoryRenderer.setViewTransform( + this.transformHandler.scale, + viewOffset.x, + viewOffset.y, + ); + this.territoryRenderer.render(); + FrameProfiler.end("TerritoryLayer:renderTerritory", renderTerritoryStart); + + const drawTerritoryStart = FrameProfiler.start(); + // Draw the WebGL territory in screen space; overlays still use world space. + context.save(); + context.setTransform(1, 0, 0, 1, 0, 0); + context.drawImage( + this.territoryRenderer.canvas, + 0, + 0, + context.canvas.width, + context.canvas.height, + ); + context.restore(); + FrameProfiler.end("TerritoryLayer:drawTerritoryCanvas", drawTerritoryStart); + + if (this.game.inSpawnPhase()) { + const highlightDrawStart = FrameProfiler.start(); + context.drawImage( + this.highlightCanvas, + -this.game.width() / 2, + -this.game.height() / 2, + this.game.width(), + this.game.height(), + ); + FrameProfiler.end( + "TerritoryLayer:drawHighlightCanvas", + highlightDrawStart, + ); + } + + if (DEBUG_TERRITORY_OVERLAY) { + const overlayStart = FrameProfiler.start(); + this.drawDebugOverlay(context); + FrameProfiler.end("TerritoryLayer:debugOverlay", overlayStart); + } + } + + private markTile(tile: TileRef) { + this.territoryRenderer?.markTile(tile); + } + + paintHighlightTile(tile: TileRef, color: Colord, alpha: number) { + const x = this.game.x(tile); + const y = this.game.y(tile); + this.highlightContext.fillStyle = color.alpha(alpha / 255).toRgbString(); + this.highlightContext.fillRect(x, y, 1, 1); + } + + clearHighlightTile(tile: TileRef) { + const x = this.game.x(tile); + const y = this.game.y(tile); + this.highlightContext.clearRect(x, y, 1, 1); + } + + private drawBreathingRing( + cx: number, + cy: number, + minRad: number, + maxRad: number, + radius: number, + transparentColor: Colord, + breathingColor: Colord, + ) { + const ctx = this.highlightContext; + if (!ctx) return; + + // Draw a semi-transparent ring around the starting location + ctx.beginPath(); + const transparent = transparentColor.alpha(0); + const radGrad = ctx.createRadialGradient(cx, cy, minRad, cx, cy, maxRad); + + radGrad.addColorStop(0, transparent.toRgbString()); + radGrad.addColorStop(0.01, transparentColor.toRgbString()); + radGrad.addColorStop(0.1, transparentColor.toRgbString()); + radGrad.addColorStop(1, transparent.toRgbString()); + + ctx.arc(cx, cy, maxRad, 0, Math.PI * 2); + ctx.fillStyle = radGrad; + ctx.closePath(); + ctx.fill(); + + const breatheInner = breathingColor.alpha(0); + ctx.beginPath(); + const radGrad2 = ctx.createRadialGradient(cx, cy, minRad, cx, cy, radius); + radGrad2.addColorStop(0, breatheInner.toRgbString()); + radGrad2.addColorStop(0.01, breathingColor.toRgbString()); + radGrad2.addColorStop(1, breathingColor.toRgbString()); + + ctx.arc(cx, cy, radius, 0, Math.PI * 2); + ctx.fillStyle = radGrad2; + ctx.fill(); + } + + private nowMs(): number { + return typeof performance !== "undefined" ? performance.now() : Date.now(); + } + + private ensureContestScratch() { + const size = this.game.width() * this.game.height(); + if (!this.contestComponentIds || this.contestComponentIds.length !== size) { + this.contestComponentIds = new Uint16Array(size); + this.contestPrevOwners = new Uint16Array(size); + this.contestAttackers = new Uint16Array(size); + this.contestTileIndices = new Int32Array(size); + this.contestTileIndices.fill(-1); + this.contestComponents.clear(); + this.contestFreeIds = []; + this.contestNextId = 1; + this.contestActive = false; + } + } + + private updateInterpolationState(now: number) { + if (!this.territoryRenderer) { + return; + } + + if (this.tickTimeMsPrev <= 0 || this.tickTimeMsCurrent <= 0) { + this.lastInterpolationPair = "prevCurrent"; + this.territoryRenderer.setInterpolationPair("prevCurrent"); + this.territoryRenderer.setSmoothProgress(1); + this.territoryRenderer.setSmoothEnabled(false); + return; + } + + const renderTime = now - this.interpolationDelayMs; + + let pair: "prevCurrent" | "olderPrev" = "prevCurrent"; + let fromTime = this.tickTimeMsPrev; + let toTime = this.tickTimeMsCurrent; + + if ( + this.tripleBufferEnabled && + this.tickTimeMsOlder > 0 && + renderTime < this.tickTimeMsPrev + ) { + pair = "olderPrev"; + fromTime = this.tickTimeMsOlder; + toTime = this.tickTimeMsPrev; + } + + // Use the real tick interval so interpolation duration scales with tick speed. + // The previous 250ms cap caused slow tick speeds (e.g. 0.5x) to finish animations early. + const denom = Math.max(1, toTime - fromTime); + const progress = Math.max(0, Math.min(1, (renderTime - fromTime) / denom)); + + this.lastInterpolationPair = pair; + this.territoryRenderer.setInterpolationPair(pair); + this.territoryRenderer.setSmoothProgress(progress); + this.territoryRenderer.setSmoothEnabled(true); + } + + private applyContestChanges( + changes: Array<{ tile: TileRef; previousOwner: number; newOwner: number }>, + nowTickPacked: number, + ) { + if (!this.territoryRenderer || changes.length === 0) { + return; + } + this.ensureContestScratch(); + + for (const change of changes) { + if (change.newOwner === change.previousOwner) { + continue; + } + const tile = change.tile; + const currentId = this.contestId(tile); + if (currentId === 0) { + this.startContestForTile( + tile, + change.previousOwner, + change.newOwner, + nowTickPacked, + ); + continue; + } + + const component = this.contestComponents.get(currentId); + if (!component) { + this.clearContestTile(tile); + this.startContestForTile( + tile, + change.previousOwner, + change.newOwner, + nowTickPacked, + ); + continue; + } + + if ( + change.newOwner === component.attacker || + change.newOwner === component.defender + ) { + const attackerEver = + change.newOwner === component.attacker || this.hasAttackerEver(tile); + this.setContestTileData( + tile, + component.defender, + component.attacker, + component.id, + attackerEver, + ); + component.lastActivityPacked = nowTickPacked; + this.territoryRenderer.setContestTime(component.id, nowTickPacked); + } else { + this.removeTileFromComponent(tile, component); + this.startContestForTile( + tile, + change.previousOwner, + change.newOwner, + nowTickPacked, + ); + } + } + } + + private updateContestStrengths() { + if (!this.territoryRenderer) { + return; + } + if (this.contestComponents.size === 0) { + return; + } + + const involvedIds = new Set(); + for (const component of this.contestComponents.values()) { + involvedIds.add(component.attacker); + involvedIds.add(component.defender); + } + const totalTroopsById = this.buildTotalTroopsLookup(involvedIds); + const attackTroopsById = this.buildAttackTroopsLookup(involvedIds); + + const pairStrength = new Map(); + for (const component of this.contestComponents.values()) { + const key = (component.attacker << 16) | component.defender; + let strength = pairStrength.get(key); + if (strength === undefined) { + strength = this.computeContestStrength( + component.attacker, + component.defender, + totalTroopsById, + attackTroopsById, + ); + pairStrength.set(key, strength); + } + component.strength = + component.strength * (1 - CONTEST_STRENGTH_EMA_ALPHA) + + strength * CONTEST_STRENGTH_EMA_ALPHA; + component.strength = Math.max( + CONTEST_STRENGTH_MIN, + Math.min(CONTEST_STRENGTH_MAX, component.strength), + ); + this.territoryRenderer.setContestStrength( + component.id, + component.strength, + ); + } + } + + private buildTotalTroopsLookup( + involvedIds: Set, + ): Map { + const totals = new Map(); + for (const id of involvedIds) { + const player = this.game.playerBySmallID(id); + if (player instanceof PlayerView) { + totals.set(id, player.troops()); + } + } + return totals; + } + + private buildAttackTroopsLookup( + involvedIds: Set, + ): Map> { + const totals = new Map>(); + for (const id of involvedIds) { + const player = this.game.playerBySmallID(id); + if (!(player instanceof PlayerView)) { + continue; + } + const outgoing = player.outgoingAttacks(); + if (outgoing.length === 0) { + continue; + } + for (const attack of outgoing) { + if (!involvedIds.has(attack.targetID)) { + continue; + } + let byTarget = totals.get(id); + if (!byTarget) { + byTarget = new Map(); + totals.set(id, byTarget); + } + byTarget.set( + attack.targetID, + (byTarget.get(attack.targetID) ?? 0) + attack.troops, + ); + } + } + return totals; + } + + private computeContestStrength( + attackerId: number, + defenderId: number, + totalTroopsById: Map, + attackTroopsById: Map>, + ) { + const attackerTroops = totalTroopsById.get(attackerId); + const defenderTroops = totalTroopsById.get(defenderId); + if (attackerTroops === undefined || defenderTroops === undefined) { + return 0.5; + } + + const attackerAttackTroops = + attackTroopsById.get(attackerId)?.get(defenderId) ?? 0; + const defenderAttackTroops = + attackTroopsById.get(defenderId)?.get(attackerId) ?? 0; + const attackerPower = attackerTroops + attackerAttackTroops; + const defenderPower = defenderTroops + defenderAttackTroops; + const totalPower = attackerPower + defenderPower; + if (totalPower <= 0) { + return 0.5; + } + return Math.max(0, Math.min(1, attackerPower / totalPower)); + } + + private updateContestState(nowTickPacked: number) { + if (!this.territoryRenderer) { + return; + } + this.ensureContestScratch(); + this.territoryRenderer.setContestNow( + nowTickPacked, + this.contestDurationTicks, + ); + + if (!this.contestActive) { + return; + } + + const expired: ContestComponent[] = []; + for (const component of this.contestComponents.values()) { + const elapsed = this.contestElapsed( + nowTickPacked, + component.lastActivityPacked, + ); + if (elapsed >= this.contestDurationTicks) { + expired.push(component); + } + } + + for (const component of expired) { + this.expireContestComponent(component); + } + } + + private startContestForTile( + tile: TileRef, + defender: number, + attacker: number, + nowTickPacked: number, + ): ContestComponent | null { + if (attacker === defender || attacker === 0 || defender === 0) { + return null; + } + const neighbors = this.collectNeighborComponents(tile, attacker, defender); + let component: ContestComponent; + if (neighbors.length === 0) { + component = this.createContestComponent( + attacker, + defender, + nowTickPacked, + ); + } else { + component = neighbors[0]; + for (let i = 1; i < neighbors.length; i++) { + this.mergeContestComponents(component, neighbors[i]); + } + } + + this.addTileToComponent(tile, component, true); + component.lastActivityPacked = nowTickPacked; + this.territoryRenderer?.setContestTime(component.id, nowTickPacked); + return component; + } + + private collectNeighborComponents( + tile: TileRef, + attacker: number, + defender: number, + ): ContestComponent[] { + const components: ContestComponent[] = []; + const seen = new Set(); + for (const neighbor of this.game.neighbors(tile)) { + const id = this.contestId(neighbor); + if (id === 0 || seen.has(id)) { + continue; + } + const component = this.contestComponents.get(id); + if (!component) { + continue; + } + if (component.attacker === attacker && component.defender === defender) { + components.push(component); + seen.add(id); + } + } + return components; + } + + private createContestComponent( + attacker: number, + defender: number, + nowTickPacked: number, + ): ContestComponent { + const id = this.allocateContestComponentId(); + const component: ContestComponent = { + id, + attacker, + defender, + lastActivityPacked: nowTickPacked, + tiles: [], + strength: 0.5, + }; + this.contestComponents.set(id, component); + this.contestActive = true; + this.territoryRenderer?.ensureContestTimeCapacity(id); + this.territoryRenderer?.setContestStrength(id, 0.5); + return component; + } + + private allocateContestComponentId(): number { + const reused = this.contestFreeIds.pop(); + if (reused !== undefined) { + return reused; + } + return this.contestNextId++; + } + + private releaseContestComponentId(id: number) { + if (id <= 0) { + return; + } + this.contestFreeIds.push(id); + } + + private addTileToComponent( + tile: TileRef, + component: ContestComponent, + attackerEver: boolean, + ) { + this.setContestTileData( + tile, + component.defender, + component.attacker, + component.id, + attackerEver, + ); + this.contestTileIndices![tile] = component.tiles.length; + component.tiles.push(tile); + this.contestActive = true; + } + + private removeTileFromComponent(tile: TileRef, component: ContestComponent) { + const tileIndex = this.contestTileIndices![tile]; + const tiles = component.tiles; + const lastIndex = tiles.length - 1; + if (tileIndex >= 0 && tileIndex <= lastIndex) { + if (tileIndex !== lastIndex) { + const swapTile = tiles[lastIndex]; + tiles[tileIndex] = swapTile; + this.contestTileIndices![swapTile] = tileIndex; + } + tiles.pop(); + } + this.contestTileIndices![tile] = -1; + this.clearContestTile(tile); + if (component.tiles.length === 0) { + this.territoryRenderer?.setContestStrength(component.id, 0); + this.contestComponents.delete(component.id); + this.releaseContestComponentId(component.id); + this.contestActive = this.contestComponents.size > 0; + } + } + + private mergeContestComponents( + target: ContestComponent, + source: ContestComponent, + ) { + const targetSize = target.tiles.length; + const sourceSize = source.tiles.length; + const totalSize = targetSize + sourceSize; + if (totalSize > 0) { + target.strength = Math.min( + 1, + (target.strength * targetSize + source.strength * sourceSize) / + totalSize, + ); + } + for (const tile of source.tiles) { + const attackerEver = this.hasAttackerEver(tile); + this.setContestTileData( + tile, + target.defender, + target.attacker, + target.id, + attackerEver, + ); + this.contestTileIndices![tile] = target.tiles.length; + target.tiles.push(tile); + } + target.lastActivityPacked = Math.max( + target.lastActivityPacked, + source.lastActivityPacked, + ); + this.territoryRenderer?.setContestTime( + target.id, + target.lastActivityPacked, + ); + this.contestComponents.delete(source.id); + this.territoryRenderer?.setContestStrength(source.id, 0); + this.releaseContestComponentId(source.id); + } + + private expireContestComponent(component: ContestComponent) { + for (const tile of component.tiles) { + this.contestTileIndices![tile] = -1; + this.clearContestTile(tile); + } + component.tiles.length = 0; + this.territoryRenderer?.setContestStrength(component.id, 0); + this.contestComponents.delete(component.id); + this.releaseContestComponentId(component.id); + this.contestActive = this.contestComponents.size > 0; + } + + private setContestTileData( + tile: TileRef, + defender: number, + attacker: number, + componentId: number, + attackerEver: boolean, + ) { + this.contestPrevOwners![tile] = defender; + this.contestAttackers![tile] = attacker; + this.contestComponentIds![tile] = + (componentId & CONTEST_ID_MASK) | + (attackerEver ? CONTEST_ATTACKER_EVER_BIT : 0); + this.territoryRenderer?.setContestTile( + tile, + defender, + attacker, + componentId, + attackerEver, + ); + } + + private clearContestTile(tile: TileRef) { + this.contestPrevOwners![tile] = 0; + this.contestAttackers![tile] = 0; + this.contestComponentIds![tile] = 0; + this.territoryRenderer?.clearContestTile(tile); + } + + private contestId(tile: TileRef): number { + return this.contestComponentIds![tile] & CONTEST_ID_MASK; + } + + private hasAttackerEver(tile: TileRef): boolean { + return (this.contestComponentIds![tile] & CONTEST_ATTACKER_EVER_BIT) !== 0; + } + + private packContestTick(tick: number): number { + return Math.floor(tick) % CONTEST_TIME_WRAP; + } + + private contestElapsed(nowPacked: number, startPacked: number): number { + if (nowPacked >= startPacked) { + return nowPacked - startPacked; + } + return CONTEST_TIME_WRAP - startPacked + nowPacked; + } + + private syncContestStateToRenderer() { + if (!this.territoryRenderer) { + return; + } + if (!this.contestComponentIds) { + return; + } + this.contestActive = this.contestComponents.size > 0; + let maxId = 0; + for (const component of this.contestComponents.values()) { + maxId = Math.max(maxId, component.id); + } + if (maxId > 0) { + this.territoryRenderer.ensureContestTimeCapacity(maxId); + this.territoryRenderer.ensureContestStrengthCapacity(maxId); + } + for (const component of this.contestComponents.values()) { + this.territoryRenderer.setContestTime( + component.id, + component.lastActivityPacked, + ); + this.territoryRenderer.setContestStrength( + component.id, + component.strength, + ); + for (const tile of component.tiles) { + const packed = this.contestComponentIds![tile]; + const attackerEver = (packed & CONTEST_ATTACKER_EVER_BIT) !== 0; + this.territoryRenderer.setContestTile( + tile, + component.defender, + component.attacker, + component.id, + attackerEver, + ); + } + } + } + + private computePaletteSignature(): string { + let maxSmallId = 0; + for (const player of this.game.playerViews()) { + maxSmallId = Math.max(maxSmallId, player.smallID()); + } + const patternsEnabled = this.userSettings.territoryPatterns(); + return `${this.game.playerViews().length}:${maxSmallId}:${patternsEnabled ? 1 : 0}`; + } + + private refreshPaletteIfNeeded() { + if (!this.territoryRenderer) { + return; + } + const signature = this.computePaletteSignature(); + if (signature !== this.lastPaletteSignature) { + this.lastPaletteSignature = signature; + this.territoryRenderer.refreshPalette(); + } + } + + private drawDebugOverlay(context: CanvasRenderingContext2D) { + if (!this.territoryRenderer) { + return; + } + const stats = this.territoryRenderer.getDebugStats(); + context.save(); + context.setTransform(1, 0, 0, 1, 0, 0); + context.font = "12px monospace"; + context.textBaseline = "top"; + const jfaStatus = stats.jfaSupported + ? "on" + : `off (${stats.jfaDisabledReason ?? "disabled"})`; + const lines = [ + `map: ${stats.mapWidth}x${stats.mapHeight}`, + `view: ${stats.viewWidth}x${stats.viewHeight}`, + `scale: ${stats.viewScale.toFixed(2)}`, + `offset: ${stats.viewOffsetX.toFixed(1)}, ${stats.viewOffsetY.toFixed(1)}`, + `smooth: ${stats.smoothEnabled ? "on" : "off"} ${stats.smoothProgress.toFixed(2)} pair ${this.lastInterpolationPair}`, + `tick: ${this.tickNumberCurrent ?? "-"} prev ${this.tickNumberPrev ?? "-"}`, + `delayMs: ${this.interpolationDelayMs.toFixed(0)}`, + `motionMode: ${this.motionMode}`, + `tripleBuf: ${this.tripleBufferEnabled ? "on" : "off"}`, + `delayMode: ${this.interpolationDelayMode}${this.interpolationDelayMode === "ema" ? ` (ema=${this.tickIntervalEmaMs.toFixed(0)}ms)` : ""}`, + `smoothPrereq: prevCopy ${stats.prevStateCopySupported ? "yes" : "no"}`, + `jfa: ${jfaStatus} dirty ${stats.jfaDirty ? "yes" : "no"}`, + `contests: ${this.contestEnabled ? "on" : "off"} comps ${this.contestComponents.size}`, + `contestPattern: ${this.contestedPatternMode}`, + `hideAllBorders: ${this.debugDisableAllBorders ? "yes" : "no"}`, + `hideStaticBorders: ${this.debugDisableStaticBorders ? "yes" : "no"}`, + `contestTiles: ${this.contestTileCount}`, + `contestTicks: ${this.contestDurationTicks}`, + `hovered: ${stats.hoveredPlayerId}`, + ]; + const padding = 6; + const lineHeight = 14; + let maxWidth = 0; + for (const line of lines) { + maxWidth = Math.max(maxWidth, context.measureText(line).width); + } + const width = Math.ceil(maxWidth + padding * 2); + const height = padding * 2 + lines.length * lineHeight; + context.fillStyle = "rgba(0, 0, 0, 0.6)"; + context.fillRect(10, 10, width, height); + context.fillStyle = "rgba(255, 255, 255, 0.9)"; + let y = 10 + padding; + for (const line of lines) { + context.fillText(line, 10 + padding, y); + y += lineHeight; + } + context.restore(); + } +} diff --git a/src/client/graphics/layers/WebGPUTerritoryBackend.ts b/src/client/graphics/layers/WebGPUTerritoryBackend.ts new file mode 100644 index 000000000..88d7b48de --- /dev/null +++ b/src/client/graphics/layers/WebGPUTerritoryBackend.ts @@ -0,0 +1,447 @@ +import { Theme } from "../../../core/configuration/Config"; +import { EventBus } from "../../../core/EventBus"; +import { UnitType } from "../../../core/game/Game"; +import { TileRef } from "../../../core/game/GameMap"; +import { GameView } from "../../../core/game/GameView"; +import { UserSettings } from "../../../core/game/UserSettings"; +import { + AlternateViewEvent, + MouseOverEvent, + WebGPUComputeMetricsEvent, +} from "../../InputHandler"; +import { FrameProfiler } from "../FrameProfiler"; +import { TransformHandler } from "../TransformHandler"; +import { + buildTerrainShaderParams, + readTerrainShaderId, +} from "../webgpu/render/TerrainShaderRegistry"; +import { + buildTerritoryPostSmoothingParams, + readTerritoryPostSmoothingId, +} from "../webgpu/render/TerritoryPostSmoothingRegistry"; +import { + buildTerritoryPreSmoothingParams, + readTerritoryPreSmoothingId, +} from "../webgpu/render/TerritoryPreSmoothingRegistry"; +import { + buildTerritoryShaderParams, + readTerritoryShaderId, +} from "../webgpu/render/TerritoryShaderRegistry"; +import { TerritoryRenderer } from "../webgpu/TerritoryRenderer"; +import { TerritoryBackend } from "./TerritoryBackend"; + +export class WebGPUTerritoryBackend implements TerritoryBackend { + readonly id = "webgpu"; + + profileName(): string { + return "WebGPUTerritoryBackend:renderLayer"; + } + + private attachedTerritoryCanvas: HTMLCanvasElement | null = null; + + private overlayWrapper: HTMLElement | null = null; + private overlayResizeObserver: ResizeObserver | null = null; + + private theme: Theme; + + private territoryRenderer: TerritoryRenderer | null = null; + private alternativeView = false; + + private lastPaletteSignature: string | null = null; + private lastDefensePostsSignature: string | null = null; + private lastTerrainShaderSignature: string | null = null; + private lastTerritoryShaderSignature: string | null = null; + private lastPreSmoothingSignature: string | null = null; + private lastPostSmoothingSignature: string | null = null; + + private lastMousePosition: { x: number; y: number } | null = null; + private hoveredOwnerSmallId: number | null = null; + private lastHoverUpdateMs = 0; + + constructor( + private game: GameView, + private eventBus: EventBus, + private transformHandler: TransformHandler, + private userSettings: UserSettings, + ) { + this.theme = game.config().theme(); + } + + shouldTransform(): boolean { + return true; + } + + init() { + this.eventBus.on(AlternateViewEvent, (e) => { + this.alternativeView = e.alternateView; + this.territoryRenderer?.setAlternativeView(this.alternativeView); + }); + this.eventBus.on(MouseOverEvent, (e) => { + this.lastMousePosition = { x: e.x, y: e.y }; + }); + this.redraw(); + } + + whenReady(): Promise { + return this.territoryRenderer?.whenReady() ?? Promise.resolve(false); + } + + getFailureReason(): string | null { + return this.territoryRenderer?.getFailureReason() ?? null; + } + + dispose() { + this.overlayResizeObserver?.disconnect(); + this.overlayResizeObserver = null; + this.attachedTerritoryCanvas?.remove(); + this.attachedTerritoryCanvas = null; + this.overlayWrapper = null; + this.territoryRenderer?.dispose(); + this.territoryRenderer = null; + } + + tick() { + const tickProfile = FrameProfiler.start(); + + const currentTheme = this.game.config().theme(); + if (currentTheme !== this.theme) { + this.theme = currentTheme; + this.territoryRenderer?.refreshTerrain(); + this.redraw(); + } + + this.refreshPaletteIfNeeded(); + this.refreshDefensePostsIfNeeded(); + this.applyTerrainShaderSettings(); + this.applyTerritoryShaderSettings(); + this.applyTerritorySmoothingSettings(); + + const updatedTiles = this.game.recentlyUpdatedTiles(); + for (let i = 0; i < updatedTiles.length; i++) { + this.markTile(updatedTiles[i]); + } + + // After collecting pending updates and handling palette/theme changes, + // invoke the renderer's tick() to process compute passes. This ensures + // compute shaders run at the simulation rate rather than every frame. + if (this.territoryRenderer) { + const start = performance.now(); + this.territoryRenderer.tick(); + const computeMs = performance.now() - start; + this.eventBus.emit(new WebGPUComputeMetricsEvent(computeMs)); + } + + FrameProfiler.end("TerritoryLayer:tick", tickProfile); + } + + redraw() { + this.configureRenderer(); + } + + private configureRenderer() { + this.territoryRenderer?.dispose(); + this.territoryRenderer = null; + + const { renderer, reason } = TerritoryRenderer.create( + this.game, + this.theme, + ); + if (!renderer) { + throw new Error(reason ?? "WebGPU is required for territory rendering."); + } + + this.territoryRenderer = renderer; + this.territoryRenderer.setAlternativeView(this.alternativeView); + this.territoryRenderer.setHighlightedOwnerId(this.hoveredOwnerSmallId); + this.applyTerrainShaderSettings(true); + this.applyTerritoryShaderSettings(true); + this.applyTerritorySmoothingSettings(true); + this.territoryRenderer.markAllDirty(); + this.territoryRenderer.refreshPalette(); + this.lastPaletteSignature = this.computePaletteSignature(); + + this.lastDefensePostsSignature = this.computeDefensePostsSignature(); + // Ensure defense posts buffer is uploaded on first tick. + this.territoryRenderer.markDefensePostsDirty(); + + // Run an initial tick to upload state and build the colour texture. Without + // this, the first render call may occur before the initial compute pass + // has been executed, resulting in undefined colours. + this.territoryRenderer.tick(); + } + + renderLayer(context: CanvasRenderingContext2D) { + if (!this.territoryRenderer) { + return; + } + + // Check for theme changes in renderLayer too (for when game is paused) + const currentTheme = this.game.config().theme(); + if (currentTheme !== this.theme) { + this.theme = currentTheme; + this.territoryRenderer.refreshTerrain(); + this.redraw(); + } + + // Apply user settings even while the game is paused (settings modal). + this.applyTerritoryShaderSettings(); + this.applyTerritorySmoothingSettings(); + + this.ensureTerritoryCanvasAttached(context.canvas); + this.updateHoverHighlight(); + + const renderTerritoryStart = FrameProfiler.start(); + this.territoryRenderer.setViewSize( + context.canvas.width, + context.canvas.height, + ); + const viewOffset = this.transformHandler.viewOffset(); + this.territoryRenderer.setViewTransform( + this.transformHandler.scale, + viewOffset.x, + viewOffset.y, + ); + this.territoryRenderer.render(); + FrameProfiler.end("TerritoryLayer:renderTerritory", renderTerritoryStart); + } + + private ensureTerritoryCanvasAttached(mainCanvas: HTMLCanvasElement) { + if (!this.territoryRenderer) { + return; + } + + const canvas = this.territoryRenderer.canvas; + + // If the renderer recreated its canvas, detach the old one. + if (this.attachedTerritoryCanvas !== canvas) { + this.attachedTerritoryCanvas?.remove(); + this.attachedTerritoryCanvas = canvas; + + // Configure overlay canvas styles once. Avoid per-frame style reads/writes. + canvas.style.pointerEvents = "none"; + canvas.style.position = "absolute"; + canvas.style.inset = "0"; + canvas.style.width = "100%"; + canvas.style.height = "100%"; + canvas.style.display = "block"; + } + + const parent = mainCanvas.parentElement; + if (!parent) { + // Fallback: if the canvas isn't in the DOM yet, append to body. + if (!canvas.isConnected) { + document.body.appendChild(canvas); + } + return; + } + + // Ensure the main canvas is wrapped in a positioned container so the + // territory canvas can overlay it without mirroring computed styles. + let wrapper: HTMLElement; + const currentParent = mainCanvas.parentElement; + if (currentParent && currentParent.dataset.territoryOverlay === "1") { + wrapper = currentParent; + } else { + wrapper = document.createElement("div"); + wrapper.dataset.territoryOverlay = "1"; + wrapper.style.position = "relative"; + wrapper.style.display = "inline-block"; + wrapper.style.lineHeight = "0"; + + // Replace mainCanvas with wrapper, then re-insert mainCanvas inside wrapper. + parent.replaceChild(wrapper, mainCanvas); + wrapper.appendChild(mainCanvas); + } + + if (this.overlayWrapper !== wrapper) { + this.overlayWrapper = wrapper; + this.overlayResizeObserver?.disconnect(); + this.overlayResizeObserver = new ResizeObserver(() => { + this.syncOverlayWrapperSize(mainCanvas, wrapper); + }); + this.overlayResizeObserver.observe(mainCanvas); + // Kick an initial size update; further updates are handled by ResizeObserver. + this.syncOverlayWrapperSize(mainCanvas, wrapper); + } + + // Ensure territory canvas is the first child so it's the lowest layer. + if (canvas.parentElement !== wrapper) { + canvas.remove(); + wrapper.insertBefore(canvas, mainCanvas); + } else if (canvas !== wrapper.firstElementChild) { + wrapper.insertBefore(canvas, mainCanvas); + } + } + + private syncOverlayWrapperSize( + mainCanvas: HTMLCanvasElement, + wrapper: HTMLElement, + ) { + // Ensure the wrapper has real layout size so the absolutely-positioned + // territory canvas (100% width/height) is non-zero even if the main canvas + // is positioned absolutely. + const rect = mainCanvas.getBoundingClientRect(); + const w = rect.width > 0 ? rect.width : mainCanvas.clientWidth; + const h = rect.height > 0 ? rect.height : mainCanvas.clientHeight; + if (w > 0) wrapper.style.width = `${w}px`; + if (h > 0) wrapper.style.height = `${h}px`; + } + + private markTile(tile: TileRef) { + this.territoryRenderer?.markTile(tile); + } + + private updateHoverHighlight() { + if (!this.territoryRenderer) { + return; + } + + const now = performance.now(); + if (now - this.lastHoverUpdateMs < 100) { + return; + } + this.lastHoverUpdateMs = now; + + let nextOwnerSmallId: number | null = null; + if (this.lastMousePosition) { + const cell = this.transformHandler.screenToWorldCoordinates( + this.lastMousePosition.x, + this.lastMousePosition.y, + ); + if (this.game.isValidCoord(cell.x, cell.y)) { + const tile = this.game.ref(cell.x, cell.y); + const owner = this.game.owner(tile); + if (owner && owner.isPlayer()) { + nextOwnerSmallId = owner.smallID(); + } + } + } + + if (nextOwnerSmallId === this.hoveredOwnerSmallId) { + return; + } + this.hoveredOwnerSmallId = nextOwnerSmallId; + this.territoryRenderer.setHighlightedOwnerId(nextOwnerSmallId); + } + + private computePaletteSignature(): string { + let maxSmallId = 0; + for (const player of this.game.playerViews()) { + maxSmallId = Math.max(maxSmallId, player.smallID()); + } + const patternsEnabled = this.userSettings.territoryPatterns(); + return `${this.game.playerViews().length}:${maxSmallId}:${patternsEnabled ? 1 : 0}`; + } + + private refreshPaletteIfNeeded() { + if (!this.territoryRenderer) { + return; + } + const signature = this.computePaletteSignature(); + if (signature !== this.lastPaletteSignature) { + this.lastPaletteSignature = signature; + this.territoryRenderer.refreshPalette(); + } + } + + private applyTerritoryShaderSettings(force: boolean = false) { + if (!this.territoryRenderer) { + return; + } + + const shaderId = readTerritoryShaderId(this.userSettings); + const { shaderPath, params0, params1 } = buildTerritoryShaderParams( + this.userSettings, + shaderId, + ); + + const signature = `${shaderPath}:${Array.from(params0).join(",")}:${Array.from(params1).join(",")}`; + if (!force && signature === this.lastTerritoryShaderSignature) { + return; + } + this.lastTerritoryShaderSignature = signature; + + this.territoryRenderer.setTerritoryShader(shaderPath); + this.territoryRenderer.setTerritoryShaderParams(params0, params1); + } + + private applyTerrainShaderSettings(force: boolean = false) { + if (!this.territoryRenderer) { + return; + } + + const terrainId = readTerrainShaderId(this.userSettings); + const { shaderPath, params0, params1 } = buildTerrainShaderParams( + this.userSettings, + terrainId, + ); + const signature = `${shaderPath}:${Array.from(params0).join(",")}:${Array.from(params1).join(",")}`; + if (!force && signature === this.lastTerrainShaderSignature) { + return; + } + this.lastTerrainShaderSignature = signature; + this.territoryRenderer.setTerrainShader(shaderPath); + this.territoryRenderer.setTerrainShaderParams(params0, params1); + } + + private applyTerritorySmoothingSettings(force: boolean = false) { + if (!this.territoryRenderer) { + return; + } + + const preId = readTerritoryPreSmoothingId(this.userSettings); + const preParams = buildTerritoryPreSmoothingParams( + this.userSettings, + preId, + ); + const preSignature = `${preId}:${Array.from(preParams.params0).join(",")}`; + if (force || preSignature !== this.lastPreSmoothingSignature) { + this.lastPreSmoothingSignature = preSignature; + this.territoryRenderer.setPreSmoothing( + preParams.enabled, + preParams.shaderPath, + preParams.params0, + ); + } + + const postId = readTerritoryPostSmoothingId(this.userSettings); + const postParams = buildTerritoryPostSmoothingParams( + this.userSettings, + postId, + ); + const postSignature = `${postId}:${Array.from(postParams.params0).join(",")}`; + if (force || postSignature !== this.lastPostSmoothingSignature) { + this.lastPostSmoothingSignature = postSignature; + this.territoryRenderer.setPostSmoothing( + postParams.enabled, + postParams.shaderPath, + postParams.params0, + ); + } + } + + private computeDefensePostsSignature(): string { + // Active + completed posts only. + const parts: string[] = []; + for (const u of this.game.units(UnitType.DefensePost)) { + if (!u.isActive() || u.isUnderConstruction()) continue; + const tile = u.tile(); + parts.push( + `${u.owner().smallID()},${this.game.x(tile)},${this.game.y(tile)}`, + ); + } + parts.sort(); + return parts.join("|"); + } + + private refreshDefensePostsIfNeeded() { + if (!this.territoryRenderer) { + return; + } + const signature = this.computeDefensePostsSignature(); + if (signature !== this.lastDefensePostsSignature) { + this.lastDefensePostsSignature = signature; + this.territoryRenderer.markDefensePostsDirty(); + } + } +} diff --git a/src/client/graphics/webgpu/TerritoryRenderer.ts b/src/client/graphics/webgpu/TerritoryRenderer.ts index c3b0f84e6..5e6d0d0bb 100644 --- a/src/client/graphics/webgpu/TerritoryRenderer.ts +++ b/src/client/graphics/webgpu/TerritoryRenderer.ts @@ -30,6 +30,7 @@ export class TerritoryRenderer { private resources: GroundTruthData | null = null; private ready = false; private initPromise: Promise | null = null; + private failureReason: string | null = null; private territoryShaderPath = "render/territory.wgsl"; private territoryShaderParams0 = new Float32Array(4); private territoryShaderParams1 = new Float32Array(4); @@ -99,15 +100,25 @@ export class TerritoryRenderer { private startInit(): void { if (this.initPromise) return; - this.initPromise = this.init(); + this.initPromise = this.init().catch((error) => { + this.ready = false; + this.failureReason = + error instanceof Error ? error.message : String(error); + console.warn("[TerritoryRenderer] WebGPU init failed", error); + }); } private async init(): Promise { const webgpuDevice = await WebGPUDevice.create(this.canvas); if (!webgpuDevice) { + this.failureReason = "WebGPU device initialization failed."; return; } this.device = webgpuDevice; + void webgpuDevice.device.lost.then((info) => { + this.ready = false; + this.failureReason = `WebGPU device lost: ${info.reason}`; + }); const state = this.game.tileStateView(); this.resources = GroundTruthData.create( @@ -182,6 +193,25 @@ export class TerritoryRenderer { this.ready = true; } + async whenReady(): Promise { + await this.initPromise; + return this.ready && this.failureReason === null; + } + + getFailureReason(): string | null { + return this.failureReason; + } + + dispose(): void { + this.ready = false; + try { + this.device?.device.destroy(); + } catch { + // Ignore device cleanup failures during renderer fallback. + } + this.canvas.remove(); + } + /** * Topological sort of passes based on dependencies. * Ensures passes run in the correct order. diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts index e0cacf57b..6b7e08c2d 100644 --- a/src/core/configuration/Config.ts +++ b/src/core/configuration/Config.ts @@ -205,6 +205,7 @@ export interface Theme { allyColor(): Colord; neutralColor(): Colord; enemyColor(): Colord; + playerHighlightColor(): Colord; spawnHighlightColor(): Colord; spawnHighlightSelfColor(): Colord; spawnHighlightTeamColor(): Colord; diff --git a/src/core/configuration/PastelTheme.ts b/src/core/configuration/PastelTheme.ts index 23ae4e653..354ca3dc8 100644 --- a/src/core/configuration/PastelTheme.ts +++ b/src/core/configuration/PastelTheme.ts @@ -35,6 +35,8 @@ export class PastelTheme implements Theme { /** Alternate View colors for enemies, red */ private _enemyColor = colord("rgb(255,0,0)"); + /** Hover highlight color for player territories */ + private _playerHighlightColor = colord("rgb(221, 221, 221)"); /** Default spawn highlight colors for other players in FFA, yellow */ private _spawnHighlightColor = colord("rgb(255,213,79)"); /** Added non-default spawn highlight colors for self, full white */ @@ -209,6 +211,9 @@ export class PastelTheme implements Theme { enemyColor(): Colord { return this._enemyColor; } + playerHighlightColor(): Colord { + return this._playerHighlightColor; + } spawnHighlightColor(): Colord { return this._spawnHighlightColor; diff --git a/src/core/configuration/PastelThemeDark.ts b/src/core/configuration/PastelThemeDark.ts index 2cff80685..d840f0fb6 100644 --- a/src/core/configuration/PastelThemeDark.ts +++ b/src/core/configuration/PastelThemeDark.ts @@ -8,6 +8,7 @@ export class PastelThemeDark extends PastelTheme { private darkWater = colord("rgb(14,11,30)"); private darkShorelineWater = colord("rgb(50,50,50)"); + private darkPlayerHighlight = colord("rgb(99, 42, 42)"); // | Terrain Type | Magnitude | Base Color Logic | Visual Description | // | :---------------- | :-------- | :---------------------------------------------- | :-------------------- | @@ -59,4 +60,8 @@ export class PastelThemeDark extends PastelTheme { }); } } + + playerHighlightColor(): Colord { + return this.darkPlayerHighlight; + } } diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts index 1d2c87763..349613319 100644 --- a/src/core/game/GameImpl.ts +++ b/src/core/game/GameImpl.ts @@ -692,6 +692,7 @@ export class GameImpl implements Game { owner._lastTileChange = this._ticks; this.updateBorders(tile); this._map.setFallout(tile, false); + this.updateDefendedStateForTileChange(tile, owner); this.recordTileUpdate(tile); } @@ -710,6 +711,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.recordTileUpdate(tile); } @@ -971,9 +975,18 @@ export class GameImpl implements Game { } } 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, searchRange: number, @@ -1254,6 +1267,49 @@ export class GameImpl implements Game { gold: goldCaptured, }); } + + 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.recordTileUpdate(tile); + } + } + } + } + + 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); + } + + 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/GameView.ts b/src/core/game/GameView.ts index 59a4b49e4..09d010cf3 100644 --- a/src/core/game/GameView.ts +++ b/src/core/game/GameView.ts @@ -669,6 +669,11 @@ export class GameView implements GameMap { private _units = new Map(); private updatedTiles: TileRef[] = []; private updatedTerrainTiles: TileRef[] = []; + private updatedOwnerChanges: Array<{ + tile: TileRef; + previousOwner: number; + newOwner: number; + }> = []; private _myPlayer: PlayerView | null = null; @@ -780,15 +785,25 @@ export class GameView implements GameMap { this.updatedTiles = []; this.updatedTerrainTiles = []; + this.updatedOwnerChanges = []; const packed = this.lastUpdate.packedTileUpdates; for (let i = 0; i + 1 < packed.length; i += 2) { const tile = packed[i]; const state = packed[i + 1]; + const previousOwner = this._map.ownerID(tile); const terrainChanged = this.updateTile(tile, state); this.updatedTiles.push(tile); if (terrainChanged) { this.updatedTerrainTiles.push(tile); } + const newOwner = this._map.ownerID(tile); + if (previousOwner !== newOwner) { + this.updatedOwnerChanges.push({ + tile, + previousOwner, + newOwner, + }); + } } if (gu.packedMotionPlans) { @@ -1107,6 +1122,14 @@ export class GameView implements GameMap { return this.updatedTerrainTiles; } + recentlyUpdatedOwnerTiles(): Array<{ + tile: TileRef; + previousOwner: number; + newOwner: number; + }> { + return this.updatedOwnerChanges; + } + nearbyUnits( tile: TileRef, searchRange: number, diff --git a/src/core/game/UnitImpl.ts b/src/core/game/UnitImpl.ts index 9444ed70b..ba9cda344 100644 --- a/src/core/game/UnitImpl.ts +++ b/src/core/game/UnitImpl.ts @@ -433,6 +433,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()); } } diff --git a/src/core/game/UserSettings.ts b/src/core/game/UserSettings.ts index 180dc67db..2454176fc 100644 --- a/src/core/game/UserSettings.ts +++ b/src/core/game/UserSettings.ts @@ -47,6 +47,12 @@ export const COLOR_KEY = "settings.territoryColor"; export const DARK_MODE_KEY = "settings.darkMode"; export const PERFORMANCE_OVERLAY_KEY = "settings.performanceOverlay"; export const KEYBINDS_KEY = "settings.keybinds"; +export const TERRITORY_RENDERER_KEY = "settings.territoryRenderer"; +export type TerritoryRendererPreference = + | "auto" + | "classic" + | "webgl" + | "webgpu"; export class UserSettings { private static cache = new Map(); @@ -154,7 +160,7 @@ export class UserSettings { } webgpuDebug(): boolean { - return this.get("settings.webgpuDebug", true); + return this.get("settings.webgpuDebug", false); } alertFrame() { @@ -197,6 +203,27 @@ export class UserSettings { return this.getInt("settings.territoryBorderMode", 1); } + territoryRenderer(): TerritoryRendererPreference { + const value = this.getString(TERRITORY_RENDERER_KEY, "auto"); + if ( + value === "auto" || + value === "classic" || + value === "webgl" || + value === "webgpu" + ) { + return value; + } + return "auto"; + } + + setTerritoryRenderer(value: string): void { + const renderer = + value === "classic" || value === "webgl" || value === "webgpu" + ? value + : "auto"; + this.setString(TERRITORY_RENDERER_KEY, renderer); + } + toggleAttackingTroopsOverlay() { this.setBool( "settings.attackingTroopsOverlay", diff --git a/tests/TerritoryBackendSelection.test.ts b/tests/TerritoryBackendSelection.test.ts new file mode 100644 index 000000000..b722f3b77 --- /dev/null +++ b/tests/TerritoryBackendSelection.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, test } from "vitest"; +import { + selectTerritoryBackend, + type TerritoryBackendCandidate, + type TerritoryRendererId, + type TerritoryRendererPreference, +} from "../src/client/graphics/layers/TerritoryBackend"; + +type FakeBackendSpec = { + initError?: string; + ready?: boolean; + failureReason?: string; +}; + +type FakeBackendSpecs = Partial>; + +class FakeBackend implements TerritoryBackendCandidate { + initialized = false; + disposed = false; + + constructor( + readonly id: TerritoryRendererId, + private readonly spec: FakeBackendSpec = {}, + ) {} + + init() { + this.initialized = true; + if (this.spec.initError) { + throw new Error(this.spec.initError); + } + } + + async whenReady(): Promise { + return this.spec.ready ?? true; + } + + getFailureReason(): string | null { + return this.spec.failureReason ?? null; + } + + dispose() { + this.disposed = true; + } +} + +class RendererSelectionHarness { + active: TerritoryRendererId | null = null; + readonly failed = new Set(); + preference: TerritoryRendererPreference; + + constructor(preference: TerritoryRendererPreference) { + this.preference = preference; + } + + setPreference(preference: TerritoryRendererPreference) { + this.preference = preference; + this.failed.clear(); + } + + async select(specs: FakeBackendSpecs = {}) { + const created: FakeBackend[] = []; + const selection = await selectTerritoryBackend( + this.preference, + this.failed, + (id) => { + const backend = new FakeBackend(id, specs[id]); + created.push(backend); + return backend; + }, + ); + + for (const failure of selection.failures) { + if (failure.id !== "classic") { + this.failed.add(failure.id); + } + } + if (selection.backend) { + this.active = selection.backend.id; + } + + return { ...selection, created }; + } + + async failActiveRuntime(specs: FakeBackendSpecs = {}) { + if (this.active && this.active !== "classic") { + this.failed.add(this.active); + } + return this.select(specs); + } +} + +describe("territory renderer backend selection", () => { + test("auto selects WebGPU when ready", async () => { + const harness = new RendererSelectionHarness("auto"); + + const result = await harness.select(); + + expect(result.backend?.id).toBe("webgpu"); + expect(harness.active).toBe("webgpu"); + expect(result.failures).toEqual([]); + expect(result.created.map((backend) => backend.id)).toEqual(["webgpu"]); + }); + + test("auto falls back to WebGL when WebGPU init fails", async () => { + const harness = new RendererSelectionHarness("auto"); + + const result = await harness.select({ + webgpu: { initError: "navigator.gpu unavailable" }, + }); + + expect(result.backend?.id).toBe("webgl"); + expect(harness.active).toBe("webgl"); + expect(result.failures.map((failure) => failure.id)).toEqual(["webgpu"]); + expect(result.created[0].disposed).toBe(true); + }); + + test("auto falls back to classic when both accelerated backends fail", async () => { + const harness = new RendererSelectionHarness("auto"); + + const result = await harness.select({ + webgpu: { initError: "navigator.gpu unavailable" }, + webgl: { failureReason: "WebGL2 unavailable" }, + }); + + expect(result.backend?.id).toBe("classic"); + expect(harness.active).toBe("classic"); + expect(result.failures.map((failure) => failure.id)).toEqual([ + "webgpu", + "webgl", + ]); + }); + + test("forced WebGPU falls back on runtime failure without changing saved setting", async () => { + const harness = new RendererSelectionHarness("webgpu"); + await harness.select(); + + const result = await harness.failActiveRuntime(); + + expect(result.backend?.id).toBe("webgl"); + expect(harness.active).toBe("webgl"); + expect(harness.preference).toBe("webgpu"); + expect(harness.failed.has("webgpu")).toBe(true); + }); + + test("manual setting change retries previously failed backends", async () => { + const harness = new RendererSelectionHarness("auto"); + await harness.select({ + webgpu: { initError: "navigator.gpu unavailable" }, + }); + + expect(harness.active).toBe("webgl"); + expect(harness.failed.has("webgpu")).toBe(true); + + harness.setPreference("auto"); + const retry = await harness.select(); + + expect(retry.backend?.id).toBe("webgpu"); + expect(harness.active).toBe("webgpu"); + expect(harness.failed.size).toBe(0); + }); +});