diff --git a/.gitignore b/.gitignore index 18fa251ae..9bceb8a7c 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ coverage/ TODO.txt resources/images/.DS_Store resources/.DS_Store +resources/certs/ .env* .DS_Store .clinic/ diff --git a/package.json b/package.json index 31fbc9083..c0a9e3803 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "start:server": "node --loader ts-node/esm --experimental-specifier-resolution=node src/server/Server.ts", "start:server-dev": "cross-env GAME_ENV=dev node --loader ts-node/esm --experimental-specifier-resolution=node src/server/Server.ts", "dev": "cross-env GAME_ENV=dev concurrently \"npm run start:client\" \"npm run start:server-dev\"", + "dev:secure": "cross-env GAME_ENV=dev DEV_HTTPS=1 DEV_CERT=resources/certs/dev.crt DEV_KEY=resources/certs/dev.key concurrently \"npm run start:client\" \"npm run start:server-dev\"", "dev:staging": "cross-env GAME_ENV=dev API_DOMAIN=api.openfront.dev concurrently \"npm run start:client\" \"npm run start:server-dev\"", "dev:prod": "cross-env GAME_ENV=dev API_DOMAIN=api.openfront.io concurrently \"npm run start:client\" \"npm run start:server-dev\"", "tunnel": "npm run build-prod && npm run start:server", diff --git a/src/client/InputHandler.ts b/src/client/InputHandler.ts index bf2510e4d..329e9344d 100644 --- a/src/client/InputHandler.ts +++ b/src/client/InputHandler.ts @@ -81,6 +81,21 @@ export class RefreshGraphicsEvent implements GameEvent {} export class TogglePerformanceOverlayEvent implements GameEvent {} +export class ToggleTerritoryWebGLEvent implements GameEvent {} + +export class TerritoryWebGLStatusEvent implements GameEvent { + constructor( + public readonly enabled: boolean, + public readonly active: boolean, + public readonly supported: boolean, + public readonly message?: string, + ) {} +} + +export class ToggleTerritoryWebGLDebugBordersEvent implements GameEvent { + constructor(public readonly enabled: boolean) {} +} + export class ToggleStructureEvent implements GameEvent { constructor(public readonly structureTypes: UnitType[] | null) {} } diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts index c99a46014..4b000e8a9 100644 --- a/src/client/graphics/GameRenderer.ts +++ b/src/client/graphics/GameRenderer.ts @@ -40,6 +40,7 @@ import { StructureLayer } from "./layers/StructureLayer"; import { TeamStats } from "./layers/TeamStats"; import { TerrainLayer } from "./layers/TerrainLayer"; import { TerritoryLayer } from "./layers/TerritoryLayer"; +import { TerritoryWebGLStatus } from "./layers/TerritoryWebGLStatus"; import { UILayer } from "./layers/UILayer"; import { UnitDisplay } from "./layers/UnitDisplay"; import { UnitLayer } from "./layers/UnitLayer"; @@ -223,6 +224,18 @@ export function createRenderer( performanceOverlay.eventBus = eventBus; performanceOverlay.userSettings = userSettings; + let territoryWebGLStatus = document.querySelector( + "territory-webgl-status", + ) as TerritoryWebGLStatus; + if (!(territoryWebGLStatus instanceof TerritoryWebGLStatus)) { + territoryWebGLStatus = document.createElement( + "territory-webgl-status", + ) as TerritoryWebGLStatus; + document.body.appendChild(territoryWebGLStatus); + } + territoryWebGLStatus.eventBus = eventBus; + territoryWebGLStatus.userSettings = userSettings; + const alertFrame = document.querySelector("alert-frame") as AlertFrame; if (!(alertFrame instanceof AlertFrame)) { console.error("alert frame not found"); @@ -240,6 +253,7 @@ export function createRenderer( // Try to group layers by the return value of shouldTransform. // Not grouping the layers may cause excessive calls to context.save() and context.restore(). const layers: Layer[] = [ + territoryWebGLStatus, new TerrainLayer(game, transformHandler), new TerritoryLayer(game, eventBus, transformHandler, userSettings), new RailroadLayer(game, transformHandler), @@ -412,7 +426,8 @@ export class GameRenderer { const layerStart = FrameProfiler.start(); layer.renderLayer?.(this.context); - FrameProfiler.end(layer.constructor?.name ?? "UnknownLayer", layerStart); + const name = layer.constructor?.name ?? "UnknownLayer"; + FrameProfiler.end(name, layerStart); } handleTransformState(false, isTransformActive); // Ensure context is clean after rendering this.transformHandler.resetChanged(); diff --git a/src/client/graphics/HoverTargetResolver.ts b/src/client/graphics/HoverTargetResolver.ts new file mode 100644 index 000000000..778c7cd88 --- /dev/null +++ b/src/client/graphics/HoverTargetResolver.ts @@ -0,0 +1,70 @@ +import { UnitType } from "../../core/game/Game"; +import { TileRef } from "../../core/game/GameMap"; +import { GameView, PlayerView, UnitView } from "../../core/game/GameView"; + +const HOVER_UNIT_TYPES: UnitType[] = [ + UnitType.Warship, + UnitType.TradeShip, + UnitType.TransportShip, +]; +const HOVER_DISTANCE_PX = 5; + +function euclideanDistWorld( + coord: { x: number; y: number }, + tileRef: TileRef, + game: GameView, +): number { + const x = game.x(tileRef); + const y = game.y(tileRef); + const dx = coord.x - x; + const dy = coord.y - y; + return Math.sqrt(dx * dx + dy * dy); +} + +function distSortUnitWorld( + coord: { x: number; y: number }, + game: GameView, +): (a: UnitView, b: UnitView) => number { + return (a, b) => { + const distA = euclideanDistWorld(coord, a.tile(), game); + const distB = euclideanDistWorld(coord, b.tile(), game); + return distA - distB; + }; +} + +export interface HoverTargetResolution { + player: PlayerView | null; + unit: UnitView | null; +} + +export function resolveHoverTarget( + game: GameView, + worldCoord: { x: number; y: number }, +): HoverTargetResolution { + if (!game.isValidCoord(worldCoord.x, worldCoord.y)) { + return { player: null, unit: null }; + } + const tile = game.ref(worldCoord.x, worldCoord.y); + + const owner = game.owner(tile); + if (owner && owner.isPlayer()) { + return { player: owner as PlayerView, unit: null }; + } + + if (game.isLand(tile)) { + return { player: null, unit: null }; + } + + const units = game + .units(...HOVER_UNIT_TYPES) + .filter( + (u) => euclideanDistWorld(worldCoord, u.tile(), game) < HOVER_DISTANCE_PX, + ) + .sort(distSortUnitWorld(worldCoord, game)); + + if (units.length > 0) { + return { player: units[0].owner(), unit: units[0] }; + } + + return { player: null, unit: null }; +} diff --git a/src/client/graphics/layers/BorderRenderer.ts b/src/client/graphics/layers/BorderRenderer.ts new file mode 100644 index 000000000..a2da016bb --- /dev/null +++ b/src/client/graphics/layers/BorderRenderer.ts @@ -0,0 +1,36 @@ +import { TileRef } from "../../../core/game/GameMap"; +import { PlayerView } from "../../../core/game/GameView"; + +export interface BorderRenderer { + setAlternativeView(enabled: boolean): void; + setHoveredPlayerId(playerSmallId: number | null): void; + drawsOwnBorders(): boolean; + + updateBorder( + tile: TileRef, + owner: PlayerView | null, + isBorder: boolean, + isDefended: boolean, + hasFallout: boolean, + ): void; + + clearTile(tile: TileRef): void; + + render(context: CanvasRenderingContext2D): void; +} + +export class NullBorderRenderer implements BorderRenderer { + drawsOwnBorders(): boolean { + return false; + } + + setAlternativeView() {} + + setHoveredPlayerId() {} + + updateBorder() {} + + clearTile() {} + + render() {} +} diff --git a/src/client/graphics/layers/PlayerInfoOverlay.ts b/src/client/graphics/layers/PlayerInfoOverlay.ts index 12fd07f87..4e062ebc3 100644 --- a/src/client/graphics/layers/PlayerInfoOverlay.ts +++ b/src/client/graphics/layers/PlayerInfoOverlay.ts @@ -15,10 +15,8 @@ import { PlayerProfile, PlayerType, Relation, - Unit, UnitType, } from "../../../core/game/Game"; -import { TileRef } from "../../../core/game/GameMap"; import { AllianceView } from "../../../core/game/GameUpdates"; import { GameView, PlayerView, UnitView } from "../../../core/game/GameView"; import { ContextMenuEvent, MouseMoveEvent } from "../../InputHandler"; @@ -28,31 +26,12 @@ import { renderTroops, translateText, } from "../../Utils"; +import { resolveHoverTarget } from "../HoverTargetResolver"; import { getFirstPlacePlayer, getPlayerIcons } from "../PlayerIcons"; import { TransformHandler } from "../TransformHandler"; import { Layer } from "./Layer"; import { CloseRadialMenuEvent } from "./RadialMenu"; -function euclideanDistWorld( - coord: { x: number; y: number }, - tileRef: TileRef, - game: GameView, -): number { - const x = game.x(tileRef); - const y = game.y(tileRef); - const dx = coord.x - x; - const dy = coord.y - y; - return Math.sqrt(dx * dx + dy * dy); -} - -function distSortUnitWorld(coord: { x: number; y: number }, game: GameView) { - return (a: Unit | UnitView, b: Unit | UnitView) => { - const distA = euclideanDistWorld(coord, a.tile(), game); - const distB = euclideanDistWorld(coord, b.tile(), game); - return distA - distB; - }; -} - @customElement("player-info-overlay") export class PlayerInfoOverlay extends LitElement implements Layer { @property({ type: Object }) @@ -115,27 +94,16 @@ export class PlayerInfoOverlay extends LitElement implements Layer { return; } - const tile = this.game.ref(worldCoord.x, worldCoord.y); - if (!tile) return; - - const owner = this.game.owner(tile); - - if (owner && owner.isPlayer()) { - this.player = owner as PlayerView; + const target = resolveHoverTarget(this.game, worldCoord); + if (target.player) { + this.player = target.player; this.player.profile().then((p) => { this.playerProfile = p; }); this.setVisible(true); - } else if (!this.game.isLand(tile)) { - const units = this.game - .units(UnitType.Warship, UnitType.TradeShip, UnitType.TransportShip) - .filter((u) => euclideanDistWorld(worldCoord, u.tile(), this.game) < 50) - .sort(distSortUnitWorld(worldCoord, this.game)); - - if (units.length > 0) { - this.unit = units[0]; - this.setVisible(true); - } + } else if (target.unit) { + this.unit = target.unit; + this.setVisible(true); } } diff --git a/src/client/graphics/layers/TerritoryBorderWebGL.ts b/src/client/graphics/layers/TerritoryBorderWebGL.ts new file mode 100644 index 000000000..96cfb5653 --- /dev/null +++ b/src/client/graphics/layers/TerritoryBorderWebGL.ts @@ -0,0 +1,896 @@ +import { Colord } from "colord"; +import { Theme } from "../../../core/configuration/Config"; +import { FrameProfiler } from "../FrameProfiler"; + +export enum TileRelation { + Unknown = 0, + Self = 1, + Friendly = 2, + Neutral = 3, + Enemy = 4, +} + +export interface BorderEdge { + startX: number; + startY: number; + endX: number; + endY: number; + color: Colord; + ownerSmallId: number; + relation: TileRelation; + flags: number; +} + +interface UniformLocations { + alternativeView: WebGLUniformLocation | null; + hoveredPlayerId: WebGLUniformLocation | null; + highlightStrength: WebGLUniformLocation | null; + highlightColor: WebGLUniformLocation | null; + hoverPulseStrength: WebGLUniformLocation | null; + hoverPulseSpeed: WebGLUniformLocation | null; + resolution: WebGLUniformLocation | null; + themeSelf: WebGLUniformLocation | null; + themeFriendly: WebGLUniformLocation | null; + themeNeutral: WebGLUniformLocation | null; + themeEnemy: WebGLUniformLocation | null; + time: WebGLUniformLocation | null; + debugPulse: WebGLUniformLocation | null; + hoverPulse: WebGLUniformLocation | null; +} + +export interface HoverHighlightOptions { + color?: Colord; + strength?: number; + pulseStrength?: number; + pulseSpeed?: number; +} + +export class TerritoryBorderWebGL { + private static readonly INITIAL_CHUNK_CAPACITY = 65536; // 256; + private static readonly MAX_EDGES_PER_TILE = 4; + private static readonly VERTICES_PER_EDGE = 2; + private static readonly MAX_VERTICES_PER_TILE = + TerritoryBorderWebGL.MAX_EDGES_PER_TILE * + TerritoryBorderWebGL.VERTICES_PER_EDGE; + private static readonly FLOATS_PER_VERTEX = 9; + private static readonly FLOATS_PER_TILE = + TerritoryBorderWebGL.MAX_VERTICES_PER_TILE * + TerritoryBorderWebGL.FLOATS_PER_VERTEX; + private static readonly STRIDE_BYTES = + TerritoryBorderWebGL.FLOATS_PER_VERTEX * 4; + + static create( + width: number, + height: number, + theme: Theme, + ): TerritoryBorderWebGL | null { + const span = FrameProfiler.start(); + const renderer = new TerritoryBorderWebGL(width, height, theme); + const result = renderer.isValid() ? renderer : null; + FrameProfiler.end("TerritoryBorderWebGL:create", span); + return result; + } + + public readonly canvas: HTMLCanvasElement; + + private readonly gl: WebGLRenderingContext | null; + private readonly program: WebGLProgram | null; + private readonly vertexBuffer: WebGLBuffer | null; + private vertexData: Float32Array; + private capacityChunks = TerritoryBorderWebGL.INITIAL_CHUNK_CAPACITY; + private usedChunks = 0; + private vertexCount = 0; + + private readonly tileToChunk = new Map(); + private readonly chunkToTile: number[] = []; + private readonly dirtyChunks: Set = new Set(); + private readonly uniforms: UniformLocations; + + private hoveredPlayerId = -1; + private alternativeView = false; + private needsRedraw = true; + private animationStartTime = Date.now(); + private debugPulseEnabled = false; + private hoverPulseEnabled = false; + private hoverHighlightStrength = 0.7; + private hoverHighlightColor: [number, number, number] = [1, 1, 1]; + private hoverPulseStrength = 0.25; + private hoverPulseSpeed = 6.28318; + + private constructor( + private readonly width: number, + private readonly height: number, + private readonly theme: Theme, + ) { + this.canvas = document.createElement("canvas"); + this.canvas.width = width; + this.canvas.height = height; + + this.gl = + (this.canvas.getContext("webgl", { + premultipliedAlpha: true, + antialias: false, + preserveDrawingBuffer: true, + }) as WebGLRenderingContext | null) ?? + (this.canvas.getContext("experimental-webgl", { + premultipliedAlpha: true, + antialias: false, + preserveDrawingBuffer: true, + }) as WebGLRenderingContext | null); + + this.vertexData = new Float32Array( + TerritoryBorderWebGL.INITIAL_CHUNK_CAPACITY * + TerritoryBorderWebGL.FLOATS_PER_TILE, + ); + // Debug: log initial capacity so we can tune INITIAL_CHUNK_CAPACITY. + // This will show up once per renderer creation. + + console.log( + "[TerritoryBorderWebGL] initial capacityChunks=", + this.capacityChunks, + "for map size", + `${this.width}x${this.height}`, + ); + + if (!this.gl) { + this.program = null; + this.vertexBuffer = null; + this.uniforms = { + alternativeView: null, + hoveredPlayerId: null, + highlightStrength: null, + highlightColor: null, + hoverPulseStrength: null, + hoverPulseSpeed: null, + resolution: null, + themeSelf: null, + themeFriendly: null, + themeNeutral: null, + themeEnemy: null, + time: null, + debugPulse: null, + hoverPulse: null, + }; + return; + } + + const gl = this.gl; + const vertexShaderSource = ` + precision mediump float; + attribute vec2 a_position; + attribute vec4 a_color; + attribute float a_owner; + attribute float a_relation; + attribute float a_flags; + + uniform vec2 u_resolution; + + varying vec4 v_color; + varying float v_owner; + varying float v_relation; + varying float v_flags; + + 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); + v_color = a_color; + v_owner = a_owner; + v_relation = a_relation; + v_flags = a_flags; + } + `; + const fragmentShaderSource = ` + precision mediump float; + + uniform bool u_alternativeView; + uniform float u_hoveredPlayerId; + uniform float u_highlightStrength; + uniform vec3 u_highlightColor; + uniform float u_hoverPulseStrength; + uniform float u_hoverPulseSpeed; + uniform vec4 u_themeSelf; + uniform vec4 u_themeFriendly; + uniform vec4 u_themeNeutral; + uniform vec4 u_themeEnemy; + uniform float u_time; + uniform bool u_debugPulse; + uniform bool u_hoverPulse; + + varying vec4 v_color; + varying float v_owner; + varying float v_relation; + varying float v_flags; + + vec4 relationColor(float relation) { + if (relation < 0.5) { + return u_themeNeutral; + } else if (relation < 1.5) { + return u_themeSelf; + } else if (relation < 2.5) { + return u_themeFriendly; + } else if (relation < 3.5) { + return u_themeNeutral; + } + return u_themeEnemy; + } + + vec3 rgbToHsl(vec3 c) { + float maxc = max(c.r, max(c.g, c.b)); + float minc = min(c.r, min(c.g, c.b)); + float h = 0.0; + float s = 0.0; + float l = (maxc + minc) * 0.5; + if (maxc != minc) { + float d = maxc - minc; + s = l > 0.5 ? d / (2.0 - maxc - minc) : d / (maxc + minc); + if (maxc == c.r) { + h = (c.g - c.b) / d + (c.g < c.b ? 6.0 : 0.0); + } else if (maxc == c.g) { + h = (c.b - c.r) / d + 2.0; + } else { + h = (c.r - c.g) / d + 4.0; + } + h /= 6.0; + } + return vec3(h, s, l); + } + + float hueToRgb(float p, float q, float t) { + if (t < 0.0) t += 1.0; + if (t > 1.0) t -= 1.0; + if (t < 1.0/6.0) return p + (q - p) * 6.0 * t; + if (t < 1.0/2.0) return q; + if (t < 2.0/3.0) return p + (q - p) * (2.0/3.0 - t) * 6.0; + return p; + } + + vec3 hslToRgb(vec3 hsl) { + float h = hsl.x; + float s = hsl.y; + float l = hsl.z; + float r; + float g; + float b; + if (s == 0.0) { + r = g = b = l; + } else { + float q = l < 0.5 ? l * (1.0 + s) : l + s - l * s; + float p = 2.0 * l - q; + r = hueToRgb(p, q, h + 1.0/3.0); + g = hueToRgb(p, q, h); + b = hueToRgb(p, q, h - 1.0/3.0); + } + return vec3(r, g, b); + } + + vec3 darken(vec3 rgb, float amount) { + vec3 hsl = rgbToHsl(rgb); + hsl.z = clamp(hsl.z - amount, 0.0, 1.0); + return hslToRgb(hsl); + } + + void main() { + if (v_color.a <= 0.0) { + discard; + } + + vec4 color = v_color; + float flags = v_flags; + bool isDefended = mod(flags, 2.0) >= 1.0; + flags = floor(flags / 2.0); + bool hasFriendly = mod(flags, 2.0) >= 1.0; + flags = floor(flags / 2.0); + bool hasEmbargo = mod(flags, 2.0) >= 1.0; + flags = floor(flags / 2.0); + bool lightTile = mod(flags, 2.0) >= 1.0; + + if (u_alternativeView) { + color = relationColor(v_relation); + color.a = 1.0; + } else { + // Relationship-based tinting (embargo -> red, friendly -> green) + if (hasEmbargo) { + color.rgb = mix(color.rgb, vec3(1.0, 0.0, 0.0), 0.35); + } else if (hasFriendly) { + color.rgb = mix(color.rgb, vec3(0.0, 1.0, 0.0), 0.35); + } + + // Defended checkerboard pattern using light/dark variants + if (isDefended) { + vec3 lightColor = darken(color.rgb, 0.2); + vec3 darkColor = darken(color.rgb, 0.4); + color.rgb = lightTile ? lightColor : darkColor; + } + } + + if ( + u_hoveredPlayerId >= 0.0 && + abs(v_owner - u_hoveredPlayerId) < 0.5 + ) { + float pulse = + u_hoverPulse + ? (1.0 - u_hoverPulseStrength) + + u_hoverPulseStrength * + (0.5 + 0.5 * sin(u_time * u_hoverPulseSpeed)) + : 1.0; + color.rgb = mix( + color.rgb, + u_highlightColor, + u_highlightStrength * pulse + ); + } + + // Optional blinking/pulsing effect to highlight WebGL-drawn borders. + // Enabled only when u_debugPulse is true. Pulses between 0.5 and 1.0 opacity + // using a smooth sine wave animation with ~1 second period. + if (u_debugPulse) { + float pulse = 0.75 + 0.25 * sin(u_time * 6.28318); // 2 * PI for full cycle + color.a *= pulse; + } + + gl_FragColor = color; + } + `; + + const vertexShader = this.compileShader( + gl.VERTEX_SHADER, + vertexShaderSource, + ); + const fragmentShader = this.compileShader( + gl.FRAGMENT_SHADER, + fragmentShaderSource, + ); + + this.program = this.createProgram(vertexShader, fragmentShader); + if (!this.program) { + this.vertexBuffer = null; + this.uniforms = { + alternativeView: null, + hoveredPlayerId: null, + highlightStrength: null, + highlightColor: null, + hoverPulseStrength: null, + hoverPulseSpeed: null, + resolution: null, + themeSelf: null, + themeFriendly: null, + themeNeutral: null, + themeEnemy: null, + time: null, + debugPulse: null, + hoverPulse: null, + }; + return; + } + + const program = this.program; + gl.useProgram(program); + + this.vertexBuffer = gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, this.vertexBuffer); + gl.bufferData(gl.ARRAY_BUFFER, this.vertexData, gl.DYNAMIC_DRAW); + + const positionLocation = gl.getAttribLocation(program, "a_position"); + const colorLocation = gl.getAttribLocation(program, "a_color"); + const ownerLocation = gl.getAttribLocation(program, "a_owner"); + const relationLocation = gl.getAttribLocation(program, "a_relation"); + const flagsLocation = gl.getAttribLocation(program, "a_flags"); + + gl.enableVertexAttribArray(positionLocation); + gl.vertexAttribPointer( + positionLocation, + 2, + gl.FLOAT, + false, + TerritoryBorderWebGL.STRIDE_BYTES, + 0, + ); + + gl.enableVertexAttribArray(colorLocation); + gl.vertexAttribPointer( + colorLocation, + 4, + gl.FLOAT, + false, + TerritoryBorderWebGL.STRIDE_BYTES, + 2 * 4, + ); + + gl.enableVertexAttribArray(ownerLocation); + gl.vertexAttribPointer( + ownerLocation, + 1, + gl.FLOAT, + false, + TerritoryBorderWebGL.STRIDE_BYTES, + 6 * 4, + ); + + gl.enableVertexAttribArray(relationLocation); + gl.vertexAttribPointer( + relationLocation, + 1, + gl.FLOAT, + false, + TerritoryBorderWebGL.STRIDE_BYTES, + 7 * 4, + ); + + gl.enableVertexAttribArray(flagsLocation); + gl.vertexAttribPointer( + flagsLocation, + 1, + gl.FLOAT, + false, + TerritoryBorderWebGL.STRIDE_BYTES, + 8 * 4, + ); + + this.uniforms = { + alternativeView: gl.getUniformLocation(program, "u_alternativeView"), + hoveredPlayerId: gl.getUniformLocation(program, "u_hoveredPlayerId"), + highlightStrength: gl.getUniformLocation(program, "u_highlightStrength"), + highlightColor: gl.getUniformLocation(program, "u_highlightColor"), + hoverPulseStrength: gl.getUniformLocation( + program, + "u_hoverPulseStrength", + ), + hoverPulseSpeed: gl.getUniformLocation(program, "u_hoverPulseSpeed"), + resolution: gl.getUniformLocation(program, "u_resolution"), + themeSelf: gl.getUniformLocation(program, "u_themeSelf"), + themeFriendly: gl.getUniformLocation(program, "u_themeFriendly"), + themeNeutral: gl.getUniformLocation(program, "u_themeNeutral"), + themeEnemy: gl.getUniformLocation(program, "u_themeEnemy"), + time: gl.getUniformLocation(program, "u_time"), + debugPulse: gl.getUniformLocation(program, "u_debugPulse"), + hoverPulse: gl.getUniformLocation(program, "u_hoverPulse"), + }; + + if (this.uniforms.hoveredPlayerId) { + gl.uniform1f(this.uniforms.hoveredPlayerId, -1); + } + if (this.uniforms.highlightStrength) { + gl.uniform1f( + this.uniforms.highlightStrength, + this.hoverHighlightStrength, + ); + } + if (this.uniforms.highlightColor) { + const [r, g, b] = this.hoverHighlightColor; + gl.uniform3f(this.uniforms.highlightColor, 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.resolution) { + gl.uniform2f(this.uniforms.resolution, this.width, this.height); + } + if (this.uniforms.hoverPulse) { + gl.uniform1i(this.uniforms.hoverPulse, 0); + } + this.applyThemeUniforms(); + + gl.enable(gl.BLEND); + gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); + gl.viewport(0, 0, width, height); + } + + isValid(): boolean { + return !!this.gl && !!this.program && !!this.vertexBuffer; + } + + setAlternativeView(enabled: boolean) { + if (this.alternativeView === enabled) { + return; + } + this.alternativeView = enabled; + this.needsRedraw = true; + } + + setHoveredPlayerId(playerSmallId: number | null) { + const encoded = playerSmallId ?? -1; + let changed = false; + if (this.hoveredPlayerId !== encoded) { + this.hoveredPlayerId = encoded; + changed = true; + } + const shouldPulse = playerSmallId !== null; + if (this.hoverPulseEnabled !== shouldPulse) { + this.hoverPulseEnabled = shouldPulse; + changed = true; + } + if (changed) { + this.needsRedraw = true; + } + } + + setHoverHighlightOptions(options: HoverHighlightOptions) { + if (options.strength !== undefined) { + this.hoverHighlightStrength = Math.max(0, Math.min(1, options.strength)); + } + if (options.color) { + const rgba = options.color.rgba; + this.hoverHighlightColor = [rgba.r / 255, rgba.g / 255, rgba.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); + } + this.needsRedraw = true; + } + + setDebugPulseEnabled(enabled: boolean) { + if (this.debugPulseEnabled === enabled) { + return; + } + this.debugPulseEnabled = enabled; + this.needsRedraw = true; + } + + clearTile(tileIndex: number) { + this.updateEdges(tileIndex, []); + } + + updateEdges(tileIndex: number, edges: BorderEdge[]) { + const span = FrameProfiler.start(); + + if (!this.gl || !this.vertexBuffer || !this.program) { + FrameProfiler.end( + "TerritoryBorderWebGL:updateEdges.noContextOrProgram", + span, + ); + return; + } + + if (edges.length === 0) { + const removeSpan = FrameProfiler.start(); + this.removeTileEdges(tileIndex); + FrameProfiler.end( + "TerritoryBorderWebGL:updateEdges.removeTileEdges", + removeSpan, + ); + FrameProfiler.end("TerritoryBorderWebGL:updateEdges.total", span); + return; + } + + let chunk = this.tileToChunk.get(tileIndex); + if (chunk === undefined) { + const addChunkSpan = FrameProfiler.start(); + chunk = this.addTileChunk(tileIndex); + FrameProfiler.end( + "TerritoryBorderWebGL:updateEdges.addTileChunk", + addChunkSpan, + ); + } + + const writeChunkSpan = FrameProfiler.start(); + this.writeChunk(chunk, edges); + FrameProfiler.end( + "TerritoryBorderWebGL:updateEdges.writeChunk", + writeChunkSpan, + ); + this.needsRedraw = true; + + FrameProfiler.end("TerritoryBorderWebGL:updateEdges.total", span); + } + + render() { + if (!this.gl || !this.program || !this.vertexBuffer) { + return; + } + if (this.dirtyChunks.size > 0) { + const uploadSpan = FrameProfiler.start(); + this.uploadDirtyChunks(); + FrameProfiler.end( + "TerritoryBorderWebGL:render.uploadDirtyChunks", + uploadSpan, + ); + this.needsRedraw = true; + } + + // Always redraw for animation, but check if we have anything to draw + if (!this.needsRedraw && this.vertexCount === 0) { + return; + } + + const gl = this.gl; + const span = FrameProfiler.start(); + gl.useProgram(this.program); + gl.bindBuffer(gl.ARRAY_BUFFER, this.vertexBuffer); + + if (this.uniforms.alternativeView) { + gl.uniform1i(this.uniforms.alternativeView, this.alternativeView ? 1 : 0); + } + if (this.uniforms.hoveredPlayerId) { + gl.uniform1f(this.uniforms.hoveredPlayerId, this.hoveredPlayerId); + } + + // Update time uniform for blinking animation + if (this.uniforms.time) { + const currentTime = (Date.now() - this.animationStartTime) / 1000.0; // Convert to seconds + gl.uniform1f(this.uniforms.time, currentTime); + } + + if (this.uniforms.debugPulse) { + gl.uniform1i(this.uniforms.debugPulse, this.debugPulseEnabled ? 1 : 0); + } + if (this.uniforms.hoverPulse) { + gl.uniform1i(this.uniforms.hoverPulse, this.hoverPulseEnabled ? 1 : 0); + } + if (this.uniforms.highlightStrength) { + gl.uniform1f( + this.uniforms.highlightStrength, + this.hoverHighlightStrength, + ); + } + if (this.uniforms.highlightColor) { + const [r, g, b] = this.hoverHighlightColor; + gl.uniform3f(this.uniforms.highlightColor, 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); + } + + const drawSpan = FrameProfiler.start(); + gl.clearColor(0, 0, 0, 0); + gl.clear(gl.COLOR_BUFFER_BIT); + if (this.vertexCount > 0) { + gl.drawArrays(gl.LINES, 0, this.vertexCount); + } + FrameProfiler.end("TerritoryBorderWebGL:render.draw", drawSpan); + + // Always mark as needing redraw for continuous animation + this.needsRedraw = true; + + FrameProfiler.end("TerritoryBorderWebGL:render.total", span); + } + + private addTileChunk(tileIndex: number): number { + const ensureSpan = FrameProfiler.start(); + this.ensureCapacity(this.usedChunks + 1); + FrameProfiler.end( + "TerritoryBorderWebGL:addTileChunk.ensureCapacity", + ensureSpan, + ); + const chunkIndex = this.usedChunks; + this.usedChunks++; + this.vertexCount = + this.usedChunks * TerritoryBorderWebGL.MAX_VERTICES_PER_TILE; + this.tileToChunk.set(tileIndex, chunkIndex); + this.chunkToTile[chunkIndex] = tileIndex; + return chunkIndex; + } + + private removeTileEdges(tileIndex: number) { + const span = FrameProfiler.start(); + + const chunk = this.tileToChunk.get(tileIndex); + if (chunk === undefined) { + FrameProfiler.end("TerritoryBorderWebGL:removeTileEdges.noChunk", span); + return; + } + const lastChunk = this.usedChunks - 1; + const lastTile = this.chunkToTile[lastChunk]; + + if (chunk !== lastChunk && lastTile !== undefined) { + const chunkFloats = TerritoryBorderWebGL.FLOATS_PER_TILE; + const destStart = chunk * chunkFloats; + const srcStart = lastChunk * chunkFloats; + this.vertexData.copyWithin(destStart, srcStart, srcStart + chunkFloats); + this.tileToChunk.set(lastTile, chunk); + this.chunkToTile[chunk] = lastTile; + this.dirtyChunks.add(chunk); + } + + this.tileToChunk.delete(tileIndex); + this.chunkToTile.length = Math.max(0, this.usedChunks - 1); + this.usedChunks = Math.max(0, this.usedChunks - 1); + this.vertexCount = + this.usedChunks * TerritoryBorderWebGL.MAX_VERTICES_PER_TILE; + this.needsRedraw = true; + + if (chunk === this.usedChunks) { + // Removed last chunk; nothing further to update. + FrameProfiler.end( + "TerritoryBorderWebGL:removeTileEdges.removedLastChunk", + span, + ); + return; + } + + FrameProfiler.end("TerritoryBorderWebGL:removeTileEdges.total", span); + } + + private writeChunk(chunk: number, edges: BorderEdge[]) { + const span = FrameProfiler.start(); + + const maxEdges = TerritoryBorderWebGL.MAX_EDGES_PER_TILE; + const floatsPerVertex = TerritoryBorderWebGL.FLOATS_PER_VERTEX; + const chunkFloats = TerritoryBorderWebGL.FLOATS_PER_TILE; + const start = chunk * chunkFloats; + const data = this.vertexData; + + let cursor = start; + let writtenVertices = 0; + + for (let i = 0; i < Math.min(edges.length, maxEdges); i++) { + const edge = edges[i]; + const color = edge.color.rgba; + const r = color.r / 255; + const g = color.g / 255; + const b = color.b / 255; + const a = color.a ?? 1; + const ownerId = edge.ownerSmallId; + const relation = edge.relation; + const flags = edge.flags; + + const vertices = [ + { x: edge.startX, y: edge.startY }, + { x: edge.endX, y: edge.endY }, + ]; + + for (const vertex of vertices) { + data[cursor] = vertex.x; + data[cursor + 1] = vertex.y; + data[cursor + 2] = r; + data[cursor + 3] = g; + data[cursor + 4] = b; + data[cursor + 5] = a; + data[cursor + 6] = ownerId; + data[cursor + 7] = relation; + data[cursor + 8] = flags; + cursor += floatsPerVertex; + writtenVertices++; + } + } + + const remainingVertices = + TerritoryBorderWebGL.MAX_VERTICES_PER_TILE - writtenVertices; + + for (let i = 0; i < remainingVertices; i++) { + data[cursor] = 0; + data[cursor + 1] = 0; + data[cursor + 2] = 0; + data[cursor + 3] = 0; + data[cursor + 4] = 0; + data[cursor + 5] = 0; + data[cursor + 6] = -1; + data[cursor + 7] = 0; + data[cursor + 8] = 0; + cursor += floatsPerVertex; + } + + this.dirtyChunks.add(chunk); + + FrameProfiler.end("TerritoryBorderWebGL:writeChunk", span); + } + + private uploadDirtyChunks() { + if (!this.gl || !this.vertexBuffer) { + return; + } + const gl = this.gl; + gl.bindBuffer(gl.ARRAY_BUFFER, this.vertexBuffer); + const chunkFloats = TerritoryBorderWebGL.FLOATS_PER_TILE; + for (const chunk of this.dirtyChunks) { + if (chunk >= this.usedChunks) { + continue; + } + const start = chunk * chunkFloats; + const view = this.vertexData.subarray(start, start + chunkFloats); + gl.bufferSubData(gl.ARRAY_BUFFER, start * 4, view); + } + this.dirtyChunks.clear(); + } + + private ensureCapacity(requiredChunks: number) { + if (requiredChunks <= this.capacityChunks) { + return; + } + const span = FrameProfiler.start(); + let nextCapacity = this.capacityChunks; + while (nextCapacity < requiredChunks) { + nextCapacity *= 2; + } + // Debug: log capacity growth so we can see typical ranges in real games. + + console.log( + "[TerritoryBorderWebGL] growing capacityChunks", + "from", + this.capacityChunks, + "to", + nextCapacity, + "requiredChunks=", + requiredChunks, + ); + const nextData = new Float32Array( + nextCapacity * TerritoryBorderWebGL.FLOATS_PER_TILE, + ); + nextData.set( + this.vertexData.subarray( + 0, + this.usedChunks * TerritoryBorderWebGL.FLOATS_PER_TILE, + ), + ); + this.vertexData = nextData; + this.capacityChunks = nextCapacity; + + if (this.gl && this.vertexBuffer) { + this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.vertexBuffer); + this.gl.bufferData( + this.gl.ARRAY_BUFFER, + this.vertexData, + this.gl.DYNAMIC_DRAW, + ); + this.dirtyChunks.clear(); + } + + FrameProfiler.end("TerritoryBorderWebGL:ensureCapacity.grow", span); + } + + private applyThemeUniforms() { + if (!this.gl || !this.program) return; + const gl = this.gl; + const toVec4 = (col: Colord) => { + const rgba = col.rgba; + return [rgba.r / 255, rgba.g / 255, rgba.b / 255, rgba.a ?? 1]; + }; + const setColor = (location: WebGLUniformLocation | null, col: Colord) => { + if (!location) return; + const vec = toVec4(col); + gl.uniform4f(location, vec[0], vec[1], vec[2], vec[3]); + }; + setColor(this.uniforms.themeSelf, this.theme.selfColor()); + setColor(this.uniforms.themeFriendly, this.theme.allyColor()); + setColor(this.uniforms.themeNeutral, this.theme.neutralColor()); + setColor(this.uniforms.themeEnemy, this.theme.enemyColor()); + } + + private compileShader(type: number, source: string): WebGLShader | null { + if (!this.gl) return null; + const shader = this.gl.createShader(type); + if (!shader) return null; + this.gl.shaderSource(shader, source); + this.gl.compileShader(shader); + if (!this.gl.getShaderParameter(shader, this.gl.COMPILE_STATUS)) { + console.error( + "TerritoryBorderWebGL shader error", + this.gl.getShaderInfoLog(shader), + ); + this.gl.deleteShader(shader); + return null; + } + return shader; + } + + private createProgram( + vertexShader: WebGLShader | null, + fragmentShader: WebGLShader | null, + ): WebGLProgram | null { + if (!this.gl || !vertexShader || !fragmentShader) return null; + const program = this.gl.createProgram(); + if (!program) return null; + this.gl.attachShader(program, vertexShader); + this.gl.attachShader(program, fragmentShader); + this.gl.linkProgram(program); + if (!this.gl.getProgramParameter(program, this.gl.LINK_STATUS)) { + console.error( + "TerritoryBorderWebGL link error", + this.gl.getProgramInfoLog(program), + ); + this.gl.deleteProgram(program); + return null; + } + return program; + } +} diff --git a/src/client/graphics/layers/TerritoryLayer.ts b/src/client/graphics/layers/TerritoryLayer.ts index 2e6fa2113..da3c35d82 100644 --- a/src/client/graphics/layers/TerritoryLayer.ts +++ b/src/client/graphics/layers/TerritoryLayer.ts @@ -16,12 +16,17 @@ import { UserSettings } from "../../../core/game/UserSettings"; import { PseudoRandom } from "../../../core/PseudoRandom"; import { AlternateViewEvent, + ContextMenuEvent, DragEvent, MouseOverEvent, + TerritoryWebGLStatusEvent, + ToggleTerritoryWebGLEvent, } from "../../InputHandler"; import { FrameProfiler } from "../FrameProfiler"; +import { resolveHoverTarget } from "../HoverTargetResolver"; import { TransformHandler } from "../TransformHandler"; import { Layer } from "./Layer"; +import { TerritoryWebGLRenderer } from "./TerritoryWebGLRenderer"; export class TerritoryLayer implements Layer { private userSettings: UserSettings; @@ -47,6 +52,7 @@ export class TerritoryLayer implements Layer { private highlightContext: CanvasRenderingContext2D; private highlightedTerritory: PlayerView | null = null; + private territoryRenderer: TerritoryWebGLRenderer | null = null; private alternativeView = false; private lastDragTime = 0; @@ -57,6 +63,9 @@ export class TerritoryLayer implements Layer { private lastRefresh = 0; private lastFocusedPlayer: PlayerView | null = null; + private lastMyPlayerSmallId: number | null = null; + private useWebGL: boolean; + private webglSupported = true; constructor( private game: GameView, @@ -67,6 +76,8 @@ export class TerritoryLayer implements Layer { this.userSettings = userSettings; this.theme = game.config().theme(); this.cachedTerritoryPatternsEnabled = undefined; + this.lastMyPlayerSmallId = game.myPlayer()?.smallID() ?? null; + this.useWebGL = this.userSettings.territoryWebGL(); } shouldTransform(): boolean { @@ -88,6 +99,11 @@ export class TerritoryLayer implements Layer { this.game.recentlyUpdatedTiles().forEach((t) => this.enqueueTile(t)); const updates = this.game.updatesSinceLastTick(); const unitUpdates = updates !== null ? updates[GameUpdateType.Unit] : []; + const playerUpdates = + updates !== null ? updates[GameUpdateType.Player] : []; + if (playerUpdates.length > 0) { + this.territoryRenderer?.refreshPalette(); + } unitUpdates.forEach((update) => { if (update.unitType === UnitType.DefensePost) { // Only update borders if the defense post is not under construction @@ -153,14 +169,21 @@ export class TerritoryLayer implements Layer { const focusedPlayer = this.game.focusedPlayer(); if (focusedPlayer !== this.lastFocusedPlayer) { - if (this.lastFocusedPlayer) { - this.paintPlayerBorder(this.lastFocusedPlayer); - } - if (focusedPlayer) { - this.paintPlayerBorder(focusedPlayer); + if (!this.territoryRenderer) { + if (this.lastFocusedPlayer) { + this.paintPlayerBorder(this.lastFocusedPlayer); + } + if (focusedPlayer) { + this.paintPlayerBorder(focusedPlayer); + } } this.lastFocusedPlayer = focusedPlayer; } + + const currentMyPlayer = this.game.myPlayer()?.smallID() ?? null; + if (currentMyPlayer !== this.lastMyPlayerSmallId) { + this.redraw(); + } } private spawnHighlight() { @@ -267,8 +290,18 @@ export class TerritoryLayer implements Layer { init() { this.eventBus.on(MouseOverEvent, (e) => this.onMouseOver(e)); + this.eventBus.on(ContextMenuEvent, (e) => this.onMouseOver(e)); this.eventBus.on(AlternateViewEvent, (e) => { this.alternativeView = e.alternateView; + this.territoryRenderer?.setAlternativeView(this.alternativeView); + this.territoryRenderer?.setHoverHighlightOptions( + this.hoverHighlightOptions(), + ); + }); + this.eventBus.on(ToggleTerritoryWebGLEvent, () => { + this.userSettings.toggleTerritoryWebGL(); + this.useWebGL = this.userSettings.territoryWebGL(); + this.redraw(); }); this.eventBus.on(DragEvent, (e) => { // TODO: consider re-enabling this on mobile or low end devices for smoother dragging. @@ -283,7 +316,9 @@ export class TerritoryLayer implements Layer { } private updateHighlightedTerritory() { - if (!this.alternativeView) { + const supportsHover = + this.alternativeView || this.territoryRenderer !== null; + if (!supportsHover) { return; } @@ -300,7 +335,7 @@ export class TerritoryLayer implements Layer { } const previousTerritory = this.highlightedTerritory; - const territory = this.getTerritoryAtCell(cell); + const territory = resolveHoverTarget(this.game, cell).player; if (territory) { this.highlightedTerritory = territory; @@ -309,32 +344,26 @@ export class TerritoryLayer implements Layer { } if (previousTerritory?.id() !== this.highlightedTerritory?.id()) { - const territories: PlayerView[] = []; - if (previousTerritory) { - territories.push(previousTerritory); + if (this.territoryRenderer) { + this.territoryRenderer.setHoveredPlayerId( + this.highlightedTerritory?.smallID() ?? null, + ); + } else { + const territories: PlayerView[] = []; + if (previousTerritory) { + territories.push(previousTerritory); + } + if (this.highlightedTerritory) { + territories.push(this.highlightedTerritory); + } + this.redrawBorder(...territories); } - if (this.highlightedTerritory) { - territories.push(this.highlightedTerritory); - } - this.redrawBorder(...territories); } } - 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.lastMyPlayerSmallId = this.game.myPlayer()?.smallID() ?? null; this.canvas = document.createElement("canvas"); const context = this.canvas.getContext("2d"); if (context === null) throw new Error("2d context not supported"); @@ -362,6 +391,8 @@ export class TerritoryLayer implements Layer { 0, ); + this.configureRenderers(); + // Add a second canvas for highlights this.highlightCanvas = document.createElement("canvas"); const highlightContext = this.highlightCanvas.getContext("2d", { @@ -377,6 +408,90 @@ export class TerritoryLayer implements Layer { }); } + private configureRenderers() { + this.territoryRenderer = null; + + if (!this.useWebGL) { + this.webglSupported = true; + this.emitWebGLStatus( + false, + false, + this.webglSupported, + "WebGL territory layer hidden.", + ); + return; + } + + const { renderer, reason } = TerritoryWebGLRenderer.create( + this.game, + this.theme, + ); + this.territoryRenderer = renderer; + if (this.territoryRenderer) { + this.territoryRenderer.setAlternativeView(this.alternativeView); + this.territoryRenderer.markAllDirty(); + this.territoryRenderer.refreshPalette(); + this.territoryRenderer.setHoverHighlightOptions( + this.hoverHighlightOptions(), + ); + this.territoryRenderer.setHoveredPlayerId( + this.highlightedTerritory?.smallID() ?? null, + ); + } + + const supported = this.territoryRenderer !== null; + const active = this.territoryRenderer !== null; + const fallbackReason = + reason ?? + "WebGL not available. Using canvas fallback for borders and fill."; + + this.webglSupported = supported; + this.emitWebGLStatus( + true, + active, + supported, + active ? undefined : fallbackReason, + ); + } + + /** + * Central configuration for WebGL border hover styling. + * Keeps main view and alternate view behavior explicit and tweakable. + */ + private hoverHighlightOptions() { + const baseColor = this.theme.spawnHighlightSelfColor(); + const rgba = baseColor.rgba; + + if (this.alternativeView) { + // Alternate view: borders are the primary visual, so make hover stronger + return { + color: { r: rgba.r, g: rgba.g, b: rgba.b }, + strength: 0.8, + pulseStrength: 0.45, + pulseSpeed: Math.PI * 2, + }; + } + + // Main view: keep highlight noticeable but a bit subtler + return { + color: { r: rgba.r, g: rgba.g, b: rgba.b }, + strength: 0.6, + pulseStrength: 0.35, + pulseSpeed: Math.PI * 2, + }; + } + + private emitWebGLStatus( + enabled: boolean, + active: boolean, + supported: boolean, + message?: string, + ) { + this.eventBus.emit( + new TerritoryWebGLStatusEvent(enabled, active, supported, message), + ); + } + redrawBorder(...players: PlayerView[]) { return Promise.all( players.map(async (player) => { @@ -400,6 +515,9 @@ export class TerritoryLayer implements Layer { renderLayer(context: CanvasRenderingContext2D) { const now = Date.now(); + const gpuTerritoryActive = this.territoryRenderer !== null; + const skipTerritoryCanvas = gpuTerritoryActive; + if ( now > this.lastDragTime + this.nodrawDragDuration && now > this.lastRefresh + this.refreshRate @@ -418,7 +536,13 @@ export class TerritoryLayer implements Layer { const w = vx1 - vx0 + 1; const h = vy1 - vy0 + 1; - if (w > 0 && h > 0) { + // When WebGL borders are active and we're in alternative view, the 2D + // territory buffer (alternativeImageData) is effectively transparent and + // all visible work is done by the WebGL layer. Skip putImageData in that + // case to avoid unnecessary CPU work each frame. + const shouldBlitTerritories = !skipTerritoryCanvas && !gpuTerritoryActive; + + if (w > 0 && h > 0 && shouldBlitTerritories) { const putImageStart = FrameProfiler.start(); this.context.putImageData( this.alternativeView ? this.alternativeImageData : this.imageData, @@ -433,15 +557,37 @@ export class TerritoryLayer implements Layer { } } - 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 (gpuTerritoryActive) { + const webglRenderStart = FrameProfiler.start(); + this.territoryRenderer?.render(); + FrameProfiler.end( + "TerritoryLayer:territoryWebGL.render", + webglRenderStart, + ); + const drawCanvasStart = FrameProfiler.start(); + context.drawImage( + this.territoryRenderer!.canvas, + -this.game.width() / 2, + -this.game.height() / 2, + this.game.width(), + this.game.height(), + ); + FrameProfiler.end( + "TerritoryLayer:territoryWebGL.drawImage", + drawCanvasStart, + ); + } else if (!skipTerritoryCanvas) { + const drawCanvasStart = FrameProfiler.start(); + context.drawImage( + this.canvas, + -this.game.width() / 2, + -this.game.height() / 2, + this.game.width(), + this.game.height(), + ); + FrameProfiler.end("TerritoryLayer:drawCanvas", drawCanvasStart); + } + if (this.game.inSpawnPhase()) { const highlightDrawStart = FrameProfiler.start(); context.drawImage( @@ -475,63 +621,71 @@ export class TerritoryLayer implements Layer { const tile = entry.tile; this.paintTerritory(tile); for (const neighbor of this.game.neighbors(tile)) { - this.paintTerritory(neighbor, true); + this.paintTerritory(neighbor, true); //this is a misuse of the _Border parameter, making it a maybe stale border } } } - 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( + paintTerritory(tile: TileRef, _maybeStaleBorder: boolean = false) { + const cpuStart = FrameProfiler.start(); + const useGpuTerritory = this.territoryRenderer !== null; + const hasOwner = this.game.hasOwner(tile); + const owner = hasOwner ? (this.game.owner(tile) as PlayerView) : null; + const isBorderTile = this.game.isBorder(tile); + const hasFallout = this.game.hasFallout(tile); + let isDefended = false; + if (owner && isBorderTile) { + isDefended = this.game.hasUnitNearby( tile, this.game.config().defensePostRange(), UnitType.DefensePost, owner.id(), ); - - 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); } + + if (useGpuTerritory) { + this.territoryRenderer?.markTile(tile); + } else { + if (!owner) { + if (hasFallout) { + this.paintTile(this.imageData, tile, this.theme.falloutColor(), 150); + this.paintTile( + this.alternativeImageData, + tile, + this.theme.falloutColor(), + 150, + ); + } else { + this.clearTile(tile); + } + } else { + const myPlayer = this.game.myPlayer(); + + if (isBorderTile) { + if (myPlayer) { + const alternativeColor = this.alternateViewColor(owner); + this.paintTile( + this.alternativeImageData, + tile, + alternativeColor, + 255, + ); + } + this.paintTile( + this.imageData, + tile, + owner.borderColor(tile, isDefended), + 255, + ); + } else { + // Alternative view only shows borders. + this.clearAlternativeTile(tile); + + this.paintTile(this.imageData, tile, owner.territoryColor(tile), 150); + } + } + } + FrameProfiler.end("TerritoryLayer:paintTerritory.cpu", cpuStart); } alternateViewColor(other: PlayerView): Colord { diff --git a/src/client/graphics/layers/TerritoryWebGLRenderer.ts b/src/client/graphics/layers/TerritoryWebGLRenderer.ts new file mode 100644 index 000000000..5e5720a28 --- /dev/null +++ b/src/client/graphics/layers/TerritoryWebGLRenderer.ts @@ -0,0 +1,742 @@ +import { Theme } from "../../../core/configuration/Config"; +import { TileRef } from "../../../core/game/GameMap"; +import { GameView, PlayerView } from "../../../core/game/GameView"; +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; +} + +/** + * WebGL2 territory renderer that reads the shared tile state buffer + * (SharedArrayBuffer) and shades tiles via a small palette texture. + * Borders are still drawn by the dedicated border renderer; this class + * only fills territory / fallout tiles. + */ +export class TerritoryWebGLRenderer { + public readonly canvas: HTMLCanvasElement; + + private readonly gl: WebGL2RenderingContext | null; + private readonly program: WebGLProgram | null; + private readonly vao: WebGLVertexArrayObject | null; + private readonly vertexBuffer: WebGLBuffer | null; + private readonly stateTexture: WebGLTexture | null; + private readonly paletteTexture: WebGLTexture | null; + private readonly relationTexture: WebGLTexture | null; + private readonly uniforms: { + resolution: WebGLUniformLocation | null; + state: WebGLUniformLocation | null; + palette: WebGLUniformLocation | null; + relations: 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; + }; + + private readonly state: Uint16Array; + private readonly dirtyRows: Map = new Map(); + private needsFullUpload = true; + private alternativeView = false; + private paletteWidth = 0; + private hoverHighlightStrength = 0.7; + private hoverHighlightColor: [number, number, number] = [1, 1, 1]; + private hoverPulseStrength = 0.25; + private hoverPulseSpeed = Math.PI * 2; + private hoveredPlayerId = -1; + private animationStartTime = Date.now(); + + private constructor( + private readonly game: GameView, + private readonly theme: Theme, + sharedState: SharedArrayBuffer, + ) { + this.canvas = document.createElement("canvas"); + this.canvas.width = game.width(); + this.canvas.height = game.height(); + + this.state = new Uint16Array(sharedState); + + this.gl = this.canvas.getContext("webgl2", { + premultipliedAlpha: true, + antialias: false, + preserveDrawingBuffer: true, + }); + + if (!this.gl) { + this.program = null; + this.vao = null; + this.vertexBuffer = null; + this.stateTexture = null; + this.paletteTexture = null; + this.relationTexture = null; + this.uniforms = { + resolution: null, + state: null, + palette: null, + relations: 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, + }; + return; + } + + const gl = this.gl; + this.program = this.createProgram(gl); + if (!this.program) { + this.vao = null; + this.vertexBuffer = null; + this.stateTexture = null; + this.paletteTexture = null; + this.relationTexture = null; + this.uniforms = { + resolution: null, + state: null, + palette: null, + relations: 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, + }; + return; + } + + this.uniforms = { + resolution: gl.getUniformLocation(this.program, "u_resolution"), + state: gl.getUniformLocation(this.program, "u_state"), + palette: gl.getUniformLocation(this.program, "u_palette"), + relations: gl.getUniformLocation(this.program, "u_relations"), + 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"), + }; + + // Vertex data: two triangles covering the full map (pixel-perfect). + const vertices = new Float32Array([ + 0, + 0, + this.canvas.width, + 0, + 0, + this.canvas.height, + 0, + this.canvas.height, + this.canvas.width, + 0, + this.canvas.width, + this.canvas.height, + ]); + + this.vao = gl.createVertexArray(); + this.vertexBuffer = gl.createBuffer(); + gl.bindVertexArray(this.vao); + gl.bindBuffer(gl.ARRAY_BUFFER, this.vertexBuffer); + gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW); + + const posLoc = gl.getAttribLocation(this.program, "a_position"); + gl.enableVertexAttribArray(posLoc); + gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 2 * 4, 0); + gl.bindVertexArray(null); + + this.stateTexture = gl.createTexture(); + this.paletteTexture = gl.createTexture(); + this.relationTexture = gl.createTexture(); + + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, this.stateTexture); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1); + gl.texImage2D( + gl.TEXTURE_2D, + 0, + gl.R16UI, + this.canvas.width, + this.canvas.height, + 0, + gl.RED_INTEGER, + gl.UNSIGNED_SHORT, + this.state, + ); + + this.uploadPalette(); + + gl.useProgram(this.program); + gl.uniform1i(this.uniforms.state, 0); + gl.uniform1i(this.uniforms.palette, 1); + gl.uniform1i(this.uniforms.relations, 2); + + if (this.uniforms.resolution) { + gl.uniform2f( + this.uniforms.resolution, + this.canvas.width, + this.canvas.height, + ); + } + if (this.uniforms.alpha) { + gl.uniform1f(this.uniforms.alpha, 150 / 255); + } + if (this.uniforms.fallout) { + const f = this.theme.falloutColor().rgba; + gl.uniform4f( + this.uniforms.fallout, + f.r / 255, + f.g / 255, + f.b / 255, + f.a ?? 1, + ); + } + if (this.uniforms.altSelf) { + const c = this.theme.selfColor().rgba; + gl.uniform4f( + this.uniforms.altSelf, + c.r / 255, + c.g / 255, + c.b / 255, + c.a ?? 1, + ); + } + if (this.uniforms.altAlly) { + const c = this.theme.allyColor().rgba; + gl.uniform4f( + this.uniforms.altAlly, + c.r / 255, + c.g / 255, + c.b / 255, + c.a ?? 1, + ); + } + if (this.uniforms.altNeutral) { + const c = this.theme.neutralColor().rgba; + gl.uniform4f( + this.uniforms.altNeutral, + c.r / 255, + c.g / 255, + c.b / 255, + c.a ?? 1, + ); + } + if (this.uniforms.altEnemy) { + const c = this.theme.enemyColor().rgba; + gl.uniform4f( + this.uniforms.altEnemy, + c.r / 255, + c.g / 255, + c.b / 255, + c.a ?? 1, + ); + } + if (this.uniforms.alternativeView) { + gl.uniform1i(this.uniforms.alternativeView, 0); + } + if (this.uniforms.hoveredPlayerId) { + gl.uniform1f(this.uniforms.hoveredPlayerId, -1); + } + if (this.uniforms.hoverHighlightStrength) { + gl.uniform1f( + this.uniforms.hoverHighlightStrength, + this.hoverHighlightStrength, + ); + } + if (this.uniforms.hoverHighlightColor) { + const [r, g, b] = this.hoverHighlightColor; + gl.uniform3f(this.uniforms.hoverHighlightColor, r, g, b); + } + if (this.uniforms.hoverPulseStrength) { + gl.uniform1f(this.uniforms.hoverPulseStrength, this.hoverPulseStrength); + } + if (this.uniforms.hoverPulseSpeed) { + gl.uniform1f(this.uniforms.hoverPulseSpeed, this.hoverPulseSpeed); + } + + gl.enable(gl.BLEND); + gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); + gl.viewport(0, 0, this.canvas.width, this.canvas.height); + } + + static create(game: GameView, theme: Theme): TerritoryWebGLCreateResult { + const sharedState = game.sharedStateBuffer(); + if (!sharedState) { + return { + renderer: null, + reason: + "Shared tile state not available. WebGL territory renderer needs SharedArrayBuffer.", + }; + } + + const expected = game.width() * game.height(); + if (new Uint16Array(sharedState).length !== expected) { + return { + renderer: null, + reason: + "Shared tile buffer size mismatch; falling back to canvas territory draw.", + }; + } + + const renderer = new TerritoryWebGLRenderer(game, theme, sharedState); + if (!renderer.isValid()) { + return { + renderer: null, + reason: "WebGL2 not available; falling back to canvas territory draw.", + }; + } + return { renderer }; + } + + isValid(): boolean { + return !!this.gl && !!this.program && !!this.vao; + } + + setAlternativeView(enabled: boolean) { + this.alternativeView = enabled; + } + + setHoveredPlayerId(playerSmallId: number | null) { + const encoded = playerSmallId ?? -1; + this.hoveredPlayerId = encoded; + } + + setHoverHighlightOptions(options: HoverHighlightOptions) { + if (options.strength !== undefined) { + this.hoverHighlightStrength = Math.max(0, Math.min(1, options.strength)); + } + if (options.color) { + this.hoverHighlightColor = [ + options.color.r / 255, + options.color.g / 255, + options.color.b / 255, + ]; + } + if (options.pulseStrength !== undefined) { + this.hoverPulseStrength = Math.max(0, Math.min(1, options.pulseStrength)); + } + if (options.pulseSpeed !== undefined) { + this.hoverPulseSpeed = Math.max(0, options.pulseSpeed); + } + } + + markTile(tile: TileRef) { + if (this.needsFullUpload) { + return; + } + const x = tile % this.canvas.width; + const y = Math.floor(tile / this.canvas.width); + const span = this.dirtyRows.get(y); + if (span === undefined) { + this.dirtyRows.set(y, { minX: x, maxX: x }); + } else { + span.minX = Math.min(span.minX, x); + span.maxX = Math.max(span.maxX, x); + } + } + + markAllDirty() { + this.needsFullUpload = true; + this.dirtyRows.clear(); + } + + refreshPalette() { + if (!this.gl || !this.paletteTexture || !this.relationTexture) { + return; + } + this.uploadPalette(); + } + + render() { + if (!this.gl || !this.program || !this.vao) { + return; + } + const gl = this.gl; + + const uploadSpan = FrameProfiler.start(); + this.uploadStateTexture(); + FrameProfiler.end("TerritoryWebGLRenderer:uploadState", uploadSpan); + + const renderSpan = FrameProfiler.start(); + gl.viewport(0, 0, this.canvas.width, this.canvas.height); + gl.useProgram(this.program); + gl.bindVertexArray(this.vao); + if (this.uniforms.alternativeView) { + gl.uniform1i(this.uniforms.alternativeView, this.alternativeView ? 1 : 0); + } + if (this.uniforms.hoveredPlayerId) { + gl.uniform1f(this.uniforms.hoveredPlayerId, this.hoveredPlayerId); + } + if (this.uniforms.hoverHighlightStrength) { + gl.uniform1f( + this.uniforms.hoverHighlightStrength, + this.hoverHighlightStrength, + ); + } + if (this.uniforms.hoverHighlightColor) { + const [r, g, b] = this.hoverHighlightColor; + gl.uniform3f(this.uniforms.hoverHighlightColor, r, g, b); + } + if (this.uniforms.hoverPulseStrength) { + gl.uniform1f(this.uniforms.hoverPulseStrength, this.hoverPulseStrength); + } + if (this.uniforms.hoverPulseSpeed) { + gl.uniform1f(this.uniforms.hoverPulseSpeed, this.hoverPulseSpeed); + } + if (this.uniforms.time) { + const currentTime = (Date.now() - this.animationStartTime) / 1000.0; + gl.uniform1f(this.uniforms.time, currentTime); + } + + gl.drawArrays(gl.TRIANGLES, 0, 6); + gl.bindVertexArray(null); + FrameProfiler.end("TerritoryWebGLRenderer:draw", renderSpan); + } + + private uploadStateTexture() { + if (!this.gl || !this.stateTexture) return; + const gl = this.gl; + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, this.stateTexture); + + if (this.needsFullUpload) { + gl.texImage2D( + gl.TEXTURE_2D, + 0, + gl.R16UI, + this.canvas.width, + this.canvas.height, + 0, + gl.RED_INTEGER, + gl.UNSIGNED_SHORT, + this.state, + ); + this.needsFullUpload = false; + this.dirtyRows.clear(); + return; + } + + if (this.dirtyRows.size === 0) { + return; + } + + for (const [y, span] of this.dirtyRows) { + const width = span.maxX - span.minX + 1; + const offset = y * this.canvas.width + span.minX; + const rowSlice = this.state.subarray(offset, offset + width); + gl.texSubImage2D( + gl.TEXTURE_2D, + 0, + span.minX, + y, + width, + 1, + gl.RED_INTEGER, + gl.UNSIGNED_SHORT, + rowSlice, + ); + } + this.dirtyRows.clear(); + } + + private uploadPalette() { + if (!this.gl || !this.paletteTexture || !this.relationTexture) return; + const gl = this.gl; + const players = this.game.playerViews().filter((p) => p.isPlayer()); + const myPlayer = this.game.myPlayer(); + + 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 * 4); + const relationData = new Uint8Array(this.paletteWidth); + + for (const p of players) { + const id = p.smallID(); + const rgba = p.territoryColor().rgba; + paletteData[id * 4] = rgba.r; + paletteData[id * 4 + 1] = rgba.g; + paletteData[id * 4 + 2] = rgba.b; + paletteData[id * 4 + 3] = Math.round((rgba.a ?? 1) * 255); + + relationData[id] = this.resolveRelationCode(p, myPlayer); + } + + 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, + 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, + 1, + 0, + gl.RED_INTEGER, + gl.UNSIGNED_BYTE, + relationData, + ); + } + + private resolveRelationCode( + owner: PlayerView, + myPlayer: PlayerView | null, + ): number { + if (!myPlayer) { + return 3; // Neutral + } + if (owner.smallID() === myPlayer.smallID()) { + return 1; // Self + } + if (owner.isFriendly(myPlayer)) { + return 2; // Ally + } + if (!owner.hasEmbargo(myPlayer)) { + return 3; // Neutral + } + return 4; // Enemy + } + + private createProgram(gl: WebGL2RenderingContext): WebGLProgram | null { + const vertexShaderSource = `#version 300 es + precision mediump float; + in vec2 a_position; + uniform vec2 u_resolution; + void main() { + vec2 zeroToOne = a_position / u_resolution; + vec2 clipSpace = zeroToOne * 2.0 - 1.0; + clipSpace.y = -clipSpace.y; + gl_Position = vec4(clipSpace, 0.0, 1.0); + } + `; + + const fragmentShaderSource = `#version 300 es + precision mediump float; + precision highp usampler2D; + + uniform usampler2D u_state; + uniform sampler2D u_palette; + uniform usampler2D u_relations; + uniform vec2 u_resolution; + uniform vec4 u_fallout; + uniform vec4 u_altSelf; + uniform vec4 u_altAlly; + uniform vec4 u_altNeutral; + uniform vec4 u_altEnemy; + uniform float u_alpha; + uniform bool u_alternativeView; + uniform float u_hoveredPlayerId; + uniform vec3 u_hoverHighlightColor; + uniform float u_hoverHighlightStrength; + uniform float u_hoverPulseStrength; + uniform float u_hoverPulseSpeed; + uniform float u_time; + + out vec4 outColor; + + uint ownerAtTex(ivec2 texCoord) { + ivec2 clamped = clamp( + texCoord, + ivec2(0, 0), + ivec2(int(u_resolution.x) - 1, int(u_resolution.y) - 1) + ); + return texelFetch(u_state, clamped, 0).r & 0xFFFu; + } + + void main() { + ivec2 fragCoord = ivec2(gl_FragCoord.xy); + // gl_FragCoord origin is bottom-left; flip Y to match top-left oriented buffers. + ivec2 texCoord = ivec2(fragCoord.x, int(u_resolution.y) - 1 - fragCoord.y); + + uint state = texelFetch(u_state, texCoord, 0).r; + uint owner = state & 0xFFFu; + bool hasFallout = (state & 0x2000u) != 0u; // bit 13 + + if (owner == 0u) { + if (hasFallout) { + outColor = vec4(u_fallout.rgb, u_alpha); + } else { + outColor = vec4(0.0); + } + return; + } + + // Border detection via neighbor comparison + bool isBorder = false; + uint nOwner = ownerAtTex(texCoord + ivec2(1, 0)); + isBorder = isBorder || (nOwner != owner); + nOwner = ownerAtTex(texCoord + ivec2(-1, 0)); + isBorder = isBorder || (nOwner != owner); + nOwner = ownerAtTex(texCoord + ivec2(0, 1)); + isBorder = isBorder || (nOwner != owner); + nOwner = ownerAtTex(texCoord + ivec2(0, -1)); + isBorder = isBorder || (nOwner != owner); + + if (u_alternativeView) { + uint relation = texelFetch(u_relations, ivec2(int(owner), 0), 0).r; + vec4 altColor = u_altNeutral; + if (relation == 1u) { + altColor = u_altSelf; + } else if (relation == 2u) { + altColor = u_altAlly; + } else if (relation >= 4u) { + altColor = u_altEnemy; + } + float a = isBorder ? 1.0 : 0.0; + vec3 color = altColor.rgb; + if (u_hoveredPlayerId >= 0.0 && abs(float(owner) - u_hoveredPlayerId) < 0.5) { + float pulse = u_hoverPulseStrength > 0.0 + ? (1.0 - u_hoverPulseStrength) + + u_hoverPulseStrength * (0.5 + 0.5 * sin(u_time * u_hoverPulseSpeed)) + : 1.0; + color = mix(color, u_hoverHighlightColor, u_hoverHighlightStrength * pulse); + } + outColor = vec4(color, a); + return; + } + + vec4 base = texelFetch(u_palette, ivec2(int(owner), 0), 0); + float a = isBorder ? 1.0 : u_alpha; + vec3 color = base.rgb; + + if (u_hoveredPlayerId >= 0.0 && abs(float(owner) - u_hoveredPlayerId) < 0.5) { + float pulse = u_hoverPulseStrength > 0.0 + ? (1.0 - u_hoverPulseStrength) + + u_hoverPulseStrength * (0.5 + 0.5 * sin(u_time * u_hoverPulseSpeed)) + : 1.0; + color = mix(color, u_hoverHighlightColor, u_hoverHighlightStrength * pulse); + } + + outColor = vec4(color, a); + } + `; + + 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/TerritoryWebGLStatus.ts b/src/client/graphics/layers/TerritoryWebGLStatus.ts new file mode 100644 index 000000000..b2170a2da --- /dev/null +++ b/src/client/graphics/layers/TerritoryWebGLStatus.ts @@ -0,0 +1,176 @@ +import { css, html, LitElement } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; +import { EventBus } from "../../../core/EventBus"; +import { UserSettings } from "../../../core/game/UserSettings"; +import { + TerritoryWebGLStatusEvent, + ToggleTerritoryWebGLEvent, +} from "../../InputHandler"; +import { Layer } from "./Layer"; + +@customElement("territory-webgl-status") +export class TerritoryWebGLStatus extends LitElement implements Layer { + @property({ attribute: false }) + public eventBus!: EventBus; + + @property({ attribute: false }) + public userSettings!: UserSettings; + + @state() + private enabled = true; + + @state() + private active = false; + + @state() + private supported = true; + + @state() + private lastMessage: string | null = null; + + static styles = css` + :host { + position: fixed; + bottom: 16px; + right: 16px; + z-index: 9998; + pointer-events: none; + } + + .panel { + background: rgba(15, 23, 42, 0.85); + color: white; + border-radius: 8px; + padding: 10px 14px; + min-width: 220px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35); + font-family: + "Inter", + system-ui, + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + sans-serif; + font-size: 12px; + pointer-events: auto; + display: flex; + flex-direction: column; + gap: 8px; + } + + .status-line { + display: flex; + flex-direction: column; + gap: 2px; + } + + .label { + text-transform: uppercase; + font-size: 10px; + letter-spacing: 0.08em; + opacity: 0.7; + } + + .value { + font-weight: 600; + } + + .status-active { + color: #4ade80; + } + + .status-fallback { + color: #fbbf24; + } + + .status-disabled { + color: #f87171; + } + + .message { + font-size: 11px; + line-height: 1.3; + opacity: 0.85; + } + + .actions { + display: flex; + justify-content: flex-end; + } + + button { + background: #1e293b; + border: 1px solid rgba(255, 255, 255, 0.1); + color: white; + font-size: 11px; + border-radius: 4px; + padding: 4px 10px; + cursor: pointer; + } + + button:hover { + background: #334155; + } + `; + + init() { + this.enabled = this.userSettings?.territoryWebGL() ?? true; + if (this.eventBus) { + this.eventBus.on(TerritoryWebGLStatusEvent, (event) => { + this.enabled = event.enabled; + this.active = event.active; + this.supported = event.supported; + this.lastMessage = event.message ?? null; + this.requestUpdate(); + }); + } + } + + shouldTransform(): boolean { + return false; + } + + private handleToggle() { + if (!this.eventBus) return; + this.eventBus.emit(new ToggleTerritoryWebGLEvent()); + } + + private statusClass(): string { + if (!this.enabled) return "status-disabled"; + if (this.enabled && this.active) return "status-active"; + if (!this.supported) return "status-disabled"; + return "status-fallback"; + } + + private statusText(): string { + if (!this.enabled) { + return "WebGL borders hidden"; + } + if (!this.supported) { + return "WebGL unsupported (fallback)"; + } + if (this.active) { + return "WebGL borders active"; + } + return "WebGL enabled (fallback)"; + } + + render() { + return html` +
+
+ Territory Renderer + ${this.statusText()} +
+ ${this.lastMessage + ? html`
${this.lastMessage}
` + : html``} +
+ +
+
+ `; + } +} diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts index 5b2a4ba6f..63dedc0eb 100644 --- a/src/core/game/GameView.ts +++ b/src/core/game/GameView.ts @@ -41,6 +41,10 @@ import { UserSettings } from "./UserSettings"; const userSettings: UserSettings = new UserSettings(); +const FRIENDLY_TINT_TARGET = { r: 0, g: 255, b: 0, a: 1 }; +const EMBARGO_TINT_TARGET = { r: 255, g: 0, b: 0, a: 1 }; +const BORDER_TINT_RATIO = 0.35; + export class UnitView { public _wasUpdated = true; public lastPos: TileRef[] = []; @@ -184,9 +188,17 @@ export class PlayerView { private _territoryColor: Colord; private _borderColor: Colord; + // Update here to include structure light and dark colors private _structureColors: { light: Colord; dark: Colord }; - private _defendedBorderColors: { light: Colord; dark: Colord }; + + // Pre-computed border color variants + private _borderColorNeutral: Colord; + private _borderColorFriendly: Colord; + private _borderColorEmbargo: Colord; + private _borderColorDefendedNeutral: { light: Colord; dark: Colord }; + private _borderColorDefendedFriendly: { light: Colord; dark: Colord }; + private _borderColorDefendedEmbargo: { light: Colord; dark: Colord }; constructor( private game: GameView, @@ -246,11 +258,56 @@ export class PlayerView { this.cosmetics.color?.color ?? maybeFocusedBorderColor.toHex(), ); + const theme = this.game.config().theme(); + const baseRgb = this._borderColor.toRgb(); - this._defendedBorderColors = this.game - .config() - .theme() - .defendedBorderColors(this._borderColor); + // Neutral is just the base color + this._borderColorNeutral = this._borderColor; + + // Compute friendly tint + this._borderColorFriendly = colord({ + r: Math.round( + baseRgb.r * (1 - BORDER_TINT_RATIO) + + FRIENDLY_TINT_TARGET.r * BORDER_TINT_RATIO, + ), + g: Math.round( + baseRgb.g * (1 - BORDER_TINT_RATIO) + + FRIENDLY_TINT_TARGET.g * BORDER_TINT_RATIO, + ), + b: Math.round( + baseRgb.b * (1 - BORDER_TINT_RATIO) + + FRIENDLY_TINT_TARGET.b * BORDER_TINT_RATIO, + ), + a: baseRgb.a, + }); + + // Compute embargo tint + this._borderColorEmbargo = colord({ + r: Math.round( + baseRgb.r * (1 - BORDER_TINT_RATIO) + + EMBARGO_TINT_TARGET.r * BORDER_TINT_RATIO, + ), + g: Math.round( + baseRgb.g * (1 - BORDER_TINT_RATIO) + + EMBARGO_TINT_TARGET.g * BORDER_TINT_RATIO, + ), + b: Math.round( + baseRgb.b * (1 - BORDER_TINT_RATIO) + + EMBARGO_TINT_TARGET.b * BORDER_TINT_RATIO, + ), + a: baseRgb.a, + }); + + // Pre-compute defended variants + this._borderColorDefendedNeutral = theme.defendedBorderColors( + this._borderColorNeutral, + ); + this._borderColorDefendedFriendly = theme.defendedBorderColors( + this._borderColorFriendly, + ); + this._borderColorDefendedEmbargo = theme.defendedBorderColors( + this._borderColorEmbargo, + ); this.decoder = this.cosmetics.pattern === undefined @@ -273,18 +330,74 @@ export class PlayerView { return this._structureColors; } + /** + * Border color for a tile: + * - Tints by neighbor relations (embargo → red, friendly → green, else neutral). + * - If defended, applies theme checkerboard to the tinted color. + */ borderColor(tile?: TileRef, isDefended: boolean = false): Colord { - if (tile === undefined || !isDefended) { + if (tile === undefined) { return this._borderColor; } + const { hasEmbargo, hasFriendly } = this.borderRelationFlags(tile); + + let baseColor: Colord; + let defendedColors: { light: Colord; dark: Colord }; + + if (hasEmbargo) { + baseColor = this._borderColorEmbargo; + defendedColors = this._borderColorDefendedEmbargo; + } else if (hasFriendly) { + baseColor = this._borderColorFriendly; + defendedColors = this._borderColorDefendedFriendly; + } else { + baseColor = this._borderColorNeutral; + defendedColors = this._borderColorDefendedNeutral; + } + + if (!isDefended) { + return baseColor; + } + const x = this.game.x(tile); const y = this.game.y(tile); const lightTile = (x % 2 === 0 && y % 2 === 0) || (y % 2 === 1 && x % 2 === 1); - return lightTile - ? this._defendedBorderColors.light - : this._defendedBorderColors.dark; + return lightTile ? defendedColors.light : defendedColors.dark; + } + + /** + * Border relation flags for a tile, used by both CPU and WebGL renderers. + */ + borderRelationFlags(tile: TileRef): { + hasEmbargo: boolean; + hasFriendly: boolean; + } { + const mySmallID = this.smallID(); + let hasEmbargo = false; + let hasFriendly = false; + + for (const n of this.game.neighbors(tile)) { + if (!this.game.hasOwner(n)) { + continue; + } + + const otherOwner = this.game.owner(n); + if (!otherOwner.isPlayer() || otherOwner.smallID() === mySmallID) { + continue; + } + + if (this.hasEmbargo(otherOwner)) { + hasEmbargo = true; + break; + } + + if (this.isFriendly(otherOwner) || otherOwner.isFriendly(this)) { + hasFriendly = true; + } + } + return { hasEmbargo, hasFriendly }; } async actions(tile?: TileRef): Promise { @@ -789,6 +902,18 @@ export class GameView implements GameMap { return this._gameID; } + hasSharedTileState(): boolean { + return this.usesSharedTileState; + } + + sharedStateBuffer(): SharedArrayBuffer | undefined { + if (!this.usesSharedTileState) { + return undefined; + } + const buffer = this._mapData.sharedStateBuffer; + return buffer instanceof SharedArrayBuffer ? buffer : undefined; + } + focusedPlayer(): PlayerView | null { return this.myPlayer(); } diff --git a/src/core/game/UserSettings.ts b/src/core/game/UserSettings.ts index fd5ac12a5..8a4cb5a56 100644 --- a/src/core/game/UserSettings.ts +++ b/src/core/game/UserSettings.ts @@ -61,6 +61,10 @@ export class UserSettings { return this.get("settings.structureSprites", true); } + territoryWebGL() { + return this.get("settings.territoryWebGL", true); + } + darkMode() { return this.get("settings.darkMode", false); } @@ -115,6 +119,10 @@ export class UserSettings { this.set("settings.structureSprites", !this.structureSprites()); } + toggleTerritoryWebGL() { + this.set("settings.territoryWebGL", !this.territoryWebGL()); + } + toggleTerritoryPatterns() { this.set("settings.territoryPatterns", !this.territoryPatterns()); } diff --git a/webpack.config.js b/webpack.config.js index baae9ca8a..fcd667f00 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -1,6 +1,7 @@ import { execSync } from "child_process"; import CopyPlugin from "copy-webpack-plugin"; import ESLintPlugin from "eslint-webpack-plugin"; +import fs from "fs"; import HtmlWebpackPlugin from "html-webpack-plugin"; import path from "path"; import { fileURLToPath } from "url"; @@ -9,11 +10,138 @@ import webpack from "webpack"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); +const crossOriginHeaders = { + "Cross-Origin-Opener-Policy": "same-origin", + "Cross-Origin-Embedder-Policy": "require-corp", + "Cross-Origin-Resource-Policy": "same-origin", + "Origin-Agent-Cluster": "?1", +}; + +const devHttpsEnabled = + process.env.DEV_HTTPS === "1" || + (process.env.DEV_HTTPS ?? "").toLowerCase() === "true"; + +const devKeyPath = + process.env.DEV_KEY ?? path.resolve(__dirname, "resources/certs/dev.key"); +const devCertPath = + process.env.DEV_CERT ?? path.resolve(__dirname, "resources/certs/dev.crt"); + +const addProxyHeaders = (proxyRes) => { + Object.entries(crossOriginHeaders).forEach(([key, value]) => { + proxyRes.headers[key] = value; + }); +}; + +const buildDevProxyConfig = () => + [ + // WebSocket proxies + { + context: ["/socket"], + target: "ws://localhost:3000", + ws: true, + changeOrigin: true, + logLevel: "debug", + }, + // Worker WebSocket proxies - using direct paths without /socket suffix + { + context: ["/w0"], + target: "ws://localhost:3001", + ws: true, + secure: false, + changeOrigin: true, + logLevel: "debug", + }, + { + context: ["/w1"], + target: "ws://localhost:3002", + ws: true, + secure: false, + changeOrigin: true, + logLevel: "debug", + }, + { + context: ["/w2"], + target: "ws://localhost:3003", + ws: true, + secure: false, + changeOrigin: true, + logLevel: "debug", + }, + // Worker proxies for HTTP requests + { + context: ["/w0"], + target: "http://localhost:3001", + pathRewrite: { "^/w0": "" }, + secure: false, + changeOrigin: true, + logLevel: "debug", + }, + { + context: ["/w1"], + target: "http://localhost:3002", + pathRewrite: { "^/w1": "" }, + secure: false, + changeOrigin: true, + logLevel: "debug", + }, + { + context: ["/w2"], + target: "http://localhost:3003", + pathRewrite: { "^/w2": "" }, + secure: false, + changeOrigin: true, + logLevel: "debug", + }, + // Original API endpoints + { + context: [ + "/api/env", + "/api/game", + "/api/public_lobbies", + "/api/join_game", + "/api/start_game", + "/api/create_game", + "/api/archive_singleplayer_game", + "/api/auth/callback", + "/api/auth/discord", + "/api/kick_player", + ], + target: "http://localhost:3000", + secure: false, + changeOrigin: true, + }, + ].map((proxyEntry) => ({ + onProxyRes: addProxyHeaders, + ...proxyEntry, + })); + +const getHttpsServerConfig = () => { + if (!devHttpsEnabled) return undefined; + + try { + return { + type: "https", + options: { + key: fs.readFileSync(devKeyPath), + cert: fs.readFileSync(devCertPath), + }, + }; + } catch (error) { + console.error( + `DEV_HTTPS enabled but could not read cert/key at ${devCertPath} / ${devKeyPath}`, + error, + ); + throw error; + } +}; + const gitCommit = process.env.GIT_COMMIT ?? execSync("git rev-parse HEAD").toString().trim(); export default async (env, argv) => { const isProduction = argv.mode === "production"; + const serverConfig = isProduction ? undefined : getHttpsServerConfig(); + const proxyConfig = isProduction ? [] : buildDevProxyConfig(); return { entry: "./src/client/Main.ts", @@ -173,6 +301,8 @@ export default async (env, argv) => { devServer: isProduction ? {} : { + server: serverConfig, + headers: crossOriginHeaders, devMiddleware: { writeToDisk: true }, static: { directory: path.join(__dirname, "static"), @@ -180,84 +310,7 @@ export default async (env, argv) => { historyApiFallback: true, compress: true, port: 9000, - proxy: [ - // WebSocket proxies - { - context: ["/socket"], - target: "ws://localhost:3000", - ws: true, - changeOrigin: true, - logLevel: "debug", - }, - // Worker WebSocket proxies - using direct paths without /socket suffix - { - context: ["/w0"], - target: "ws://localhost:3001", - ws: true, - secure: false, - changeOrigin: true, - logLevel: "debug", - }, - { - context: ["/w1"], - target: "ws://localhost:3002", - ws: true, - secure: false, - changeOrigin: true, - logLevel: "debug", - }, - { - context: ["/w2"], - target: "ws://localhost:3003", - ws: true, - secure: false, - changeOrigin: true, - logLevel: "debug", - }, - // Worker proxies for HTTP requests - { - context: ["/w0"], - target: "http://localhost:3001", - pathRewrite: { "^/w0": "" }, - secure: false, - changeOrigin: true, - logLevel: "debug", - }, - { - context: ["/w1"], - target: "http://localhost:3002", - pathRewrite: { "^/w1": "" }, - secure: false, - changeOrigin: true, - logLevel: "debug", - }, - { - context: ["/w2"], - target: "http://localhost:3003", - pathRewrite: { "^/w2": "" }, - secure: false, - changeOrigin: true, - logLevel: "debug", - }, - // Original API endpoints - { - context: [ - "/api/env", - "/api/game", - "/api/public_lobbies", - "/api/join_game", - "/api/start_game", - "/api/create_game", - "/api/archive_singleplayer_game", - "/api/auth/callback", - "/api/auth/discord", - "/api/kick_player", - ], - target: "http://localhost:3000", - secure: false, - changeOrigin: true, - }, - ], + proxy: proxyConfig, }, }; };