Implement tile transition effects in TerritoryLayer

- Introduced a new `TileTransition` type to manage tile transitions.
- Added methods to handle the beginning and updating of tile transitions.
- Enhanced `TerritoryRenderer` interfaces to support transition progress.
- Updated `TerritoryWebGLRenderer` to manage transition textures and rendering.
- Modified `GameView` to track owner changes for tiles, enabling transition effects during gameplay.
This commit is contained in:
scamiv
2026-01-09 18:55:07 +01:00
parent 5ffb90dce8
commit 62345fc730
4 changed files with 305 additions and 14 deletions
+132 -7
View File
@@ -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<TileRef, TileTransition> = new Map();
private transitionHighlightTiles: TileRef[] = [];
private transitionHighlightAlphas: number[] = [];
private lastGameTick = 0;
private lastTickTime = 0;
private lastTickDurationMs = 100;
constructor(
private game: GameView,
@@ -77,6 +90,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 {
@@ -92,6 +106,8 @@ export class TerritoryLayer implements Layer {
tick() {
const tickProfile = FrameProfiler.start();
const now = Date.now();
this.updateTickTiming(now);
if (this.game.inSpawnPhase()) {
this.spawnHighlight();
}
@@ -104,6 +120,7 @@ export class TerritoryLayer implements Layer {
this.refreshPaletteIfNeeded();
this.game.recentlyUpdatedTiles().forEach((t) => this.enqueueTile(t));
this.beginTileTransitions(this.game.recentlyUpdatedOwnerTiles(), now);
const updates = this.game.updatesSinceLastTick();
const unitUpdates = updates !== null ? updates[GameUpdateType.Unit] : [];
unitUpdates.forEach((update) => {
@@ -431,6 +448,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
@@ -521,6 +541,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);
@@ -542,6 +564,8 @@ export class TerritoryLayer implements Layer {
);
}
this.drawTransitionHighlights(context, now);
if (this.game.inSpawnPhase()) {
const highlightDrawStart = FrameProfiler.start();
context.drawImage(
@@ -562,13 +586,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);
@@ -680,6 +700,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()) {
@@ -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<TileRef, number> = 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 },
@@ -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<number, DirtySpan> = new Map();
private readonly transitionDirtyRows: Map<number, DirtySpan> = 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);
}
`;
+24
View File
@@ -587,6 +587,11 @@ export class GameView implements GameMap {
private _players = new Map<PlayerID, PlayerView>();
private _units = new Map<number, UnitView>();
private updatedTiles: TileRef[] = [];
private updatedOwnerChanges: Array<{
tile: TileRef;
previousOwner: number;
newOwner: number;
}> = [];
private _myPlayer: PlayerView | null = null;
@@ -635,8 +640,19 @@ export class GameView implements GameMap {
this.lastUpdate = gu;
this.updatedTiles = [];
this.updatedOwnerChanges = [];
this.lastUpdate.packedTileUpdates.forEach((tu) => {
const tileRef = Number(tu >> 16n);
const previousOwner = this._map.ownerID(tileRef);
this.updatedTiles.push(this.updateTile(tu));
const newOwner = this._map.ownerID(tileRef);
if (previousOwner !== newOwner) {
this.updatedOwnerChanges.push({
tile: tileRef,
previousOwner,
newOwner,
});
}
});
if (gu.updates === null) {
@@ -695,6 +711,14 @@ export class GameView implements GameMap {
return this.updatedTiles;
}
recentlyUpdatedOwnerTiles(): Array<{
tile: TileRef;
previousOwner: number;
newOwner: number;
}> {
return this.updatedOwnerChanges;
}
nearbyUnits(
tile: TileRef,
searchRange: number,