diff --git a/src/client/graphics/layers/TerritoryLayer.ts b/src/client/graphics/layers/TerritoryLayer.ts index 95e0a2057..476766f93 100644 --- a/src/client/graphics/layers/TerritoryLayer.ts +++ b/src/client/graphics/layers/TerritoryLayer.ts @@ -17,6 +17,18 @@ import { TransformHandler } from "../TransformHandler"; import { Layer } from "./Layer"; import { TerritoryWebGLRenderer } from "./TerritoryWebGLRenderer"; +const CONTEST_ID_MASK = 0x7fff; +const CONTEST_ATTACKER_EVER_BIT = 0x8000; +const CONTEST_TIME_WRAP = 32768; + +type ContestComponent = { + id: number; + attacker: number; + defender: number; + lastActivityPacked: number; + tiles: TileRef[]; +}; + export class TerritoryLayer implements Layer { profileName(): string { return "TerritoryLayer:renderLayer"; @@ -42,14 +54,15 @@ export class TerritoryLayer implements Layer { private lastFocusedPlayer: PlayerView | null = null; private lastMyPlayerSmallId: number | null = null; private lastPaletteSignature: string | null = null; - private transitionActive = false; - private transitionDurationMs = 500; - private transitionTiles: TileRef[] = []; - private transitionStartTimes: Uint16Array | null = null; - private transitionActiveMask: Uint8Array | null = null; - private lastGameTick = 0; - private lastTickTime = 0; - private lastTickDurationMs = 100; + private contestDurationMs = 5000; + private contestActive = false; + private contestNextId = 1; + private contestFreeIds: number[] = []; + private contestComponentIds: Uint16Array | null = null; + private contestPrevOwners: Uint16Array | null = null; + private contestAttackers: Uint16Array | null = null; + private contestTileIndices: Int32Array | null = null; + private contestComponents = new Map(); constructor( private game: GameView, @@ -59,7 +72,6 @@ export class TerritoryLayer implements Layer { this.theme = game.config().theme(); this.cachedTerritoryPatternsEnabled = undefined; this.lastMyPlayerSmallId = game.myPlayer()?.smallID() ?? null; - this.lastTickTime = this.nowMs(); } shouldTransform(): boolean { @@ -69,7 +81,6 @@ export class TerritoryLayer implements Layer { tick() { const tickProfile = FrameProfiler.start(); const now = this.nowMs(); - this.updateTickTiming(now); if (this.game.inSpawnPhase()) { this.spawnHighlight(); } @@ -82,7 +93,7 @@ export class TerritoryLayer implements Layer { this.refreshPaletteIfNeeded(); this.game.recentlyUpdatedTiles().forEach((t) => this.markTile(t)); - this.beginTileTransitions(this.game.recentlyUpdatedOwnerTiles(), now); + this.applyContestChanges(this.game.recentlyUpdatedOwnerTiles(), now); const updates = this.game.updatesSinceLastTick(); // Detect alliance mutations @@ -343,11 +354,8 @@ export class TerritoryLayer implements Layer { this.lastMyPlayerSmallId = this.game.myPlayer()?.smallID() ?? null; this.cachedTerritoryPatternsEnabled = this.userSettings.territoryPatterns(); this.configureRenderers(); - this.transitionActive = false; - this.transitionTiles = []; - this.ensureTransitionScratch(); - this.transitionStartTimes?.fill(0); - this.transitionActiveMask?.fill(0); + this.ensureContestScratch(); + this.syncContestStateToRenderer(); // Add a second canvas for highlights this.highlightCanvas = document.createElement("canvas"); @@ -408,7 +416,7 @@ export class TerritoryLayer implements Layer { return; } const now = this.nowMs(); - this.updateTransitionState(now); + this.updateContestState(now); const renderTerritoryStart = FrameProfiler.start(); this.territoryRenderer.render(); @@ -500,116 +508,353 @@ export class TerritoryLayer implements Layer { return typeof performance !== "undefined" ? performance.now() : Date.now(); } - private ensureTransitionScratch() { + private ensureContestScratch() { const size = this.game.width() * this.game.height(); - if ( - !this.transitionStartTimes || - this.transitionStartTimes.length !== size - ) { - this.transitionStartTimes = new Uint16Array(size); - this.transitionActiveMask = new Uint8Array(size); + if (!this.contestComponentIds || this.contestComponentIds.length !== size) { + this.contestComponentIds = new Uint16Array(size); + this.contestPrevOwners = new Uint16Array(size); + this.contestAttackers = new Uint16Array(size); + this.contestTileIndices = new Int32Array(size); + this.contestTileIndices.fill(-1); + this.contestComponents.clear(); + this.contestFreeIds = []; + this.contestNextId = 1; + this.contestActive = false; } } - private 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( + private applyContestChanges( changes: Array<{ tile: TileRef; previousOwner: number; newOwner: number }>, now: number, ) { - if (!this.territoryRenderer) { + if (!this.territoryRenderer || changes.length === 0) { return; } - this.ensureTransitionScratch(); - const startTimes = this.transitionStartTimes!; - const activeMask = this.transitionActiveMask!; - const renderer = this.territoryRenderer; - if (changes.length === 0) { - return; - } - const nowPacked = this.packTransitionTime(now); - const startPacked = nowPacked | 0x8000; + this.ensureContestScratch(); + const nowPacked = this.packContestTime(now); for (const change of changes) { if (change.newOwner === change.previousOwner) { continue; } const tile = change.tile; - if (activeMask[tile] === 0) { - activeMask[tile] = 1; - this.transitionTiles.push(tile); + const currentId = this.contestId(tile); + if (currentId === 0) { + this.startContestForTile( + tile, + change.previousOwner, + change.newOwner, + nowPacked, + ); + continue; } - startTimes[tile] = nowPacked; - renderer.setTransitionTile(tile, change.previousOwner, startPacked); - } - this.transitionActive = this.transitionTiles.length > 0; + const component = this.contestComponents.get(currentId); + if (!component) { + this.clearContestTile(tile); + this.startContestForTile( + tile, + change.previousOwner, + change.newOwner, + nowPacked, + ); + continue; + } + + if ( + change.newOwner === component.attacker || + change.newOwner === component.defender + ) { + const attackerEver = + change.newOwner === component.attacker || this.hasAttackerEver(tile); + this.setContestTileData( + tile, + component.defender, + component.attacker, + component.id, + attackerEver, + ); + component.lastActivityPacked = nowPacked; + this.territoryRenderer.setContestTime(component.id, nowPacked); + } else { + this.removeTileFromComponent(tile, component); + this.startContestForTile( + tile, + change.previousOwner, + change.newOwner, + nowPacked, + ); + } + } } - private updateTransitionState(now: number) { + private updateContestState(now: number) { if (!this.territoryRenderer) { return; } - this.ensureTransitionScratch(); - const nowPacked = this.packTransitionTime(now); - this.territoryRenderer.setTransitionTime( - nowPacked, - this.transitionDurationMs, - ); + this.ensureContestScratch(); + const nowPacked = this.packContestTime(now); + this.territoryRenderer.setContestNow(nowPacked, this.contestDurationMs); - if (!this.transitionActive || this.transitionTiles.length === 0) { + if (!this.contestActive) { return; } - const startTimes = this.transitionStartTimes!; - const activeMask = this.transitionActiveMask!; - const tiles = this.transitionTiles; - const duration = this.transitionDurationMs; - let writeIndex = 0; - - for (let i = 0; i < tiles.length; i++) { - const tile = tiles[i]; - const start = startTimes[tile]; - const elapsed = this.transitionElapsed(nowPacked, start); - if (elapsed >= duration) { - activeMask[tile] = 0; - startTimes[tile] = 0; - this.territoryRenderer.clearTransitionTile( - tile, - this.game.ownerID(tile), - ); - } else { - tiles[writeIndex++] = tile; + const expired: ContestComponent[] = []; + for (const component of this.contestComponents.values()) { + const elapsed = this.contestElapsed( + nowPacked, + component.lastActivityPacked, + ); + if (elapsed >= this.contestDurationMs) { + expired.push(component); } } - tiles.length = writeIndex; - this.transitionActive = tiles.length > 0; + + for (const component of expired) { + this.expireContestComponent(component); + } } - private packTransitionTime(now: number): number { - const wrap = 32768; - return Math.floor(now) % wrap | 0; + private startContestForTile( + tile: TileRef, + defender: number, + attacker: number, + nowPacked: number, + ) { + if (attacker === defender || attacker === 0 || defender === 0) { + return; + } + const neighbors = this.collectNeighborComponents(tile, attacker, defender); + let component: ContestComponent; + if (neighbors.length === 0) { + component = this.createContestComponent(attacker, defender, nowPacked); + } else { + component = neighbors[0]; + for (let i = 1; i < neighbors.length; i++) { + this.mergeContestComponents(component, neighbors[i]); + } + } + + this.addTileToComponent(tile, component, true); + component.lastActivityPacked = nowPacked; + this.territoryRenderer?.setContestTime(component.id, nowPacked); } - private transitionElapsed(nowPacked: number, startPacked: number): number { - const wrap = 32768; + private collectNeighborComponents( + tile: TileRef, + attacker: number, + defender: number, + ): ContestComponent[] { + const components: ContestComponent[] = []; + const seen = new Set(); + for (const neighbor of this.game.neighbors(tile)) { + const id = this.contestId(neighbor); + if (id === 0 || seen.has(id)) { + continue; + } + const component = this.contestComponents.get(id); + if (!component) { + continue; + } + if (component.attacker === attacker && component.defender === defender) { + components.push(component); + seen.add(id); + } + } + return components; + } + + private createContestComponent( + attacker: number, + defender: number, + nowPacked: number, + ): ContestComponent { + const id = this.allocateContestComponentId(); + const component: ContestComponent = { + id, + attacker, + defender, + lastActivityPacked: nowPacked, + tiles: [], + }; + this.contestComponents.set(id, component); + this.contestActive = true; + this.territoryRenderer?.ensureContestTimeCapacity(id); + return component; + } + + private allocateContestComponentId(): number { + const reused = this.contestFreeIds.pop(); + if (reused !== undefined) { + return reused; + } + return this.contestNextId++; + } + + private releaseContestComponentId(id: number) { + if (id <= 0) { + return; + } + this.contestFreeIds.push(id); + } + + private addTileToComponent( + tile: TileRef, + component: ContestComponent, + attackerEver: boolean, + ) { + this.setContestTileData( + tile, + component.defender, + component.attacker, + component.id, + attackerEver, + ); + this.contestTileIndices![tile] = component.tiles.length; + component.tiles.push(tile); + this.contestActive = true; + } + + private removeTileFromComponent(tile: TileRef, component: ContestComponent) { + const tileIndex = this.contestTileIndices![tile]; + const tiles = component.tiles; + const lastIndex = tiles.length - 1; + if (tileIndex >= 0 && tileIndex <= lastIndex) { + if (tileIndex !== lastIndex) { + const swapTile = tiles[lastIndex]; + tiles[tileIndex] = swapTile; + this.contestTileIndices![swapTile] = tileIndex; + } + tiles.pop(); + } + this.contestTileIndices![tile] = -1; + this.clearContestTile(tile); + if (component.tiles.length === 0) { + this.contestComponents.delete(component.id); + this.releaseContestComponentId(component.id); + this.contestActive = this.contestComponents.size > 0; + } + } + + private mergeContestComponents( + target: ContestComponent, + source: ContestComponent, + ) { + for (const tile of source.tiles) { + const attackerEver = this.hasAttackerEver(tile); + this.setContestTileData( + tile, + target.defender, + target.attacker, + target.id, + attackerEver, + ); + this.contestTileIndices![tile] = target.tiles.length; + target.tiles.push(tile); + } + target.lastActivityPacked = Math.max( + target.lastActivityPacked, + source.lastActivityPacked, + ); + this.territoryRenderer?.setContestTime( + target.id, + target.lastActivityPacked, + ); + this.contestComponents.delete(source.id); + this.releaseContestComponentId(source.id); + } + + private expireContestComponent(component: ContestComponent) { + for (const tile of component.tiles) { + this.contestTileIndices![tile] = -1; + this.clearContestTile(tile); + } + component.tiles.length = 0; + this.contestComponents.delete(component.id); + this.releaseContestComponentId(component.id); + this.contestActive = this.contestComponents.size > 0; + } + + private setContestTileData( + tile: TileRef, + defender: number, + attacker: number, + componentId: number, + attackerEver: boolean, + ) { + this.contestPrevOwners![tile] = defender; + this.contestAttackers![tile] = attacker; + this.contestComponentIds![tile] = + (componentId & CONTEST_ID_MASK) | + (attackerEver ? CONTEST_ATTACKER_EVER_BIT : 0); + this.territoryRenderer?.setContestTile( + tile, + defender, + attacker, + componentId, + attackerEver, + ); + } + + private clearContestTile(tile: TileRef) { + this.contestPrevOwners![tile] = 0; + this.contestAttackers![tile] = 0; + this.contestComponentIds![tile] = 0; + this.territoryRenderer?.clearContestTile(tile); + } + + private contestId(tile: TileRef): number { + return this.contestComponentIds![tile] & CONTEST_ID_MASK; + } + + private hasAttackerEver(tile: TileRef): boolean { + return (this.contestComponentIds![tile] & CONTEST_ATTACKER_EVER_BIT) !== 0; + } + + private packContestTime(now: number): number { + return Math.floor(now) % CONTEST_TIME_WRAP; + } + + private contestElapsed(nowPacked: number, startPacked: number): number { if (nowPacked >= startPacked) { return nowPacked - startPacked; } - return wrap - startPacked + nowPacked; + return CONTEST_TIME_WRAP - startPacked + nowPacked; + } + + private syncContestStateToRenderer() { + if (!this.territoryRenderer) { + return; + } + if (!this.contestComponentIds) { + return; + } + this.contestActive = this.contestComponents.size > 0; + let maxId = 0; + for (const component of this.contestComponents.values()) { + maxId = Math.max(maxId, component.id); + } + if (maxId > 0) { + this.territoryRenderer.ensureContestTimeCapacity(maxId); + } + for (const component of this.contestComponents.values()) { + this.territoryRenderer.setContestTime( + component.id, + component.lastActivityPacked, + ); + for (const tile of component.tiles) { + const packed = this.contestComponentIds![tile]; + const attackerEver = (packed & CONTEST_ATTACKER_EVER_BIT) !== 0; + this.territoryRenderer.setContestTile( + tile, + component.defender, + component.attacker, + component.id, + attackerEver, + ); + } + } } private computePaletteSignature(): string { diff --git a/src/client/graphics/layers/TerritoryWebGLRenderer.ts b/src/client/graphics/layers/TerritoryWebGLRenderer.ts index 421023341..8903784a5 100644 --- a/src/client/graphics/layers/TerritoryWebGLRenderer.ts +++ b/src/client/graphics/layers/TerritoryWebGLRenderer.ts @@ -35,16 +35,20 @@ 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 contestOwnersTexture: WebGLTexture | null; + private readonly contestIdsTexture: WebGLTexture | null; + private readonly contestTimesTexture: WebGLTexture | null; private readonly uniforms: { resolution: WebGLUniformLocation | null; state: WebGLUniformLocation | null; palette: WebGLUniformLocation | null; relations: WebGLUniformLocation | null; patterns: WebGLUniformLocation | null; - transitions: WebGLUniformLocation | null; - transitionNow: WebGLUniformLocation | null; - transitionDuration: WebGLUniformLocation | null; + contestOwners: WebGLUniformLocation | null; + contestIds: WebGLUniformLocation | null; + contestTimes: WebGLUniformLocation | null; + contestNow: WebGLUniformLocation | null; + contestDuration: WebGLUniformLocation | null; patternStride: WebGLUniformLocation | null; patternRows: WebGLUniformLocation | null; fallout: WebGLUniformLocation | null; @@ -64,11 +68,14 @@ export class TerritoryWebGLRenderer { }; private readonly state: Uint16Array; - private readonly transitionState: Uint16Array; + private contestOwnersState: Uint16Array; + private contestIdsState: Uint16Array; + private contestTimesState: Uint16Array; private readonly dirtyRows: Map = new Map(); - private readonly transitionDirtyRows: Map = new Map(); + private readonly contestDirtyRows: Map = new Map(); private needsFullUpload = true; - private needsTransitionFullUpload = true; + private needsContestFullUpload = true; + private needsContestTimesUpload = true; private alternativeView = false; private paletteWidth = 0; private hoverHighlightStrength = 0.7; @@ -77,8 +84,8 @@ export class TerritoryWebGLRenderer { private hoverPulseSpeed = Math.PI * 2; private hoveredPlayerId = -1; private animationStartTime = Date.now(); - private transitionNow = 0; - private transitionDurationMs = 500; + private contestNow = 0; + private contestDurationMs = 5000; private readonly userSettings = new UserSettings(); private readonly patternBytesCache = new Map(); @@ -92,11 +99,9 @@ export class TerritoryWebGLRenderer { this.canvas.height = game.height(); this.state = state; - this.transitionState = new Uint16Array(state.length * 2); - for (let i = 0; i < state.length; i++) { - this.transitionState[i * 2] = state[i] & 0x0fff; - this.transitionState[i * 2 + 1] = 0; - } + this.contestOwnersState = new Uint16Array(state.length * 2); + this.contestIdsState = new Uint16Array(state.length); + this.contestTimesState = new Uint16Array(1); this.gl = this.canvas.getContext("webgl2", { premultipliedAlpha: true, @@ -112,16 +117,20 @@ export class TerritoryWebGLRenderer { this.paletteTexture = null; this.relationTexture = null; this.patternTexture = null; - this.transitionTexture = null; + this.contestOwnersTexture = null; + this.contestIdsTexture = null; + this.contestTimesTexture = null; this.uniforms = { resolution: null, state: null, palette: null, relations: null, patterns: null, - transitions: null, - transitionNow: null, - transitionDuration: null, + contestOwners: null, + contestIds: null, + contestTimes: null, + contestNow: null, + contestDuration: null, patternStride: null, patternRows: null, fallout: null, @@ -151,16 +160,20 @@ export class TerritoryWebGLRenderer { this.paletteTexture = null; this.relationTexture = null; this.patternTexture = null; - this.transitionTexture = null; + this.contestOwnersTexture = null; + this.contestIdsTexture = null; + this.contestTimesTexture = null; this.uniforms = { resolution: null, state: null, palette: null, relations: null, patterns: null, - transitions: null, - transitionNow: null, - transitionDuration: null, + contestOwners: null, + contestIds: null, + contestTimes: null, + contestNow: null, + contestDuration: null, patternStride: null, patternRows: null, fallout: null, @@ -187,11 +200,13 @@ 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"), - transitionNow: gl.getUniformLocation(this.program, "u_transitionNow"), - transitionDuration: gl.getUniformLocation( + contestOwners: gl.getUniformLocation(this.program, "u_contestOwners"), + contestIds: gl.getUniformLocation(this.program, "u_contestIds"), + contestTimes: gl.getUniformLocation(this.program, "u_contestTimes"), + contestNow: gl.getUniformLocation(this.program, "u_contestNow"), + contestDuration: gl.getUniformLocation( this.program, - "u_transitionDurationMs", + "u_contestDurationMs", ), patternStride: gl.getUniformLocation(this.program, "u_patternStride"), patternRows: gl.getUniformLocation(this.program, "u_patternRows"), @@ -251,7 +266,9 @@ export class TerritoryWebGLRenderer { this.paletteTexture = gl.createTexture(); this.relationTexture = gl.createTexture(); this.patternTexture = gl.createTexture(); - this.transitionTexture = gl.createTexture(); + this.contestOwnersTexture = gl.createTexture(); + this.contestIdsTexture = gl.createTexture(); + this.contestTimesTexture = gl.createTexture(); gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, this.stateTexture); @@ -275,7 +292,7 @@ export class TerritoryWebGLRenderer { this.uploadPalette(); gl.activeTexture(gl.TEXTURE4); - gl.bindTexture(gl.TEXTURE_2D, this.transitionTexture); + gl.bindTexture(gl.TEXTURE_2D, this.contestOwnersTexture); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); @@ -290,7 +307,45 @@ export class TerritoryWebGLRenderer { 0, gl.RG_INTEGER, gl.UNSIGNED_SHORT, - this.transitionState, + this.contestOwnersState, + ); + + gl.activeTexture(gl.TEXTURE5); + gl.bindTexture(gl.TEXTURE_2D, this.contestIdsTexture); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1); + gl.texImage2D( + gl.TEXTURE_2D, + 0, + gl.R16UI, + this.canvas.width, + this.canvas.height, + 0, + gl.RED_INTEGER, + gl.UNSIGNED_SHORT, + this.contestIdsState, + ); + + gl.activeTexture(gl.TEXTURE6); + gl.bindTexture(gl.TEXTURE_2D, this.contestTimesTexture); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1); + gl.texImage2D( + gl.TEXTURE_2D, + 0, + gl.R16UI, + this.contestTimesState.length, + 1, + 0, + gl.RED_INTEGER, + gl.UNSIGNED_SHORT, + this.contestTimesState, ); gl.useProgram(this.program); @@ -298,7 +353,9 @@ export class TerritoryWebGLRenderer { gl.uniform1i(this.uniforms.palette, 1); gl.uniform1i(this.uniforms.relations, 2); gl.uniform1i(this.uniforms.patterns, 3); - gl.uniform1i(this.uniforms.transitions, 4); + gl.uniform1i(this.uniforms.contestOwners, 4); + gl.uniform1i(this.uniforms.contestIds, 5); + gl.uniform1i(this.uniforms.contestTimes, 6); if (this.uniforms.resolution) { gl.uniform2f( @@ -386,11 +443,11 @@ export class TerritoryWebGLRenderer { if (this.uniforms.hoverPulseSpeed) { gl.uniform1f(this.uniforms.hoverPulseSpeed, this.hoverPulseSpeed); } - if (this.uniforms.transitionNow) { - gl.uniform1i(this.uniforms.transitionNow, this.transitionNow); + if (this.uniforms.contestNow) { + gl.uniform1i(this.uniforms.contestNow, this.contestNow); } - if (this.uniforms.transitionDuration) { - gl.uniform1f(this.uniforms.transitionDuration, this.transitionDurationMs); + if (this.uniforms.contestDuration) { + gl.uniform1f(this.uniforms.contestDuration, this.contestDurationMs); } gl.enable(gl.BLEND); @@ -465,46 +522,83 @@ export class TerritoryWebGLRenderer { } } - setTransitionTile(tile: TileRef, previousOwner: number, startPacked: number) { + setContestTile( + tile: TileRef, + defenderOwner: number, + attackerOwner: number, + componentId: number, + attackerEver: boolean, + ) { const offset = tile * 2; - const ownerValue = previousOwner & 0xffff; - const startValue = startPacked & 0xffff; + const defenderValue = defenderOwner & 0xffff; + const attackerValue = attackerOwner & 0xffff; + const idValue = (componentId & 0x7fff) | (attackerEver ? 0x8000 : 0); if ( - this.transitionState[offset] === ownerValue && - this.transitionState[offset + 1] === startValue + this.contestOwnersState[offset] === defenderValue && + this.contestOwnersState[offset + 1] === attackerValue && + this.contestIdsState[tile] === idValue ) { return; } - this.transitionState[offset] = ownerValue; - this.transitionState[offset + 1] = startValue; - if (this.needsTransitionFullUpload) { + this.contestOwnersState[offset] = defenderValue; + this.contestOwnersState[offset + 1] = attackerValue; + this.contestIdsState[tile] = idValue; + if (this.needsContestFullUpload) { return; } const x = tile % this.canvas.width; const y = Math.floor(tile / this.canvas.width); - const span = this.transitionDirtyRows.get(y); + const span = this.contestDirtyRows.get(y); if (span === undefined) { - this.transitionDirtyRows.set(y, { minX: x, maxX: x }); + this.contestDirtyRows.set(y, { minX: x, maxX: x }); } else { span.minX = Math.min(span.minX, x); span.maxX = Math.max(span.maxX, x); } } - clearTransitionTile(tile: TileRef, currentOwner: number) { - this.setTransitionTile(tile, currentOwner, 0); + clearContestTile(tile: TileRef) { + this.setContestTile(tile, 0, 0, 0, false); } - setTransitionTime(nowPacked: number, durationMs: number) { - this.transitionNow = nowPacked | 0; - this.transitionDurationMs = Math.max(1, durationMs); + setContestTime(componentId: number, nowPacked: number) { + if (componentId <= 0) { + return; + } + this.ensureContestTimeCapacity(componentId); + const packed = nowPacked & 0xffff; + if (this.contestTimesState[componentId] === packed) { + return; + } + this.contestTimesState[componentId] = packed; + this.needsContestTimesUpload = true; + } + + ensureContestTimeCapacity(componentId: number) { + if (componentId < this.contestTimesState.length) { + return; + } + let nextLength = Math.max(1, this.contestTimesState.length); + while (nextLength <= componentId) { + nextLength *= 2; + } + const nextState = new Uint16Array(nextLength); + nextState.set(this.contestTimesState); + this.contestTimesState = nextState; + this.needsContestTimesUpload = true; + } + + setContestNow(nowPacked: number, durationMs: number) { + this.contestNow = nowPacked | 0; + this.contestDurationMs = Math.max(1, durationMs); } markAllDirty() { this.needsFullUpload = true; this.dirtyRows.clear(); - this.needsTransitionFullUpload = true; - this.transitionDirtyRows.clear(); + this.needsContestFullUpload = true; + this.needsContestTimesUpload = true; + this.contestDirtyRows.clear(); } refreshPalette() { @@ -524,11 +618,18 @@ export class TerritoryWebGLRenderer { this.uploadStateTexture(); FrameProfiler.end("TerritoryWebGLRenderer:uploadState", uploadStateSpan); - const uploadTransitionSpan = FrameProfiler.start(); - this.uploadTransitionTexture(); + const uploadContestSpan = FrameProfiler.start(); + this.uploadContestTexture(); FrameProfiler.end( - "TerritoryWebGLRenderer:uploadTransitions", - uploadTransitionSpan, + "TerritoryWebGLRenderer:uploadContests", + uploadContestSpan, + ); + + const uploadContestTimesSpan = FrameProfiler.start(); + this.uploadContestTimesTexture(); + FrameProfiler.end( + "TerritoryWebGLRenderer:uploadContestTimes", + uploadContestTimesSpan, ); const renderSpan = FrameProfiler.start(); @@ -565,11 +666,11 @@ export class TerritoryWebGLRenderer { const viewerId = this.game.myPlayer()?.smallID() ?? 0; gl.uniform1i(this.uniforms.viewerId, viewerId); } - if (this.uniforms.transitionNow) { - gl.uniform1i(this.uniforms.transitionNow, this.transitionNow); + if (this.uniforms.contestNow) { + gl.uniform1i(this.uniforms.contestNow, this.contestNow); } - if (this.uniforms.transitionDuration) { - gl.uniform1f(this.uniforms.transitionDuration, this.transitionDurationMs); + if (this.uniforms.contestDuration) { + gl.uniform1f(this.uniforms.contestDuration, this.contestDurationMs); } gl.clearColor(0, 0, 0, 0); @@ -634,17 +735,21 @@ export class TerritoryWebGLRenderer { return { rows: rowsUploaded, bytes: bytesUploaded }; } - private uploadTransitionTexture(): { rows: number; bytes: number } { - if (!this.gl || !this.transitionTexture) return { rows: 0, bytes: 0 }; + private uploadContestTexture(): { rows: number; bytes: number } { + if (!this.gl || !this.contestOwnersTexture || !this.contestIdsTexture) { + return { rows: 0, bytes: 0 }; + } const gl = this.gl; - gl.activeTexture(gl.TEXTURE4); - gl.bindTexture(gl.TEXTURE_2D, this.transitionTexture); + gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1); - const bytesPerPixel = Uint16Array.BYTES_PER_ELEMENT * 2; + const bytesPerOwnerPixel = Uint16Array.BYTES_PER_ELEMENT * 2; + const bytesPerIdPixel = Uint16Array.BYTES_PER_ELEMENT; let rowsUploaded = 0; let bytesUploaded = 0; - if (this.needsTransitionFullUpload) { + if (this.needsContestFullUpload) { + gl.activeTexture(gl.TEXTURE4); + gl.bindTexture(gl.TEXTURE_2D, this.contestOwnersTexture); gl.texImage2D( gl.TEXTURE_2D, 0, @@ -654,26 +759,47 @@ export class TerritoryWebGLRenderer { 0, gl.RG_INTEGER, gl.UNSIGNED_SHORT, - this.transitionState, + this.contestOwnersState, ); - this.needsTransitionFullUpload = false; - this.transitionDirtyRows.clear(); + + gl.activeTexture(gl.TEXTURE5); + gl.bindTexture(gl.TEXTURE_2D, this.contestIdsTexture); + gl.texImage2D( + gl.TEXTURE_2D, + 0, + gl.R16UI, + this.canvas.width, + this.canvas.height, + 0, + gl.RED_INTEGER, + gl.UNSIGNED_SHORT, + this.contestIdsState, + ); + + this.needsContestFullUpload = false; + this.contestDirtyRows.clear(); rowsUploaded = this.canvas.height; - bytesUploaded = this.canvas.width * this.canvas.height * bytesPerPixel; + bytesUploaded = + this.canvas.width * + this.canvas.height * + (bytesPerOwnerPixel + bytesPerIdPixel); return { rows: rowsUploaded, bytes: bytesUploaded }; } - if (this.transitionDirtyRows.size === 0) { + if (this.contestDirtyRows.size === 0) { return { rows: 0, bytes: 0 }; } - for (const [y, span] of this.transitionDirtyRows) { + for (const [y, span] of this.contestDirtyRows) { const width = span.maxX - span.minX + 1; - const offset = (y * this.canvas.width + span.minX) * 2; - const rowSlice = this.transitionState.subarray( - offset, - offset + width * 2, + const ownerOffset = (y * this.canvas.width + span.minX) * 2; + const ownerSlice = this.contestOwnersState.subarray( + ownerOffset, + ownerOffset + width * 2, ); + + gl.activeTexture(gl.TEXTURE4); + gl.bindTexture(gl.TEXTURE_2D, this.contestOwnersTexture); gl.texSubImage2D( gl.TEXTURE_2D, 0, @@ -683,15 +809,59 @@ export class TerritoryWebGLRenderer { 1, gl.RG_INTEGER, gl.UNSIGNED_SHORT, - rowSlice, + ownerSlice, ); + + const idOffset = y * this.canvas.width + span.minX; + const idSlice = this.contestIdsState.subarray(idOffset, idOffset + width); + gl.activeTexture(gl.TEXTURE5); + gl.bindTexture(gl.TEXTURE_2D, this.contestIdsTexture); + gl.texSubImage2D( + gl.TEXTURE_2D, + 0, + span.minX, + y, + width, + 1, + gl.RED_INTEGER, + gl.UNSIGNED_SHORT, + idSlice, + ); + rowsUploaded++; - bytesUploaded += width * bytesPerPixel; + bytesUploaded += width * (bytesPerOwnerPixel + bytesPerIdPixel); } - this.transitionDirtyRows.clear(); + this.contestDirtyRows.clear(); return { rows: rowsUploaded, bytes: bytesUploaded }; } + private uploadContestTimesTexture(): { rows: number; bytes: number } { + if (!this.gl || !this.contestTimesTexture) { + return { rows: 0, bytes: 0 }; + } + if (!this.needsContestTimesUpload) { + return { rows: 0, bytes: 0 }; + } + const gl = this.gl; + gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1); + gl.activeTexture(gl.TEXTURE6); + gl.bindTexture(gl.TEXTURE_2D, this.contestTimesTexture); + gl.texImage2D( + gl.TEXTURE_2D, + 0, + gl.R16UI, + this.contestTimesState.length, + 1, + 0, + gl.RED_INTEGER, + gl.UNSIGNED_SHORT, + this.contestTimesState, + ); + this.needsContestTimesUpload = false; + const bytes = this.contestTimesState.length * Uint16Array.BYTES_PER_ELEMENT; + return { rows: 1, bytes }; + } + private uploadPalette() { if ( !this.gl || @@ -879,9 +1049,11 @@ export class TerritoryWebGLRenderer { uniform sampler2D u_palette; uniform usampler2D u_relations; uniform usampler2D u_patterns; - uniform usampler2D u_transitions; - uniform int u_transitionNow; - uniform float u_transitionDurationMs; + uniform usampler2D u_contestOwners; + uniform usampler2D u_contestIds; + uniform usampler2D u_contestTimes; + uniform int u_contestNow; + uniform float u_contestDurationMs; uniform int u_patternStride; uniform int u_patternRows; uniform int u_viewerId; @@ -911,22 +1083,22 @@ export class TerritoryWebGLRenderer { return texelFetch(u_state, clamped, 0).r & 0xFFFu; } - uint prevOwnerAtTex(ivec2 texCoord) { + uvec2 contestOwnersAtTex(ivec2 texCoord) { ivec2 clamped = clamp( texCoord, ivec2(0, 0), ivec2(int(u_resolution.x) - 1, int(u_resolution.y) - 1) ); - return texelFetch(u_transitions, clamped, 0).r & 0xFFFu; + return texelFetch(u_contestOwners, clamped, 0).rg; } - uint transitionPackedAtTex(ivec2 texCoord) { + uint contestIdRawAtTex(ivec2 texCoord) { ivec2 clamped = clamp( texCoord, ivec2(0, 0), ivec2(int(u_resolution.x) - 1, int(u_resolution.y) - 1) ); - return texelFetch(u_transitions, clamped, 0).g; + return texelFetch(u_contestIds, clamped, 0).r; } uint relationCode(uint owner, uint other) { @@ -979,34 +1151,51 @@ export class TerritoryWebGLRenderer { return (byteVal & (1u << bitIndex)) == 0u; } + vec3 applyDefended(vec3 color, bool defended, ivec2 texCoord) { + if (!defended) { + return color; + } + bool isLightTile = ((texCoord.x % 2) == (texCoord.y % 2)); + const float LIGHT_FACTOR = 1.2; + const float DARK_FACTOR = 0.8; + return color * (isLightTile ? LIGHT_FACTOR : DARK_FACTOR); + } + void main() { ivec2 fragCoord = ivec2(gl_FragCoord.xy); ivec2 texCoord = ivec2(fragCoord.x, int(u_resolution.y) - 1 - fragCoord.y); uint state = texelFetch(u_state, texCoord, 0).r; uint owner = state & 0xFFFu; - uint prevOwner = prevOwnerAtTex(texCoord); - uint startPacked = transitionPackedAtTex(texCoord); - const uint TRANSITION_FLAG = 0x8000u; - const uint TRANSITION_TIME_MASK = 0x7FFFu; - const uint TRANSITION_WRAP = 32768u; - bool hasTransition = (startPacked & TRANSITION_FLAG) != 0u; - float t = 1.0; - if (hasTransition) { - uint start = startPacked & TRANSITION_TIME_MASK; - uint nowTime = uint(u_transitionNow); - uint elapsed = nowTime >= start - ? (nowTime - start) - : (TRANSITION_WRAP - start + nowTime); - t = clamp(float(elapsed) / u_transitionDurationMs, 0.0, 1.0); - } - bool doTransition = hasTransition && t < 1.0 && prevOwner != owner; bool hasFallout = (state & 0x2000u) != 0u; bool isDefended = (state & 0x1000u) != 0u; + uint contestIdRaw = contestIdRawAtTex(texCoord); + const uint CONTEST_ID_MASK = 0x7FFFu; + const uint CONTEST_ATTACKER_EVER = 0x8000u; + uint contestId = contestIdRaw & CONTEST_ID_MASK; + bool attackerEver = (contestIdRaw & CONTEST_ATTACKER_EVER) != 0u; + uvec2 contestOwners = contestOwnersAtTex(texCoord); + uint defender = contestOwners.r & 0xFFFu; + uint attacker = contestOwners.g & 0xFFFu; + + bool contested = false; + if (contestId != 0u) { + uint lastTime = texelFetch(u_contestTimes, ivec2(int(contestId), 0), 0).r; + const uint CONTEST_WRAP = 32768u; + uint nowTime = uint(u_contestNow); + uint elapsed = nowTime >= lastTime + ? (nowTime - lastTime) + : (CONTEST_WRAP - lastTime + nowTime); + contested = float(elapsed) < u_contestDurationMs; + } + bool isBorder = false; bool hasFriendlyRelation = false; bool hasEmbargoRelation = false; + bool pushedBorder = false; + bool regainedBorder = false; + uint nOwner = ownerAtTex(texCoord + ivec2(1, 0)); isBorder = isBorder || (nOwner != owner); if (nOwner != owner && nOwner != 0u) { @@ -1014,6 +1203,19 @@ export class TerritoryWebGLRenderer { hasEmbargoRelation = hasEmbargoRelation || isEmbargo(rel); hasFriendlyRelation = hasFriendlyRelation || isFriendly(rel); } + if (contested) { + uint nContestRaw = contestIdRawAtTex(texCoord + ivec2(1, 0)); + uint nContestId = nContestRaw & CONTEST_ID_MASK; + bool sameComponent = nContestId == contestId; + bool nAttackerEver = sameComponent && ((nContestRaw & CONTEST_ATTACKER_EVER) != 0u); + if (attackerEver && !nAttackerEver) { + pushedBorder = true; + } + if (sameComponent && owner == defender && nOwner == attacker) { + regainedBorder = true; + } + } + nOwner = ownerAtTex(texCoord + ivec2(-1, 0)); isBorder = isBorder || (nOwner != owner); if (nOwner != owner && nOwner != 0u) { @@ -1021,6 +1223,19 @@ export class TerritoryWebGLRenderer { hasEmbargoRelation = hasEmbargoRelation || isEmbargo(rel); hasFriendlyRelation = hasFriendlyRelation || isFriendly(rel); } + if (contested) { + uint nContestRaw = contestIdRawAtTex(texCoord + ivec2(-1, 0)); + uint nContestId = nContestRaw & CONTEST_ID_MASK; + bool sameComponent = nContestId == contestId; + bool nAttackerEver = sameComponent && ((nContestRaw & CONTEST_ATTACKER_EVER) != 0u); + if (attackerEver && !nAttackerEver) { + pushedBorder = true; + } + if (sameComponent && owner == defender && nOwner == attacker) { + regainedBorder = true; + } + } + nOwner = ownerAtTex(texCoord + ivec2(0, 1)); isBorder = isBorder || (nOwner != owner); if (nOwner != owner && nOwner != 0u) { @@ -1028,6 +1243,19 @@ export class TerritoryWebGLRenderer { hasEmbargoRelation = hasEmbargoRelation || isEmbargo(rel); hasFriendlyRelation = hasFriendlyRelation || isFriendly(rel); } + if (contested) { + uint nContestRaw = contestIdRawAtTex(texCoord + ivec2(0, 1)); + uint nContestId = nContestRaw & CONTEST_ID_MASK; + bool sameComponent = nContestId == contestId; + bool nAttackerEver = sameComponent && ((nContestRaw & CONTEST_ATTACKER_EVER) != 0u); + if (attackerEver && !nAttackerEver) { + pushedBorder = true; + } + if (sameComponent && owner == defender && nOwner == attacker) { + regainedBorder = true; + } + } + nOwner = ownerAtTex(texCoord + ivec2(0, -1)); isBorder = isBorder || (nOwner != owner); if (nOwner != owner && nOwner != 0u) { @@ -1035,44 +1263,22 @@ export class TerritoryWebGLRenderer { hasEmbargoRelation = hasEmbargoRelation || isEmbargo(rel); hasFriendlyRelation = hasFriendlyRelation || isFriendly(rel); } - - bool oldIsBorder = false; - bool oldFriendlyRelation = false; - bool oldEmbargoRelation = false; - if (doTransition && prevOwner != 0u) { - uint prevNeighbor = prevOwnerAtTex(texCoord + ivec2(1, 0)); - oldIsBorder = oldIsBorder || (prevNeighbor != prevOwner); - if (prevNeighbor != prevOwner && prevNeighbor != 0u) { - uint rel = relationCode(prevOwner, prevNeighbor); - oldEmbargoRelation = oldEmbargoRelation || isEmbargo(rel); - oldFriendlyRelation = oldFriendlyRelation || isFriendly(rel); + if (contested) { + uint nContestRaw = contestIdRawAtTex(texCoord + ivec2(0, -1)); + uint nContestId = nContestRaw & CONTEST_ID_MASK; + bool sameComponent = nContestId == contestId; + bool nAttackerEver = sameComponent && ((nContestRaw & CONTEST_ATTACKER_EVER) != 0u); + if (attackerEver && !nAttackerEver) { + pushedBorder = true; } - prevNeighbor = prevOwnerAtTex(texCoord + ivec2(-1, 0)); - oldIsBorder = oldIsBorder || (prevNeighbor != prevOwner); - if (prevNeighbor != prevOwner && prevNeighbor != 0u) { - uint rel = relationCode(prevOwner, prevNeighbor); - oldEmbargoRelation = oldEmbargoRelation || isEmbargo(rel); - oldFriendlyRelation = oldFriendlyRelation || isFriendly(rel); - } - prevNeighbor = prevOwnerAtTex(texCoord + ivec2(0, 1)); - oldIsBorder = oldIsBorder || (prevNeighbor != prevOwner); - if (prevNeighbor != prevOwner && prevNeighbor != 0u) { - uint rel = relationCode(prevOwner, prevNeighbor); - oldEmbargoRelation = oldEmbargoRelation || isEmbargo(rel); - oldFriendlyRelation = oldFriendlyRelation || isFriendly(rel); - } - prevNeighbor = prevOwnerAtTex(texCoord + ivec2(0, -1)); - oldIsBorder = oldIsBorder || (prevNeighbor != prevOwner); - if (prevNeighbor != prevOwner && prevNeighbor != 0u) { - uint rel = relationCode(prevOwner, prevNeighbor); - oldEmbargoRelation = oldEmbargoRelation || isEmbargo(rel); - oldFriendlyRelation = oldFriendlyRelation || isFriendly(rel); + if (sameComponent && owner == defender && nOwner == attacker) { + regainedBorder = true; } } if (u_alternativeView) { - vec3 newColor = vec3(0.0); - float newAlpha = 0.0; + vec3 color = vec3(0.0); + float a = 0.0; if (owner != 0u) { uint relationAlt = relationCode(owner, uint(u_viewerId)); vec4 altColor = u_altNeutral; @@ -1083,32 +1289,9 @@ export class TerritoryWebGLRenderer { } else if (isEmbargo(relationAlt)) { altColor = u_altEnemy; } - newColor = altColor.rgb; - newAlpha = isBorder ? 1.0 : 0.0; + color = altColor.rgb; + a = isBorder ? 1.0 : 0.0; } - - vec3 color = newColor; - float a = newAlpha; - if (doTransition) { - vec3 oldColor = vec3(0.0); - float oldAlpha = 0.0; - if (prevOwner != 0u) { - uint relationAltOld = relationCode(prevOwner, uint(u_viewerId)); - vec4 altColorOld = u_altNeutral; - if (isSelf(relationAltOld)) { - altColorOld = u_altSelf; - } else if (isFriendly(relationAltOld)) { - altColorOld = u_altAlly; - } else if (isEmbargo(relationAltOld)) { - altColorOld = u_altEnemy; - } - oldColor = altColorOld.rgb; - oldAlpha = oldIsBorder ? 1.0 : 0.0; - } - color = mix(oldColor, newColor, t); - a = mix(oldAlpha, newAlpha, t); - } - if (u_hoveredPlayerId >= 0.0 && abs(float(owner) - u_hoveredPlayerId) < 0.5) { float pulse = u_hoverPulseStrength > 0.0 ? (1.0 - u_hoverPulseStrength) + @@ -1120,12 +1303,17 @@ export class TerritoryWebGLRenderer { return; } - vec3 newColor = vec3(0.0); - float newAlpha = 0.0; + vec3 fillColor = vec3(0.0); + float fillAlpha = 0.0; + vec3 borderColor = vec3(0.0); + float borderAlpha = 0.0; + vec3 ownerBase = vec3(0.0); + vec4 ownerBorder = vec4(0.0); + if (owner == 0u) { if (hasFallout) { - newColor = u_fallout.rgb; - newAlpha = u_alpha; + fillColor = u_fallout.rgb; + fillAlpha = u_alpha; } } else { vec4 base = texelFetch(u_palette, ivec2(int(owner) * 2, 0), 0); @@ -1134,92 +1322,85 @@ export class TerritoryWebGLRenderer { ivec2(int(owner) * 2 + 1, 0), 0 ); + ownerBase = base.rgb; + ownerBorder = baseBorder; if (isBorder) { - vec3 borderColor = baseBorder.rgb; + vec3 bColor = baseBorder.rgb; const float BORDER_TINT_RATIO = 0.35; const vec3 FRIENDLY_TINT_TARGET = vec3(0.0, 1.0, 0.0); const vec3 EMBARGO_TINT_TARGET = vec3(1.0, 0.0, 0.0); if (hasFriendlyRelation) { - borderColor = borderColor * (1.0 - BORDER_TINT_RATIO) + - FRIENDLY_TINT_TARGET * BORDER_TINT_RATIO; + bColor = bColor * (1.0 - BORDER_TINT_RATIO) + + FRIENDLY_TINT_TARGET * BORDER_TINT_RATIO; } if (hasEmbargoRelation) { - borderColor = borderColor * (1.0 - BORDER_TINT_RATIO) + - EMBARGO_TINT_TARGET * BORDER_TINT_RATIO; + bColor = bColor * (1.0 - BORDER_TINT_RATIO) + + EMBARGO_TINT_TARGET * BORDER_TINT_RATIO; } - if (isDefended) { - bool isLightTile = ((texCoord.x % 2) == (texCoord.y % 2)); - const float LIGHT_FACTOR = 1.2; - const float DARK_FACTOR = 0.8; - borderColor *= isLightTile ? LIGHT_FACTOR : DARK_FACTOR; - } - - newColor = borderColor; - newAlpha = baseBorder.a; + borderColor = applyDefended(bColor, isDefended, texCoord); + borderAlpha = baseBorder.a; } else { bool isPrimary = patternIsPrimary(owner, texCoord); - newColor = isPrimary ? base.rgb : baseBorder.rgb; - newAlpha = u_alpha; + fillColor = isPrimary ? base.rgb : baseBorder.rgb; + fillAlpha = u_alpha; } } - vec3 color = newColor; - float a = newAlpha; - if (doTransition) { - vec3 oldColor = vec3(0.0); - float oldAlpha = 0.0; - if (prevOwner == 0u) { - if (hasFallout) { - oldColor = u_fallout.rgb; - oldAlpha = u_alpha; - } - } else { - vec4 oldBase = texelFetch( + vec3 contestedFillColor = fillColor; + float contestedFillAlpha = fillAlpha; + if (contested && owner != 0u) { + vec3 defenderBase = ownerBase; + if (defender != 0u) { + vec4 defenderColor = texelFetch( u_palette, - ivec2(int(prevOwner) * 2, 0), + ivec2(int(defender) * 2, 0), 0 ); - vec4 oldBorder = texelFetch( - u_palette, - ivec2(int(prevOwner) * 2 + 1, 0), - 0 - ); - if (oldIsBorder) { - vec3 borderColor = oldBorder.rgb; - - const float BORDER_TINT_RATIO = 0.35; - const vec3 FRIENDLY_TINT_TARGET = vec3(0.0, 1.0, 0.0); - const vec3 EMBARGO_TINT_TARGET = vec3(1.0, 0.0, 0.0); - - if (oldFriendlyRelation) { - borderColor = borderColor * (1.0 - BORDER_TINT_RATIO) + - FRIENDLY_TINT_TARGET * BORDER_TINT_RATIO; - } - if (oldEmbargoRelation) { - borderColor = borderColor * (1.0 - BORDER_TINT_RATIO) + - EMBARGO_TINT_TARGET * BORDER_TINT_RATIO; - } - - if (isDefended) { - bool isLightTile = ((texCoord.x % 2) == (texCoord.y % 2)); - const float LIGHT_FACTOR = 1.2; - const float DARK_FACTOR = 0.8; - borderColor *= isLightTile ? LIGHT_FACTOR : DARK_FACTOR; - } - - oldColor = borderColor; - oldAlpha = oldBorder.a; - } else { - bool isPrimary = patternIsPrimary(prevOwner, texCoord); - oldColor = isPrimary ? oldBase.rgb : oldBorder.rgb; - oldAlpha = u_alpha; - } + defenderBase = defenderColor.rgb; + } + bool isLightTile = ((texCoord.x % 2) == (texCoord.y % 2)); + contestedFillColor = isLightTile ? ownerBase : defenderBase; + contestedFillAlpha = u_alpha; + } + + vec3 attackerBorderColor = vec3(0.0); + float attackerBorderAlpha = 0.0; + if (attacker != 0u) { + vec4 attackerBorder = texelFetch( + u_palette, + ivec2(int(attacker) * 2 + 1, 0), + 0 + ); + attackerBorderColor = applyDefended(attackerBorder.rgb, isDefended, texCoord); + attackerBorderAlpha = attackerBorder.a; + } + + vec3 color = contested ? contestedFillColor : fillColor; + float a = contested ? contestedFillAlpha : fillAlpha; + + if (isBorder && owner != 0u) { + color = borderColor; + a = borderAlpha; + } + + if (contested) { + if (regainedBorder) { + vec3 regained = applyDefended(vec3(1.0, 0.2, 0.2), isDefended, texCoord); + color = regained; + a = 1.0; + } else if (pushedBorder) { + color = attackerBorderColor; + a = attackerBorderAlpha; + } else if (isBorder && owner != 0u) { + color = borderColor; + a = borderAlpha; + } else if (owner != 0u) { + color = contestedFillColor; + a = contestedFillAlpha; } - color = mix(oldColor, newColor, t); - a = mix(oldAlpha, newAlpha, t); } if (u_hoveredPlayerId >= 0.0 && abs(float(owner) - u_hoveredPlayerId) < 0.5) {