From c9f210f6dbf5ddfb6c627694e778b57c149e2232 Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Fri, 28 Nov 2025 19:54:00 +0100 Subject: [PATCH 01/16] quick push --- .gitignore | 1 + package.json | 1 + src/client/ClientGameRunner.ts | 364 +++++- src/client/InputHandler.ts | 31 + src/client/graphics/GameRenderer.ts | 54 +- src/client/graphics/HoverTarget.ts | 63 + src/client/graphics/layers/Layer.ts | 1 + .../graphics/layers/PerformanceOverlay.ts | 139 ++- .../graphics/layers/PlayerInfoOverlay.ts | 53 +- src/client/graphics/layers/TerritoryLayer.ts | 406 +++---- .../graphics/layers/TerritoryRenderers.ts | 342 ++++++ .../graphics/layers/TerritoryWebGLRenderer.ts | 1010 +++++++++++++++++ .../graphics/layers/TerritoryWebGLStatus.ts | 176 +++ src/core/GameRunner.ts | 27 +- src/core/game/GameImpl.ts | 16 + src/core/game/GameMap.ts | 65 +- src/core/game/GameView.ts | 180 ++- src/core/game/TerrainMapLoader.ts | 50 +- src/core/game/UserSettings.ts | 8 + src/core/worker/SharedTileRing.ts | 85 ++ src/core/worker/Worker.worker.ts | 59 +- src/core/worker/WorkerClient.ts | 14 +- src/core/worker/WorkerMessages.ts | 10 +- webpack.config.js | 209 ++-- 24 files changed, 2970 insertions(+), 394 deletions(-) create mode 100644 src/client/graphics/HoverTarget.ts create mode 100644 src/client/graphics/layers/TerritoryRenderers.ts create mode 100644 src/client/graphics/layers/TerritoryWebGLRenderer.ts create mode 100644 src/client/graphics/layers/TerritoryWebGLStatus.ts create mode 100644 src/core/worker/SharedTileRing.ts diff --git a/.gitignore b/.gitignore index 18fa251ae7..9bceb8a7c5 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 31fbc9083c..c0a9e3803d 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/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index 575977f9a4..09f1d21e7d 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -12,7 +12,7 @@ import { import { createPartialGameRecord, replacer } from "../core/Util"; import { ServerConfig } from "../core/configuration/Config"; import { getConfig } from "../core/configuration/ConfigLoader"; -import { PlayerActions, UnitType } from "../core/game/Game"; +import { GameUpdates, PlayerActions, UnitType } from "../core/game/Game"; import { TileRef } from "../core/game/GameMap"; import { GameMapLoader } from "../core/game/GameMapLoader"; import { @@ -25,9 +25,18 @@ import { import { GameView, PlayerView } from "../core/game/GameView"; import { loadTerrainMap, TerrainMapData } from "../core/game/TerrainMapLoader"; import { UserSettings } from "../core/game/UserSettings"; +import { + createSharedTileRingBuffers, + createSharedTileRingViews, + drainTileUpdates, + SharedTileRingBuffers, + SharedTileRingViews, + TILE_RING_HEADER_OVERFLOW, +} from "../core/worker/SharedTileRing"; import { WorkerClient } from "../core/worker/WorkerClient"; import { AutoUpgradeEvent, + BacklogStatusEvent, DoBoatAttackEvent, DoGroundAttackEvent, InputHandler, @@ -161,9 +170,44 @@ async function createClientGame( mapLoader, ); } + + let sharedTileRingBuffers: SharedTileRingBuffers | undefined; + let sharedTileRingViews: SharedTileRingViews | null = null; + let sharedDirtyBuffer: SharedArrayBuffer | undefined; + let sharedDirtyFlags: Uint8Array | null = null; + const isIsolated = + typeof (globalThis as any).crossOriginIsolated === "boolean" + ? (globalThis as any).crossOriginIsolated === true + : false; + const canUseSharedBuffers = + typeof SharedArrayBuffer !== "undefined" && + typeof Atomics !== "undefined" && + isIsolated; + const sharedStateBuffer = + canUseSharedBuffers && gameMap.sharedStateBuffer + ? gameMap.sharedStateBuffer + : undefined; + const usesSharedTileState = !!sharedStateBuffer; + + if (canUseSharedBuffers) { + const numTiles = gameMap.gameMap.width() * gameMap.gameMap.height(); + // Ring capacity scales with world size: at most one entry per tile. + const TILE_RING_CAPACITY = numTiles; + sharedTileRingBuffers = createSharedTileRingBuffers( + TILE_RING_CAPACITY, + numTiles, + ); + sharedTileRingViews = createSharedTileRingViews(sharedTileRingBuffers); + sharedDirtyBuffer = sharedTileRingBuffers.dirty; + sharedDirtyFlags = sharedTileRingViews.dirtyFlags; + } + const worker = new WorkerClient( lobbyConfig.gameStartInfo, lobbyConfig.clientID, + sharedTileRingBuffers, + sharedStateBuffer, + sharedDirtyBuffer, ); await worker.initialize(); const gameView = new GameView( @@ -173,6 +217,7 @@ async function createClientGame( lobbyConfig.clientID, lobbyConfig.gameStartInfo.gameID, lobbyConfig.gameStartInfo.players, + usesSharedTileState, ); const canvas = createCanvas(); @@ -190,6 +235,8 @@ async function createClientGame( transport, worker, gameView, + sharedTileRingViews, + sharedDirtyFlags, ); } @@ -208,6 +255,22 @@ export class ClientGameRunner { private lastTickReceiveTime: number = 0; private currentTickDelay: number | undefined = undefined; + // Track how far behind the client simulation is compared to the server. + private serverTurnHighWater: number = 0; + private lastProcessedTick: number = 0; + private backlogTurns: number = 0; + private backlogGrowing: boolean = false; + private lastRenderedTick: number = 0; + private workerTicksSinceSample: number = 0; + private renderTicksSinceSample: number = 0; + private metricsSampleStart: number = 0; + + private pendingUpdates: GameUpdateViewData[] = []; + private pendingStart = 0; + private isProcessingUpdates = false; + private tileRingViews: SharedTileRingViews | null; + private dirtyFlags: Uint8Array | null; + constructor( private lobby: LobbyConfig, private eventBus: EventBus, @@ -216,8 +279,12 @@ export class ClientGameRunner { private transport: Transport, private worker: WorkerClient, private gameView: GameView, + tileRingViews: SharedTileRingViews | null, + dirtyFlags: Uint8Array | null, ) { this.lastMessageTime = Date.now(); + this.tileRingViews = tileRingViews; + this.dirtyFlags = dirtyFlags; } private saveGame(update: WinUpdate) { @@ -292,33 +359,9 @@ export class ClientGameRunner { this.stop(); return; } - this.transport.turnComplete(); - gu.updates[GameUpdateType.Hash].forEach((hu: HashUpdate) => { - this.eventBus.emit(new SendHashEvent(hu.tick, hu.hash)); - }); - this.gameView.update(gu); - this.renderer.tick(); - - // Emit tick metrics event for performance overlay - this.eventBus.emit( - new TickMetricsEvent(gu.tickExecutionDuration, this.currentTickDelay), - ); - - // Reset tick delay for next measurement - this.currentTickDelay = undefined; - - if (gu.updates[GameUpdateType.Win].length > 0) { - this.saveGame(gu.updates[GameUpdateType.Win][0]); - } + this.pendingUpdates.push(gu); + this.processPendingUpdates(); }); - const worker = this.worker; - const keepWorkerAlive = () => { - if (this.isActive) { - worker.sendHeartbeat(); - requestAnimationFrame(keepWorkerAlive); - } - }; - requestAnimationFrame(keepWorkerAlive); const onconnect = () => { console.log("Connected to game server!"); @@ -363,6 +406,10 @@ export class ClientGameRunner { } for (const turn of message.turns) { + this.serverTurnHighWater = Math.max( + this.serverTurnHighWater, + turn.turnNumber, + ); if (turn.turnNumber < this.turnsSeen) { continue; } @@ -415,6 +462,11 @@ export class ClientGameRunner { } this.lastTickReceiveTime = now; + this.serverTurnHighWater = Math.max( + this.serverTurnHighWater, + message.turn.turnNumber, + ); + if (this.turnsSeen !== message.turn.turnNumber) { console.error( `got wrong turn have turns ${this.turnsSeen}, received turn ${message.turn.turnNumber}`, @@ -445,6 +497,264 @@ export class ClientGameRunner { } } + private processPendingUpdates() { + const pendingCount = this.pendingUpdates.length - this.pendingStart; + if (this.isProcessingUpdates || pendingCount <= 0) { + return; + } + + this.isProcessingUpdates = true; + const processFrame = () => { + const BASE_SLICE_BUDGET_MS = 8; // keep UI responsive while catching up + const MAX_SLICE_BUDGET_MS = 1000; // allow longer slices when backlog is large + const BACKLOG_FREE_TURNS = 10; // scaling starts at this many turns + const BACKLOG_MAX_TURNS = 500; // MAX_SLICE_BUDGET_MS is reached at this many turns + const MAX_TICKS_PER_SLICE = 1000; + + const backlogOverhead = Math.max( + 0, + this.backlogTurns - BACKLOG_FREE_TURNS, + ); + const backlogScale = Math.min( + 1, + backlogOverhead / (BACKLOG_MAX_TURNS - BACKLOG_FREE_TURNS), + ); + const sliceBudgetMs = + BASE_SLICE_BUDGET_MS + + backlogScale * (MAX_SLICE_BUDGET_MS - BASE_SLICE_BUDGET_MS); + + const frameStart = performance.now(); + const batch: GameUpdateViewData[] = []; + let lastTickDuration: number | undefined; + let lastTick: number | undefined; + + let processedCount = 0; + + // Consume updates until we hit the time budget or per-slice cap. + while (this.pendingStart < this.pendingUpdates.length) { + const gu = this.pendingUpdates[this.pendingStart++]; + processedCount++; + this.workerTicksSinceSample++; + batch.push(gu); + + this.transport.turnComplete(); + gu.updates[GameUpdateType.Hash].forEach((hu: HashUpdate) => { + this.eventBus.emit(new SendHashEvent(hu.tick, hu.hash)); + }); + this.updateBacklogMetrics(gu.tick); + + if (gu.updates[GameUpdateType.Win].length > 0) { + this.saveGame(gu.updates[GameUpdateType.Win][0]); + } + + if (gu.tickExecutionDuration !== undefined) { + lastTickDuration = gu.tickExecutionDuration; + } + lastTick = gu.tick; + + const elapsed = performance.now() - frameStart; + if (processedCount >= MAX_TICKS_PER_SLICE || elapsed >= sliceBudgetMs) { + break; + } + } + + // Compact the queue if we've advanced far into it. + if ( + this.pendingStart > 0 && + (this.pendingStart > 1024 || + this.pendingStart >= this.pendingUpdates.length / 2) + ) { + this.pendingUpdates = this.pendingUpdates.slice(this.pendingStart); + this.pendingStart = 0; + } + + // Only update view and render when ALL processing is complete + if ( + this.pendingStart >= this.pendingUpdates.length && + batch.length > 0 && + lastTick !== undefined + ) { + const { gameUpdate: combinedGu, tileMetrics } = + this.mergeGameUpdates(batch); + if (combinedGu) { + this.gameView.update(combinedGu); + } + + const ticksPerRender = + this.lastRenderedTick === 0 + ? lastTick + : lastTick - this.lastRenderedTick; + this.lastRenderedTick = lastTick; + + this.renderTicksSinceSample++; + + let workerTicksPerSecond: number | undefined; + let renderTicksPerSecond: number | undefined; + const now = performance.now(); + if (this.metricsSampleStart === 0) { + this.metricsSampleStart = now; + } else { + const elapsedSeconds = (now - this.metricsSampleStart) / 1000; + if (elapsedSeconds > 0) { + workerTicksPerSecond = this.workerTicksSinceSample / elapsedSeconds; + renderTicksPerSecond = this.renderTicksSinceSample / elapsedSeconds; + } + this.metricsSampleStart = now; + this.workerTicksSinceSample = 0; + this.renderTicksSinceSample = 0; + } + + this.renderer.tick(); + this.eventBus.emit( + new TickMetricsEvent( + lastTickDuration, + this.currentTickDelay, + this.backlogTurns, + ticksPerRender, + workerTicksPerSecond, + renderTicksPerSecond, + tileMetrics.count, + tileMetrics.utilization, + tileMetrics.overflow, + tileMetrics.drainTime, + ), + ); + + // Reset tick delay for next measurement + this.currentTickDelay = undefined; + } + + if (this.pendingStart < this.pendingUpdates.length) { + requestAnimationFrame(processFrame); + } else { + this.isProcessingUpdates = false; + } + }; + + requestAnimationFrame(processFrame); + } + + private mergeGameUpdates(batch: GameUpdateViewData[]): { + gameUpdate: GameUpdateViewData | null; + tileMetrics: { + count: number; + utilization: number; + overflow: number; + drainTime: number; + }; + } { + if (batch.length === 0) { + return { + gameUpdate: null, + tileMetrics: { + count: 0, + utilization: 0, + overflow: 0, + drainTime: 0, + }, + }; + } + + const last = batch[batch.length - 1]; + const combinedUpdates: GameUpdates = {} as GameUpdates; + + // Initialize combinedUpdates with empty arrays for each existing key + for (const key in last.updates) { + const type = Number(key) as GameUpdateType; + combinedUpdates[type] = []; + } + + const combinedPackedTileUpdates: bigint[] = []; + + for (const gu of batch) { + for (const key in gu.updates) { + const type = Number(key) as GameUpdateType; + // We don't care about the specific update subtype here; just treat it + // as an array we can concatenate. + const updatesForType = gu.updates[type] as unknown as any[]; + (combinedUpdates[type] as unknown as any[]).push(...updatesForType); + } + } + + let tileMetrics = { + count: 0, + utilization: 0, + overflow: 0, + drainTime: 0, + }; + + if (this.tileRingViews) { + const MAX_TILE_UPDATES_PER_RENDER = 100000; + const tileRefs: TileRef[] = []; + const drainStart = performance.now(); + drainTileUpdates( + this.tileRingViews, + MAX_TILE_UPDATES_PER_RENDER, + tileRefs, + ); + const drainTime = performance.now() - drainStart; + + // Deduplicate tile refs for this render slice + const uniqueTiles = new Set(); + for (const ref of tileRefs) { + uniqueTiles.add(ref); + } + + // Calculate ring buffer utilization and overflow using dynamic capacity + const TILE_RING_CAPACITY = this.tileRingViews.capacity; + const utilization = (uniqueTiles.size / TILE_RING_CAPACITY) * 100; + const overflow = Atomics.load( + this.tileRingViews.header, + TILE_RING_HEADER_OVERFLOW, + ); + + tileMetrics = { + count: uniqueTiles.size, + utilization, + overflow, + drainTime, + }; + + for (const ref of uniqueTiles) { + if (this.dirtyFlags) { + Atomics.store(this.dirtyFlags, ref, 0); + } + combinedPackedTileUpdates.push(BigInt(ref)); + } + } else { + // Non-SAB mode: count tile updates from batch + let totalTileUpdates = 0; + for (const gu of batch) { + totalTileUpdates += gu.packedTileUpdates.length; + } + tileMetrics.count = totalTileUpdates; + } + + return { + gameUpdate: { + tick: last.tick, + updates: combinedUpdates, + packedTileUpdates: new BigUint64Array(combinedPackedTileUpdates), + playerNameViewData: last.playerNameViewData, + tickExecutionDuration: last.tickExecutionDuration, + }, + tileMetrics, + }; + } + + private updateBacklogMetrics(processedTick: number) { + this.lastProcessedTick = processedTick; + const previousBacklog = this.backlogTurns; + this.backlogTurns = Math.max( + 0, + this.serverTurnHighWater - this.lastProcessedTick, + ); + this.backlogGrowing = this.backlogTurns > previousBacklog; + this.eventBus.emit( + new BacklogStatusEvent(this.backlogTurns, this.backlogGrowing), + ); + } + private inputEvent(event: MouseUpEvent) { if (!this.isActive || this.renderer.uiState.ghostStructure !== null) { return; diff --git a/src/client/InputHandler.ts b/src/client/InputHandler.ts index 26d8f6c27d..459e4c8603 100644 --- a/src/client/InputHandler.ts +++ b/src/client/InputHandler.ts @@ -81,6 +81,17 @@ 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 ToggleStructureEvent implements GameEvent { constructor(public readonly structureTypes: UnitType[] | null) {} } @@ -129,6 +140,26 @@ export class TickMetricsEvent implements GameEvent { constructor( public readonly tickExecutionDuration?: number, public readonly tickDelay?: number, + // Number of turns the client is behind the server (if known) + public readonly backlogTurns?: number, + // Number of simulation ticks applied since last render + public readonly ticksPerRender?: number, + // Approximate worker simulation ticks per second + public readonly workerTicksPerSecond?: number, + // Approximate render tick() calls per second + public readonly renderTicksPerSecond?: number, + // Tile update metrics + public readonly tileUpdatesCount?: number, + public readonly ringBufferUtilization?: number, + public readonly ringBufferOverflows?: number, + public readonly ringDrainTime?: number, + ) {} +} + +export class BacklogStatusEvent implements GameEvent { + constructor( + public readonly backlogTurns: number, + public readonly backlogGrowing: boolean, ) {} } diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts index 1410cdbbd9..8e336b311d 100644 --- a/src/client/graphics/GameRenderer.ts +++ b/src/client/graphics/GameRenderer.ts @@ -2,7 +2,10 @@ import { EventBus } from "../../core/EventBus"; import { GameView } from "../../core/game/GameView"; import { UserSettings } from "../../core/game/UserSettings"; import { GameStartingModal } from "../GameStartingModal"; -import { RefreshGraphicsEvent as RedrawGraphicsEvent } from "../InputHandler"; +import { + BacklogStatusEvent, + RefreshGraphicsEvent as RedrawGraphicsEvent, +} from "../InputHandler"; import { FrameProfiler } from "./FrameProfiler"; import { TransformHandler } from "./TransformHandler"; import { UIState } from "./UIState"; @@ -37,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"; @@ -220,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"); @@ -237,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), @@ -292,6 +309,9 @@ export function createRenderer( export class GameRenderer { private context: CanvasRenderingContext2D; + private backlogTurns: number = 0; + private backlogGrowing: boolean = false; + private lastRenderTime: number = 0; constructor( private game: GameView, @@ -309,6 +329,10 @@ export class GameRenderer { initialize() { this.eventBus.on(RedrawGraphicsEvent, () => this.redraw()); + this.eventBus.on(BacklogStatusEvent, (event: BacklogStatusEvent) => { + this.backlogTurns = event.backlogTurns; + this.backlogGrowing = event.backlogGrowing; + }); this.layers.forEach((l) => l.init?.()); document.body.appendChild(this.canvas); @@ -344,6 +368,28 @@ export class GameRenderer { } renderGame() { + const now = performance.now(); + + if (this.backlogTurns > 0) { + const BASE_FPS = 60; + const MIN_FPS = 30; + const BACKLOG_MAX_TURNS = 50; + + const scale = Math.min(1, this.backlogTurns / BACKLOG_MAX_TURNS); + const targetFps = BASE_FPS - scale * (BASE_FPS - MIN_FPS); + const minFrameInterval = 1000 / targetFps; + + if (this.lastRenderTime !== 0) { + const sinceLast = now - this.lastRenderTime; + if (sinceLast < minFrameInterval) { + requestAnimationFrame(() => this.renderGame()); + return; + } + } + } + + this.lastRenderTime = now; + FrameProfiler.clear(); const start = performance.now(); // Set background @@ -380,7 +426,11 @@ export class GameRenderer { const layerStart = FrameProfiler.start(); layer.renderLayer?.(this.context); - FrameProfiler.end(layer.constructor?.name ?? "UnknownLayer", layerStart); + const profileName = + (layer as any).profileName?.() ?? + layer.constructor?.name ?? + "UnknownLayer"; + FrameProfiler.end(profileName, layerStart); } handleTransformState(false, isTransformActive); // Ensure context is clean after rendering this.transformHandler.resetChanged(); diff --git a/src/client/graphics/HoverTarget.ts b/src/client/graphics/HoverTarget.ts new file mode 100644 index 0000000000..7e7cee8721 --- /dev/null +++ b/src/client/graphics/HoverTarget.ts @@ -0,0 +1,63 @@ +import { UnitType } from "../../core/game/Game"; +import { TileRef } from "../../core/game/GameMap"; +import { GameView, PlayerView, UnitView } from "../../core/game/GameView"; + +export interface HoverTargetResolution { + player: PlayerView | null; + unit: UnitView | null; +} + +const HOVER_UNIT_TYPES: UnitType[] = [ + UnitType.Warship, + UnitType.TradeShip, + UnitType.TransportShip, +]; + +const HOVER_DISTANCE_PX = 5; + +function distSquared( + game: GameView, + tile: TileRef, + coord: { x: number; y: number }, +): number { + const dx = game.x(tile) - coord.x; + const dy = game.y(tile) - coord.y; + return dx * dx + dy * dy; +} + +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 as any).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) => + distSquared(game, u.tile(), worldCoord) < + HOVER_DISTANCE_PX * HOVER_DISTANCE_PX, + ) + .sort( + (a, b) => + distSquared(game, a.tile(), worldCoord) - + distSquared(game, b.tile(), worldCoord), + ); + + if (units.length > 0) { + return { player: units[0].owner(), unit: units[0] }; + } + + return { player: null, unit: null }; +} diff --git a/src/client/graphics/layers/Layer.ts b/src/client/graphics/layers/Layer.ts index 2399374355..16bd67f50b 100644 --- a/src/client/graphics/layers/Layer.ts +++ b/src/client/graphics/layers/Layer.ts @@ -4,4 +4,5 @@ export interface Layer { renderLayer?: (context: CanvasRenderingContext2D) => void; shouldTransform?: () => boolean; redraw?: () => void; + profileName?: () => string; } diff --git a/src/client/graphics/layers/PerformanceOverlay.ts b/src/client/graphics/layers/PerformanceOverlay.ts index fb744d4a03..6c53bb5b00 100644 --- a/src/client/graphics/layers/PerformanceOverlay.ts +++ b/src/client/graphics/layers/PerformanceOverlay.ts @@ -229,7 +229,18 @@ export class PerformanceOverlay extends LitElement implements Layer { this.setVisible(this.userSettings.performanceOverlay()); }); this.eventBus.on(TickMetricsEvent, (event: TickMetricsEvent) => { - this.updateTickMetrics(event.tickExecutionDuration, event.tickDelay); + this.updateTickMetrics( + event.tickExecutionDuration, + event.tickDelay, + event.backlogTurns, + event.ticksPerRender, + event.workerTicksPerSecond, + event.renderTicksPerSecond, + event.tileUpdatesCount, + event.ringBufferUtilization, + event.ringBufferOverflows, + event.ringDrainTime, + ); }); } @@ -312,6 +323,14 @@ export class PerformanceOverlay extends LitElement implements Layer { this.layerStats.clear(); this.layerBreakdown = []; + // reset tile metrics + this.tileUpdatesPerRender = 0; + this.tileUpdatesPeak = 0; + this.ringBufferUtilization = 0; + this.ringBufferOverflows = 0; + this.ringDrainTime = 0; + this.totalTilesUpdated = 0; + this.requestUpdate(); }; @@ -418,7 +437,48 @@ export class PerformanceOverlay extends LitElement implements Layer { this.layerBreakdown = breakdown; } - updateTickMetrics(tickExecutionDuration?: number, tickDelay?: number) { + @state() + private backlogTurns: number = 0; + + @state() + private ticksPerRender: number = 0; + + @state() + private workerTicksPerSecond: number = 0; + + @state() + private renderTicksPerSecond: number = 0; + + @state() + private tileUpdatesPerRender: number = 0; + + @state() + private tileUpdatesPeak: number = 0; + + @state() + private ringBufferUtilization: number = 0; + + @state() + private ringBufferOverflows: number = 0; + + @state() + private ringDrainTime: number = 0; + + @state() + private totalTilesUpdated: number = 0; + + updateTickMetrics( + tickExecutionDuration?: number, + tickDelay?: number, + backlogTurns?: number, + ticksPerRender?: number, + workerTicksPerSecond?: number, + renderTicksPerSecond?: number, + tileUpdatesCount?: number, + ringBufferUtilization?: number, + ringBufferOverflows?: number, + ringDrainTime?: number, + ) { if (!this.isVisible || !this.userSettings.performanceOverlay()) return; // Update tick execution duration stats @@ -455,6 +515,42 @@ export class PerformanceOverlay extends LitElement implements Layer { } } + if (backlogTurns !== undefined) { + this.backlogTurns = backlogTurns; + } + + if (ticksPerRender !== undefined) { + this.ticksPerRender = ticksPerRender; + } + + if (workerTicksPerSecond !== undefined) { + this.workerTicksPerSecond = workerTicksPerSecond; + } + + if (renderTicksPerSecond !== undefined) { + this.renderTicksPerSecond = renderTicksPerSecond; + } + + if (tileUpdatesCount !== undefined) { + this.tileUpdatesPerRender = tileUpdatesCount; + this.tileUpdatesPeak = Math.max(this.tileUpdatesPeak, tileUpdatesCount); + this.totalTilesUpdated += tileUpdatesCount; + } + + if (ringBufferUtilization !== undefined) { + this.ringBufferUtilization = + Math.round(ringBufferUtilization * 100) / 100; + } + + if (ringBufferOverflows !== undefined && ringBufferOverflows !== 0) { + // Remember that an overflow has occurred at least once this run. + this.ringBufferOverflows = 1; + } + + if (ringDrainTime !== undefined) { + this.ringDrainTime = Math.round(ringDrainTime * 100) / 100; + } + this.requestUpdate(); } @@ -485,6 +581,14 @@ export class PerformanceOverlay extends LitElement implements Layer { executionSamples: [...this.tickExecutionTimes], delaySamples: [...this.tickDelayTimes], }, + tiles: { + updatesPerRender: this.tileUpdatesPerRender, + peakUpdates: this.tileUpdatesPeak, + ringBufferUtilization: this.ringBufferUtilization, + ringBufferOverflows: this.ringBufferOverflows, + ringDrainTimeMs: this.ringDrainTime, + totalTilesUpdated: this.totalTilesUpdated, + }, layers: this.layerBreakdown.map((layer) => ({ ...layer })), }; } @@ -600,6 +704,37 @@ export class PerformanceOverlay extends LitElement implements Layer { ${this.tickDelayAvg.toFixed(2)}ms (max: ${this.tickDelayMax}ms) +
+ Worker ticks/s: + ${this.workerTicksPerSecond.toFixed(1)} +
+
+ Render ticks/s: + ${this.renderTicksPerSecond.toFixed(1)} +
+
+ Ticks per render: + ${this.ticksPerRender} +
+
+ Backlog turns: + ${this.backlogTurns} +
+
+ Tile updates/render: + ${this.tileUpdatesPerRender} + (peak: ${this.tileUpdatesPeak}) +
+
+ Ring buffer: + ${this.ringBufferUtilization}% + (${this.totalTilesUpdated} total, ${this.ringBufferOverflows} + overflows) +
+
+ Ring drain time: + ${this.ringDrainTime.toFixed(2)}ms +
${this.layerBreakdown.length ? html`
diff --git a/src/client/graphics/layers/PlayerInfoOverlay.ts b/src/client/graphics/layers/PlayerInfoOverlay.ts index 12fd07f871..9375d5e7d7 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 "../HoverTarget"; 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 = this.resolveHoverTarget(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); } } @@ -186,6 +154,13 @@ export class PlayerInfoOverlay extends LitElement implements Layer { } } + private resolveHoverTarget(worldCoord: { x: number; y: number }): { + player: PlayerView | null; + unit: UnitView | null; + } { + return resolveHoverTarget(this.game, worldCoord); + } + private displayUnitCount( player: PlayerView, type: UnitType, diff --git a/src/client/graphics/layers/TerritoryLayer.ts b/src/client/graphics/layers/TerritoryLayer.ts index 2e6fa21131..7b7140f411 100644 --- a/src/client/graphics/layers/TerritoryLayer.ts +++ b/src/client/graphics/layers/TerritoryLayer.ts @@ -3,7 +3,6 @@ import { Colord } from "colord"; import { Theme } from "../../../core/configuration/Config"; import { EventBus } from "../../../core/EventBus"; import { - Cell, ColoredTeams, PlayerType, Team, @@ -16,19 +15,29 @@ 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 "../HoverTarget"; import { TransformHandler } from "../TransformHandler"; import { Layer } from "./Layer"; +import { + CanvasTerritoryRenderer, + TerritoryRendererStrategy, + WebglTerritoryRenderer, +} from "./TerritoryRenderers"; +import { TerritoryWebGLRenderer } from "./TerritoryWebGLRenderer"; export class TerritoryLayer implements Layer { + profileName(): string { + return "TerritoryLayer:renderLayer"; + } + private userSettings: UserSettings; - private canvas: HTMLCanvasElement; - private context: CanvasRenderingContext2D; - private imageData: ImageData; - private alternativeImageData: ImageData; private borderAnimTime = 0; private cachedTerritoryPatternsEnabled: boolean | undefined; @@ -47,6 +56,7 @@ export class TerritoryLayer implements Layer { private highlightContext: CanvasRenderingContext2D; private highlightedTerritory: PlayerView | null = null; + private territoryRenderer: TerritoryRendererStrategy | null = null; private alternativeView = false; private lastDragTime = 0; @@ -57,6 +67,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 +80,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 { @@ -81,6 +96,7 @@ export class TerritoryLayer implements Layer { } tick() { + const tickProfile = FrameProfiler.start(); if (this.game.inSpawnPhase()) { this.spawnHighlight(); } @@ -88,6 +104,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 +174,26 @@ 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) { + // Force a full repaint so the GPU textures match the new focus context + // (e.g., when jumping to another location during spawn). + this.redraw(); + } else { + if (this.lastFocusedPlayer) { + this.paintPlayerBorder(this.lastFocusedPlayer); + } + if (focusedPlayer) { + this.paintPlayerBorder(focusedPlayer); + } } this.lastFocusedPlayer = focusedPlayer; } + + const currentMyPlayer = this.game.myPlayer()?.smallID() ?? null; + if (currentMyPlayer !== this.lastMyPlayerSmallId) { + this.redraw(); + } + FrameProfiler.end("TerritoryLayer:tick", tickProfile); } private spawnHighlight() { @@ -267,8 +300,19 @@ export class TerritoryLayer implements Layer { init() { this.eventBus.on(MouseOverEvent, (e) => this.onMouseOver(e)); + this.eventBus.on(ContextMenuEvent, (e) => this.onMouseOver(e)); this.eventBus.on(AlternateViewEvent, (e) => { this.alternativeView = e.alternateView; + this.territoryRenderer?.setAlternativeView(this.alternativeView); + this.territoryRenderer?.markAllDirty(); + this.territoryRenderer?.setHoverHighlightOptions( + this.hoverHighlightOptions(), + ); + }); + this.eventBus.on(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 +327,9 @@ export class TerritoryLayer implements Layer { } private updateHighlightedTerritory() { - if (!this.alternativeView) { + const supportsHover = + this.alternativeView || this.territoryRenderer !== null; + if (!supportsHover) { return; } @@ -295,12 +341,8 @@ export class TerritoryLayer implements Layer { this.lastMousePosition.x, this.lastMousePosition.y, ); - if (!this.game.isValidCoord(cell.x, cell.y)) { - return; - } - const previousTerritory = this.highlightedTerritory; - const territory = this.getTerritoryAtCell(cell); + const territory = resolveHoverTarget(this.game, cell).player; if (territory) { this.highlightedTerritory = territory; @@ -309,58 +351,28 @@ export class TerritoryLayer implements Layer { } if (previousTerritory?.id() !== this.highlightedTerritory?.id()) { - const territories: PlayerView[] = []; - if (previousTerritory) { - territories.push(previousTerritory); - } - if (this.highlightedTerritory) { - territories.push(this.highlightedTerritory); + if (this.territoryRenderer?.isWebGL()) { + this.territoryRenderer.setHover( + this.highlightedTerritory?.smallID() ?? null, + ); + } else { + const territories: PlayerView[] = []; + if (previousTerritory) { + territories.push(previousTerritory); + } + if (this.highlightedTerritory) { + territories.push(this.highlightedTerritory); + } + this.redrawBorder(...territories); } - this.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, - ); + this.lastMyPlayerSmallId = this.game.myPlayer()?.smallID() ?? null; + this.configureRenderers(); + this.territoryRenderer?.redraw(); // Add a second canvas for highlights this.highlightCanvas = document.createElement("canvas"); @@ -377,6 +389,95 @@ export class TerritoryLayer implements Layer { }); } + private configureRenderers() { + this.territoryRenderer = null; + + if (!this.useWebGL) { + this.territoryRenderer = new CanvasTerritoryRenderer( + this.game, + this.theme, + ); + this.territoryRenderer.setAlternativeView(this.alternativeView); + this.territoryRenderer.setHoverHighlightOptions( + this.hoverHighlightOptions(), + ); + this.webglSupported = true; + this.emitWebGLStatus( + false, + false, + this.webglSupported, + "WebGL territory layer hidden.", + ); + return; + } + + const { renderer, reason } = TerritoryWebGLRenderer.create( + this.game, + this.theme, + ); + if (renderer) { + const strategy = new WebglTerritoryRenderer(renderer, this.game); + strategy.setAlternativeView(this.alternativeView); + strategy.markAllDirty(); + strategy.refreshPalette(); + strategy.setHoverHighlightOptions(this.hoverHighlightOptions()); + strategy.setHover(this.highlightedTerritory?.smallID() ?? null); + this.territoryRenderer = strategy; + this.webglSupported = true; + this.emitWebGLStatus(true, true, true, undefined); + return; + } + + const fallbackReason = + reason ?? + "WebGL not available. Using canvas fallback for borders and fill."; + this.territoryRenderer = new CanvasTerritoryRenderer(this.game, this.theme); + this.territoryRenderer.setAlternativeView(this.alternativeView); + this.territoryRenderer.setHoverHighlightOptions( + this.hoverHighlightOptions(), + ); + this.webglSupported = false; + this.emitWebGLStatus(true, false, false, fallbackReason); + } + + /** + * 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) => { @@ -388,60 +489,39 @@ export class TerritoryLayer implements Layer { ); } - initImageData() { - this.game.forEachTile((tile) => { - const cell = new Cell(this.game.x(tile), this.game.y(tile)); - const index = cell.y * this.game.width() + cell.x; - const offset = index * 4; - this.imageData.data[offset + 3] = 0; - this.alternativeImageData.data[offset + 3] = 0; - }); - } - renderLayer(context: CanvasRenderingContext2D) { const now = Date.now(); - if ( + const canRefresh = now > this.lastDragTime + this.nodrawDragDuration && - now > this.lastRefresh + this.refreshRate - ) { + now > this.lastRefresh + this.refreshRate; + if (canRefresh) { this.lastRefresh = now; const renderTerritoryStart = FrameProfiler.start(); this.renderTerritory(); FrameProfiler.end("TerritoryLayer:renderTerritory", renderTerritoryStart); + } - const [topLeft, bottomRight] = this.transformHandler.screenBoundingRect(); - const vx0 = Math.max(0, topLeft.x); - const vy0 = Math.max(0, topLeft.y); - const vx1 = Math.min(this.game.width() - 1, bottomRight.x); - const vy1 = Math.min(this.game.height() - 1, bottomRight.y); - - const w = vx1 - vx0 + 1; - const h = vy1 - vy0 + 1; - - if (w > 0 && h > 0) { - const putImageStart = FrameProfiler.start(); - this.context.putImageData( - this.alternativeView ? this.alternativeImageData : this.imageData, - 0, - 0, - vx0, - vy0, - w, - h, - ); - FrameProfiler.end("TerritoryLayer:putImageData", putImageStart); - } + const [topLeft, bottomRight] = this.transformHandler.screenBoundingRect(); + const vx0 = Math.max(0, topLeft.x); + const vy0 = Math.max(0, topLeft.y); + const vx1 = Math.min(this.game.width() - 1, bottomRight.x); + const vy1 = Math.min(this.game.height() - 1, bottomRight.y); + + const w = vx1 - vx0 + 1; + const h = vy1 - vy0 + 1; + if (this.territoryRenderer) { + this.territoryRenderer.render( + context, + { + x: vx0, + y: vy0, + width: w, + height: h, + }, + canRefresh, + ); } - const drawCanvasStart = FrameProfiler.start(); - context.drawImage( - this.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( @@ -459,11 +539,21 @@ export class TerritoryLayer implements Layer { } renderTerritory() { + if (!this.territoryRenderer) { + return; + } let numToRender = Math.floor(this.tileToRenderQueue.size() / 10); - if (numToRender === 0 || this.game.inSpawnPhase()) { + if ( + numToRender === 0 || + this.game.inSpawnPhase() || + this.territoryRenderer.isWebGL() + ) { numToRender = this.tileToRenderQueue.size(); } + const useNeighborPaint = !(this.territoryRenderer?.isWebGL() ?? false); + const neighborsToPaint: TileRef[] = []; + const mainSpan = FrameProfiler.start(); while (numToRender > 0) { numToRender--; @@ -474,105 +564,33 @@ export class TerritoryLayer implements Layer { 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; + if (useNeighborPaint) { + for (const neighbor of this.game.neighbors(tile)) { + neighborsToPaint.push(neighbor); + } } - 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(); + FrameProfiler.end("TerritoryLayer:renderTerritory.mainPaint", mainSpan); - 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); + if (useNeighborPaint && neighborsToPaint.length > 0) { + const neighborSpan = FrameProfiler.start(); + for (const neighbor of neighborsToPaint) { + this.paintTerritory(neighbor, true); //this is a misuse of the _Border parameter, making it a maybe stale border } - const isDefended = this.game.hasUnitNearby( - tile, - this.game.config().defensePostRange(), - UnitType.DefensePost, - owner.id(), - ); - - this.paintTile( - this.imageData, - tile, - owner.borderColor(tile, isDefended), - 255, + FrameProfiler.end( + "TerritoryLayer:renderTerritory.neighborPaint", + neighborSpan, ); - } else { - // Alternative view only shows borders. - this.clearAlternativeTile(tile); - - this.paintTile(this.imageData, tile, owner.territoryColor(tile), 150); - } - } - - alternateViewColor(other: PlayerView): Colord { - const myPlayer = this.game.myPlayer(); - if (!myPlayer) { - return this.theme.neutralColor(); - } - if (other.smallID() === myPlayer.smallID()) { - return this.theme.selfColor(); } - if (other.isFriendly(myPlayer)) { - return this.theme.allyColor(); - } - if (!other.hasEmbargo(myPlayer)) { - return this.theme.neutralColor(); - } - return this.theme.enemyColor(); - } - - paintAlternateViewTile(tile: TileRef, other: PlayerView) { - const color = this.alternateViewColor(other); - this.paintTile(this.alternativeImageData, tile, color, 255); } - paintTile(imageData: ImageData, tile: TileRef, color: Colord, alpha: number) { - const offset = tile * 4; - imageData.data[offset] = color.rgba.r; - imageData.data[offset + 1] = color.rgba.g; - imageData.data[offset + 2] = color.rgba.b; - imageData.data[offset + 3] = alpha; + paintTerritory(tile: TileRef, _maybeStaleBorder: boolean = false) { + this.territoryRenderer?.paintTile(tile); } clearTile(tile: TileRef) { - const offset = tile * 4; - this.imageData.data[offset + 3] = 0; // Set alpha to 0 (fully transparent) - this.alternativeImageData.data[offset + 3] = 0; // Set alpha to 0 (fully transparent) - } - - clearAlternativeTile(tile: TileRef) { - const offset = tile * 4; - this.alternativeImageData.data[offset + 3] = 0; // Set alpha to 0 (fully transparent) + this.territoryRenderer?.clearTile(tile); } enqueueTile(tile: TileRef) { diff --git a/src/client/graphics/layers/TerritoryRenderers.ts b/src/client/graphics/layers/TerritoryRenderers.ts new file mode 100644 index 0000000000..9710fc9a15 --- /dev/null +++ b/src/client/graphics/layers/TerritoryRenderers.ts @@ -0,0 +1,342 @@ +import { Colord } from "colord"; +import { Theme } from "../../../core/configuration/Config"; +import { UnitType } from "../../../core/game/Game"; +import { TileRef } from "../../../core/game/GameMap"; +import { GameView, PlayerView } from "../../../core/game/GameView"; +import { FrameProfiler } from "../FrameProfiler"; +import { + HoverHighlightOptions, + TerritoryWebGLRenderer, +} from "./TerritoryWebGLRenderer"; + +export interface TerritoryRendererStrategy { + isWebGL(): boolean; + redraw(): void; + markAllDirty(): void; + paintTile(tile: TileRef): void; + render( + context: CanvasRenderingContext2D, + viewport: { x: number; y: number; width: number; height: number }, + shouldBlit: boolean, + ): void; + setAlternativeView(enabled: boolean): void; + setHover(playerSmallId: number | null): void; + setHoverHighlightOptions(options: HoverHighlightOptions): void; + refreshPalette(): void; + clearTile(tile: TileRef): void; +} + +export class CanvasTerritoryRenderer implements TerritoryRendererStrategy { + private canvas: HTMLCanvasElement; + private context: CanvasRenderingContext2D; + private imageData: ImageData; + private alternativeImageData: ImageData; + private alternativeView = false; + + constructor( + private readonly game: GameView, + private readonly theme: Theme, + ) { + this.canvas = document.createElement("canvas"); + const context = this.canvas.getContext("2d"); + if (!context) throw new Error("2d context not supported"); + this.context = context; + this.imageData = context.createImageData(1, 1); + this.alternativeImageData = context.createImageData(1, 1); + } + + isWebGL(): boolean { + return false; + } + + redraw() { + this.canvas.width = this.game.width(); + this.canvas.height = this.game.height(); + this.imageData = this.context.getImageData( + 0, + 0, + this.canvas.width, + this.canvas.height, + ); + this.alternativeImageData = this.context.getImageData( + 0, + 0, + this.canvas.width, + this.canvas.height, + ); + this.initImageData(); + } + + markAllDirty(): void { + // No special handling needed for canvas path. + } + + paintTile(tile: TileRef) { + const cpuStart = FrameProfiler.start(); + const hasOwner = this.game.hasOwner(tile); + const rawOwner = hasOwner ? this.game.owner(tile) : null; + const owner = + rawOwner && + typeof (rawOwner as any).isPlayer === "function" && + (rawOwner as any).isPlayer() + ? (rawOwner as PlayerView) + : null; + const isBorderTile = this.game.isBorder(tile); + const hasFallout = this.game.hasFallout(tile); + let isDefended = false; + if (owner && isBorderTile) { + isDefended = this.game.hasUnitNearby( + tile, + this.game.config().defensePostRange(), + UnitType.DefensePost, + owner.id(), + ); + } + + if (!owner) { + if (hasFallout) { + this.paintTileColor( + this.imageData, + tile, + this.theme.falloutColor(), + 150, + ); + this.paintTileColor( + this.alternativeImageData, + tile, + this.theme.falloutColor(), + 150, + ); + } else { + this.clearTile(tile); + } + FrameProfiler.end("CanvasTerritoryRenderer:paintTile", cpuStart); + return; + } + + const myPlayer = this.game.myPlayer(); + + if (isBorderTile) { + if (myPlayer) { + const alternativeColor = this.alternateViewColor(owner); + this.paintTileColor( + this.alternativeImageData, + tile, + alternativeColor, + 255, + ); + } + this.paintTileColor( + this.imageData, + tile, + owner.borderColor(tile, isDefended), + 255, + ); + } else { + // Alternative view only shows borders. + this.clearAlternativeTile(tile); + this.paintTileColor( + this.imageData, + tile, + owner.territoryColor(tile), + 150, + ); + } + FrameProfiler.end("CanvasTerritoryRenderer:paintTile", cpuStart); + } + + render( + context: CanvasRenderingContext2D, + viewport: { x: number; y: number; width: number; height: number }, + shouldBlit: boolean, + ) { + const { x, y, width, height } = viewport; + if (width <= 0 || height <= 0) { + return; + } + if (shouldBlit) { + const putImageStart = FrameProfiler.start(); + this.context.putImageData( + this.alternativeView ? this.alternativeImageData : this.imageData, + 0, + 0, + x, + y, + width, + height, + ); + FrameProfiler.end("CanvasTerritoryRenderer:putImageData", putImageStart); + } + + const drawCanvasStart = FrameProfiler.start(); + context.drawImage( + this.canvas, + -this.game.width() / 2, + -this.game.height() / 2, + this.game.width(), + this.game.height(), + ); + FrameProfiler.end("CanvasTerritoryRenderer:drawCanvas", drawCanvasStart); + } + + setAlternativeView(enabled: boolean): void { + this.alternativeView = enabled; + } + + setHover(): void { + // Canvas path relies on CPU highlight redraw in TerritoryLayer. + } + + setHoverHighlightOptions(): void { + // Not used in canvas mode. + } + + refreshPalette(): void { + // Nothing to refresh for canvas path. + } + + clearTile(tile: TileRef) { + const offset = tile * 4; + this.imageData.data[offset + 3] = 0; + this.alternativeImageData.data[offset + 3] = 0; + } + + private alternateViewColor(other: PlayerView): Colord { + const myPlayer = this.game.myPlayer(); + if (!myPlayer) { + return this.theme.neutralColor(); + } + if (other.smallID() === myPlayer.smallID()) { + return this.theme.selfColor(); + } + if (other.isFriendly(myPlayer)) { + return this.theme.allyColor(); + } + if (!other.hasEmbargo(myPlayer)) { + return this.theme.neutralColor(); + } + return this.theme.enemyColor(); + } + + private paintTileColor( + imageData: ImageData, + tile: TileRef, + color: Colord, + alpha: number, + ) { + const offset = tile * 4; + imageData.data[offset] = color.rgba.r; + imageData.data[offset + 1] = color.rgba.g; + imageData.data[offset + 2] = color.rgba.b; + imageData.data[offset + 3] = alpha; + } + + private clearAlternativeTile(tile: TileRef) { + const offset = tile * 4; + this.alternativeImageData.data[offset + 3] = 0; + } + + private initImageData() { + this.game.forEachTile((tile) => { + const offset = tile * 4; + this.imageData.data[offset + 3] = 0; + this.alternativeImageData.data[offset + 3] = 0; + }); + } +} + +export class WebglTerritoryRenderer implements TerritoryRendererStrategy { + constructor( + private readonly renderer: TerritoryWebGLRenderer, + private readonly game: GameView, + ) {} + + isWebGL(): boolean { + return true; + } + + redraw(): void { + this.markAllDirty(); + } + + markAllDirty(): void { + this.renderer.markAllDirty(); + } + + paintTile(tile: TileRef): void { + const hasOwner = this.game.hasOwner(tile); + const rawOwner = hasOwner ? this.game.owner(tile) : null; + const owner = + rawOwner && + typeof (rawOwner as any).isPlayer === "function" && + (rawOwner as any).isPlayer() + ? (rawOwner as PlayerView) + : null; + const isBorderTile = this.game.isBorder(tile); + + // Update defended and relation state in the shared buffer + if (owner && isBorderTile) { + const isDefended = this.game.hasUnitNearby( + tile, + this.game.config().defensePostRange(), + UnitType.DefensePost, + owner.id(), + ); + const { hasEmbargo, hasFriendly } = owner.borderRelationFlags(tile); + let relation = 0; // neutral + if (hasFriendly) { + relation = 1; // friendly + } else if (hasEmbargo) { + relation = 2; // embargo + } + this.game.setDefended(tile, isDefended); + this.game.setRelation(tile, relation); + } else { + // Clear defended/relation state for non-border tiles + this.game.setDefended(tile, false); + this.game.setRelation(tile, 0); + } + + this.renderer.markTile(tile); + } + + render( + context: CanvasRenderingContext2D, + _viewport: { x: number; y: number; width: number; height: number }, + _shouldBlit: boolean, + ): void { + const webglRenderStart = FrameProfiler.start(); + this.renderer.render(); + FrameProfiler.end("WebglTerritoryRenderer:render", webglRenderStart); + + const drawCanvasStart = FrameProfiler.start(); + context.drawImage( + this.renderer.canvas, + -this.game.width() / 2, + -this.game.height() / 2, + this.game.width(), + this.game.height(), + ); + FrameProfiler.end("WebglTerritoryRenderer:drawImage", drawCanvasStart); + } + + setAlternativeView(enabled: boolean): void { + this.renderer.setAlternativeView(enabled); + } + + setHover(playerSmallId: number | null): void { + this.renderer.setHoveredPlayerId(playerSmallId ?? null); + } + + setHoverHighlightOptions(options: HoverHighlightOptions): void { + this.renderer.setHoverHighlightOptions(options); + } + + refreshPalette(): void { + this.renderer.refreshPalette(); + } + + clearTile(): void { + // No-op for WebGL; canvas alpha clearing is not used. + } +} diff --git a/src/client/graphics/layers/TerritoryWebGLRenderer.ts b/src/client/graphics/layers/TerritoryWebGLRenderer.ts new file mode 100644 index 0000000000..ad2be6d88e --- /dev/null +++ b/src/client/graphics/layers/TerritoryWebGLRenderer.ts @@ -0,0 +1,1010 @@ +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 borderColorTexture: WebGLTexture | null; + private readonly uniforms: { + resolution: WebGLUniformLocation | null; + state: WebGLUniformLocation | null; + palette: WebGLUniformLocation | null; + relations: WebGLUniformLocation | null; + borderColor: 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; + // Border color uniforms for shader-computed borders + borderNeutral: WebGLUniformLocation | null; + borderFriendly: WebGLUniformLocation | null; + borderEmbargo: WebGLUniformLocation | null; + borderDefendedNeutralLight: WebGLUniformLocation | null; + borderDefendedNeutralDark: WebGLUniformLocation | null; + borderDefendedFriendlyLight: WebGLUniformLocation | null; + borderDefendedFriendlyDark: WebGLUniformLocation | null; + borderDefendedEmbargoLight: WebGLUniformLocation | null; + borderDefendedEmbargoDark: WebGLUniformLocation | null; + }; + + private readonly state: Uint16Array; + private readonly dirtyRows: Map = new Map(); + private readonly borderDirtyRows: Map = new Map(); + private needsFullUpload = true; + private borderNeedsFullUpload = 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 borderColorData: Uint8Array; + + 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.borderColorData = new Uint8Array( + this.canvas.width * this.canvas.height * 4, + ); + + 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.borderColorTexture = null; + this.uniforms = { + resolution: null, + state: null, + palette: null, + relations: null, + borderColor: 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, + borderNeutral: null, + borderFriendly: null, + borderEmbargo: null, + borderDefendedNeutralLight: null, + borderDefendedNeutralDark: null, + borderDefendedFriendlyLight: null, + borderDefendedFriendlyDark: null, + borderDefendedEmbargoLight: null, + borderDefendedEmbargoDark: 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.borderColorTexture = null; + this.uniforms = { + resolution: null, + state: null, + palette: null, + relations: null, + borderColor: 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, + borderNeutral: null, + borderFriendly: null, + borderEmbargo: null, + borderDefendedNeutralLight: null, + borderDefendedNeutralDark: null, + borderDefendedFriendlyLight: null, + borderDefendedFriendlyDark: null, + borderDefendedEmbargoLight: null, + borderDefendedEmbargoDark: 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"), + borderColor: gl.getUniformLocation(this.program, "u_borderColor"), + 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"), + borderNeutral: gl.getUniformLocation(this.program, "u_borderNeutral"), + borderFriendly: gl.getUniformLocation(this.program, "u_borderFriendly"), + borderEmbargo: gl.getUniformLocation(this.program, "u_borderEmbargo"), + borderDefendedNeutralLight: gl.getUniformLocation( + this.program, + "u_borderDefendedNeutralLight", + ), + borderDefendedNeutralDark: gl.getUniformLocation( + this.program, + "u_borderDefendedNeutralDark", + ), + borderDefendedFriendlyLight: gl.getUniformLocation( + this.program, + "u_borderDefendedFriendlyLight", + ), + borderDefendedFriendlyDark: gl.getUniformLocation( + this.program, + "u_borderDefendedFriendlyDark", + ), + borderDefendedEmbargoLight: gl.getUniformLocation( + this.program, + "u_borderDefendedEmbargoLight", + ), + borderDefendedEmbargoDark: gl.getUniformLocation( + this.program, + "u_borderDefendedEmbargoDark", + ), + }; + + // 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.borderColorTexture = gl.createTexture(); + gl.activeTexture(gl.TEXTURE3); + gl.bindTexture(gl.TEXTURE_2D, this.borderColorTexture); + 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.canvas.width, + this.canvas.height, + 0, + gl.RGBA, + gl.UNSIGNED_BYTE, + this.borderColorData, + ); + + this.uploadPalette(); + + gl.useProgram(this.program); + gl.uniform1i(this.uniforms.state, 0); + gl.uniform1i(this.uniforms.palette, 1); + gl.uniform1i(this.uniforms.relations, 2); + gl.uniform1i(this.uniforms.borderColor, 3); + + if (this.uniforms.resolution) { + gl.uniform2f( + this.uniforms.resolution, + this.canvas.width, + this.canvas.height, + ); + } + if (this.uniforms.alpha) { + gl.uniform1f(this.uniforms.alpha, 150 / 255); + } + if (this.uniforms.fallout) { + const f = this.theme.falloutColor().rgba; + gl.uniform4f( + this.uniforms.fallout, + f.r / 255, + f.g / 255, + f.b / 255, + f.a ?? 1, + ); + } + if (this.uniforms.altSelf) { + const c = this.theme.selfColor().rgba; + gl.uniform4f( + this.uniforms.altSelf, + c.r / 255, + c.g / 255, + c.b / 255, + c.a ?? 1, + ); + } + if (this.uniforms.altAlly) { + const c = this.theme.allyColor().rgba; + gl.uniform4f( + this.uniforms.altAlly, + c.r / 255, + c.g / 255, + c.b / 255, + c.a ?? 1, + ); + } + if (this.uniforms.altNeutral) { + const c = this.theme.neutralColor().rgba; + gl.uniform4f( + this.uniforms.altNeutral, + c.r / 255, + c.g / 255, + c.b / 255, + c.a ?? 1, + ); + } + if (this.uniforms.altEnemy) { + const c = this.theme.enemyColor().rgba; + gl.uniform4f( + this.uniforms.altEnemy, + c.r / 255, + c.g / 255, + c.b / 255, + c.a ?? 1, + ); + } + if (this.uniforms.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; + } + + setBorderColor( + tile: TileRef, + rgba: { r: number; g: number; b: number; a: number }, + ) { + const offset = tile * 4; + this.borderColorData[offset] = rgba.r; + this.borderColorData[offset + 1] = rgba.g; + this.borderColorData[offset + 2] = rgba.b; + this.borderColorData[offset + 3] = rgba.a; + this.markBorderDirty(tile); + } + + clearBorderColor(tile: TileRef) { + const offset = tile * 4; + this.borderColorData[offset] = 0; + this.borderColorData[offset + 1] = 0; + this.borderColorData[offset + 2] = 0; + this.borderColorData[offset + 3] = 0; + this.markBorderDirty(tile); + } + + 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); + } + } + + private markBorderDirty(tile: TileRef) { + if (this.borderNeedsFullUpload) { + return; + } + const x = tile % this.canvas.width; + const y = Math.floor(tile / this.canvas.width); + const span = this.borderDirtyRows.get(y); + if (span === undefined) { + this.borderDirtyRows.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(); + this.borderNeedsFullUpload = true; + this.borderDirtyRows.clear(); + } + + refreshPalette() { + if (!this.gl || !this.paletteTexture || !this.relationTexture) { + return; + } + this.uploadPalette(); + } + + render() { + if (!this.gl || !this.program || !this.vao) { + return; + } + const gl = this.gl; + + const uploadStateSpan = FrameProfiler.start(); + this.uploadStateTexture(); + FrameProfiler.end("TerritoryWebGLRenderer:uploadState", uploadStateSpan); + + const uploadBorderSpan = FrameProfiler.start(); + this.uploadBorderTexture(); + FrameProfiler.end("TerritoryWebGLRenderer:uploadBorder", uploadBorderSpan); + + 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.clearColor(0, 0, 0, 0); + gl.clear(gl.COLOR_BUFFER_BIT); + gl.drawArrays(gl.TRIANGLES, 0, 6); + gl.bindVertexArray(null); + FrameProfiler.end("TerritoryWebGLRenderer:draw", renderSpan); + } + + private uploadStateTexture(): { rows: number; bytes: number } { + if (!this.gl || !this.stateTexture) return { rows: 0, bytes: 0 }; + const gl = this.gl; + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, this.stateTexture); + + const bytesPerPixel = Uint16Array.BYTES_PER_ELEMENT; + let rowsUploaded = 0; + let bytesUploaded = 0; + + if (this.needsFullUpload) { + gl.texImage2D( + gl.TEXTURE_2D, + 0, + gl.R16UI, + this.canvas.width, + this.canvas.height, + 0, + gl.RED_INTEGER, + gl.UNSIGNED_SHORT, + this.state, + ); + this.needsFullUpload = false; + this.dirtyRows.clear(); + rowsUploaded = this.canvas.height; + bytesUploaded = this.canvas.width * this.canvas.height * bytesPerPixel; + return { rows: rowsUploaded, bytes: bytesUploaded }; + } + + if (this.dirtyRows.size === 0) { + return { rows: 0, bytes: 0 }; + } + + for (const [y, span] of this.dirtyRows) { + const width = span.maxX - span.minX + 1; + const offset = y * this.canvas.width + span.minX; + const rowSlice = this.state.subarray(offset, offset + width); + gl.texSubImage2D( + gl.TEXTURE_2D, + 0, + span.minX, + y, + width, + 1, + gl.RED_INTEGER, + gl.UNSIGNED_SHORT, + rowSlice, + ); + rowsUploaded++; + bytesUploaded += width * bytesPerPixel; + } + this.dirtyRows.clear(); + return { rows: rowsUploaded, bytes: bytesUploaded }; + } + + private uploadBorderTexture(): { rows: number; bytes: number } { + if (!this.gl || !this.borderColorTexture) return { rows: 0, bytes: 0 }; + const gl = this.gl; + gl.activeTexture(gl.TEXTURE3); + gl.bindTexture(gl.TEXTURE_2D, this.borderColorTexture); + + const bytesPerPixel = Uint8Array.BYTES_PER_ELEMENT * 4; // RGBA8 + let rowsUploaded = 0; + let bytesUploaded = 0; + + if (this.borderNeedsFullUpload) { + gl.texImage2D( + gl.TEXTURE_2D, + 0, + gl.RGBA8, + this.canvas.width, + this.canvas.height, + 0, + gl.RGBA, + gl.UNSIGNED_BYTE, + this.borderColorData, + ); + this.borderNeedsFullUpload = false; + this.borderDirtyRows.clear(); + rowsUploaded = this.canvas.height; + bytesUploaded = this.canvas.width * this.canvas.height * bytesPerPixel; + return { rows: rowsUploaded, bytes: bytesUploaded }; + } + + if (this.borderDirtyRows.size === 0) { + return { rows: 0, bytes: 0 }; + } + + for (const [y, span] of this.borderDirtyRows) { + const width = span.maxX - span.minX + 1; + const offset = (y * this.canvas.width + span.minX) * 4; + const rowSlice = this.borderColorData.subarray( + offset, + offset + width * 4, + ); + gl.texSubImage2D( + gl.TEXTURE_2D, + 0, + span.minX, + y, + width, + 1, + gl.RGBA, + gl.UNSIGNED_BYTE, + rowSlice, + ); + rowsUploaded++; + bytesUploaded += width * bytesPerPixel; + } + this.borderDirtyRows.clear(); + return { rows: rowsUploaded, bytes: bytesUploaded }; + } + + private labelUpload( + base: string, + metrics: { rows: number; bytes: number }, + ): string { + if (metrics.rows === 0 || metrics.bytes === 0) { + return `${base} (skip)`; + } + const rowBucket = + metrics.rows >= this.canvas.height + ? "full" + : `${Math.ceil(metrics.rows / 50) * 50}`; + const kb = Math.max(1, Math.round(metrics.bytes / 1024)); + const kbBucket = kb > 1024 ? `${Math.round(kb / 1024)}MB` : `${kb}KB`; + return `${base} rows:${rowBucket} bytes:${kbBucket}`; + } + + 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 * 8); // 8 bytes per player: territory RGBA + border RGBA + const relationData = new Uint8Array(this.paletteWidth); + + for (const p of players) { + const id = p.smallID(); + // Territory color (first 4 bytes) + 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); + + // Base border color (next 4 bytes) + const borderRgba = p.borderColor().rgba; // Get base border color without relation/defended + 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); + + 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 * 2, // 2 pixels per player (territory + border) + 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 sampler2D u_borderColor; + 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 vec4 u_borderNeutral; + uniform vec4 u_borderFriendly; + uniform vec4 u_borderEmbargo; + uniform vec4 u_borderDefendedNeutralLight; + uniform vec4 u_borderDefendedNeutralDark; + uniform vec4 u_borderDefendedFriendlyLight; + uniform vec4 u_borderDefendedFriendlyDark; + uniform vec4 u_borderDefendedEmbargoLight; + uniform vec4 u_borderDefendedEmbargoDark; + 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 + bool isDefended = (state & 0x1000u) != 0u; // bit 12 + uint relation = (state & 0xC000u) >> 14u; // bits 14-15 + + 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) * 2, 0), 0); // territory color + vec4 baseBorder = texelFetch(u_palette, ivec2(int(owner) * 2 + 1, 0), 0); // base border color + vec3 color = base.rgb; + float a = u_alpha; + + if (isBorder) { + // Start with base border color and apply relation tint + vec3 borderColor = baseBorder.rgb; + + // Apply relation-based tinting (same logic as PlayerView.borderColor) + const float BORDER_TINT_RATIO = 0.35; + const vec3 FRIENDLY_TINT_TARGET = vec3(0.0, 1.0, 0.0); // green + const vec3 EMBARGO_TINT_TARGET = vec3(1.0, 0.0, 0.0); // red + + if (relation == 1u) { // friendly + borderColor = borderColor * (1.0 - BORDER_TINT_RATIO) + + FRIENDLY_TINT_TARGET * BORDER_TINT_RATIO; + } else if (relation == 2u) { // embargo + borderColor = borderColor * (1.0 - BORDER_TINT_RATIO) + + EMBARGO_TINT_TARGET * BORDER_TINT_RATIO; + } + // relation == 0u (neutral) uses base border color as-is + + // Apply defended checkerboard pattern + if (isDefended) { + bool isLightTile = ((texCoord.x % 2) == (texCoord.y % 2)); + // Simple checkerboard: alternate between lighter and darker versions + const float LIGHT_FACTOR = 1.2; + const float DARK_FACTOR = 0.8; + borderColor *= isLightTile ? LIGHT_FACTOR : DARK_FACTOR; + } + + color = borderColor; + a = baseBorder.a; // Already in 0-1 range from RGBA8 texture + } + + 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 0000000000..b2170a2dac --- /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/GameRunner.ts b/src/core/GameRunner.ts index 6a8a4042f3..552a15a9eb 100644 --- a/src/core/GameRunner.ts +++ b/src/core/GameRunner.ts @@ -37,12 +37,15 @@ export async function createGameRunner( clientID: ClientID, mapLoader: GameMapLoader, callBack: (gu: GameUpdateViewData | ErrorUpdate) => void, + tileUpdateSink?: (tile: TileRef) => void, + sharedStateBuffer?: SharedArrayBuffer, ): Promise { const config = await getConfig(gameStart.config, null); const gameMap = await loadGameMap( gameStart.config.gameMap, gameStart.config.gameMapSize, mapLoader, + sharedStateBuffer, ); const random = new PseudoRandom(simpleHash(gameStart.gameID)); @@ -85,6 +88,7 @@ export async function createGameRunner( game, new Executor(game, gameStart.gameID, clientID), callBack, + tileUpdateSink, ); gr.init(); return gr; @@ -101,6 +105,7 @@ export class GameRunner { public game: Game, private execManager: Executor, private callBack: (gu: GameUpdateViewData | ErrorUpdate) => void, + private tileUpdateSink?: (tile: TileRef) => void, ) {} init() { @@ -175,13 +180,25 @@ export class GameRunner { }); } - // Many tiles are updated to pack it into an array - const packedTileUpdates = updates[GameUpdateType.Tile].map((u) => u.update); + // Many tiles are updated; either publish them via a shared sink or pack + // them into the view data. + let packedTileUpdates: BigUint64Array; + const tileUpdates = updates[GameUpdateType.Tile]; + if (this.tileUpdateSink !== undefined) { + for (const u of tileUpdates) { + const tileRef = Number(u.update >> 16n) as TileRef; + this.tileUpdateSink(tileRef); + } + packedTileUpdates = new BigUint64Array(); + } else { + const raw = tileUpdates.map((u) => u.update); + packedTileUpdates = new BigUint64Array(raw); + } updates[GameUpdateType.Tile] = []; this.callBack({ tick: this.game.ticks(), - packedTileUpdates: new BigUint64Array(packedTileUpdates), + packedTileUpdates, updates: updates, playerNameViewData: this.playerViewData, tickExecutionDuration: tickExecutionDuration, @@ -272,4 +289,8 @@ export class GameRunner { } return player.bestTransportShipSpawn(targetTile); } + + public hasPendingTurns(): boolean { + return this.currTurn < this.turns.length; + } } diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts index 2c23ad8641..26127f5581 100644 --- a/src/core/game/GameImpl.ts +++ b/src/core/game/GameImpl.ts @@ -852,6 +852,22 @@ export class GameImpl implements Game { hasFallout(ref: TileRef): boolean { return this._map.hasFallout(ref); } + + isDefended(ref: TileRef): boolean { + return this._map.isDefended(ref); + } + + setDefended(ref: TileRef, value: boolean): void { + return this._map.setDefended(ref, value); + } + + getRelation(ref: TileRef): number { + return this._map.getRelation(ref); + } + + setRelation(ref: TileRef, relation: number): void { + return this._map.setRelation(ref, relation); + } isBorder(ref: TileRef): boolean { return this._map.isBorder(ref); } diff --git a/src/core/game/GameMap.ts b/src/core/game/GameMap.ts index 7a3bd8e6d5..deeddd845a 100644 --- a/src/core/game/GameMap.ts +++ b/src/core/game/GameMap.ts @@ -27,6 +27,10 @@ export interface GameMap { setOwnerID(ref: TileRef, playerId: number): void; hasFallout(ref: TileRef): boolean; setFallout(ref: TileRef, value: boolean): void; + isDefended(ref: TileRef): boolean; + setDefended(ref: TileRef, value: boolean): void; + getRelation(ref: TileRef): number; + setRelation(ref: TileRef, relation: number): void; isOnEdgeOfMap(ref: TileRef): boolean; isBorder(ref: TileRef): boolean; neighbors(ref: TileRef): TileRef[]; @@ -72,14 +76,20 @@ export class GameMapImpl implements GameMap { // State bits (Uint16Array) private static readonly PLAYER_ID_MASK = 0xfff; private static readonly FALLOUT_BIT = 13; - private static readonly DEFENSE_BONUS_BIT = 14; - // Bit 15 still reserved + private static readonly DEFENDED_BIT = 12; + private static readonly RELATION_MASK = 0xc000; // bits 14-15 + private static readonly RELATION_SHIFT = 14; + // Relation values (stored in bits 14-15) + private static readonly RELATION_NEUTRAL = 0; + private static readonly RELATION_FRIENDLY = 1; + private static readonly RELATION_EMBARGO = 2; constructor( width: number, height: number, terrainData: Uint8Array, private numLandTiles_: number, + stateBuffer?: ArrayBufferLike, ) { if (terrainData.length !== width * height) { throw new Error( @@ -89,7 +99,17 @@ export class GameMapImpl implements GameMap { this.width_ = width; this.height_ = height; this.terrain = terrainData; - this.state = new Uint16Array(width * height); + if (stateBuffer !== undefined) { + const state = new Uint16Array(stateBuffer); + if (state.length !== width * height) { + throw new Error( + `State buffer length ${state.length} doesn't match dimensions ${width}x${height}`, + ); + } + this.state = state; + } else { + this.state = new Uint16Array(width * height); + } // Precompute the LUTs let ref = 0; this.refToX = new Array(width * height); @@ -206,6 +226,33 @@ export class GameMapImpl implements GameMap { } } + isDefended(ref: TileRef): boolean { + return Boolean(this.state[ref] & (1 << GameMapImpl.DEFENDED_BIT)); + } + + setDefended(ref: TileRef, value: boolean): void { + if (value) { + this.state[ref] |= 1 << GameMapImpl.DEFENDED_BIT; + } else { + this.state[ref] &= ~(1 << GameMapImpl.DEFENDED_BIT); + } + } + + getRelation(ref: TileRef): number { + return ( + (this.state[ref] & GameMapImpl.RELATION_MASK) >> + GameMapImpl.RELATION_SHIFT + ); + } + + setRelation(ref: TileRef, relation: number): void { + // Clear existing relation bits + this.state[ref] &= ~GameMapImpl.RELATION_MASK; + // Set new relation bits + this.state[ref] |= + (relation << GameMapImpl.RELATION_SHIFT) & GameMapImpl.RELATION_MASK; + } + isOnEdgeOfMap(ref: TileRef): boolean { const x = this.x(ref); const y = this.y(ref); @@ -220,18 +267,6 @@ export class GameMapImpl implements GameMap { ); } - hasDefenseBonus(ref: TileRef): boolean { - return Boolean(this.state[ref] & (1 << GameMapImpl.DEFENSE_BONUS_BIT)); - } - - setDefenseBonus(ref: TileRef, value: boolean): void { - if (value) { - this.state[ref] |= 1 << GameMapImpl.DEFENSE_BONUS_BIT; - } else { - this.state[ref] &= ~(1 << GameMapImpl.DEFENSE_BONUS_BIT); - } - } - // Helper methods isWater(ref: TileRef): boolean { return !this.isLand(ref); diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts index 63ce987de5..ee94299715 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(); + + // 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, + }); - this._defendedBorderColors = this.game - .config() - .theme() - .defendedBorderColors(this._borderColor); + // 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 { @@ -472,6 +585,8 @@ export class GameView implements GameMap { private _cosmetics: Map = new Map(); private _map: GameMap; + private readonly usesSharedTileState: boolean; + private readonly terraNullius = new TerraNulliusImpl(); constructor( public worker: WorkerClient, @@ -480,8 +595,10 @@ export class GameView implements GameMap { private _myClientID: ClientID, private _gameID: GameID, private humans: Player[], + usesSharedTileState: boolean = false, ) { this._map = this._mapData.gameMap; + this.usesSharedTileState = usesSharedTileState; this.lastUpdate = null; this.unitGrid = new UnitGrid(this._map); this._cosmetics = new Map( @@ -510,9 +627,16 @@ export class GameView implements GameMap { this.lastUpdate = gu; this.updatedTiles = []; - this.lastUpdate.packedTileUpdates.forEach((tu) => { - this.updatedTiles.push(this.updateTile(tu)); - }); + if (this.usesSharedTileState) { + this.lastUpdate.packedTileUpdates.forEach((tu) => { + const tileRef = Number(tu); + this.updatedTiles.push(tileRef); + }); + } else { + this.lastUpdate.packedTileUpdates.forEach((tu) => { + this.updatedTiles.push(this.updateTile(tu)); + }); + } if (gu.updates === null) { throw new Error("lastUpdate.updates not initialized"); @@ -618,11 +742,11 @@ export class GameView implements GameMap { playerBySmallID(id: number): PlayerView | TerraNullius { if (id === 0) { - return new TerraNulliusImpl(); + return this.terraNullius; } const playerId = this.smallIDToID.get(id); if (playerId === undefined) { - throw new Error(`small id ${id} not found`); + return this.terraNullius; } return this.player(playerId); } @@ -730,6 +854,22 @@ export class GameView implements GameMap { setFallout(ref: TileRef, value: boolean): void { return this._map.setFallout(ref, value); } + + isDefended(ref: TileRef): boolean { + return this._map.isDefended(ref); + } + + setDefended(ref: TileRef, value: boolean): void { + return this._map.setDefended(ref, value); + } + + getRelation(ref: TileRef): number { + return this._map.getRelation(ref); + } + + setRelation(ref: TileRef, relation: number): void { + return this._map.setRelation(ref, relation); + } isBorder(ref: TileRef): boolean { return this._map.isBorder(ref); } @@ -779,6 +919,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/TerrainMapLoader.ts b/src/core/game/TerrainMapLoader.ts index e11dd7131b..3f7e527744 100644 --- a/src/core/game/TerrainMapLoader.ts +++ b/src/core/game/TerrainMapLoader.ts @@ -6,6 +6,8 @@ export type TerrainMapData = { nations: Nation[]; gameMap: GameMap; miniGameMap: GameMap; + sharedStateBuffer?: SharedArrayBuffer; + sharedDirtyBuffer?: SharedArrayBuffer; }; const loadedMaps = new Map(); @@ -35,15 +37,42 @@ export async function loadTerrainMap( map: GameMapType, mapSize: GameMapSize, terrainMapFileLoader: GameMapLoader, + sharedStateBuffer?: SharedArrayBuffer, ): Promise { - const cached = loadedMaps.get(map); - if (cached !== undefined) return cached; + const useCache = sharedStateBuffer === undefined; + const canUseSharedBuffers = + typeof SharedArrayBuffer !== "undefined" && + typeof Atomics !== "undefined" && + typeof (globalThis as any).crossOriginIsolated === "boolean" && + (globalThis as any).crossOriginIsolated === true; + + // Don't use cache if we can create SharedArrayBuffer but none was provided + const shouldUseCache = useCache && !canUseSharedBuffers; + + if (shouldUseCache) { + const cached = loadedMaps.get(map); + if (cached !== undefined) return cached; + } const mapFiles = terrainMapFileLoader.getMapData(map); const manifest = await mapFiles.manifest(); + const stateBuffer = + sharedStateBuffer ?? + (canUseSharedBuffers + ? new SharedArrayBuffer( + manifest.map.width * + manifest.map.height * + Uint16Array.BYTES_PER_ELEMENT, + ) + : undefined); + const gameMap = mapSize === GameMapSize.Normal - ? await genTerrainFromBin(manifest.map, await mapFiles.mapBin()) + ? await genTerrainFromBin( + manifest.map, + await mapFiles.mapBin(), + stateBuffer, + ) : await genTerrainFromBin(manifest.map4x, await mapFiles.map4xBin()); const miniMap = @@ -63,18 +92,28 @@ export async function loadTerrainMap( }); } - const result = { + const result: TerrainMapData = { nations: manifest.nations, gameMap: gameMap, miniGameMap: miniMap, + sharedStateBuffer: + typeof SharedArrayBuffer !== "undefined" && + stateBuffer instanceof SharedArrayBuffer + ? stateBuffer + : undefined, + sharedDirtyBuffer: undefined, // populated by consumer when needed }; - loadedMaps.set(map, result); + // Only cache the result when caching is actually used (non-SAB path) + if (shouldUseCache) { + loadedMaps.set(map, result); + } return result; } export async function genTerrainFromBin( mapData: MapMetadata, data: Uint8Array, + stateBuffer?: ArrayBufferLike, ): Promise { if (data.length !== mapData.width * mapData.height) { throw new Error( @@ -87,5 +126,6 @@ export async function genTerrainFromBin( mapData.height, data, mapData.num_land_tiles, + stateBuffer, ); } diff --git a/src/core/game/UserSettings.ts b/src/core/game/UserSettings.ts index fd5ac12a5f..8a4cb5a564 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/src/core/worker/SharedTileRing.ts b/src/core/worker/SharedTileRing.ts new file mode 100644 index 0000000000..328def7306 --- /dev/null +++ b/src/core/worker/SharedTileRing.ts @@ -0,0 +1,85 @@ +import { TileRef } from "../game/GameMap"; + +export interface SharedTileRingBuffers { + header: SharedArrayBuffer; + data: SharedArrayBuffer; + dirty: SharedArrayBuffer; +} + +export interface SharedTileRingViews { + header: Int32Array; + buffer: Uint32Array; + dirtyFlags: Uint8Array; + capacity: number; +} + +// Header indices +export const TILE_RING_HEADER_WRITE_INDEX = 0; +export const TILE_RING_HEADER_READ_INDEX = 1; +export const TILE_RING_HEADER_OVERFLOW = 2; + +export function createSharedTileRingBuffers( + capacity: number, + numTiles: number, +): SharedTileRingBuffers { + const header = new SharedArrayBuffer(3 * Int32Array.BYTES_PER_ELEMENT); + const data = new SharedArrayBuffer(capacity * Uint32Array.BYTES_PER_ELEMENT); + const dirty = new SharedArrayBuffer(numTiles * Uint8Array.BYTES_PER_ELEMENT); + return { header, data, dirty }; +} + +export function createSharedTileRingViews( + buffers: SharedTileRingBuffers, +): SharedTileRingViews { + const header = new Int32Array(buffers.header); + const buffer = new Uint32Array(buffers.data); + const dirtyFlags = new Uint8Array(buffers.dirty); + return { + header, + buffer, + dirtyFlags, + capacity: buffer.length, + }; +} + +export function pushTileUpdate( + views: SharedTileRingViews, + value: TileRef, +): void { + const { header, buffer, capacity } = views; + + const write = Atomics.load(header, TILE_RING_HEADER_WRITE_INDEX); + const read = Atomics.load(header, TILE_RING_HEADER_READ_INDEX); + const nextWrite = (write + 1) % capacity; + + // If the buffer is full, advance read (drop oldest) and mark overflow. + if (nextWrite === read) { + Atomics.store(header, TILE_RING_HEADER_OVERFLOW, 1); + const nextRead = (read + 1) % capacity; + Atomics.store(header, TILE_RING_HEADER_READ_INDEX, nextRead); + } + + buffer[write] = value; + Atomics.store(header, TILE_RING_HEADER_WRITE_INDEX, nextWrite); +} + +export function drainTileUpdates( + views: SharedTileRingViews, + maxItems: number, + out: TileRef[], +): void { + const { header, buffer, capacity } = views; + + let read = Atomics.load(header, TILE_RING_HEADER_READ_INDEX); + const write = Atomics.load(header, TILE_RING_HEADER_WRITE_INDEX); + + let count = 0; + + while (read !== write && count < maxItems) { + out.push(buffer[read]); + read = (read + 1) % capacity; + count++; + } + + Atomics.store(header, TILE_RING_HEADER_READ_INDEX, read); +} diff --git a/src/core/worker/Worker.worker.ts b/src/core/worker/Worker.worker.ts index 1014968fb2..aae5a69efd 100644 --- a/src/core/worker/Worker.worker.ts +++ b/src/core/worker/Worker.worker.ts @@ -1,7 +1,13 @@ import version from "../../../resources/version.txt"; import { createGameRunner, GameRunner } from "../GameRunner"; import { FetchGameMapLoader } from "../game/FetchGameMapLoader"; +import { TileRef } from "../game/GameMap"; import { ErrorUpdate, GameUpdateViewData } from "../game/GameUpdates"; +import { + createSharedTileRingViews, + pushTileUpdate, + SharedTileRingViews, +} from "./SharedTileRing"; import { AttackAveragePositionResultMessage, InitializedMessage, @@ -16,6 +22,9 @@ import { const ctx: Worker = self as any; let gameRunner: Promise | null = null; const mapLoader = new FetchGameMapLoader(`/maps`, version); +let isProcessingTurns = false; +let sharedTileRing: SharedTileRingViews | null = null; +let dirtyFlags: Uint8Array | null = null; function gameUpdate(gu: GameUpdateViewData | ErrorUpdate) { // skip if ErrorUpdate @@ -32,25 +41,68 @@ function sendMessage(message: WorkerMessage) { ctx.postMessage(message); } +async function processPendingTurns() { + if (isProcessingTurns) { + return; + } + if (!gameRunner) { + return; + } + + const gr = await gameRunner; + if (!gr || !gr.hasPendingTurns()) { + return; + } + + isProcessingTurns = true; + try { + while (gr.hasPendingTurns()) { + gr.executeNextTick(); + } + } finally { + isProcessingTurns = false; + } +} + ctx.addEventListener("message", async (e: MessageEvent) => { const message = e.data; switch (message.type) { - case "heartbeat": - (await gameRunner)?.executeNextTick(); - break; case "init": try { + if (message.sharedTileRingHeader && message.sharedTileRingData) { + sharedTileRing = createSharedTileRingViews({ + header: message.sharedTileRingHeader, + data: message.sharedTileRingData, + dirty: message.sharedDirtyBuffer!, + }); + dirtyFlags = sharedTileRing.dirtyFlags; + } else { + sharedTileRing = null; + dirtyFlags = null; + } + gameRunner = createGameRunner( message.gameStartInfo, message.clientID, mapLoader, gameUpdate, + sharedTileRing && dirtyFlags + ? (tile: TileRef) => { + if (Atomics.compareExchange(dirtyFlags!, tile, 0, 1) === 0) { + pushTileUpdate(sharedTileRing!, tile); + } + } + : sharedTileRing + ? (tile: TileRef) => pushTileUpdate(sharedTileRing!, tile) + : undefined, + message.sharedStateBuffer, ).then((gr) => { sendMessage({ type: "initialized", id: message.id, } as InitializedMessage); + processPendingTurns(); return gr; }); } catch (error) { @@ -67,6 +119,7 @@ ctx.addEventListener("message", async (e: MessageEvent) => { try { const gr = await gameRunner; await gr.addTurn(message.turn); + processPendingTurns(); } catch (error) { console.error("Failed to process turn:", error); throw error; diff --git a/src/core/worker/WorkerClient.ts b/src/core/worker/WorkerClient.ts index bde436f398..1d824546d7 100644 --- a/src/core/worker/WorkerClient.ts +++ b/src/core/worker/WorkerClient.ts @@ -9,6 +9,7 @@ import { TileRef } from "../game/GameMap"; import { ErrorUpdate, GameUpdateViewData } from "../game/GameUpdates"; import { ClientID, GameStartInfo, Turn } from "../Schemas"; import { generateID } from "../Util"; +import { SharedTileRingBuffers } from "./SharedTileRing"; import { WorkerMessage } from "./WorkerMessages"; export class WorkerClient { @@ -22,6 +23,9 @@ export class WorkerClient { constructor( private gameStartInfo: GameStartInfo, private clientID: ClientID, + private sharedTileRingBuffers?: SharedTileRingBuffers, + private sharedStateBuffer?: SharedArrayBuffer, + private sharedDirtyBuffer?: SharedArrayBuffer, ) { this.worker = new Worker(new URL("./Worker.worker.ts", import.meta.url)); this.messageHandlers = new Map(); @@ -70,6 +74,10 @@ export class WorkerClient { id: messageId, gameStartInfo: this.gameStartInfo, clientID: this.clientID, + sharedTileRingHeader: this.sharedTileRingBuffers?.header, + sharedTileRingData: this.sharedTileRingBuffers?.data, + sharedStateBuffer: this.sharedStateBuffer, + sharedDirtyBuffer: this.sharedDirtyBuffer, }); // Add timeout for initialization @@ -100,12 +108,6 @@ export class WorkerClient { }); } - sendHeartbeat() { - this.worker.postMessage({ - type: "heartbeat", - }); - } - playerProfile(playerID: number): Promise { return new Promise((resolve, reject) => { if (!this.isInitialized) { diff --git a/src/core/worker/WorkerMessages.ts b/src/core/worker/WorkerMessages.ts index a8d30e9b1f..c6b8114188 100644 --- a/src/core/worker/WorkerMessages.ts +++ b/src/core/worker/WorkerMessages.ts @@ -9,7 +9,6 @@ import { GameUpdateViewData } from "../game/GameUpdates"; import { ClientID, GameStartInfo, Turn } from "../Schemas"; export type WorkerMessageType = - | "heartbeat" | "init" | "initialized" | "turn" @@ -31,15 +30,15 @@ interface BaseWorkerMessage { id?: string; } -export interface HeartbeatMessage extends BaseWorkerMessage { - type: "heartbeat"; -} - // Messages from main thread to worker export interface InitMessage extends BaseWorkerMessage { type: "init"; gameStartInfo: GameStartInfo; clientID: ClientID; + sharedTileRingHeader?: SharedArrayBuffer; + sharedTileRingData?: SharedArrayBuffer; + sharedStateBuffer?: SharedArrayBuffer; + sharedDirtyBuffer?: SharedArrayBuffer; } export interface TurnMessage extends BaseWorkerMessage { @@ -114,7 +113,6 @@ export interface TransportShipSpawnResultMessage extends BaseWorkerMessage { // Union types for type safety export type MainThreadMessage = - | HeartbeatMessage | InitMessage | TurnMessage | PlayerActionsMessage diff --git a/webpack.config.js b/webpack.config.js index baae9ca8a1..fcd667f000 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, }, }; }; From c21773b8de9c1ff5b174a41f48e2aa3a1f949b01 Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Fri, 28 Nov 2025 20:04:37 +0100 Subject: [PATCH 02/16] Update WebGL status message --- src/client/graphics/layers/TerritoryWebGLStatus.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/client/graphics/layers/TerritoryWebGLStatus.ts b/src/client/graphics/layers/TerritoryWebGLStatus.ts index b2170a2dac..7c6edde667 100644 --- a/src/client/graphics/layers/TerritoryWebGLStatus.ts +++ b/src/client/graphics/layers/TerritoryWebGLStatus.ts @@ -144,13 +144,13 @@ export class TerritoryWebGLStatus extends LitElement implements Layer { private statusText(): string { if (!this.enabled) { - return "WebGL borders hidden"; + return "WebGL hidden"; } if (!this.supported) { return "WebGL unsupported (fallback)"; } if (this.active) { - return "WebGL borders active"; + return "WebGL active"; } return "WebGL enabled (fallback)"; } From da8b7210069a3a759b1b1dff508ab8b16d729e25 Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Fri, 28 Nov 2025 20:52:22 +0100 Subject: [PATCH 03/16] update comment --- src/client/graphics/layers/TerritoryWebGLRenderer.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/client/graphics/layers/TerritoryWebGLRenderer.ts b/src/client/graphics/layers/TerritoryWebGLRenderer.ts index ad2be6d88e..991a5eda4f 100644 --- a/src/client/graphics/layers/TerritoryWebGLRenderer.ts +++ b/src/client/graphics/layers/TerritoryWebGLRenderer.ts @@ -20,8 +20,7 @@ export interface HoverHighlightOptions { /** * 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. + * Handles both territory/fallout fills and border rendering in a unified shader. */ export class TerritoryWebGLRenderer { public readonly canvas: HTMLCanvasElement; From 18d04a5f3b741b1657f118f62b8f47ea621b0389 Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Sat, 29 Nov 2025 12:25:07 +0100 Subject: [PATCH 04/16] test replayspeed --- src/client/LocalServer.ts | 61 +++++++++++++++++++++++++++++++-------- 1 file changed, 49 insertions(+), 12 deletions(-) diff --git a/src/client/LocalServer.ts b/src/client/LocalServer.ts index c21114911e..169e2271ce 100644 --- a/src/client/LocalServer.ts +++ b/src/client/LocalServer.ts @@ -18,7 +18,7 @@ import { replacer, } from "../core/Util"; import { LobbyConfig } from "./ClientGameRunner"; -import { ReplaySpeedChangeEvent } from "./InputHandler"; +import { BacklogStatusEvent, ReplaySpeedChangeEvent } from "./InputHandler"; import { getPersistentID } from "./Main"; import { defaultReplaySpeedMultiplier } from "./utilities/ReplaySpeedMultiplier"; @@ -34,6 +34,10 @@ export class LocalServer { private paused = false; private replaySpeedMultiplier = defaultReplaySpeedMultiplier; + private clientBacklog = 0; + private readonly TARGET_BACKLOG = 10; + private readonly MAX_TURNS_PER_BATCH = 50; // Prevent sending too many at once + private winner: ClientSendWinnerMessage | null = null; private allPlayersStats: AllPlayersStats = {}; @@ -51,18 +55,16 @@ export class LocalServer { ) {} start() { - this.turnCheckInterval = setInterval(() => { - const turnIntervalMs = - this.lobbyConfig.serverConfig.turnIntervalMs() * - this.replaySpeedMultiplier; + this.eventBus.on(BacklogStatusEvent, (event) => { + this.clientBacklog = event.backlogTurns; + }); - if ( - this.turnsExecuted === this.turns.length && - Date.now() > this.turnStartTime + turnIntervalMs - ) { - this.turnStartTime = Date.now(); - // End turn on the server means the client will start processing the turn. - this.endTurn(); + this.turnCheckInterval = setInterval(() => { + if (this.replaySpeedMultiplier === 0) { + // Only for fastest speed + this.handleFastestReplay(); + } else { + this.handleTimedReplay(); } }, 5); @@ -88,6 +90,41 @@ export class LocalServer { } satisfies ServerStartGameMessage); } + private handleFastestReplay() { + const turnsNeeded = Math.max( + 0, + this.TARGET_BACKLOG * 2 - this.clientBacklog, + ); + if (turnsNeeded > 0) { + const turnsToSend = Math.min(turnsNeeded, this.MAX_TURNS_PER_BATCH); + this.sendTurnBatch(turnsToSend); + } + } + + private handleTimedReplay() { + const turnIntervalMs = + this.lobbyConfig.serverConfig.turnIntervalMs() * + this.replaySpeedMultiplier; + + if ( + this.turnsExecuted === this.turns.length && + Date.now() > this.turnStartTime + turnIntervalMs + ) { + this.turnStartTime = Date.now(); + this.endTurn(); + } + } + + private sendTurnBatch(count: number) { + for (let i = 0; i < count; i++) { + if (this.turnsExecuted === this.turns.length) { + this.endTurn(); + } else { + break; // No more turns available + } + } + } + pause() { this.paused = true; } From 14bf09f3fcd9541119df531a5cf834fdfb3cde19 Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Sat, 29 Nov 2025 15:28:08 +0100 Subject: [PATCH 05/16] perf: Optimize cluster calculation with DFS and zero-allocation patterns Replace BFS with DFS and eliminate GC pressure in calculateClusters() hot path: - Switch from O(N) queue.shift() to O(1) stack.pop() operations - Replace Set.has()/Set.add() with Uint8Array bitfield - Add reusable buffer management to avoid repeated allocations - Implement callback-based neighbor iteration to eliminate array allocations - Add forEachNeighborWithDiag() method to Game interface and GameImpl - Remove now unused GameImpl import from PlayerExecution --- src/core/execution/PlayerExecution.ts | 64 +++++++++++++++++---------- src/core/game/Game.ts | 7 +++ src/core/game/GameImpl.ts | 24 ++++++++++ 3 files changed, 71 insertions(+), 24 deletions(-) diff --git a/src/core/execution/PlayerExecution.ts b/src/core/execution/PlayerExecution.ts index a8cc3fb421..a76622be39 100644 --- a/src/core/execution/PlayerExecution.ts +++ b/src/core/execution/PlayerExecution.ts @@ -1,6 +1,5 @@ import { Config } from "../configuration/Config"; import { Execution, Game, Player, UnitType } from "../game/Game"; -import { GameImpl } from "../game/GameImpl"; import { GameMap, TileRef } from "../game/GameMap"; import { calculateBoundingBox, getMode, inscribed, simpleHash } from "../Util"; @@ -11,8 +10,11 @@ export class PlayerExecution implements Execution { private lastCalc = 0; private mg: Game; private active = true; + private _visitedBuffer: Uint8Array; - constructor(private player: Player) {} + constructor(private player: Player) { + this._visitedBuffer = new Uint8Array(0); // Initialize empty buffer + } activeDuringSpawnPhase(): boolean { return false; @@ -259,31 +261,45 @@ export class PlayerExecution implements Execution { } private calculateClusters(): Set[] { - const seen = new Set(); - const border = this.player.borderTiles(); - const clusters: Set[] = []; - for (const tile of border) { - if (seen.has(tile)) { - continue; - } + const borderTiles = this.player.borderTiles(); + if (borderTiles.size === 0) return []; + + // Ensure buffer is large enough + const mapSize = this.mg.width() * this.mg.height(); + if (!this._visitedBuffer || this._visitedBuffer.length < mapSize) { + this._visitedBuffer = new Uint8Array(mapSize); + } else { + // Fast clear (much faster than creating a new Set) + this._visitedBuffer.fill(0); + } - const cluster = new Set(); - const queue: TileRef[] = [tile]; - seen.add(tile); - while (queue.length > 0) { - const curr = queue.shift(); - if (curr === undefined) throw new Error("curr is undefined"); - cluster.add(curr); - - const neighbors = (this.mg as GameImpl).neighborsWithDiag(curr); - for (const neighbor of neighbors) { - if (border.has(neighbor) && !seen.has(neighbor)) { - queue.push(neighbor); - seen.add(neighbor); + const clusters: Set[] = []; + const stack: TileRef[] = []; // Reusable stack + + for (const startTile of borderTiles) { + // FAST: Array access instead of Set.has() + if (this._visitedBuffer[startTile] === 1) continue; + + const currentCluster = new Set(); + stack.push(startTile); + this._visitedBuffer[startTile] = 1; + + while (stack.length > 0) { + const tile = stack.pop()!; + currentCluster.add(tile); + + //Use callback to avoid creating a 'neighbors' Array + this.mg.forEachNeighborWithDiag(tile, (neighbor) => { + if ( + borderTiles.has(neighbor) && + this._visitedBuffer[neighbor] === 0 + ) { + stack.push(neighbor); + this._visitedBuffer[neighbor] = 1; } - } + }); } - clusters.push(cluster); + clusters.push(currentCluster); } return clusters; } diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index 0cd4b07c53..a46fbbd955 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -664,6 +664,13 @@ export interface Game extends GameMap { map(): GameMap; miniMap(): GameMap; forEachTile(fn: (tile: TileRef) => void): void; + // Zero-allocation neighbor iteration for performance-critical cluster calculation + // Alternative to neighborsWithDiag() that returns arrays + // Avoids creating intermediate arrays and uses a callback for better performance + forEachNeighborWithDiag( + tile: TileRef, + callback: (neighbor: TileRef) => void, + ): void; // Player Management player(id: PlayerID): Player; diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts index 2c23ad8641..c2c0e7b0e7 100644 --- a/src/core/game/GameImpl.ts +++ b/src/core/game/GameImpl.ts @@ -517,6 +517,30 @@ export class GameImpl implements Game { return ns; } + // Zero-allocation neighbor iteration for performance-critical code + forEachNeighborWithDiag( + tile: TileRef, + callback: (neighbor: TileRef) => void, + ): void { + const x = this.x(tile); + const y = this.y(tile); + for (let dx = -1; dx <= 1; dx++) { + for (let dy = -1; dy <= 1; dy++) { + if (dx === 0 && dy === 0) continue; // Skip the center tile + const newX = x + dx; + const newY = y + dy; + if ( + newX >= 0 && + newX < this._width && + newY >= 0 && + newY < this._height + ) { + callback(this._map.ref(newX, newY)); + } + } + } + } + conquer(owner: PlayerImpl, tile: TileRef): void { if (!this.isLand(tile)) { throw Error(`cannot conquer water`); From a10b3aa0352c871d15bf0062f5bc83e8f1f3cfe3 Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Mon, 1 Dec 2025 18:37:24 +0100 Subject: [PATCH 06/16] Use game-scoped generational visited buffer in PlayerExecution - Introduce ClusterTraversalState and a WeakMap in PlayerExecution.ts to store visited: Uint32Array and gen per game instance. - Remove the per-player _visitedBuffer - Update calculateClusters() to allocate/resize a single Uint32Array(totalTiles) per Game, use a generation counter instead of calling fill(0) to clear. - Switch visited checks to visited[tile] === currentGen. --- src/core/execution/PlayerExecution.ts | 56 +++++++++++++++++---------- 1 file changed, 35 insertions(+), 21 deletions(-) diff --git a/src/core/execution/PlayerExecution.ts b/src/core/execution/PlayerExecution.ts index a76622be39..8f81c62421 100644 --- a/src/core/execution/PlayerExecution.ts +++ b/src/core/execution/PlayerExecution.ts @@ -3,6 +3,14 @@ import { Execution, Game, Player, UnitType } from "../game/Game"; import { GameMap, TileRef } from "../game/GameMap"; import { calculateBoundingBox, getMode, inscribed, simpleHash } from "../Util"; +interface ClusterTraversalState { + visited: Uint32Array; + gen: number; +} + +// Per-game traversal state used by calculateClusters() to avoid per-player buffers. +const traversalStates = new WeakMap(); + export class PlayerExecution implements Execution { private readonly ticksPerClusterCalc = 20; @@ -10,11 +18,8 @@ export class PlayerExecution implements Execution { private lastCalc = 0; private mg: Game; private active = true; - private _visitedBuffer: Uint8Array; - constructor(private player: Player) { - this._visitedBuffer = new Uint8Array(0); // Initialize empty buffer - } + constructor(private player: Player) {} activeDuringSpawnPhase(): boolean { return false; @@ -264,38 +269,47 @@ export class PlayerExecution implements Execution { const borderTiles = this.player.borderTiles(); if (borderTiles.size === 0) return []; - // Ensure buffer is large enough - const mapSize = this.mg.width() * this.mg.height(); - if (!this._visitedBuffer || this._visitedBuffer.length < mapSize) { - this._visitedBuffer = new Uint8Array(mapSize); - } else { - // Fast clear (much faster than creating a new Set) - this._visitedBuffer.fill(0); + const totalTiles = this.mg.width() * this.mg.height(); + + // Retrieve or initialize traversal state for this specific Game instance. + let state = traversalStates.get(this.mg); + if (!state || state.visited.length < totalTiles) { + state = { + visited: new Uint32Array(totalTiles), + gen: 0, + }; + traversalStates.set(this.mg, state); + } + + // Generational clear: bump generation instead of filling the array. + state.gen++; + if (state.gen === 0xffffffff) { + // Extremely rare wrap-around; reset the buffer. + state.visited.fill(0); + state.gen = 1; } + const currentGen = state.gen; + const visited = state.visited; + const clusters: Set[] = []; - const stack: TileRef[] = []; // Reusable stack + const stack: TileRef[] = []; for (const startTile of borderTiles) { - // FAST: Array access instead of Set.has() - if (this._visitedBuffer[startTile] === 1) continue; + if (visited[startTile] === currentGen) continue; const currentCluster = new Set(); stack.push(startTile); - this._visitedBuffer[startTile] = 1; + visited[startTile] = currentGen; while (stack.length > 0) { const tile = stack.pop()!; currentCluster.add(tile); - //Use callback to avoid creating a 'neighbors' Array this.mg.forEachNeighborWithDiag(tile, (neighbor) => { - if ( - borderTiles.has(neighbor) && - this._visitedBuffer[neighbor] === 0 - ) { + if (borderTiles.has(neighbor) && visited[neighbor] !== currentGen) { stack.push(neighbor); - this._visitedBuffer[neighbor] = 1; + visited[neighbor] = currentGen; } }); } From e77c9f27ed1894f38af430dcac43ee382619bbfa Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Mon, 1 Dec 2025 21:43:32 +0100 Subject: [PATCH 07/16] unify flood-fill and neighbor callbacks in PlayerExecution - Convert neighbor loops in surroundedBySamePlayer, isSurrounded, getCapturingPlayer to forEachNeighbor callbacks - Implement floodFillWithGen() method for configurable zero-allocation flood fill - Replace BFS in removeCluster() with floodFillWithGen using cardinal neighbors - Refactor calculateClusters() to use floodFillWithGen with diagonal neighbors - Add generational state management and forEachNeighbor interface method --- src/core/execution/PlayerExecution.ts | 157 ++++++++++++++++---------- src/core/game/Game.ts | 2 + src/core/game/GameImpl.ts | 9 ++ 3 files changed, 109 insertions(+), 59 deletions(-) diff --git a/src/core/execution/PlayerExecution.ts b/src/core/execution/PlayerExecution.ts index 8f81c62421..16549c6071 100644 --- a/src/core/execution/PlayerExecution.ts +++ b/src/core/execution/PlayerExecution.ts @@ -1,6 +1,6 @@ import { Config } from "../configuration/Config"; import { Execution, Game, Player, UnitType } from "../game/Game"; -import { GameMap, TileRef } from "../game/GameMap"; +import { TileRef } from "../game/GameMap"; import { calculateBoundingBox, getMode, inscribed, simpleHash } from "../Util"; interface ClusterTraversalState { @@ -139,17 +139,23 @@ export class PlayerExecution implements Execution { private surroundedBySamePlayer(cluster: Set): false | Player { const enemies = new Set(); for (const tile of cluster) { - if ( - this.mg.isOceanShore(tile) || - this.mg.isOnEdgeOfMap(tile) || - this.mg.neighbors(tile).some((n) => !this.mg?.hasOwner(n)) - ) { + let hasUnownedNeighbor = false; + if (this.mg.isOceanShore(tile) || this.mg.isOnEdgeOfMap(tile)) { + return false; + } + this.mg.forEachNeighbor(tile, (n) => { + if (!this.mg.hasOwner(n)) { + hasUnownedNeighbor = true; + return; + } + const ownerId = this.mg.ownerID(n); + if (ownerId !== this.player.smallID()) { + enemies.add(ownerId); + } + }); + if (hasUnownedNeighbor) { return false; } - this.mg - .neighbors(tile) - .filter((n) => this.mg?.ownerID(n) !== this.player?.smallID()) - .forEach((p) => this.mg && enemies.add(this.mg.ownerID(p))); if (enemies.size !== 1) { return false; } @@ -172,14 +178,12 @@ export class PlayerExecution implements Execution { if (this.mg.isShore(tr) || this.mg.isOnEdgeOfMap(tr)) { return false; } - this.mg - .neighbors(tr) - .filter( - (n) => - this.mg?.owner(n).isPlayer() && - this.mg?.ownerID(n) !== this.player?.smallID(), - ) - .forEach((n) => enemyTiles.add(n)); + this.mg.forEachNeighbor(tr, (n) => { + const owner = this.mg.owner(n); + if (owner.isPlayer() && this.mg.ownerID(n) !== this.player.smallID()) { + enemyTiles.add(n); + } + }); } if (enemyTiles.size === 0) { return false; @@ -210,9 +214,13 @@ export class PlayerExecution implements Execution { return; } - const filter = (_: GameMap, t: TileRef): boolean => - this.mg?.ownerID(t) === this.player?.smallID(); - const tiles = this.mg.bfs(firstTile, filter); + const tiles = this.floodFillWithGen( + this.bumpGeneration(), + this.traversalState().visited, + [firstTile], + (tile, cb) => this.mg.forEachNeighbor(tile, cb), + (tile) => this.mg.ownerID(tile) === this.player.smallID(), + ); if (this.player.numTilesOwned() === tiles.size) { this.mg.conquerPlayer(capturing, this.player); @@ -226,7 +234,7 @@ export class PlayerExecution implements Execution { private getCapturingPlayer(cluster: Set): Player | null { const neighbors = new Map(); for (const t of cluster) { - for (const neighbor of this.mg.neighbors(t)) { + this.mg.forEachNeighbor(t, (neighbor) => { const owner = this.mg.owner(neighbor); if ( owner.isPlayer() && @@ -235,7 +243,7 @@ export class PlayerExecution implements Execution { ) { neighbors.set(owner, (neighbors.get(owner) ?? 0) + 1); } - } + }); } // If there are no enemies, return null @@ -269,9 +277,40 @@ export class PlayerExecution implements Execution { const borderTiles = this.player.borderTiles(); if (borderTiles.size === 0) return []; - const totalTiles = this.mg.width() * this.mg.height(); + const state = this.traversalState(); + const currentGen = this.bumpGeneration(); + const visited = state.visited; + + const clusters: Set[] = []; + + for (const startTile of borderTiles) { + if (visited[startTile] === currentGen) continue; + + const cluster = this.floodFillWithGen( + currentGen, + visited, + [startTile], + (tile, cb) => this.mg.forEachNeighborWithDiag(tile, cb), + (tile) => borderTiles.has(tile), + ); + clusters.push(cluster); + } + return clusters; + } + + owner(): Player { + if (this.player === null) { + throw new Error("Not initialized"); + } + return this.player; + } - // Retrieve or initialize traversal state for this specific Game instance. + isActive(): boolean { + return this.active; + } + + private traversalState(): ClusterTraversalState { + const totalTiles = this.mg.width() * this.mg.height(); let state = traversalStates.get(this.mg); if (!state || state.visited.length < totalTiles) { state = { @@ -280,52 +319,52 @@ export class PlayerExecution implements Execution { }; traversalStates.set(this.mg, state); } + return state; + } - // Generational clear: bump generation instead of filling the array. + private bumpGeneration(): number { + const state = this.traversalState(); state.gen++; if (state.gen === 0xffffffff) { - // Extremely rare wrap-around; reset the buffer. state.visited.fill(0); state.gen = 1; } + return state.gen; + } - const currentGen = state.gen; - const visited = state.visited; - - const clusters: Set[] = []; + private floodFillWithGen( + currentGen: number, + visited: Uint32Array, + startTiles: TileRef[], + neighborFn: (tile: TileRef, callback: (neighbor: TileRef) => void) => void, + includeFn: (tile: TileRef) => boolean, + ): Set { + const result = new Set(); const stack: TileRef[] = []; - for (const startTile of borderTiles) { - if (visited[startTile] === currentGen) continue; - - const currentCluster = new Set(); - stack.push(startTile); - visited[startTile] = currentGen; - - while (stack.length > 0) { - const tile = stack.pop()!; - currentCluster.add(tile); - - this.mg.forEachNeighborWithDiag(tile, (neighbor) => { - if (borderTiles.has(neighbor) && visited[neighbor] !== currentGen) { - stack.push(neighbor); - visited[neighbor] = currentGen; - } - }); - } - clusters.push(currentCluster); + for (const start of startTiles) { + if (visited[start] === currentGen) continue; + if (!includeFn(start)) continue; + visited[start] = currentGen; + result.add(start); + stack.push(start); } - return clusters; - } - owner(): Player { - if (this.player === null) { - throw new Error("Not initialized"); + while (stack.length > 0) { + const tile = stack.pop()!; + neighborFn(tile, (neighbor) => { + if (visited[neighbor] === currentGen) { + return; + } + if (!includeFn(neighbor)) { + return; + } + visited[neighbor] = currentGen; + result.add(neighbor); + stack.push(neighbor); + }); } - return this.player; - } - isActive(): boolean { - return this.active; + return result; } } diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index a46fbbd955..385b5d11e5 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -664,6 +664,8 @@ export interface Game extends GameMap { map(): GameMap; miniMap(): GameMap; forEachTile(fn: (tile: TileRef) => void): void; + // Zero-allocation neighbor iteration (cardinal only) to avoid creating arrays + forEachNeighbor(tile: TileRef, callback: (neighbor: TileRef) => void): void; // Zero-allocation neighbor iteration for performance-critical cluster calculation // Alternative to neighborsWithDiag() that returns arrays // Avoids creating intermediate arrays and uses a callback for better performance diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts index c2c0e7b0e7..61a6e38d72 100644 --- a/src/core/game/GameImpl.ts +++ b/src/core/game/GameImpl.ts @@ -882,6 +882,15 @@ export class GameImpl implements Game { neighbors(ref: TileRef): TileRef[] { return this._map.neighbors(ref); } + // Zero-allocation neighbor iteration (cardinal only) + forEachNeighbor(tile: TileRef, callback: (neighbor: TileRef) => void): void { + const x = this.x(tile); + const y = this.y(tile); + if (x > 0) callback(this._map.ref(x - 1, y)); + if (x + 1 < this._width) callback(this._map.ref(x + 1, y)); + if (y > 0) callback(this._map.ref(x, y - 1)); + if (y + 1 < this._height) callback(this._map.ref(x, y + 1)); + } isWater(ref: TileRef): boolean { return this._map.isWater(ref); } From 4ec831f5af4a65c44b5845a8e5eb92745e01d9f3 Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Tue, 2 Dec 2025 16:46:28 +0100 Subject: [PATCH 08/16] Revert "test replayspeed" This reverts commit 18d04a5f3b741b1657f118f62b8f47ea621b0389. --- src/client/LocalServer.ts | 61 ++++++++------------------------------- 1 file changed, 12 insertions(+), 49 deletions(-) diff --git a/src/client/LocalServer.ts b/src/client/LocalServer.ts index 169e2271ce..c21114911e 100644 --- a/src/client/LocalServer.ts +++ b/src/client/LocalServer.ts @@ -18,7 +18,7 @@ import { replacer, } from "../core/Util"; import { LobbyConfig } from "./ClientGameRunner"; -import { BacklogStatusEvent, ReplaySpeedChangeEvent } from "./InputHandler"; +import { ReplaySpeedChangeEvent } from "./InputHandler"; import { getPersistentID } from "./Main"; import { defaultReplaySpeedMultiplier } from "./utilities/ReplaySpeedMultiplier"; @@ -34,10 +34,6 @@ export class LocalServer { private paused = false; private replaySpeedMultiplier = defaultReplaySpeedMultiplier; - private clientBacklog = 0; - private readonly TARGET_BACKLOG = 10; - private readonly MAX_TURNS_PER_BATCH = 50; // Prevent sending too many at once - private winner: ClientSendWinnerMessage | null = null; private allPlayersStats: AllPlayersStats = {}; @@ -55,16 +51,18 @@ export class LocalServer { ) {} start() { - this.eventBus.on(BacklogStatusEvent, (event) => { - this.clientBacklog = event.backlogTurns; - }); - this.turnCheckInterval = setInterval(() => { - if (this.replaySpeedMultiplier === 0) { - // Only for fastest speed - this.handleFastestReplay(); - } else { - this.handleTimedReplay(); + const turnIntervalMs = + this.lobbyConfig.serverConfig.turnIntervalMs() * + this.replaySpeedMultiplier; + + if ( + this.turnsExecuted === this.turns.length && + Date.now() > this.turnStartTime + turnIntervalMs + ) { + this.turnStartTime = Date.now(); + // End turn on the server means the client will start processing the turn. + this.endTurn(); } }, 5); @@ -90,41 +88,6 @@ export class LocalServer { } satisfies ServerStartGameMessage); } - private handleFastestReplay() { - const turnsNeeded = Math.max( - 0, - this.TARGET_BACKLOG * 2 - this.clientBacklog, - ); - if (turnsNeeded > 0) { - const turnsToSend = Math.min(turnsNeeded, this.MAX_TURNS_PER_BATCH); - this.sendTurnBatch(turnsToSend); - } - } - - private handleTimedReplay() { - const turnIntervalMs = - this.lobbyConfig.serverConfig.turnIntervalMs() * - this.replaySpeedMultiplier; - - if ( - this.turnsExecuted === this.turns.length && - Date.now() > this.turnStartTime + turnIntervalMs - ) { - this.turnStartTime = Date.now(); - this.endTurn(); - } - } - - private sendTurnBatch(count: number) { - for (let i = 0; i < count; i++) { - if (this.turnsExecuted === this.turns.length) { - this.endTurn(); - } else { - break; // No more turns available - } - } - } - pause() { this.paused = true; } From a7126dc4227935b4b8fa71978a5d4184dc1f8cfc Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Wed, 3 Dec 2025 18:05:57 +0100 Subject: [PATCH 09/16] fix TerritoryWebGLRenderer premultiplied alpha --- src/client/graphics/layers/TerritoryWebGLRenderer.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/client/graphics/layers/TerritoryWebGLRenderer.ts b/src/client/graphics/layers/TerritoryWebGLRenderer.ts index 991a5eda4f..66f779da07 100644 --- a/src/client/graphics/layers/TerritoryWebGLRenderer.ts +++ b/src/client/graphics/layers/TerritoryWebGLRenderer.ts @@ -399,7 +399,7 @@ export class TerritoryWebGLRenderer { } gl.enable(gl.BLEND); - gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); + gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA); gl.viewport(0, 0, this.canvas.width, this.canvas.height); } @@ -868,7 +868,9 @@ export class TerritoryWebGLRenderer { if (owner == 0u) { if (hasFallout) { - outColor = vec4(u_fallout.rgb, u_alpha); + vec3 color = u_fallout.rgb; + float a = u_alpha; + outColor = vec4(color * a, a); } else { outColor = vec4(0.0); } @@ -905,7 +907,7 @@ export class TerritoryWebGLRenderer { : 1.0; color = mix(color, u_hoverHighlightColor, u_hoverHighlightStrength * pulse); } - outColor = vec4(color, a); + outColor = vec4(color * a, a); return; } @@ -953,7 +955,7 @@ export class TerritoryWebGLRenderer { color = mix(color, u_hoverHighlightColor, u_hoverHighlightStrength * pulse); } - outColor = vec4(color, a); + outColor = vec4(color * a, a); } `; From 4035132ed099ae28652b79e2ee18064a30719671 Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Wed, 3 Dec 2025 20:01:43 +0100 Subject: [PATCH 10/16] Move relationship calculation (for borders) into the shader. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GPU Relation Calculations: Moved border diplomatic relation logic from CPU buffer to WebGL shader, eliminating per-tile CPU computation - Relation Matrix: Converted 1D relation array to 2D owner×other matrix for O(1) GPU lookups - Palette Refresh: Batched palette refreshes to single call per update cycle WebGL Shader Updates - Added u_viewerId uniform and bitmask relation helper functions (isFriendly(), isEmbargo(), isSelf()) - Enhanced border detection with per-neighbor relation evaluation Removed CPU-side relation state management Files: TerritoryLayer.ts, TerritoryRenderers.ts, TerritoryWebGLRenderer.ts (+102/-39 lines) --- src/client/graphics/layers/TerritoryLayer.ts | 10 +- .../graphics/layers/TerritoryRenderers.ts | 13 +- .../graphics/layers/TerritoryWebGLRenderer.ts | 118 ++++++++++++++---- 3 files changed, 102 insertions(+), 39 deletions(-) diff --git a/src/client/graphics/layers/TerritoryLayer.ts b/src/client/graphics/layers/TerritoryLayer.ts index 7b7140f411..c63e25534e 100644 --- a/src/client/graphics/layers/TerritoryLayer.ts +++ b/src/client/graphics/layers/TerritoryLayer.ts @@ -106,9 +106,7 @@ export class TerritoryLayer implements Layer { const unitUpdates = updates !== null ? updates[GameUpdateType.Unit] : []; const playerUpdates = updates !== null ? updates[GameUpdateType.Player] : []; - if (playerUpdates.length > 0) { - this.territoryRenderer?.refreshPalette(); - } + let needsRelationRefresh = playerUpdates.length > 0; unitUpdates.forEach((update) => { if (update.unitType === UnitType.DefensePost) { // Only update borders if the defense post is not under construction @@ -138,6 +136,7 @@ export class TerritoryLayer implements Layer { const territory = this.game.playerBySmallID(update.betrayedID); if (territory && territory instanceof PlayerView) { this.redrawBorder(territory); + needsRelationRefresh = true; } }); @@ -154,6 +153,7 @@ export class TerritoryLayer implements Layer { const territory = this.game.playerBySmallID(territoryId); if (territory && territory instanceof PlayerView) { this.redrawBorder(territory); + needsRelationRefresh = true; } } }); @@ -168,9 +168,13 @@ export class TerritoryLayer implements Layer { embargoed.id() === myPlayer?.id() ) { this.redrawBorder(player, embargoed); + needsRelationRefresh = true; } }); } + if (needsRelationRefresh) { + this.territoryRenderer?.refreshPalette(); + } const focusedPlayer = this.game.focusedPlayer(); if (focusedPlayer !== this.lastFocusedPlayer) { diff --git a/src/client/graphics/layers/TerritoryRenderers.ts b/src/client/graphics/layers/TerritoryRenderers.ts index 9710fc9a15..c3ab6aafae 100644 --- a/src/client/graphics/layers/TerritoryRenderers.ts +++ b/src/client/graphics/layers/TerritoryRenderers.ts @@ -274,7 +274,7 @@ export class WebglTerritoryRenderer implements TerritoryRendererStrategy { : null; const isBorderTile = this.game.isBorder(tile); - // Update defended and relation state in the shared buffer + // Update defended state in the shared buffer (used for checkerboard pattern). if (owner && isBorderTile) { const isDefended = this.game.hasUnitNearby( tile, @@ -282,19 +282,10 @@ export class WebglTerritoryRenderer implements TerritoryRendererStrategy { UnitType.DefensePost, owner.id(), ); - const { hasEmbargo, hasFriendly } = owner.borderRelationFlags(tile); - let relation = 0; // neutral - if (hasFriendly) { - relation = 1; // friendly - } else if (hasEmbargo) { - relation = 2; // embargo - } this.game.setDefended(tile, isDefended); - this.game.setRelation(tile, relation); } else { - // Clear defended/relation state for non-border tiles + // Clear defended state for non-border tiles this.game.setDefended(tile, false); - this.game.setRelation(tile, 0); } this.renderer.markTile(tile); diff --git a/src/client/graphics/layers/TerritoryWebGLRenderer.ts b/src/client/graphics/layers/TerritoryWebGLRenderer.ts index 66f779da07..8b69aff177 100644 --- a/src/client/graphics/layers/TerritoryWebGLRenderer.ts +++ b/src/client/graphics/layers/TerritoryWebGLRenderer.ts @@ -52,6 +52,7 @@ export class TerritoryWebGLRenderer { hoverPulseStrength: WebGLUniformLocation | null; hoverPulseSpeed: WebGLUniformLocation | null; time: WebGLUniformLocation | null; + viewerId: WebGLUniformLocation | null; // Border color uniforms for shader-computed borders borderNeutral: WebGLUniformLocation | null; borderFriendly: WebGLUniformLocation | null; @@ -126,6 +127,7 @@ export class TerritoryWebGLRenderer { hoverPulseStrength: null, hoverPulseSpeed: null, time: null, + viewerId: null, borderNeutral: null, borderFriendly: null, borderEmbargo: null, @@ -167,6 +169,7 @@ export class TerritoryWebGLRenderer { hoverPulseStrength: null, hoverPulseSpeed: null, time: null, + viewerId: null, borderNeutral: null, borderFriendly: null, borderEmbargo: null, @@ -208,6 +211,7 @@ export class TerritoryWebGLRenderer { ), hoverPulseSpeed: gl.getUniformLocation(this.program, "u_hoverPulseSpeed"), time: gl.getUniformLocation(this.program, "u_time"), + viewerId: gl.getUniformLocation(this.program, "u_viewerId"), borderNeutral: gl.getUniformLocation(this.program, "u_borderNeutral"), borderFriendly: gl.getUniformLocation(this.program, "u_borderFriendly"), borderEmbargo: gl.getUniformLocation(this.program, "u_borderEmbargo"), @@ -375,6 +379,10 @@ export class TerritoryWebGLRenderer { c.a ?? 1, ); } + if (this.uniforms.viewerId) { + const viewerId = this.game.myPlayer()?.smallID() ?? 0; + gl.uniform1i(this.uniforms.viewerId, viewerId); + } if (this.uniforms.alternativeView) { gl.uniform1i(this.uniforms.alternativeView, 0); } @@ -573,6 +581,10 @@ export class TerritoryWebGLRenderer { const currentTime = (Date.now() - this.animationStartTime) / 1000.0; gl.uniform1f(this.uniforms.time, currentTime); } + if (this.uniforms.viewerId) { + const viewerId = this.game.myPlayer()?.smallID() ?? 0; + gl.uniform1i(this.uniforms.viewerId, viewerId); + } gl.clearColor(0, 0, 0, 0); gl.clear(gl.COLOR_BUFFER_BIT); @@ -714,13 +726,12 @@ export class TerritoryWebGLRenderer { 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 * 8); // 8 bytes per player: territory RGBA + border RGBA - const relationData = new Uint8Array(this.paletteWidth); + const relationData = new Uint8Array(this.paletteWidth * this.paletteWidth); for (const p of players) { const id = p.smallID(); @@ -737,8 +748,16 @@ export class TerritoryWebGLRenderer { paletteData[id * 8 + 5] = borderRgba.g; paletteData[id * 8 + 6] = borderRgba.b; paletteData[id * 8 + 7] = Math.round((borderRgba.a ?? 1) * 255); + } - relationData[id] = this.resolveRelationCode(p, myPlayer); + // Build relation matrix: friendly/embargo/self flags per owner/other pair. + 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.activeTexture(gl.TEXTURE1); @@ -772,7 +791,7 @@ export class TerritoryWebGLRenderer { 0, gl.R8UI, this.paletteWidth, - 1, + this.paletteWidth, 0, gl.RED_INTEGER, gl.UNSIGNED_BYTE, @@ -781,22 +800,31 @@ export class TerritoryWebGLRenderer { } private resolveRelationCode( - owner: PlayerView, - myPlayer: PlayerView | null, + owner: PlayerView | null, + other: PlayerView | null, ): number { - if (!myPlayer) { - return 3; // Neutral + if (!owner || !other || !owner.isPlayer() || !other.isPlayer()) { + return 0; // Neutral / no relation } - if (owner.smallID() === myPlayer.smallID()) { - return 1; // Self + + let code = 0; + if (owner.smallID() === other.smallID()) { + code |= 4; // self bit } - if (owner.isFriendly(myPlayer)) { - return 2; // Ally + // Friendly if either side is friendly toward the other. + if (owner.isFriendly(other) || other.isFriendly(owner)) { + code |= 1; } - if (!owner.hasEmbargo(myPlayer)) { - return 3; // Neutral + // Embargo if owner has embargo against other. + if (owner.hasEmbargo(other)) { + code |= 2; } - return 4; // Enemy + return code; + } + + private safePlayerBySmallId(id: number): PlayerView | null { + const player = this.game.playerBySmallID(id); + return player instanceof PlayerView ? player : null; } private createProgram(gl: WebGL2RenderingContext): WebGLProgram | null { @@ -820,6 +848,7 @@ export class TerritoryWebGLRenderer { uniform sampler2D u_palette; uniform usampler2D u_relations; uniform sampler2D u_borderColor; + uniform int u_viewerId; uniform vec2 u_resolution; uniform vec4 u_fallout; uniform vec4 u_altSelf; @@ -855,6 +884,25 @@ export class TerritoryWebGLRenderer { return texelFetch(u_state, clamped, 0).r & 0xFFFu; } + uint relationCode(uint owner, uint other) { + if (owner == 0u || other == 0u) { + return 0u; + } + return texelFetch(u_relations, ivec2(int(owner), int(other)), 0).r; + } + + bool isFriendly(uint code) { + return (code & 1u) != 0u; + } + + bool isEmbargo(uint code) { + return (code & 2u) != 0u; + } + + bool isSelf(uint code) { + return (code & 4u) != 0u; + } + void main() { ivec2 fragCoord = ivec2(gl_FragCoord.xy); // gl_FragCoord origin is bottom-left; flip Y to match top-left oriented buffers. @@ -864,7 +912,6 @@ export class TerritoryWebGLRenderer { uint owner = state & 0xFFFu; bool hasFallout = (state & 0x2000u) != 0u; // bit 13 bool isDefended = (state & 0x1000u) != 0u; // bit 12 - uint relation = (state & 0xC000u) >> 14u; // bits 14-15 if (owner == 0u) { if (hasFallout) { @@ -877,25 +924,47 @@ export class TerritoryWebGLRenderer { return; } - // Border detection via neighbor comparison + // Border detection via neighbor comparison and relation checks bool isBorder = false; + bool hasFriendlyRelation = false; + bool hasEmbargoRelation = false; uint nOwner = ownerAtTex(texCoord + ivec2(1, 0)); isBorder = isBorder || (nOwner != owner); + if (nOwner != owner && nOwner != 0u) { + uint rel = relationCode(owner, nOwner); + hasEmbargoRelation = hasEmbargoRelation || isEmbargo(rel); + hasFriendlyRelation = hasFriendlyRelation || isFriendly(rel); + } nOwner = ownerAtTex(texCoord + ivec2(-1, 0)); isBorder = isBorder || (nOwner != owner); + if (nOwner != owner && nOwner != 0u) { + uint rel = relationCode(owner, nOwner); + hasEmbargoRelation = hasEmbargoRelation || isEmbargo(rel); + hasFriendlyRelation = hasFriendlyRelation || isFriendly(rel); + } nOwner = ownerAtTex(texCoord + ivec2(0, 1)); isBorder = isBorder || (nOwner != owner); + if (nOwner != owner && nOwner != 0u) { + uint rel = relationCode(owner, nOwner); + hasEmbargoRelation = hasEmbargoRelation || isEmbargo(rel); + hasFriendlyRelation = hasFriendlyRelation || isFriendly(rel); + } nOwner = ownerAtTex(texCoord + ivec2(0, -1)); isBorder = isBorder || (nOwner != owner); + if (nOwner != owner && nOwner != 0u) { + uint rel = relationCode(owner, nOwner); + hasEmbargoRelation = hasEmbargoRelation || isEmbargo(rel); + hasFriendlyRelation = hasFriendlyRelation || isFriendly(rel); + } if (u_alternativeView) { - uint relation = texelFetch(u_relations, ivec2(int(owner), 0), 0).r; + uint relationAlt = relationCode(owner, uint(u_viewerId)); vec4 altColor = u_altNeutral; - if (relation == 1u) { + if (isSelf(relationAlt)) { altColor = u_altSelf; - } else if (relation == 2u) { + } else if (isFriendly(relationAlt)) { altColor = u_altAlly; - } else if (relation >= 4u) { + } else if (isEmbargo(relationAlt)) { altColor = u_altEnemy; } float a = isBorder ? 1.0 : 0.0; @@ -925,19 +994,18 @@ export class TerritoryWebGLRenderer { const vec3 FRIENDLY_TINT_TARGET = vec3(0.0, 1.0, 0.0); // green const vec3 EMBARGO_TINT_TARGET = vec3(1.0, 0.0, 0.0); // red - if (relation == 1u) { // friendly + if (hasFriendlyRelation) { // friendly borderColor = borderColor * (1.0 - BORDER_TINT_RATIO) + FRIENDLY_TINT_TARGET * BORDER_TINT_RATIO; - } else if (relation == 2u) { // embargo + } + if (hasEmbargoRelation) { // embargo borderColor = borderColor * (1.0 - BORDER_TINT_RATIO) + EMBARGO_TINT_TARGET * BORDER_TINT_RATIO; } - // relation == 0u (neutral) uses base border color as-is // Apply defended checkerboard pattern if (isDefended) { bool isLightTile = ((texCoord.x % 2) == (texCoord.y % 2)); - // Simple checkerboard: alternate between lighter and darker versions const float LIGHT_FACTOR = 1.2; const float DARK_FACTOR = 0.8; borderColor *= isLightTile ? LIGHT_FACTOR : DARK_FACTOR; From fa519016714086e6b95eb834c14a9c84baa95d0c Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Thu, 4 Dec 2025 00:46:38 +0100 Subject: [PATCH 11/16] Refactor territory defense management in GameImpl and CanvasTerritoryRenderer - Consolidated defended state logic for tiles into dedicated methods in GameImpl to improve clarity and maintainability. - Updated CanvasTerritoryRenderer to utilize the new isDefended method for determining tile defense status. - Removed redundant checks and streamlined the painting logic for territory tiles. --- .../graphics/layers/TerritoryRenderers.ts | 36 +-------- src/core/game/GameImpl.ts | 73 +++++++++++++++++++ 2 files changed, 75 insertions(+), 34 deletions(-) diff --git a/src/client/graphics/layers/TerritoryRenderers.ts b/src/client/graphics/layers/TerritoryRenderers.ts index c3ab6aafae..3d453d5394 100644 --- a/src/client/graphics/layers/TerritoryRenderers.ts +++ b/src/client/graphics/layers/TerritoryRenderers.ts @@ -1,6 +1,5 @@ import { Colord } from "colord"; import { Theme } from "../../../core/configuration/Config"; -import { UnitType } from "../../../core/game/Game"; import { TileRef } from "../../../core/game/GameMap"; import { GameView, PlayerView } from "../../../core/game/GameView"; import { FrameProfiler } from "../FrameProfiler"; @@ -83,15 +82,8 @@ export class CanvasTerritoryRenderer implements TerritoryRendererStrategy { : 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(), - ); - } + const isDefended = + owner && isBorderTile ? this.game.isDefended(tile) : false; if (!owner) { if (hasFallout) { @@ -264,30 +256,6 @@ export class WebglTerritoryRenderer implements TerritoryRendererStrategy { } paintTile(tile: TileRef): void { - const hasOwner = this.game.hasOwner(tile); - const rawOwner = hasOwner ? this.game.owner(tile) : null; - const owner = - rawOwner && - typeof (rawOwner as any).isPlayer === "function" && - (rawOwner as any).isPlayer() - ? (rawOwner as PlayerView) - : null; - const isBorderTile = this.game.isBorder(tile); - - // Update defended state in the shared buffer (used for checkerboard pattern). - if (owner && isBorderTile) { - const isDefended = this.game.hasUnitNearby( - tile, - this.game.config().defensePostRange(), - UnitType.DefensePost, - owner.id(), - ); - this.game.setDefended(tile, isDefended); - } else { - // Clear defended state for non-border tiles - this.game.setDefended(tile, false); - } - this.renderer.markTile(tile); } diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts index 73a5faf87f..f2d5c78a10 100644 --- a/src/core/game/GameImpl.ts +++ b/src/core/game/GameImpl.ts @@ -556,6 +556,10 @@ export class GameImpl implements Game { owner._lastTileChange = this._ticks; this.updateBorders(tile); this._map.setFallout(tile, false); + + // Update defended state for the conquered tile and nearby border tiles + this.updateDefendedStateForTileChange(tile, owner); + this.addUpdate({ type: GameUpdateType.Tile, update: this.toTileUpdate(tile), @@ -577,6 +581,9 @@ export class GameImpl implements Game { this._map.setOwnerID(tile, 0); this.updateBorders(tile); + if (this._map.isDefended(tile)) { + this._map.setDefended(tile, false); + } this.addUpdate({ type: GameUpdateType.Tile, update: this.toTileUpdate(tile), @@ -776,14 +783,23 @@ export class GameImpl implements Game { addUnit(u: Unit) { this.unitGrid.addUnit(u); + if (u.type() === UnitType.DefensePost) { + this.updateDefendedStateForDefensePost(u.tile(), u.owner() as PlayerImpl); + } } removeUnit(u: Unit) { + if (u.type() === UnitType.DefensePost) { + this.updateDefendedStateForDefensePost(u.tile(), u.owner() as PlayerImpl); + } this.unitGrid.removeUnit(u); if (u.hasTrainStation()) { this._railNetwork.removeStation(u); } } updateUnitTile(u: Unit) { + if (u.type() === UnitType.DefensePost) { + this.updateDefendedStateForDefensePost(u.tile(), u.owner() as PlayerImpl); + } this.unitGrid.updateUnitCell(u); } @@ -988,6 +1004,63 @@ export class GameImpl implements Game { // Record stats this.stats().goldWar(conqueror, conquered, gold); } + + /** + * Update defended state for border tiles within range of a defense post. + */ + private updateDefendedStateForDefensePost( + center: TileRef, + owner: PlayerImpl, + ) { + const range = this.config().defensePostRange(); + const rangeSq = range * range; + + for (const tile of owner._borderTiles) { + if (this._map.euclideanDistSquared(center, tile) <= rangeSq) { + const wasDefended = this._map.isDefended(tile); + const isDefended = this.unitGrid.hasUnitNearby( + tile, + range, + UnitType.DefensePost, + owner.id(), + ); + if (wasDefended !== isDefended) { + this._map.setDefended(tile, isDefended); + this.addUpdate({ + type: GameUpdateType.Tile, + update: this.toTileUpdate(tile), + }); + } + } + } + } + + /** + * Update defended state when a tile changes ownership. + */ + private updateDefendedStateForTileChange(tile: TileRef, owner: PlayerImpl) { + const wasDefended = this._map.isDefended(tile); + const isDefended = this.unitGrid.hasUnitNearby( + tile, + this.config().defensePostRange(), + UnitType.DefensePost, + owner.id(), + ); + if (wasDefended !== isDefended) { + this._map.setDefended(tile, isDefended); + this.addUpdate({ + type: GameUpdateType.Tile, + update: this.toTileUpdate(tile), + }); + } + + // If the conquered tile has a defense post, update nearby border tiles + if ( + this.unitGrid.hasUnitNearby(tile, 0, UnitType.DefensePost, owner.id()) + ) { + this.updateDefendedStateForDefensePost(tile, owner); + } + } } // Or a more dynamic approach that will catch new enum values: From 21ce1ad6c00e388b4de886bb0e71ef4babceb705 Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Thu, 4 Dec 2025 02:57:06 +0100 Subject: [PATCH 12/16] Removed old per-tile relation handling; relations are now handled via the u_relations texture in TerritoryWebGLRenderer --- src/core/game/GameImpl.ts | 8 -------- src/core/game/GameMap.ts | 23 ----------------------- src/core/game/GameView.ts | 8 -------- 3 files changed, 39 deletions(-) diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts index f2d5c78a10..8e86ce0079 100644 --- a/src/core/game/GameImpl.ts +++ b/src/core/game/GameImpl.ts @@ -900,14 +900,6 @@ export class GameImpl implements Game { setDefended(ref: TileRef, value: boolean): void { return this._map.setDefended(ref, value); } - - getRelation(ref: TileRef): number { - return this._map.getRelation(ref); - } - - setRelation(ref: TileRef, relation: number): void { - return this._map.setRelation(ref, relation); - } isBorder(ref: TileRef): boolean { return this._map.isBorder(ref); } diff --git a/src/core/game/GameMap.ts b/src/core/game/GameMap.ts index deeddd845a..4e06494357 100644 --- a/src/core/game/GameMap.ts +++ b/src/core/game/GameMap.ts @@ -29,8 +29,6 @@ export interface GameMap { setFallout(ref: TileRef, value: boolean): void; isDefended(ref: TileRef): boolean; setDefended(ref: TileRef, value: boolean): void; - getRelation(ref: TileRef): number; - setRelation(ref: TileRef, relation: number): void; isOnEdgeOfMap(ref: TileRef): boolean; isBorder(ref: TileRef): boolean; neighbors(ref: TileRef): TileRef[]; @@ -77,12 +75,6 @@ export class GameMapImpl implements GameMap { private static readonly PLAYER_ID_MASK = 0xfff; private static readonly FALLOUT_BIT = 13; private static readonly DEFENDED_BIT = 12; - private static readonly RELATION_MASK = 0xc000; // bits 14-15 - private static readonly RELATION_SHIFT = 14; - // Relation values (stored in bits 14-15) - private static readonly RELATION_NEUTRAL = 0; - private static readonly RELATION_FRIENDLY = 1; - private static readonly RELATION_EMBARGO = 2; constructor( width: number, @@ -238,21 +230,6 @@ export class GameMapImpl implements GameMap { } } - getRelation(ref: TileRef): number { - return ( - (this.state[ref] & GameMapImpl.RELATION_MASK) >> - GameMapImpl.RELATION_SHIFT - ); - } - - setRelation(ref: TileRef, relation: number): void { - // Clear existing relation bits - this.state[ref] &= ~GameMapImpl.RELATION_MASK; - // Set new relation bits - this.state[ref] |= - (relation << GameMapImpl.RELATION_SHIFT) & GameMapImpl.RELATION_MASK; - } - isOnEdgeOfMap(ref: TileRef): boolean { const x = this.x(ref); const y = this.y(ref); diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts index ee94299715..4d7cdd9009 100644 --- a/src/core/game/GameView.ts +++ b/src/core/game/GameView.ts @@ -862,14 +862,6 @@ export class GameView implements GameMap { setDefended(ref: TileRef, value: boolean): void { return this._map.setDefended(ref, value); } - - getRelation(ref: TileRef): number { - return this._map.getRelation(ref); - } - - setRelation(ref: TileRef, relation: number): void { - return this._map.setRelation(ref, relation); - } isBorder(ref: TileRef): boolean { return this._map.isBorder(ref); } From 6014a39a86a3959544900266a8fe8b9bc88f579f Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Thu, 4 Dec 2025 03:20:10 +0100 Subject: [PATCH 13/16] Removed legacy border color texture and all related plumbing In src/client/graphics/layers/TerritoryWebGLRenderer.ts: - Deleted borderColorTexture, borderColorData, borderDirtyRows, borderNeedsFullUpload. - Removed setBorderColor, clearBorderColor, markBorderDirty, and uploadBorderTexture, plus the extra profiling calls. - Dropped the u_borderColor uniform from both the uniform map and the fragment shader. - Removed all texture creation/binding/uniform setup for the old border color texture; only u_state, u_palette, and u_relations remain. The WebGL renderer now relies solely on the palette + relations + state buffers for borders. --- .../graphics/layers/TerritoryWebGLRenderer.ts | 135 ------------------ 1 file changed, 135 deletions(-) diff --git a/src/client/graphics/layers/TerritoryWebGLRenderer.ts b/src/client/graphics/layers/TerritoryWebGLRenderer.ts index 8b69aff177..e637ea6269 100644 --- a/src/client/graphics/layers/TerritoryWebGLRenderer.ts +++ b/src/client/graphics/layers/TerritoryWebGLRenderer.ts @@ -32,13 +32,11 @@ export class TerritoryWebGLRenderer { private readonly stateTexture: WebGLTexture | null; private readonly paletteTexture: WebGLTexture | null; private readonly relationTexture: WebGLTexture | null; - private readonly borderColorTexture: WebGLTexture | null; private readonly uniforms: { resolution: WebGLUniformLocation | null; state: WebGLUniformLocation | null; palette: WebGLUniformLocation | null; relations: WebGLUniformLocation | null; - borderColor: WebGLUniformLocation | null; fallout: WebGLUniformLocation | null; altSelf: WebGLUniformLocation | null; altAlly: WebGLUniformLocation | null; @@ -67,9 +65,7 @@ export class TerritoryWebGLRenderer { private readonly state: Uint16Array; private readonly dirtyRows: Map = new Map(); - private readonly borderDirtyRows: Map = new Map(); private needsFullUpload = true; - private borderNeedsFullUpload = true; private alternativeView = false; private paletteWidth = 0; private hoverHighlightStrength = 0.7; @@ -78,7 +74,6 @@ export class TerritoryWebGLRenderer { private hoverPulseSpeed = Math.PI * 2; private hoveredPlayerId = -1; private animationStartTime = Date.now(); - private borderColorData: Uint8Array; private constructor( private readonly game: GameView, @@ -90,9 +85,6 @@ export class TerritoryWebGLRenderer { this.canvas.height = game.height(); this.state = new Uint16Array(sharedState); - this.borderColorData = new Uint8Array( - this.canvas.width * this.canvas.height * 4, - ); this.gl = this.canvas.getContext("webgl2", { premultipliedAlpha: true, @@ -107,13 +99,11 @@ export class TerritoryWebGLRenderer { this.stateTexture = null; this.paletteTexture = null; this.relationTexture = null; - this.borderColorTexture = null; this.uniforms = { resolution: null, state: null, palette: null, relations: null, - borderColor: null, fallout: null, altSelf: null, altAlly: null, @@ -149,13 +139,11 @@ export class TerritoryWebGLRenderer { this.stateTexture = null; this.paletteTexture = null; this.relationTexture = null; - this.borderColorTexture = null; this.uniforms = { resolution: null, state: null, palette: null, relations: null, - borderColor: null, fallout: null, altSelf: null, altAlly: null, @@ -188,7 +176,6 @@ export class TerritoryWebGLRenderer { state: gl.getUniformLocation(this.program, "u_state"), palette: gl.getUniformLocation(this.program, "u_palette"), relations: gl.getUniformLocation(this.program, "u_relations"), - borderColor: gl.getUniformLocation(this.program, "u_borderColor"), fallout: gl.getUniformLocation(this.program, "u_fallout"), altSelf: gl.getUniformLocation(this.program, "u_altSelf"), altAlly: gl.getUniformLocation(this.program, "u_altAlly"), @@ -291,33 +278,12 @@ export class TerritoryWebGLRenderer { this.state, ); - this.borderColorTexture = gl.createTexture(); - gl.activeTexture(gl.TEXTURE3); - gl.bindTexture(gl.TEXTURE_2D, this.borderColorTexture); - 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.canvas.width, - this.canvas.height, - 0, - gl.RGBA, - gl.UNSIGNED_BYTE, - this.borderColorData, - ); - this.uploadPalette(); gl.useProgram(this.program); gl.uniform1i(this.uniforms.state, 0); gl.uniform1i(this.uniforms.palette, 1); gl.uniform1i(this.uniforms.relations, 2); - gl.uniform1i(this.uniforms.borderColor, 3); if (this.uniforms.resolution) { gl.uniform2f( @@ -448,27 +414,6 @@ export class TerritoryWebGLRenderer { this.alternativeView = enabled; } - setBorderColor( - tile: TileRef, - rgba: { r: number; g: number; b: number; a: number }, - ) { - const offset = tile * 4; - this.borderColorData[offset] = rgba.r; - this.borderColorData[offset + 1] = rgba.g; - this.borderColorData[offset + 2] = rgba.b; - this.borderColorData[offset + 3] = rgba.a; - this.markBorderDirty(tile); - } - - clearBorderColor(tile: TileRef) { - const offset = tile * 4; - this.borderColorData[offset] = 0; - this.borderColorData[offset + 1] = 0; - this.borderColorData[offset + 2] = 0; - this.borderColorData[offset + 3] = 0; - this.markBorderDirty(tile); - } - setHoveredPlayerId(playerSmallId: number | null) { const encoded = playerSmallId ?? -1; this.hoveredPlayerId = encoded; @@ -508,26 +453,9 @@ export class TerritoryWebGLRenderer { } } - private markBorderDirty(tile: TileRef) { - if (this.borderNeedsFullUpload) { - return; - } - const x = tile % this.canvas.width; - const y = Math.floor(tile / this.canvas.width); - const span = this.borderDirtyRows.get(y); - if (span === undefined) { - this.borderDirtyRows.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(); - this.borderNeedsFullUpload = true; - this.borderDirtyRows.clear(); } refreshPalette() { @@ -547,10 +475,6 @@ export class TerritoryWebGLRenderer { this.uploadStateTexture(); FrameProfiler.end("TerritoryWebGLRenderer:uploadState", uploadStateSpan); - const uploadBorderSpan = FrameProfiler.start(); - this.uploadBorderTexture(); - FrameProfiler.end("TerritoryWebGLRenderer:uploadBorder", uploadBorderSpan); - const renderSpan = FrameProfiler.start(); gl.viewport(0, 0, this.canvas.width, this.canvas.height); gl.useProgram(this.program); @@ -648,64 +572,6 @@ export class TerritoryWebGLRenderer { return { rows: rowsUploaded, bytes: bytesUploaded }; } - private uploadBorderTexture(): { rows: number; bytes: number } { - if (!this.gl || !this.borderColorTexture) return { rows: 0, bytes: 0 }; - const gl = this.gl; - gl.activeTexture(gl.TEXTURE3); - gl.bindTexture(gl.TEXTURE_2D, this.borderColorTexture); - - const bytesPerPixel = Uint8Array.BYTES_PER_ELEMENT * 4; // RGBA8 - let rowsUploaded = 0; - let bytesUploaded = 0; - - if (this.borderNeedsFullUpload) { - gl.texImage2D( - gl.TEXTURE_2D, - 0, - gl.RGBA8, - this.canvas.width, - this.canvas.height, - 0, - gl.RGBA, - gl.UNSIGNED_BYTE, - this.borderColorData, - ); - this.borderNeedsFullUpload = false; - this.borderDirtyRows.clear(); - rowsUploaded = this.canvas.height; - bytesUploaded = this.canvas.width * this.canvas.height * bytesPerPixel; - return { rows: rowsUploaded, bytes: bytesUploaded }; - } - - if (this.borderDirtyRows.size === 0) { - return { rows: 0, bytes: 0 }; - } - - for (const [y, span] of this.borderDirtyRows) { - const width = span.maxX - span.minX + 1; - const offset = (y * this.canvas.width + span.minX) * 4; - const rowSlice = this.borderColorData.subarray( - offset, - offset + width * 4, - ); - gl.texSubImage2D( - gl.TEXTURE_2D, - 0, - span.minX, - y, - width, - 1, - gl.RGBA, - gl.UNSIGNED_BYTE, - rowSlice, - ); - rowsUploaded++; - bytesUploaded += width * bytesPerPixel; - } - this.borderDirtyRows.clear(); - return { rows: rowsUploaded, bytes: bytesUploaded }; - } - private labelUpload( base: string, metrics: { rows: number; bytes: number }, @@ -847,7 +713,6 @@ export class TerritoryWebGLRenderer { uniform usampler2D u_state; uniform sampler2D u_palette; uniform usampler2D u_relations; - uniform sampler2D u_borderColor; uniform int u_viewerId; uniform vec2 u_resolution; uniform vec4 u_fallout; From 03eaca31bfb84c893c032e6b6b1bbd6bd30adff9 Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Thu, 4 Dec 2025 16:33:15 +0100 Subject: [PATCH 14/16] add comment to unused labelUpload method and clean up removeUnit ordering - Move unitGrid.removeUnit() call to beginning of removeUnit method for cleaner code organization --- src/client/graphics/layers/TerritoryWebGLRenderer.ts | 6 ++++++ src/core/game/GameImpl.ts | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/client/graphics/layers/TerritoryWebGLRenderer.ts b/src/client/graphics/layers/TerritoryWebGLRenderer.ts index e637ea6269..034ed0cada 100644 --- a/src/client/graphics/layers/TerritoryWebGLRenderer.ts +++ b/src/client/graphics/layers/TerritoryWebGLRenderer.ts @@ -572,6 +572,12 @@ export class TerritoryWebGLRenderer { return { rows: rowsUploaded, bytes: bytesUploaded }; } + /** + * Formats upload metrics into a human-readable string for logging/debugging. + * Used for performance monitoring of WebGL texture uploads, bucketing values + * to provide meaningful categories rather than exact numbers. + * currently unused. + */ private labelUpload( base: string, metrics: { rows: number; bytes: number }, diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts index 8e86ce0079..82c16768a7 100644 --- a/src/core/game/GameImpl.ts +++ b/src/core/game/GameImpl.ts @@ -788,10 +788,10 @@ export class GameImpl implements Game { } } removeUnit(u: Unit) { + this.unitGrid.removeUnit(u); if (u.type() === UnitType.DefensePost) { this.updateDefendedStateForDefensePost(u.tile(), u.owner() as PlayerImpl); } - this.unitGrid.removeUnit(u); if (u.hasTrainStation()) { this._railNetwork.removeStation(u); } From 145ae7aa41594f872e8855be7510833fd69c663d Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Thu, 4 Dec 2025 16:36:57 +0100 Subject: [PATCH 15/16] Refactor GameImpl and GameView methods to remove unnecessary return statements - Updated setOwnerID, setDefended, forEachTile, and setFallout methods in GameImpl and GameView to eliminate redundant return statements --- src/core/game/GameImpl.ts | 6 +++--- src/core/game/GameView.ts | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts index 82c16768a7..80b50d0e2e 100644 --- a/src/core/game/GameImpl.ts +++ b/src/core/game/GameImpl.ts @@ -887,7 +887,7 @@ export class GameImpl implements Game { return this._map.hasOwner(ref); } setOwnerID(ref: TileRef, playerId: number): void { - return this._map.setOwnerID(ref, playerId); + this._map.setOwnerID(ref, playerId); } hasFallout(ref: TileRef): boolean { return this._map.hasFallout(ref); @@ -898,7 +898,7 @@ export class GameImpl implements Game { } setDefended(ref: TileRef, value: boolean): void { - return this._map.setDefended(ref, value); + this._map.setDefended(ref, value); } isBorder(ref: TileRef): boolean { return this._map.isBorder(ref); @@ -931,7 +931,7 @@ export class GameImpl implements Game { return this._map.terrainType(ref); } forEachTile(fn: (tile: TileRef) => void): void { - return this._map.forEachTile(fn); + this._map.forEachTile(fn); } manhattanDist(c1: TileRef, c2: TileRef): number { return this._map.manhattanDist(c1, c2); diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts index 4d7cdd9009..11f48c88c1 100644 --- a/src/core/game/GameView.ts +++ b/src/core/game/GameView.ts @@ -846,13 +846,13 @@ export class GameView implements GameMap { return this._map.hasOwner(ref); } setOwnerID(ref: TileRef, playerId: number): void { - return this._map.setOwnerID(ref, playerId); + this._map.setOwnerID(ref, playerId); } hasFallout(ref: TileRef): boolean { return this._map.hasFallout(ref); } setFallout(ref: TileRef, value: boolean): void { - return this._map.setFallout(ref, value); + this._map.setFallout(ref, value); } isDefended(ref: TileRef): boolean { @@ -860,7 +860,7 @@ export class GameView implements GameMap { } setDefended(ref: TileRef, value: boolean): void { - return this._map.setDefended(ref, value); + this._map.setDefended(ref, value); } isBorder(ref: TileRef): boolean { return this._map.isBorder(ref); @@ -884,7 +884,7 @@ export class GameView implements GameMap { return this._map.terrainType(ref); } forEachTile(fn: (tile: TileRef) => void): void { - return this._map.forEachTile(fn); + this._map.forEachTile(fn); } manhattanDist(c1: TileRef, c2: TileRef): number { return this._map.manhattanDist(c1, c2); From 798f36892d39de5783763bb1062096747b428241 Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Thu, 4 Dec 2025 16:42:13 +0100 Subject: [PATCH 16/16] Enhance shared tile ring initialization in Worker.worker.ts - Updated the initialization logic to include a check for sharedDirtyBuffer alongside sharedTileRingHeader and sharedTileRingData, ensuring all necessary data is present before creating sharedTileRing views. --- src/core/worker/Worker.worker.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/core/worker/Worker.worker.ts b/src/core/worker/Worker.worker.ts index aae5a69efd..b6aeb7ec85 100644 --- a/src/core/worker/Worker.worker.ts +++ b/src/core/worker/Worker.worker.ts @@ -70,11 +70,15 @@ ctx.addEventListener("message", async (e: MessageEvent) => { switch (message.type) { case "init": try { - if (message.sharedTileRingHeader && message.sharedTileRingData) { + if ( + message.sharedTileRingHeader && + message.sharedTileRingData && + message.sharedDirtyBuffer + ) { sharedTileRing = createSharedTileRingViews({ header: message.sharedTileRingHeader, data: message.sharedTileRingData, - dirty: message.sharedDirtyBuffer!, + dirty: message.sharedDirtyBuffer, }); dirtyFlags = sharedTileRing.dirtyFlags; } else {