diff --git a/src/client/graphics/layers/TerritoryLayer.ts b/src/client/graphics/layers/TerritoryLayer.ts index 720591e6c..802bc5b90 100644 --- a/src/client/graphics/layers/TerritoryLayer.ts +++ b/src/client/graphics/layers/TerritoryLayer.ts @@ -29,6 +29,13 @@ import { } from "./TerritoryRenderers"; import { TerritoryWebGLRenderer } from "./TerritoryWebGLRenderer"; +type TileTransition = { + startTime: number; + durationMs: number; + highlight: boolean; + lastProgressByte: number; +}; + export class TerritoryLayer implements Layer { profileName(): string { return "TerritoryLayer:renderLayer"; @@ -66,6 +73,12 @@ export class TerritoryLayer implements Layer { private lastFocusedPlayer: PlayerView | null = null; private lastMyPlayerSmallId: number | null = null; private lastPaletteSignature: string | null = null; + private tileTransitions: Map = new Map(); + private transitionHighlightTiles: TileRef[] = []; + private transitionHighlightAlphas: number[] = []; + private lastGameTick = 0; + private lastTickTime = 0; + private lastTickDurationMs = 100; constructor( private game: GameView, @@ -75,6 +88,7 @@ export class TerritoryLayer implements Layer { this.theme = game.config().theme(); this.cachedTerritoryPatternsEnabled = undefined; this.lastMyPlayerSmallId = game.myPlayer()?.smallID() ?? null; + this.lastTickTime = Date.now(); } shouldTransform(): boolean { @@ -90,6 +104,8 @@ export class TerritoryLayer implements Layer { tick() { const tickProfile = FrameProfiler.start(); + const now = Date.now(); + this.updateTickTiming(now); if (this.game.inSpawnPhase()) { this.spawnHighlight(); } @@ -109,6 +125,7 @@ export class TerritoryLayer implements Layer { this.clearTile(t); } }); + this.beginTileTransitions(this.game.recentlyUpdatedOwnerTiles(), now); const updates = this.game.updatesSinceLastTick(); const unitUpdates = updates !== null ? updates[GameUpdateType.Unit] : []; unitUpdates.forEach((update) => { @@ -432,6 +449,9 @@ export class TerritoryLayer implements Layer { this.lastMyPlayerSmallId = this.game.myPlayer()?.smallID() ?? null; this.cachedTerritoryPatternsEnabled = this.userSettings.territoryPatterns(); this.configureRenderers(); + this.tileTransitions.clear(); + this.transitionHighlightTiles.length = 0; + this.transitionHighlightAlphas.length = 0; this.territoryRenderer?.redraw(); // Add a second canvas for highlights @@ -522,6 +542,8 @@ export class TerritoryLayer implements Layer { FrameProfiler.end("TerritoryLayer:renderTerritory", renderTerritoryStart); } + this.updateTransitionProgress(now); + const [topLeft, bottomRight] = this.transformHandler.screenBoundingRect(); const vx0 = Math.max(0, topLeft.x); const vy0 = Math.max(0, topLeft.y); @@ -543,6 +565,8 @@ export class TerritoryLayer implements Layer { ); } + this.drawTransitionHighlights(context, now); + if (this.game.inSpawnPhase()) { const highlightDrawStart = FrameProfiler.start(); context.drawImage( @@ -563,13 +587,9 @@ export class TerritoryLayer implements Layer { if (!this.territoryRenderer) { return; } - let numToRender = Math.floor(this.tileToRenderQueue.size() / 10); - if ( - numToRender === 0 || - this.game.inSpawnPhase() || - this.territoryRenderer.isWebGL() - ) { - numToRender = this.tileToRenderQueue.size(); + let numToRender = this.tileToRenderQueue.size(); + if (numToRender === 0) { + return; } const useNeighborPaint = !(this.territoryRenderer?.isWebGL() ?? false); @@ -681,6 +701,111 @@ export class TerritoryLayer implements Layer { ctx.fill(); } + private updateTickTiming(now: number) { + const currentTick = this.game.ticks(); + if (currentTick === this.lastGameTick) { + return; + } + if (this.lastGameTick !== 0) { + const tickDelta = Math.max(1, currentTick - this.lastGameTick); + const elapsed = now - this.lastTickTime; + const estimate = elapsed / tickDelta; + this.lastTickDurationMs = Math.max(50, Math.min(200, estimate)); + } + this.lastGameTick = currentTick; + this.lastTickTime = now; + } + + private beginTileTransitions( + changes: Array<{ tile: TileRef; previousOwner: number; newOwner: number }>, + now: number, + ) { + if (changes.length === 0) { + return; + } + const durationMs = this.lastTickDurationMs; + for (const change of changes) { + if (change.newOwner === change.previousOwner) { + continue; + } + if (change.newOwner === 0) { + this.tileTransitions.delete(change.tile); + this.territoryRenderer?.setTransitionProgress(change.tile, 1); + continue; + } + this.tileTransitions.set(change.tile, { + startTime: now, + durationMs, + highlight: change.newOwner !== 0, + lastProgressByte: -1, + }); + this.territoryRenderer?.setTransitionProgress(change.tile, 0); + } + } + + private updateTransitionProgress(now: number) { + this.transitionHighlightTiles.length = 0; + this.transitionHighlightAlphas.length = 0; + if (!this.territoryRenderer || this.tileTransitions.size === 0) { + return; + } + + const toDelete: TileRef[] = []; + for (const [tile, transition] of this.tileTransitions) { + const elapsed = now - transition.startTime; + const duration = transition.durationMs > 0 ? transition.durationMs : 1; + const progress = Math.max(0, Math.min(1, elapsed / duration)); + const progressByte = Math.round(progress * 255); + if (progressByte !== transition.lastProgressByte) { + transition.lastProgressByte = progressByte; + this.territoryRenderer.setTransitionProgress(tile, progress); + } + if (transition.highlight && progress < 1) { + const alpha = (1 - progress) * 0.35; + if (alpha > 0.01) { + this.transitionHighlightTiles.push(tile); + this.transitionHighlightAlphas.push(alpha); + } + } + if (progress >= 1) { + toDelete.push(tile); + } + } + for (const tile of toDelete) { + this.tileTransitions.delete(tile); + } + } + + private drawTransitionHighlights( + context: CanvasRenderingContext2D, + now: number, + ) { + if (this.transitionHighlightTiles.length === 0) { + return; + } + const pulse = 0.75 + 0.25 * Math.sin((now - this.lastTickTime) * 0.015); + const highlight = this.theme.spawnHighlightColor(); + const offsetX = -this.game.width() / 2; + const offsetY = -this.game.height() / 2; + context.save(); + context.fillStyle = highlight.toRgbString(); + for (let i = 0; i < this.transitionHighlightTiles.length; i++) { + const alpha = this.transitionHighlightAlphas[i] * pulse; + if (alpha <= 0) { + continue; + } + const tile = this.transitionHighlightTiles[i]; + context.globalAlpha = alpha; + context.fillRect( + this.game.x(tile) + offsetX, + this.game.y(tile) + offsetY, + 1, + 1, + ); + } + context.restore(); + } + private computePaletteSignature(): string { let maxSmallId = 0; for (const player of this.game.playerViews()) { diff --git a/src/client/graphics/layers/TerritoryRenderers.ts b/src/client/graphics/layers/TerritoryRenderers.ts index 3d453d539..2a7d542cb 100644 --- a/src/client/graphics/layers/TerritoryRenderers.ts +++ b/src/client/graphics/layers/TerritoryRenderers.ts @@ -13,6 +13,7 @@ export interface TerritoryRendererStrategy { redraw(): void; markAllDirty(): void; paintTile(tile: TileRef): void; + setTransitionProgress(tile: TileRef, progress: number): void; render( context: CanvasRenderingContext2D, viewport: { x: number; y: number; width: number; height: number }, @@ -31,6 +32,7 @@ export class CanvasTerritoryRenderer implements TerritoryRendererStrategy { private imageData: ImageData; private alternativeImageData: ImageData; private alternativeView = false; + private transitionProgress: Map = new Map(); constructor( private readonly game: GameView, @@ -85,19 +87,20 @@ export class CanvasTerritoryRenderer implements TerritoryRendererStrategy { const isDefended = owner && isBorderTile ? this.game.isDefended(tile) : false; + const transitionFactor = this.transitionProgress.get(tile) ?? 1; if (!owner) { if (hasFallout) { this.paintTileColor( this.imageData, tile, this.theme.falloutColor(), - 150, + Math.round(150 * transitionFactor), ); this.paintTileColor( this.alternativeImageData, tile, this.theme.falloutColor(), - 150, + Math.round(150 * transitionFactor), ); } else { this.clearTile(tile); @@ -115,14 +118,14 @@ export class CanvasTerritoryRenderer implements TerritoryRendererStrategy { this.alternativeImageData, tile, alternativeColor, - 255, + Math.round(255 * transitionFactor), ); } this.paintTileColor( this.imageData, tile, owner.borderColor(tile, isDefended), - 255, + Math.round(255 * transitionFactor), ); } else { // Alternative view only shows borders. @@ -131,7 +134,7 @@ export class CanvasTerritoryRenderer implements TerritoryRendererStrategy { this.imageData, tile, owner.territoryColor(tile), - 150, + Math.round(150 * transitionFactor), ); } FrameProfiler.end("CanvasTerritoryRenderer:paintTile", cpuStart); @@ -175,6 +178,22 @@ export class CanvasTerritoryRenderer implements TerritoryRendererStrategy { this.alternativeView = enabled; } + setTransitionProgress(tile: TileRef, progress: number): void { + const clamped = Math.max(0, Math.min(1, progress)); + if (clamped >= 1) { + if (this.transitionProgress.delete(tile)) { + this.paintTile(tile); + } + return; + } + const previous = this.transitionProgress.get(tile); + if (previous !== undefined && Math.abs(previous - clamped) < 1 / 255) { + return; + } + this.transitionProgress.set(tile, clamped); + this.paintTile(tile); + } + setHover(): void { // Canvas path relies on CPU highlight redraw in TerritoryLayer. } @@ -259,6 +278,10 @@ export class WebglTerritoryRenderer implements TerritoryRendererStrategy { this.renderer.markTile(tile); } + setTransitionProgress(tile: TileRef, progress: number): void { + this.renderer.setTransitionProgress(tile, progress); + } + render( context: CanvasRenderingContext2D, _viewport: { x: number; y: number; width: number; height: number }, diff --git a/src/client/graphics/layers/TerritoryWebGLRenderer.ts b/src/client/graphics/layers/TerritoryWebGLRenderer.ts index 9a3cca48c..5647877f8 100644 --- a/src/client/graphics/layers/TerritoryWebGLRenderer.ts +++ b/src/client/graphics/layers/TerritoryWebGLRenderer.ts @@ -35,12 +35,14 @@ export class TerritoryWebGLRenderer { private readonly paletteTexture: WebGLTexture | null; private readonly relationTexture: WebGLTexture | null; private readonly patternTexture: WebGLTexture | null; + private readonly transitionTexture: WebGLTexture | null; private readonly uniforms: { resolution: WebGLUniformLocation | null; state: WebGLUniformLocation | null; palette: WebGLUniformLocation | null; relations: WebGLUniformLocation | null; patterns: WebGLUniformLocation | null; + transitions: WebGLUniformLocation | null; patternStride: WebGLUniformLocation | null; patternRows: WebGLUniformLocation | null; fallout: WebGLUniformLocation | null; @@ -60,8 +62,11 @@ export class TerritoryWebGLRenderer { }; private readonly state: Uint16Array; + private readonly transitionState: Uint8Array; private readonly dirtyRows: Map = new Map(); + private readonly transitionDirtyRows: Map = new Map(); private needsFullUpload = true; + private needsTransitionFullUpload = true; private alternativeView = false; private paletteWidth = 0; private hoverHighlightStrength = 0.7; @@ -83,6 +88,8 @@ export class TerritoryWebGLRenderer { this.canvas.height = game.height(); this.state = state; + this.transitionState = new Uint8Array(state.length); + this.transitionState.fill(255); this.gl = this.canvas.getContext("webgl2", { premultipliedAlpha: true, @@ -98,12 +105,14 @@ export class TerritoryWebGLRenderer { this.paletteTexture = null; this.relationTexture = null; this.patternTexture = null; + this.transitionTexture = null; this.uniforms = { resolution: null, state: null, palette: null, relations: null, patterns: null, + transitions: null, patternStride: null, patternRows: null, fallout: null, @@ -133,12 +142,14 @@ export class TerritoryWebGLRenderer { this.paletteTexture = null; this.relationTexture = null; this.patternTexture = null; + this.transitionTexture = null; this.uniforms = { resolution: null, state: null, palette: null, relations: null, patterns: null, + transitions: null, patternStride: null, patternRows: null, fallout: null, @@ -165,6 +176,7 @@ export class TerritoryWebGLRenderer { palette: gl.getUniformLocation(this.program, "u_palette"), relations: gl.getUniformLocation(this.program, "u_relations"), patterns: gl.getUniformLocation(this.program, "u_patterns"), + transitions: gl.getUniformLocation(this.program, "u_transitions"), patternStride: gl.getUniformLocation(this.program, "u_patternStride"), patternRows: gl.getUniformLocation(this.program, "u_patternRows"), fallout: gl.getUniformLocation(this.program, "u_fallout"), @@ -223,6 +235,7 @@ export class TerritoryWebGLRenderer { this.paletteTexture = gl.createTexture(); this.relationTexture = gl.createTexture(); this.patternTexture = gl.createTexture(); + this.transitionTexture = gl.createTexture(); gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, this.stateTexture); @@ -245,11 +258,31 @@ export class TerritoryWebGLRenderer { this.uploadPalette(); + gl.activeTexture(gl.TEXTURE4); + gl.bindTexture(gl.TEXTURE_2D, this.transitionTexture); + 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.canvas.width, + this.canvas.height, + 0, + gl.RED_INTEGER, + gl.UNSIGNED_BYTE, + this.transitionState, + ); + gl.useProgram(this.program); gl.uniform1i(this.uniforms.state, 0); gl.uniform1i(this.uniforms.palette, 1); gl.uniform1i(this.uniforms.relations, 2); gl.uniform1i(this.uniforms.patterns, 3); + gl.uniform1i(this.uniforms.transitions, 4); if (this.uniforms.resolution) { gl.uniform2f( @@ -411,6 +444,27 @@ export class TerritoryWebGLRenderer { } } + setTransitionProgress(tile: TileRef, progress: number) { + const clamped = Math.max(0, Math.min(1, progress)); + const value = Math.round(clamped * 255); + if (this.transitionState[tile] === value) { + return; + } + this.transitionState[tile] = value; + if (this.needsTransitionFullUpload) { + return; + } + const x = tile % this.canvas.width; + const y = Math.floor(tile / this.canvas.width); + const span = this.transitionDirtyRows.get(y); + if (span === undefined) { + this.transitionDirtyRows.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(); @@ -433,6 +487,13 @@ export class TerritoryWebGLRenderer { this.uploadStateTexture(); FrameProfiler.end("TerritoryWebGLRenderer:uploadState", uploadStateSpan); + const uploadTransitionSpan = FrameProfiler.start(); + this.uploadTransitionTexture(); + FrameProfiler.end( + "TerritoryWebGLRenderer:uploadTransitions", + uploadTransitionSpan, + ); + const renderSpan = FrameProfiler.start(); gl.viewport(0, 0, this.canvas.width, this.canvas.height); gl.useProgram(this.program); @@ -530,6 +591,61 @@ export class TerritoryWebGLRenderer { return { rows: rowsUploaded, bytes: bytesUploaded }; } + private uploadTransitionTexture(): { rows: number; bytes: number } { + if (!this.gl || !this.transitionTexture) return { rows: 0, bytes: 0 }; + const gl = this.gl; + gl.activeTexture(gl.TEXTURE4); + gl.bindTexture(gl.TEXTURE_2D, this.transitionTexture); + + const bytesPerPixel = Uint8Array.BYTES_PER_ELEMENT; + let rowsUploaded = 0; + let bytesUploaded = 0; + + if (this.needsTransitionFullUpload) { + gl.texImage2D( + gl.TEXTURE_2D, + 0, + gl.R8UI, + this.canvas.width, + this.canvas.height, + 0, + gl.RED_INTEGER, + gl.UNSIGNED_BYTE, + this.transitionState, + ); + this.needsTransitionFullUpload = false; + this.transitionDirtyRows.clear(); + rowsUploaded = this.canvas.height; + bytesUploaded = this.canvas.width * this.canvas.height * bytesPerPixel; + return { rows: rowsUploaded, bytes: bytesUploaded }; + } + + if (this.transitionDirtyRows.size === 0) { + return { rows: 0, bytes: 0 }; + } + + for (const [y, span] of this.transitionDirtyRows) { + const width = span.maxX - span.minX + 1; + const offset = y * this.canvas.width + span.minX; + const rowSlice = this.transitionState.subarray(offset, offset + width); + gl.texSubImage2D( + gl.TEXTURE_2D, + 0, + span.minX, + y, + width, + 1, + gl.RED_INTEGER, + gl.UNSIGNED_BYTE, + rowSlice, + ); + rowsUploaded++; + bytesUploaded += width * bytesPerPixel; + } + this.transitionDirtyRows.clear(); + return { rows: rowsUploaded, bytes: bytesUploaded }; + } + private uploadPalette() { if ( !this.gl || @@ -717,6 +833,7 @@ export class TerritoryWebGLRenderer { uniform sampler2D u_palette; uniform usampler2D u_relations; uniform usampler2D u_patterns; + uniform usampler2D u_transitions; uniform int u_patternStride; uniform int u_patternRows; uniform int u_viewerId; @@ -801,6 +918,7 @@ export class TerritoryWebGLRenderer { ivec2 texCoord = ivec2(fragCoord.x, int(u_resolution.y) - 1 - fragCoord.y); uint state = texelFetch(u_state, texCoord, 0).r; + float transition = float(texelFetch(u_transitions, texCoord, 0).r) / 255.0; uint owner = state & 0xFFFu; bool hasFallout = (state & 0x2000u) != 0u; bool isDefended = (state & 0x1000u) != 0u; @@ -808,7 +926,7 @@ export class TerritoryWebGLRenderer { if (owner == 0u) { if (hasFallout) { vec3 color = u_fallout.rgb; - float a = u_alpha; + float a = u_alpha * transition; outColor = vec4(color * a, a); } else { outColor = vec4(0.0); @@ -858,7 +976,7 @@ export class TerritoryWebGLRenderer { } else if (isEmbargo(relationAlt)) { altColor = u_altEnemy; } - float a = isBorder ? 1.0 : 0.0; + float a = (isBorder ? 1.0 : 0.0) * transition; vec3 color = altColor.rgb; if (u_hoveredPlayerId >= 0.0 && abs(float(owner) - u_hoveredPlayerId) < 0.5) { float pulse = u_hoverPulseStrength > 0.0 @@ -915,6 +1033,7 @@ export class TerritoryWebGLRenderer { color = mix(color, u_hoverHighlightColor, u_hoverHighlightStrength * pulse); } + a *= transition; outColor = vec4(color * a, a); } `; diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts index 7973d039e..0f3dd4a31 100644 --- a/src/core/game/GameView.ts +++ b/src/core/game/GameView.ts @@ -669,6 +669,11 @@ export class GameView implements GameMap { private _units = new Map(); private updatedTiles: TileRef[] = []; private updatedTerrainTiles: TileRef[] = []; + private updatedOwnerChanges: Array<{ + tile: TileRef; + previousOwner: number; + newOwner: number; + }> = []; private _myPlayer: PlayerView | null = null; @@ -780,15 +785,25 @@ export class GameView implements GameMap { this.updatedTiles = []; this.updatedTerrainTiles = []; + this.updatedOwnerChanges = []; const packed = this.lastUpdate.packedTileUpdates; for (let i = 0; i + 1 < packed.length; i += 2) { const tile = packed[i]; const state = packed[i + 1]; + const previousOwner = this._map.ownerID(tile); const terrainChanged = this.updateTile(tile, state); this.updatedTiles.push(tile); if (terrainChanged) { this.updatedTerrainTiles.push(tile); } + const newOwner = this._map.ownerID(tile); + if (previousOwner !== newOwner) { + this.updatedOwnerChanges.push({ + tile, + previousOwner, + newOwner, + }); + } } if (gu.packedMotionPlans) { @@ -1107,6 +1122,14 @@ export class GameView implements GameMap { return this.updatedTerrainTiles; } + recentlyUpdatedOwnerTiles(): Array<{ + tile: TileRef; + previousOwner: number; + newOwner: number; + }> { + return this.updatedOwnerChanges; + } + nearbyUnits( tile: TileRef, searchRange: number,