diff --git a/resources/lang/en.json b/resources/lang/en.json
index e119dae09..887fb7cd6 100644
--- a/resources/lang/en.json
+++ b/resources/lang/en.json
@@ -705,6 +705,8 @@
"attacking_troops_overlay_desc": "Show attacker vs defender troop counts on active front lines.",
"territory_border_mode_label": "Territory Borders",
"territory_border_mode_desc": "Select border rendering style (visual only)",
+ "renderer_label": "Renderer",
+ "renderer_desc": "Choose territory rendering backend. Auto uses WebGPU, then WebGL, then Classic.",
"performance_overlay_label": "Performance Overlay",
"performance_overlay_desc": "Toggle the performance overlay. When enabled, the performance overlay will be displayed. Press shift-D during game to toggle.",
"easter_writing_speed_label": "Writing Speed Multiplier",
diff --git a/src/client/UserSettingModal.ts b/src/client/UserSettingModal.ts
index 6f6ddb698..fafb8e0e2 100644
--- a/src/client/UserSettingModal.ts
+++ b/src/client/UserSettingModal.ts
@@ -300,7 +300,9 @@ export class UserSettingModal extends BaseModal {
this.requestUpdate();
}
- private changeTerritoryBorderMode(e: CustomEvent<{ value: number | string }>) {
+ private changeTerritoryBorderMode(
+ e: CustomEvent<{ value: number | string }>,
+ ) {
const rawValue = e.detail?.value;
const value =
typeof rawValue === "number" ? rawValue : parseInt(String(rawValue), 10);
@@ -310,6 +312,12 @@ export class UserSettingModal extends BaseModal {
this.requestUpdate();
}
+ private changeTerritoryRenderer(e: CustomEvent<{ value: number | string }>) {
+ const value = String(e.detail?.value ?? "auto");
+ this.userSettings.setTerritoryRenderer(value);
+ this.requestUpdate();
+ }
+
private toggleTerritoryPatterns() {
this.userSettings.toggleTerritoryPatterns();
@@ -777,6 +785,20 @@ export class UserSettingModal extends BaseModal {
@change=${this.changeTerritoryBorderMode}
>
+
+
= new PriorityQueue((a, b) => {
+ return a.lastUpdate - b.lastUpdate;
+ });
+ private random = new PseudoRandom(123);
+ private theme: Theme;
+
+ // Used for spawn highlighting
+ private highlightCanvas: HTMLCanvasElement;
+ private highlightContext: CanvasRenderingContext2D;
+
+ private highlightedTerritory: PlayerView | null = null;
+
+ private alternativeView = false;
+ private lastDragTime = 0;
+ private nodrawDragDuration = 200;
+ private lastMousePosition: { x: number; y: number } | null = null;
+
+ private refreshRate = 10; //refresh every 10ms
+ private lastRefresh = 0;
+
+ private lastFocusedPlayer: PlayerView | null = null;
+
+ constructor(
+ private game: GameView,
+ private eventBus: EventBus,
+ private transformHandler: TransformHandler,
+ ) {
+ this.theme = game.config().theme();
+ this.cachedTerritoryPatternsEnabled = undefined;
+ }
+
+ shouldTransform(): boolean {
+ return true;
+ }
+
+ async paintPlayerBorder(player: PlayerView) {
+ const tiles = await player.borderTiles();
+ tiles.borderTiles.forEach((tile: TileRef) => {
+ this.paintTerritory(tile, true); // Immediately paint the tile instead of enqueueing
+ });
+ }
+
+ tick() {
+ if (this.game.inSpawnPhase()) {
+ this.spawnHighlight();
+ }
+
+ this.game.recentlyUpdatedTiles().forEach((t) => {
+ this.enqueueTile(t);
+ // Immediately clear territory overlay for water tiles so old
+ // borders/territory don't persist visually (e.g. after nuke turns land to water)
+ if (this.game.isWater(t)) {
+ this.clearTile(t);
+ }
+ });
+ const updates = this.game.updatesSinceLastTick();
+ const unitUpdates = updates !== null ? updates[GameUpdateType.Unit] : [];
+ unitUpdates.forEach((update) => {
+ if (update.unitType === UnitType.DefensePost) {
+ // Only update borders if the defense post is not under construction
+ if (update.underConstruction) {
+ return; // Skip barrier creation while under construction
+ }
+
+ const tile = update.pos;
+ this.game
+ .bfs(tile, euclDistFN(tile, this.game.config().defensePostRange()))
+ .forEach((t) => {
+ if (
+ this.game.isBorder(t) &&
+ (this.game.ownerID(t) === update.ownerID ||
+ this.game.ownerID(t) === update.lastOwnerID)
+ ) {
+ this.enqueueTile(t);
+ }
+ });
+ }
+ });
+
+ // Detect alliance mutations
+ const myPlayer = this.game.myPlayer();
+ if (myPlayer) {
+ updates?.[GameUpdateType.BrokeAlliance]?.forEach((update) => {
+ const territory = this.game.playerBySmallID(update.betrayedID);
+ if (territory && territory instanceof PlayerView) {
+ this.redrawBorder(territory);
+ }
+ });
+
+ updates?.[GameUpdateType.AllianceRequestReply]?.forEach((update) => {
+ if (
+ update.accepted &&
+ (update.request.requestorID === myPlayer.smallID() ||
+ update.request.recipientID === myPlayer.smallID())
+ ) {
+ const territoryId =
+ update.request.requestorID === myPlayer.smallID()
+ ? update.request.recipientID
+ : update.request.requestorID;
+ const territory = this.game.playerBySmallID(territoryId);
+ if (territory && territory instanceof PlayerView) {
+ this.redrawBorder(territory);
+ }
+ }
+ });
+ updates?.[GameUpdateType.EmbargoEvent]?.forEach((update) => {
+ const player = this.game.playerBySmallID(update.playerID) as PlayerView;
+ const embargoed = this.game.playerBySmallID(
+ update.embargoedID,
+ ) as PlayerView;
+
+ if (
+ player.id() === myPlayer?.id() ||
+ embargoed.id() === myPlayer?.id()
+ ) {
+ this.redrawBorder(player, embargoed);
+ }
+ });
+ }
+
+ const focusedPlayer = this.game.focusedPlayer();
+ if (focusedPlayer !== this.lastFocusedPlayer) {
+ if (this.lastFocusedPlayer) {
+ this.paintPlayerBorder(this.lastFocusedPlayer);
+ }
+ if (focusedPlayer) {
+ this.paintPlayerBorder(focusedPlayer);
+ }
+ this.lastFocusedPlayer = focusedPlayer;
+ }
+ }
+
+ private spawnHighlight() {
+ this.highlightContext.clearRect(
+ 0,
+ 0,
+ this.game.width(),
+ this.game.height(),
+ );
+
+ this.drawFocusedPlayerHighlight();
+
+ const humans = this.game
+ .playerViews()
+ .filter((p) => p.type() === PlayerType.Human);
+
+ const focusedPlayer = this.game.focusedPlayer();
+ const teamColors = Object.values(ColoredTeams);
+ for (const human of humans) {
+ if (human === focusedPlayer) {
+ continue;
+ }
+ const center = human.nameLocation();
+ if (!center) {
+ continue;
+ }
+ const centerTile = this.game.ref(center.x, center.y);
+ if (!centerTile) {
+ continue;
+ }
+ let color = this.theme.spawnHighlightColor();
+ const myPlayer = this.game.myPlayer();
+ if (myPlayer !== null && myPlayer !== human && myPlayer.team() === null) {
+ // In FFA games (when team === null), use default yellow spawn highlight color
+ color = this.theme.spawnHighlightColor();
+ } else if (myPlayer !== null && myPlayer !== human) {
+ // In Team games, the spawn highlight color becomes that player's team color
+ // Optionally, this could be broken down to teammate or enemy and simplified to green and red, respectively
+ const team = human.team();
+ if (team !== null && teamColors.includes(team)) {
+ color = this.theme.teamColor(team);
+ } else {
+ if (myPlayer.isFriendly(human)) {
+ color = this.theme.spawnHighlightTeamColor();
+ } else {
+ color = this.theme.spawnHighlightColor();
+ }
+ }
+ }
+
+ for (const tile of this.game.bfs(
+ centerTile,
+ euclDistFN(centerTile, 9, true),
+ )) {
+ if (!this.game.hasOwner(tile)) {
+ this.paintHighlightTile(tile, color, 255);
+ }
+ }
+ }
+ }
+
+ private drawFocusedPlayerHighlight() {
+ const focusedPlayer = this.game.focusedPlayer();
+
+ if (!focusedPlayer) {
+ return;
+ }
+ const center = focusedPlayer.nameLocation();
+ if (!center) {
+ return;
+ }
+ // Breathing border animation
+ this.borderAnimTime += 0.5;
+ const minRad = 8;
+ const maxRad = 24;
+ // Range: [minPadding..maxPadding]
+ const radius =
+ minRad + (maxRad - minRad) * (0.5 + 0.5 * Math.sin(this.borderAnimTime));
+
+ const baseColor = this.theme.spawnHighlightSelfColor(); //white
+ let teamColor: Colord | null = null;
+
+ const team: Team | null = focusedPlayer.team();
+ if (team !== null && Object.values(ColoredTeams).includes(team)) {
+ teamColor = this.theme.teamColor(team).alpha(0.5);
+ } else {
+ teamColor = baseColor;
+ }
+
+ this.drawBreathingRing(
+ center.x,
+ center.y,
+ minRad,
+ maxRad,
+ radius,
+ baseColor, // Always draw white static semi-transparent ring
+ teamColor, // Pass the breathing ring color. White for FFA, Duos, Trios, Quads. Transparent team color for TEAM games.
+ );
+
+ // Draw breathing rings for teammates in team games (helps colorblind players identify teammates)
+ this.drawTeammateHighlights(minRad, maxRad, radius);
+ }
+
+ private drawTeammateHighlights(
+ minRad: number,
+ maxRad: number,
+ radius: number,
+ ) {
+ const myPlayer = this.game.myPlayer();
+ if (myPlayer === null || myPlayer.team() === null) {
+ return;
+ }
+
+ const teammates = this.game
+ .playerViews()
+ .filter((p) => p !== myPlayer && myPlayer.isOnSameTeam(p));
+
+ // Smaller radius for teammates (more subtle than self highlight)
+ const teammateMinRad = 5;
+ const teammateMaxRad = 14;
+ const teammateRadius =
+ teammateMinRad +
+ (teammateMaxRad - teammateMinRad) *
+ ((radius - minRad) / (maxRad - minRad));
+
+ const teamColors = Object.values(ColoredTeams);
+ for (const teammate of teammates) {
+ const center = teammate.nameLocation();
+ if (!center) {
+ continue;
+ }
+
+ const team = teammate.team();
+ let baseColor: Colord;
+ let breathingColor: Colord;
+
+ if (team !== null && teamColors.includes(team)) {
+ baseColor = this.theme.teamColor(team).alpha(0.5);
+ breathingColor = this.theme.teamColor(team).alpha(0.5);
+ } else {
+ baseColor = this.theme.spawnHighlightTeamColor();
+ breathingColor = this.theme.spawnHighlightTeamColor();
+ }
+
+ this.drawBreathingRing(
+ center.x,
+ center.y,
+ teammateMinRad,
+ teammateMaxRad,
+ teammateRadius,
+ baseColor,
+ breathingColor,
+ );
+ }
+ }
+
+ init() {
+ this.eventBus.on(MouseOverEvent, (e) => this.onMouseOver(e));
+ this.eventBus.on(AlternateViewEvent, (e) => {
+ this.alternativeView = e.alternateView;
+ });
+ this.eventBus.on(DragEvent, (e) => {
+ // TODO: consider re-enabling this on mobile or low end devices for smoother dragging.
+ // this.lastDragTime = Date.now();
+ });
+ this.redraw();
+ }
+
+ onMouseOver(event: MouseOverEvent) {
+ this.lastMousePosition = { x: event.x, y: event.y };
+ this.updateHighlightedTerritory();
+ }
+
+ private updateHighlightedTerritory() {
+ if (!this.alternativeView) {
+ return;
+ }
+
+ if (!this.lastMousePosition) {
+ return;
+ }
+
+ const cell = this.transformHandler.screenToWorldCoordinates(
+ this.lastMousePosition.x,
+ this.lastMousePosition.y,
+ );
+ if (!this.game.isValidCoord(cell.x, cell.y)) {
+ return;
+ }
+
+ const previousTerritory = this.highlightedTerritory;
+ const territory = this.getTerritoryAtCell(cell);
+
+ if (territory) {
+ this.highlightedTerritory = territory;
+ } else {
+ this.highlightedTerritory = null;
+ }
+
+ if (previousTerritory?.id() !== this.highlightedTerritory?.id()) {
+ const territories: PlayerView[] = [];
+ if (previousTerritory) {
+ territories.push(previousTerritory);
+ }
+ if (this.highlightedTerritory) {
+ territories.push(this.highlightedTerritory);
+ }
+ this.redrawBorder(...territories);
+ }
+ }
+
+ private getTerritoryAtCell(cell: { x: number; y: number }) {
+ const tile = this.game.ref(cell.x, cell.y);
+ if (!tile) {
+ return null;
+ }
+ // If the tile has no owner, it is either a fallout tile or a terra nullius tile.
+ if (!this.game.hasOwner(tile)) {
+ return null;
+ }
+ const owner = this.game.owner(tile);
+ return owner instanceof PlayerView ? owner : null;
+ }
+
+ redraw() {
+ console.log("redrew territory layer");
+ this.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,
+ );
+
+ // Add a second canvas for highlights
+ this.highlightCanvas = document.createElement("canvas");
+ const highlightContext = this.highlightCanvas.getContext("2d", {
+ alpha: true,
+ });
+ if (highlightContext === null) throw new Error("2d context not supported");
+ this.highlightContext = highlightContext;
+ this.highlightCanvas.width = this.game.width();
+ this.highlightCanvas.height = this.game.height();
+
+ this.game.forEachTile((t) => {
+ this.paintTerritory(t);
+ });
+ }
+
+ redrawBorder(...players: PlayerView[]) {
+ return Promise.all(
+ players.map(async (player) => {
+ const tiles = await player.borderTiles();
+ tiles.borderTiles.forEach((tile: TileRef) => {
+ this.paintTerritory(tile, true);
+ });
+ }),
+ );
+ }
+
+ 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 (
+ now > this.lastDragTime + this.nodrawDragDuration &&
+ now > this.lastRefresh + this.refreshRate
+ ) {
+ 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 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(
+ this.highlightCanvas,
+ -this.game.width() / 2,
+ -this.game.height() / 2,
+ this.game.width(),
+ this.game.height(),
+ );
+ FrameProfiler.end(
+ "TerritoryLayer:drawHighlightCanvas",
+ highlightDrawStart,
+ );
+ }
+ }
+
+ renderTerritory() {
+ let numToRender = Math.floor(this.tileToRenderQueue.size() / 10);
+ if (numToRender === 0 || this.game.inSpawnPhase()) {
+ numToRender = this.tileToRenderQueue.size();
+ }
+
+ while (numToRender > 0) {
+ numToRender--;
+
+ const entry = this.tileToRenderQueue.pop();
+ if (!entry) {
+ break;
+ }
+
+ 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;
+ }
+ this.clearTile(tile);
+ return;
+ }
+ const owner = this.game.owner(tile) as PlayerView;
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ const isHighlighted =
+ this.highlightedTerritory &&
+ this.highlightedTerritory.id() === owner.id();
+ const myPlayer = this.game.myPlayer();
+
+ if (this.game.isBorder(tile)) {
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ const playerIsFocused = owner && this.game.focusedPlayer() === owner;
+ if (myPlayer) {
+ const alternativeColor = this.alternateViewColor(owner);
+ this.paintTile(this.alternativeImageData, tile, alternativeColor, 255);
+ }
+ const isDefended = this.game.hasUnitNearby(
+ tile,
+ this.game.config().defensePostRange(),
+ UnitType.DefensePost,
+ owner.id(),
+ );
+
+ this.paintTile(
+ this.imageData,
+ tile,
+ owner.borderColor(tile, isDefended),
+ 255,
+ );
+ } else {
+ // Alternative view only shows borders.
+ this.clearAlternativeTile(tile);
+
+ this.paintTile(this.imageData, tile, owner.territoryColor(tile), 150);
+ }
+ }
+
+ 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;
+ }
+
+ 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)
+ }
+
+ enqueueTile(tile: TileRef) {
+ this.tileToRenderQueue.push({
+ tile: tile,
+ lastUpdate: this.game.ticks() + this.random.nextFloat(0, 0.5),
+ });
+ }
+
+ async enqueuePlayerBorder(player: PlayerView) {
+ const playerBorderTiles = await player.borderTiles();
+ playerBorderTiles.borderTiles.forEach((tile: TileRef) => {
+ this.enqueueTile(tile);
+ });
+ }
+
+ paintHighlightTile(tile: TileRef, color: Colord, alpha: number) {
+ this.clearTile(tile);
+ const x = this.game.x(tile);
+ const y = this.game.y(tile);
+ this.highlightContext.fillStyle = color.alpha(alpha / 255).toRgbString();
+ this.highlightContext.fillRect(x, y, 1, 1);
+ }
+
+ clearHighlightTile(tile: TileRef) {
+ const x = this.game.x(tile);
+ const y = this.game.y(tile);
+ this.highlightContext.clearRect(x, y, 1, 1);
+ }
+
+ private drawBreathingRing(
+ cx: number,
+ cy: number,
+ minRad: number,
+ maxRad: number,
+ radius: number,
+ transparentColor: Colord,
+ breathingColor: Colord,
+ ) {
+ const ctx = this.highlightContext;
+ if (!ctx) return;
+
+ // Draw a semi-transparent ring around the starting location
+ ctx.beginPath();
+ // Transparency matches the highlight color provided
+ const transparent = transparentColor.alpha(0);
+ const radGrad = ctx.createRadialGradient(cx, cy, minRad, cx, cy, maxRad);
+
+ // Pixels with radius < minRad are transparent
+ radGrad.addColorStop(0, transparent.toRgbString());
+ // The ring then starts with solid highlight color
+ radGrad.addColorStop(0.01, transparentColor.toRgbString());
+ radGrad.addColorStop(0.1, transparentColor.toRgbString());
+ // The outer edge of the ring is transparent
+ radGrad.addColorStop(1, transparent.toRgbString());
+
+ // Draw an arc at the max radius and fill with the created radial gradient
+ ctx.arc(cx, cy, maxRad, 0, Math.PI * 2);
+ ctx.fillStyle = radGrad;
+ ctx.closePath();
+ ctx.fill();
+
+ const breatheInner = breathingColor.alpha(0);
+ // Draw a solid ring around the starting location with outer radius = the breathing radius
+ ctx.beginPath();
+ const radGrad2 = ctx.createRadialGradient(cx, cy, minRad, cx, cy, radius);
+ // Pixels with radius < minRad are transparent
+ radGrad2.addColorStop(0, breatheInner.toRgbString());
+ // The ring then starts with solid highlight color
+ radGrad2.addColorStop(0.01, breathingColor.toRgbString());
+ // The ring is solid throughout
+ radGrad2.addColorStop(1, breathingColor.toRgbString());
+
+ // Draw an arc at the current breathing radius and fill with the created "gradient"
+ ctx.arc(cx, cy, radius, 0, Math.PI * 2);
+ ctx.fillStyle = radGrad2;
+ ctx.fill();
+ }
+}
diff --git a/src/client/graphics/layers/ClassicTerritoryBackend.ts b/src/client/graphics/layers/ClassicTerritoryBackend.ts
new file mode 100644
index 000000000..1a1431f69
--- /dev/null
+++ b/src/client/graphics/layers/ClassicTerritoryBackend.ts
@@ -0,0 +1,59 @@
+import { EventBus } from "../../../core/EventBus";
+import { GameView } from "../../../core/game/GameView";
+import { TransformHandler } from "../TransformHandler";
+import { ClassicCanvasTerritoryLayer } from "./ClassicCanvasTerritoryLayer";
+import { TerrainLayer } from "./TerrainLayer";
+import { TerritoryBackend } from "./TerritoryBackend";
+
+export class ClassicTerritoryBackend implements TerritoryBackend {
+ readonly id = "classic";
+
+ private readonly terrainLayer: TerrainLayer;
+ private readonly territoryLayer: ClassicCanvasTerritoryLayer;
+
+ constructor(
+ game: GameView,
+ eventBus: EventBus,
+ transformHandler: TransformHandler,
+ ) {
+ this.terrainLayer = new TerrainLayer(game, transformHandler);
+ this.territoryLayer = new ClassicCanvasTerritoryLayer(
+ game,
+ eventBus,
+ transformHandler,
+ );
+ }
+
+ profileName(): string {
+ return "ClassicTerritoryBackend:renderLayer";
+ }
+
+ shouldTransform(): boolean {
+ return true;
+ }
+
+ init() {
+ this.terrainLayer.init?.();
+ this.territoryLayer.init?.();
+ }
+
+ tick() {
+ this.terrainLayer.tick?.();
+ this.territoryLayer.tick?.();
+ }
+
+ redraw() {
+ this.terrainLayer.redraw?.();
+ this.territoryLayer.redraw?.();
+ }
+
+ renderLayer(context: CanvasRenderingContext2D) {
+ this.terrainLayer.renderLayer?.(context);
+ this.territoryLayer.renderLayer?.(context);
+ }
+
+ dispose() {
+ // Classic layers own only offscreen canvases and event-bus listeners.
+ // The event bus does not currently expose unsubscribe hooks.
+ }
+}
diff --git a/src/client/graphics/layers/TerrainLayer.ts b/src/client/graphics/layers/TerrainLayer.ts
new file mode 100644
index 000000000..353555912
--- /dev/null
+++ b/src/client/graphics/layers/TerrainLayer.ts
@@ -0,0 +1,107 @@
+import { Config, Theme } from "../../../core/configuration/Config";
+import { GameView } from "../../../core/game/GameView";
+import { TransformHandler } from "../TransformHandler";
+import { Layer } from "./Layer";
+
+export class TerrainLayer implements Layer {
+ private canvas: HTMLCanvasElement;
+ private context: CanvasRenderingContext2D;
+ private imageData: ImageData;
+ private theme: Theme;
+ private config: Config;
+
+ constructor(
+ private game: GameView,
+ private transformHandler: TransformHandler,
+ ) {
+ this.config = this.game.config();
+ }
+ shouldTransform(): boolean {
+ return true;
+ }
+ tick() {
+ if (this.config.theme() !== this.theme) {
+ this.redraw();
+ return;
+ }
+ // Repaint terrain for tiles whose terrain changed (e.g. nuke
+ // turning land to water).
+ const updatedTiles = this.game.recentlyUpdatedTerrainTiles();
+ if (updatedTiles.length > 0) {
+ let dirty = false;
+ for (const tile of updatedTiles) {
+ const terrainColor = this.theme.terrainColor(this.game, tile);
+ const offset = tile * 4;
+ const r = terrainColor.rgba.r;
+ const g = terrainColor.rgba.g;
+ const b = terrainColor.rgba.b;
+ if (
+ this.imageData.data[offset] !== r ||
+ this.imageData.data[offset + 1] !== g ||
+ this.imageData.data[offset + 2] !== b
+ ) {
+ this.imageData.data[offset] = r;
+ this.imageData.data[offset + 1] = g;
+ this.imageData.data[offset + 2] = b;
+ dirty = true;
+ }
+ }
+ if (dirty) {
+ this.context.putImageData(this.imageData, 0, 0);
+ }
+ }
+ }
+
+ init() {
+ console.log("redrew terrain layer");
+ this.redraw();
+ }
+
+ redraw(): void {
+ this.canvas = document.createElement("canvas");
+ this.canvas.width = this.game.width();
+ this.canvas.height = this.game.height();
+
+ const context = this.canvas.getContext("2d", { alpha: false });
+ if (context === null) throw new Error("2d context not supported");
+ this.context = context;
+
+ this.imageData = this.context.createImageData(
+ this.canvas.width,
+ this.canvas.height,
+ );
+
+ this.initImageData();
+ this.context.putImageData(this.imageData, 0, 0);
+ }
+
+ initImageData() {
+ this.theme = this.config.theme();
+ this.game.forEachTile((tile) => {
+ const terrainColor = this.theme.terrainColor(this.game, tile);
+ // TODO: isn't tileref and index the same?
+ const index = this.game.y(tile) * this.game.width() + this.game.x(tile);
+ const offset = index * 4;
+ this.imageData.data[offset] = terrainColor.rgba.r;
+ this.imageData.data[offset + 1] = terrainColor.rgba.g;
+ this.imageData.data[offset + 2] = terrainColor.rgba.b;
+ this.imageData.data[offset + 3] = 255;
+ });
+ }
+
+ renderLayer(context: CanvasRenderingContext2D) {
+ if (this.transformHandler.scale < 1) {
+ context.imageSmoothingEnabled = true;
+ context.imageSmoothingQuality = "low";
+ } else {
+ context.imageSmoothingEnabled = false;
+ }
+ context.drawImage(
+ this.canvas,
+ -this.game.width() / 2,
+ -this.game.height() / 2,
+ this.game.width(),
+ this.game.height(),
+ );
+ }
+}
diff --git a/src/client/graphics/layers/TerritoryBackend.ts b/src/client/graphics/layers/TerritoryBackend.ts
new file mode 100644
index 000000000..7b02694ca
--- /dev/null
+++ b/src/client/graphics/layers/TerritoryBackend.ts
@@ -0,0 +1,134 @@
+import { Layer } from "./Layer";
+
+export type TerritoryRendererId = "classic" | "webgl" | "webgpu";
+export type TerritoryRendererPreference = "auto" | TerritoryRendererId;
+
+export const TERRITORY_RENDERER_OPTIONS: TerritoryRendererPreference[] = [
+ "auto",
+ "classic",
+ "webgl",
+ "webgpu",
+];
+
+export interface TerritoryBackend extends Layer {
+ readonly id: TerritoryRendererId;
+ dispose?: () => void;
+ getFailureReason?: () => string | null;
+ whenReady?: () => Promise;
+}
+
+export interface TerritoryBackendCandidate {
+ readonly id: TerritoryRendererId;
+ init?: () => void | Promise;
+ dispose?: () => void;
+ getFailureReason?: () => string | null;
+ whenReady?: () => Promise;
+}
+
+export interface TerritoryBackendSelectionFailure {
+ id: TerritoryRendererId;
+ reason: string;
+ error?: unknown;
+}
+
+export interface TerritoryBackendSelection<
+ T extends TerritoryBackendCandidate,
+> {
+ backend: T | null;
+ failures: TerritoryBackendSelectionFailure[];
+ cancelled: boolean;
+}
+
+export function normalizeTerritoryRendererPreference(
+ value: string | null | undefined,
+): TerritoryRendererPreference {
+ if (
+ value === "classic" ||
+ value === "webgl" ||
+ value === "webgpu" ||
+ value === "auto"
+ ) {
+ return value;
+ }
+ return "auto";
+}
+
+export function territoryRendererOrder(
+ preference: TerritoryRendererPreference,
+ failedBackends: ReadonlySet = new Set(),
+): TerritoryRendererId[] {
+ const preferredOrder: TerritoryRendererId[] =
+ preference === "classic"
+ ? ["classic"]
+ : preference === "webgl"
+ ? ["webgl", "classic"]
+ : ["webgpu", "webgl", "classic"];
+
+ return preferredOrder.filter(
+ (id) => id === "classic" || !failedBackends.has(id),
+ );
+}
+
+export async function selectTerritoryBackend<
+ T extends TerritoryBackendCandidate,
+>(
+ preference: TerritoryRendererPreference,
+ failedBackends: ReadonlySet,
+ createBackend: (id: TerritoryRendererId) => T,
+ shouldContinue: () => boolean = () => true,
+): Promise> {
+ const failures: TerritoryBackendSelectionFailure[] = [];
+
+ for (const id of territoryRendererOrder(preference, failedBackends)) {
+ if (!shouldContinue()) {
+ return { backend: null, failures, cancelled: true };
+ }
+
+ const backend = createBackend(id);
+ try {
+ await backend.init?.();
+
+ if (!shouldContinue()) {
+ backend.dispose?.();
+ return { backend: null, failures, cancelled: true };
+ }
+
+ let reason = backend.getFailureReason?.() ?? null;
+ if (reason !== null) {
+ backend.dispose?.();
+ failures.push({ id, reason });
+ continue;
+ }
+
+ if (backend.whenReady) {
+ const ready = await backend.whenReady();
+
+ if (!shouldContinue()) {
+ backend.dispose?.();
+ return { backend: null, failures, cancelled: true };
+ }
+
+ reason = backend.getFailureReason?.() ?? null;
+ if (!ready || reason !== null) {
+ backend.dispose?.();
+ failures.push({
+ id,
+ reason: reason ?? "initialization failed",
+ });
+ continue;
+ }
+ }
+
+ return { backend, failures, cancelled: false };
+ } catch (error) {
+ backend.dispose?.();
+ failures.push({
+ id,
+ reason: error instanceof Error ? error.message : String(error),
+ error,
+ });
+ }
+ }
+
+ return { backend: null, failures, cancelled: false };
+}
diff --git a/src/client/graphics/layers/TerritoryLayer.ts b/src/client/graphics/layers/TerritoryLayer.ts
index 94b27d886..60833078b 100644
--- a/src/client/graphics/layers/TerritoryLayer.ts
+++ b/src/client/graphics/layers/TerritoryLayer.ts
@@ -1,68 +1,42 @@
-import { Theme } from "../../../core/configuration/Config";
import { EventBus } from "../../../core/EventBus";
-import { UnitType } from "../../../core/game/Game";
-import { TileRef } from "../../../core/game/GameMap";
import { GameView } from "../../../core/game/GameView";
-import { UserSettings } from "../../../core/game/UserSettings";
import {
- AlternateViewEvent,
- MouseOverEvent,
- WebGPUComputeMetricsEvent,
-} from "../../InputHandler";
-import { FrameProfiler } from "../FrameProfiler";
+ TERRITORY_RENDERER_KEY,
+ USER_SETTINGS_CHANGED_EVENT,
+ UserSettings,
+} from "../../../core/game/UserSettings";
import { TransformHandler } from "../TransformHandler";
+import { ClassicTerritoryBackend } from "./ClassicTerritoryBackend";
import {
- buildTerrainShaderParams,
- readTerrainShaderId,
-} from "../webgpu/render/TerrainShaderRegistry";
-import {
- buildTerritoryPostSmoothingParams,
- readTerritoryPostSmoothingId,
-} from "../webgpu/render/TerritoryPostSmoothingRegistry";
-import {
- buildTerritoryPreSmoothingParams,
- readTerritoryPreSmoothingId,
-} from "../webgpu/render/TerritoryPreSmoothingRegistry";
-import {
- buildTerritoryShaderParams,
- readTerritoryShaderId,
-} from "../webgpu/render/TerritoryShaderRegistry";
-import { TerritoryRenderer } from "../webgpu/TerritoryRenderer";
-import { Layer } from "./Layer";
+ TerritoryBackend,
+ TerritoryRendererId,
+ selectTerritoryBackend,
+ territoryRendererOrder,
+} from "./TerritoryBackend";
+import { WebGLTerritoryBackend } from "./WebGLTerritoryBackend";
+import { WebGPUTerritoryBackend } from "./WebGPUTerritoryBackend";
-export class TerritoryLayer implements Layer {
- profileName(): string {
- return "TerritoryLayer:renderLayer";
- }
+export class TerritoryLayer implements TerritoryBackend {
+ readonly id = "classic";
- private attachedTerritoryCanvas: HTMLCanvasElement | null = null;
-
- private overlayWrapper: HTMLElement | null = null;
- private overlayResizeObserver: ResizeObserver | null = null;
-
- private theme: Theme;
-
- private territoryRenderer: TerritoryRenderer | null = null;
- private alternativeView = false;
-
- private lastPaletteSignature: string | null = null;
- private lastDefensePostsSignature: string | null = null;
- private lastTerrainShaderSignature: string | null = null;
- private lastTerritoryShaderSignature: string | null = null;
- private lastPreSmoothingSignature: string | null = null;
- private lastPostSmoothingSignature: string | null = null;
-
- private lastMousePosition: { x: number; y: number } | null = null;
- private hoveredOwnerSmallId: number | null = null;
- private lastHoverUpdateMs = 0;
+ private activeBackend: TerritoryBackend | null = null;
+ private failedBackends = new Set();
+ private selectionToken = 0;
+ private initialized = false;
+ private readonly settingsChanged = () => {
+ this.failedBackends.clear();
+ void this.selectConfiguredBackend();
+ };
constructor(
private game: GameView,
private eventBus: EventBus,
private transformHandler: TransformHandler,
private userSettings: UserSettings,
- ) {
- this.theme = game.config().theme();
+ ) {}
+
+ profileName(): string {
+ return "TerritoryLayer:renderLayer";
}
shouldTransform(): boolean {
@@ -70,355 +44,201 @@ export class TerritoryLayer implements Layer {
}
init() {
- this.eventBus.on(AlternateViewEvent, (e) => {
- this.alternativeView = e.alternateView;
- this.territoryRenderer?.setAlternativeView(this.alternativeView);
- });
- this.eventBus.on(MouseOverEvent, (e) => {
- this.lastMousePosition = { x: e.x, y: e.y };
- });
- this.redraw();
+ this.initialized = true;
+ globalThis.addEventListener?.(
+ `${USER_SETTINGS_CHANGED_EVENT}:${TERRITORY_RENDERER_KEY}`,
+ this.settingsChanged,
+ );
+
+ // Keep the map visible while accelerated renderers initialize.
+ this.activateBackend(this.createBackend("classic"));
+ void this.selectConfiguredBackend();
}
tick() {
- const tickProfile = FrameProfiler.start();
-
- const currentTheme = this.game.config().theme();
- if (currentTheme !== this.theme) {
- this.theme = currentTheme;
- this.territoryRenderer?.refreshTerrain();
- this.redraw();
- }
-
- this.refreshPaletteIfNeeded();
- this.refreshDefensePostsIfNeeded();
- this.applyTerrainShaderSettings();
- this.applyTerritoryShaderSettings();
- this.applyTerritorySmoothingSettings();
-
- const updatedTiles = this.game.recentlyUpdatedTiles();
- for (let i = 0; i < updatedTiles.length; i++) {
- this.markTile(updatedTiles[i]);
- }
-
- // After collecting pending updates and handling palette/theme changes,
- // invoke the renderer's tick() to process compute passes. This ensures
- // compute shaders run at the simulation rate rather than every frame.
- if (this.territoryRenderer) {
- const start = performance.now();
- this.territoryRenderer.tick();
- const computeMs = performance.now() - start;
- this.eventBus.emit(new WebGPUComputeMetricsEvent(computeMs));
- }
-
- FrameProfiler.end("TerritoryLayer:tick", tickProfile);
+ this.runActive("tick", (backend) => backend.tick?.());
}
redraw() {
- this.configureRenderer();
- }
-
- private configureRenderer() {
- const { renderer, reason } = TerritoryRenderer.create(
- this.game,
- this.theme,
- );
- if (!renderer) {
- throw new Error(reason ?? "WebGPU is required for territory rendering.");
+ if (!this.initialized) {
+ return;
}
-
- this.territoryRenderer = renderer;
- this.territoryRenderer.setAlternativeView(this.alternativeView);
- this.territoryRenderer.setHighlightedOwnerId(this.hoveredOwnerSmallId);
- this.applyTerrainShaderSettings(true);
- this.applyTerritoryShaderSettings(true);
- this.applyTerritorySmoothingSettings(true);
- this.territoryRenderer.markAllDirty();
- this.territoryRenderer.refreshPalette();
- this.lastPaletteSignature = this.computePaletteSignature();
-
- this.lastDefensePostsSignature = this.computeDefensePostsSignature();
- // Ensure defense posts buffer is uploaded on first tick.
- this.territoryRenderer.markDefensePostsDirty();
-
- // Run an initial tick to upload state and build the colour texture. Without
- // this, the first render call may occur before the initial compute pass
- // has been executed, resulting in undefined colours.
- this.territoryRenderer.tick();
+ this.runActive("redraw", (backend) => backend.redraw?.());
+ void this.selectConfiguredBackend();
}
renderLayer(context: CanvasRenderingContext2D) {
- if (!this.territoryRenderer) {
+ if (!this.activeBackend) {
return;
}
- // Check for theme changes in renderLayer too (for when game is paused)
- const currentTheme = this.game.config().theme();
- if (currentTheme !== this.theme) {
- this.theme = currentTheme;
- this.territoryRenderer.refreshTerrain();
- this.redraw();
+ if (this.activeBackend.id !== "webgpu") {
+ this.fillBackground(context);
}
- // Apply user settings even while the game is paused (settings modal).
- this.applyTerritoryShaderSettings();
- this.applyTerritorySmoothingSettings();
+ this.runActive("renderLayer", (backend) => backend.renderLayer?.(context));
+ }
- this.ensureTerritoryCanvasAttached(context.canvas);
- this.updateHoverHighlight();
-
- const renderTerritoryStart = FrameProfiler.start();
- this.territoryRenderer.setViewSize(
- context.canvas.width,
- context.canvas.height,
+ dispose() {
+ globalThis.removeEventListener?.(
+ `${USER_SETTINGS_CHANGED_EVENT}:${TERRITORY_RENDERER_KEY}`,
+ this.settingsChanged,
);
- const viewOffset = this.transformHandler.viewOffset();
- this.territoryRenderer.setViewTransform(
- this.transformHandler.scale,
- viewOffset.x,
- viewOffset.y,
+ this.activeBackend?.dispose?.();
+ this.activeBackend = null;
+ }
+
+ private async selectConfiguredBackend() {
+ const token = ++this.selectionToken;
+ const preference = this.userSettings.territoryRenderer();
+ const order = territoryRendererOrder(preference, this.failedBackends);
+ if (
+ this.activeBackend?.id === order[0] &&
+ !this.activeBackend.getFailureReason?.()
+ ) {
+ return;
+ }
+
+ const selection = await selectTerritoryBackend(
+ preference,
+ this.failedBackends,
+ (id) => this.createBackend(id),
+ () => token === this.selectionToken,
);
- this.territoryRenderer.render();
- FrameProfiler.end("TerritoryLayer:renderTerritory", renderTerritoryStart);
- }
- private ensureTerritoryCanvasAttached(mainCanvas: HTMLCanvasElement) {
- if (!this.territoryRenderer) {
+ if (selection.cancelled) {
return;
}
- const canvas = this.territoryRenderer.canvas;
-
- // If the renderer recreated its canvas, detach the old one.
- if (this.attachedTerritoryCanvas !== canvas) {
- this.attachedTerritoryCanvas?.remove();
- this.attachedTerritoryCanvas = canvas;
-
- // Configure overlay canvas styles once. Avoid per-frame style reads/writes.
- canvas.style.pointerEvents = "none";
- canvas.style.position = "absolute";
- canvas.style.inset = "0";
- canvas.style.width = "100%";
- canvas.style.height = "100%";
- canvas.style.display = "block";
- }
-
- const parent = mainCanvas.parentElement;
- if (!parent) {
- // Fallback: if the canvas isn't in the DOM yet, append to body.
- if (!canvas.isConnected) {
- document.body.appendChild(canvas);
- }
- return;
- }
-
- // Ensure the main canvas is wrapped in a positioned container so the
- // territory canvas can overlay it without mirroring computed styles.
- let wrapper: HTMLElement;
- const currentParent = mainCanvas.parentElement;
- if (currentParent && currentParent.dataset.territoryOverlay === "1") {
- wrapper = currentParent;
- } else {
- wrapper = document.createElement("div");
- wrapper.dataset.territoryOverlay = "1";
- wrapper.style.position = "relative";
- wrapper.style.display = "inline-block";
- wrapper.style.lineHeight = "0";
-
- // Replace mainCanvas with wrapper, then re-insert mainCanvas inside wrapper.
- parent.replaceChild(wrapper, mainCanvas);
- wrapper.appendChild(mainCanvas);
- }
-
- if (this.overlayWrapper !== wrapper) {
- this.overlayWrapper = wrapper;
- this.overlayResizeObserver?.disconnect();
- this.overlayResizeObserver = new ResizeObserver(() => {
- this.syncOverlayWrapperSize(mainCanvas, wrapper);
- });
- this.overlayResizeObserver.observe(mainCanvas);
- // Kick an initial size update; further updates are handled by ResizeObserver.
- this.syncOverlayWrapperSize(mainCanvas, wrapper);
- }
-
- // Ensure territory canvas is the first child so it's the lowest layer.
- if (canvas.parentElement !== wrapper) {
- canvas.remove();
- wrapper.insertBefore(canvas, mainCanvas);
- } else if (canvas !== wrapper.firstElementChild) {
- wrapper.insertBefore(canvas, mainCanvas);
- }
- }
-
- private syncOverlayWrapperSize(
- mainCanvas: HTMLCanvasElement,
- wrapper: HTMLElement,
- ) {
- // Ensure the wrapper has real layout size so the absolutely-positioned
- // territory canvas (100% width/height) is non-zero even if the main canvas
- // is positioned absolutely.
- const rect = mainCanvas.getBoundingClientRect();
- const w = rect.width > 0 ? rect.width : mainCanvas.clientWidth;
- const h = rect.height > 0 ? rect.height : mainCanvas.clientHeight;
- if (w > 0) wrapper.style.width = `${w}px`;
- if (h > 0) wrapper.style.height = `${h}px`;
- }
-
- private markTile(tile: TileRef) {
- this.territoryRenderer?.markTile(tile);
- }
-
- private updateHoverHighlight() {
- if (!this.territoryRenderer) {
- return;
- }
-
- const now = performance.now();
- if (now - this.lastHoverUpdateMs < 100) {
- return;
- }
- this.lastHoverUpdateMs = now;
-
- let nextOwnerSmallId: number | null = null;
- if (this.lastMousePosition) {
- const cell = this.transformHandler.screenToWorldCoordinates(
- this.lastMousePosition.x,
- this.lastMousePosition.y,
+ for (const failure of selection.failures) {
+ console.warn(
+ `[TerritoryLayer] ${failure.id} renderer unavailable: ${failure.reason}`,
+ failure.error ?? "",
);
- if (this.game.isValidCoord(cell.x, cell.y)) {
- const tile = this.game.ref(cell.x, cell.y);
- const owner = this.game.owner(tile);
- if (owner && owner.isPlayer()) {
- nextOwnerSmallId = owner.smallID();
+ if (failure.id !== "classic") {
+ this.failedBackends.add(failure.id);
+ }
+ }
+
+ if (selection.backend !== null) {
+ this.activateBackend(selection.backend);
+ }
+ }
+
+ private async initializeCandidate(
+ backend: TerritoryBackend,
+ token: number,
+ ): Promise {
+ try {
+ await backend.init?.();
+ if (token !== this.selectionToken) {
+ return false;
+ }
+ if (backend.getFailureReason?.()) {
+ console.warn(
+ `[TerritoryLayer] ${backend.id} renderer unavailable: ${backend.getFailureReason()}`,
+ );
+ return false;
+ }
+ if (backend.whenReady) {
+ const ready = await backend.whenReady();
+ if (!ready || backend.getFailureReason?.()) {
+ console.warn(
+ `[TerritoryLayer] ${backend.id} renderer unavailable: ${
+ backend.getFailureReason?.() ?? "initialization failed"
+ }`,
+ );
+ return false;
}
}
- }
-
- if (nextOwnerSmallId === this.hoveredOwnerSmallId) {
- return;
- }
- this.hoveredOwnerSmallId = nextOwnerSmallId;
- this.territoryRenderer.setHighlightedOwnerId(nextOwnerSmallId);
- }
-
- private computePaletteSignature(): string {
- let maxSmallId = 0;
- for (const player of this.game.playerViews()) {
- maxSmallId = Math.max(maxSmallId, player.smallID());
- }
- const patternsEnabled = this.userSettings.territoryPatterns();
- return `${this.game.playerViews().length}:${maxSmallId}:${patternsEnabled ? 1 : 0}`;
- }
-
- private refreshPaletteIfNeeded() {
- if (!this.territoryRenderer) {
- return;
- }
- const signature = this.computePaletteSignature();
- if (signature !== this.lastPaletteSignature) {
- this.lastPaletteSignature = signature;
- this.territoryRenderer.refreshPalette();
- }
- }
-
- private applyTerritoryShaderSettings(force: boolean = false) {
- if (!this.territoryRenderer) {
- return;
- }
-
- const shaderId = readTerritoryShaderId(this.userSettings);
- const { shaderPath, params0, params1 } = buildTerritoryShaderParams(
- this.userSettings,
- shaderId,
- );
-
- const signature = `${shaderPath}:${Array.from(params0).join(",")}:${Array.from(params1).join(",")}`;
- if (!force && signature === this.lastTerritoryShaderSignature) {
- return;
- }
- this.lastTerritoryShaderSignature = signature;
-
- this.territoryRenderer.setTerritoryShader(shaderPath);
- this.territoryRenderer.setTerritoryShaderParams(params0, params1);
- }
-
- private applyTerrainShaderSettings(force: boolean = false) {
- if (!this.territoryRenderer) {
- return;
- }
-
- const terrainId = readTerrainShaderId(this.userSettings);
- const { shaderPath, params0, params1 } = buildTerrainShaderParams(
- this.userSettings,
- terrainId,
- );
- const signature = `${shaderPath}:${Array.from(params0).join(",")}:${Array.from(params1).join(",")}`;
- if (!force && signature === this.lastTerrainShaderSignature) {
- return;
- }
- this.lastTerrainShaderSignature = signature;
- this.territoryRenderer.setTerrainShader(shaderPath);
- this.territoryRenderer.setTerrainShaderParams(params0, params1);
- }
-
- private applyTerritorySmoothingSettings(force: boolean = false) {
- if (!this.territoryRenderer) {
- return;
- }
-
- const preId = readTerritoryPreSmoothingId(this.userSettings);
- const preParams = buildTerritoryPreSmoothingParams(
- this.userSettings,
- preId,
- );
- const preSignature = `${preId}:${Array.from(preParams.params0).join(",")}`;
- if (force || preSignature !== this.lastPreSmoothingSignature) {
- this.lastPreSmoothingSignature = preSignature;
- this.territoryRenderer.setPreSmoothing(
- preParams.enabled,
- preParams.shaderPath,
- preParams.params0,
+ return true;
+ } catch (error) {
+ console.warn(
+ `[TerritoryLayer] ${backend.id} renderer failed init`,
+ error,
);
+ return false;
+ }
+ }
+
+ private activateBackend(backend: TerritoryBackend) {
+ if (this.activeBackend === backend) {
+ return;
+ }
+ const previous = this.activeBackend;
+ this.activeBackend = backend;
+ previous?.dispose?.();
+ console.info(`[TerritoryLayer] active renderer: ${backend.id}`);
+ }
+
+ private runActive(
+ operation: "tick" | "redraw" | "renderLayer",
+ run: (backend: TerritoryBackend) => void,
+ ) {
+ const backend = this.activeBackend;
+ if (!backend) {
+ return;
}
- const postId = readTerritoryPostSmoothingId(this.userSettings);
- const postParams = buildTerritoryPostSmoothingParams(
- this.userSettings,
- postId,
- );
- const postSignature = `${postId}:${Array.from(postParams.params0).join(",")}`;
- if (force || postSignature !== this.lastPostSmoothingSignature) {
- this.lastPostSmoothingSignature = postSignature;
- this.territoryRenderer.setPostSmoothing(
- postParams.enabled,
- postParams.shaderPath,
- postParams.params0,
+ try {
+ run(backend);
+ const reason = backend.getFailureReason?.();
+ if (reason) {
+ this.handleBackendFailure(backend, `${operation}: ${reason}`);
+ }
+ } catch (error) {
+ this.handleBackendFailure(backend, `${operation}: ${String(error)}`);
+ }
+ }
+
+ private handleBackendFailure(backend: TerritoryBackend, reason: string) {
+ console.warn(`[TerritoryLayer] ${backend.id} renderer failed: ${reason}`);
+ if (backend.id !== "classic") {
+ this.failedBackends.add(backend.id);
+ }
+ if (this.activeBackend === backend) {
+ this.activeBackend = null;
+ backend.dispose?.();
+ const classic = this.createBackend("classic");
+ void this.initializeCandidate(classic, ++this.selectionToken).then(
+ (ready) => {
+ if (ready) {
+ this.activateBackend(classic);
+ void this.selectConfiguredBackend();
+ }
+ },
);
}
}
- private computeDefensePostsSignature(): string {
- // Active + completed posts only.
- const parts: string[] = [];
- for (const u of this.game.units(UnitType.DefensePost)) {
- if (!u.isActive() || u.isUnderConstruction()) continue;
- const tile = u.tile();
- parts.push(
- `${u.owner().smallID()},${this.game.x(tile)},${this.game.y(tile)}`,
+ private createBackend(id: TerritoryRendererId): TerritoryBackend {
+ if (id === "webgpu") {
+ return new WebGPUTerritoryBackend(
+ this.game,
+ this.eventBus,
+ this.transformHandler,
+ this.userSettings,
);
}
- parts.sort();
- return parts.join("|");
+ if (id === "webgl") {
+ return new WebGLTerritoryBackend(
+ this.game,
+ this.eventBus,
+ this.transformHandler,
+ );
+ }
+ return new ClassicTerritoryBackend(
+ this.game,
+ this.eventBus,
+ this.transformHandler,
+ );
}
- private refreshDefensePostsIfNeeded() {
- if (!this.territoryRenderer) {
- return;
- }
- const signature = this.computeDefensePostsSignature();
- if (signature !== this.lastDefensePostsSignature) {
- this.lastDefensePostsSignature = signature;
- this.territoryRenderer.markDefensePostsDirty();
- }
+ private fillBackground(context: CanvasRenderingContext2D) {
+ context.save();
+ context.setTransform(1, 0, 0, 1, 0, 0);
+ context.fillStyle = this.game.config().theme().backgroundColor().toHex();
+ context.fillRect(0, 0, context.canvas.width, context.canvas.height);
+ context.restore();
}
}
diff --git a/src/client/graphics/layers/TerritoryWebGLRenderer.ts b/src/client/graphics/layers/TerritoryWebGLRenderer.ts
new file mode 100644
index 000000000..f506f45aa
--- /dev/null
+++ b/src/client/graphics/layers/TerritoryWebGLRenderer.ts
@@ -0,0 +1,3870 @@
+import { base64url } from "jose";
+import { DefaultPattern } from "../../../core/CosmeticSchemas";
+import { Theme } from "../../../core/configuration/Config";
+import { TileRef } from "../../../core/game/GameMap";
+import { GameView, PlayerView } from "../../../core/game/GameView";
+import { UserSettings } from "../../../core/game/UserSettings";
+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;
+}
+
+const PATTERN_STRIDE_BYTES = 1052;
+
+// WebGL2 territory renderer that shades tiles from packed tile state
+// (Uint16Array) using palette, relation, and pattern textures.
+export class TerritoryWebGLRenderer {
+ public readonly canvas: HTMLCanvasElement;
+
+ private contestEnabled = false;
+ private contestPatternMode: 0 | 1 | 2 = 0; // 0=blueNoise(strength), 1=checkerboard(50/50), 2=bayer4x4(strength)
+ private debugDisableStaticBorders = false;
+ private debugDisableAllBorders = false;
+ private seedSamplingMode: 0 | 1 | 2 = 1; // 0=none(single texel), 1=2x2, 2=3x3
+ private debugStripeFixedColors = false; // Use fixed debug colors for moving stripe
+ private motionMode: 0 | 1 | 2 | 3 = 0; // 0=euclidean, 1=axisSnap, 2=manhattan, 3=chebyshev
+
+ private readonly gl: WebGL2RenderingContext | null;
+ private readonly program: WebGLProgram | null;
+ private readonly vao: WebGLVertexArrayObject | null;
+ private readonly vertexBuffer: WebGLBuffer | null;
+ private readonly jfaVao: WebGLVertexArrayObject | null;
+ private readonly jfaVertexBuffer: WebGLBuffer | null;
+ private readonly stateTexture: WebGLTexture | null;
+ private readonly terrainTexture: WebGLTexture | null;
+ private readonly paletteTexture: WebGLTexture | null;
+ private readonly relationTexture: WebGLTexture | null;
+ private readonly patternTexture: WebGLTexture | null;
+ private readonly contestOwnersTexture: WebGLTexture | null;
+ private readonly contestIdsTexture: WebGLTexture | null;
+ private readonly contestTimesTexture: WebGLTexture | null;
+ private readonly contestStrengthsTexture: WebGLTexture | null;
+ private readonly prevOwnerTexture: WebGLTexture | null;
+ private readonly olderOwnerTexture: WebGLTexture | null;
+ private readonly stateFramebuffer: WebGLFramebuffer | null;
+ private readonly prevStateFramebuffer: WebGLFramebuffer | null;
+ private readonly olderStateFramebuffer: WebGLFramebuffer | null;
+ private readonly jfaTextureA: WebGLTexture | null;
+ private readonly jfaTextureB: WebGLTexture | null;
+ private readonly jfaFramebufferA: WebGLFramebuffer | null;
+ private readonly jfaFramebufferB: WebGLFramebuffer | null;
+ private readonly jfaResultOlderTexture: WebGLTexture | null;
+ private readonly jfaResultOldTexture: WebGLTexture | null;
+ private readonly jfaResultNewTexture: WebGLTexture | null;
+ private readonly jfaResultOlderFramebuffer: WebGLFramebuffer | null;
+ private readonly jfaResultOldFramebuffer: WebGLFramebuffer | null;
+ private readonly jfaResultNewFramebuffer: WebGLFramebuffer | null;
+ private readonly jfaSeedProgram: WebGLProgram | null;
+ private readonly jfaProgram: WebGLProgram | null;
+ private readonly changeMaskProgram: WebGLProgram | null;
+ private readonly changeMaskTextureOlder: WebGLTexture | null;
+ private readonly changeMaskTextureOld: WebGLTexture | null;
+ private readonly changeMaskTextureNew: WebGLTexture | null;
+ private readonly changeMaskFramebufferOlder: WebGLFramebuffer | null;
+ private readonly changeMaskFramebufferOld: WebGLFramebuffer | null;
+ private readonly changeMaskFramebufferNew: WebGLFramebuffer | null;
+ private readonly jfaSeedUniforms: {
+ resolution: WebGLUniformLocation | null;
+ owner: WebGLUniformLocation | null;
+ };
+ private readonly jfaUniforms: {
+ resolution: WebGLUniformLocation | null;
+ step: WebGLUniformLocation | null;
+ seeds: WebGLUniformLocation | null;
+ };
+ private readonly changeMaskUniforms: {
+ resolution: WebGLUniformLocation | null;
+ oldTexture: WebGLUniformLocation | null;
+ newTexture: WebGLUniformLocation | null;
+ };
+ private readonly uniforms: {
+ mapResolution: WebGLUniformLocation | null;
+ viewResolution: WebGLUniformLocation | null;
+ viewScale: WebGLUniformLocation | null;
+ viewOffset: WebGLUniformLocation | null;
+ state: WebGLUniformLocation | null;
+ terrain: WebGLUniformLocation | null;
+ latestState: WebGLUniformLocation | null;
+ palette: WebGLUniformLocation | null;
+ relations: WebGLUniformLocation | null;
+ patterns: WebGLUniformLocation | null;
+ contestEnabled: WebGLUniformLocation | null;
+ contestPatternMode: WebGLUniformLocation | null;
+ debugDisableStaticBorders: WebGLUniformLocation | null;
+ debugDisableAllBorders: WebGLUniformLocation | null;
+ seedSamplingMode: WebGLUniformLocation | null;
+ debugStripeFixedColors: WebGLUniformLocation | null;
+ motionMode: WebGLUniformLocation | null;
+ contestOwners: WebGLUniformLocation | null;
+ contestIds: WebGLUniformLocation | null;
+ contestTimes: WebGLUniformLocation | null;
+ contestStrengths: WebGLUniformLocation | null;
+ jfaAvailable: WebGLUniformLocation | null;
+ contestNow: WebGLUniformLocation | null;
+ contestDuration: WebGLUniformLocation | null;
+ prevOwner: WebGLUniformLocation | null;
+ jfaSeedsOld: WebGLUniformLocation | null;
+ jfaSeedsNew: WebGLUniformLocation | null;
+ smoothProgress: WebGLUniformLocation | null;
+ changeMask: WebGLUniformLocation | null;
+ smoothEnabled: WebGLUniformLocation | null;
+ patternStride: WebGLUniformLocation | null;
+ patternRows: 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;
+ viewerId: WebGLUniformLocation | null;
+ darkMode: WebGLUniformLocation | null;
+ };
+
+ private readonly mapWidth: number;
+ private readonly mapHeight: number;
+ private viewWidth: number;
+ private viewHeight: number;
+ private viewScale = 1;
+ private viewOffsetX = 0;
+ private viewOffsetY = 0;
+
+ private readonly state: Uint16Array;
+ private contestOwnersState: Uint16Array;
+ private contestIdsState: Uint16Array;
+ private contestTimesState: Uint16Array;
+ private contestStrengthsState: Uint16Array;
+ private readonly dirtyRows: Map = new Map();
+ private readonly contestDirtyRows: Map = new Map();
+ private needsFullUpload = true;
+ private needsContestFullUpload = true;
+ private needsContestTimesUpload = true;
+ private needsContestStrengthsUpload = true;
+ private alternativeView = false;
+ private paletteWidth = 0;
+ // Defaults are overridden by setHoverHighlightOptions() from TerritoryLayer.
+ private hoverHighlightStrength = 0.3;
+ // Defaults are overridden by setHoverHighlightOptions() from TerritoryLayer.
+ private hoverHighlightColor: [number, number, number] = [1, 1, 1];
+ // Defaults are overridden by setHoverHighlightOptions() from TerritoryLayer.
+ private hoverPulseStrength = 0.25;
+ // Defaults are overridden by setHoverHighlightOptions() from TerritoryLayer.
+ private hoverPulseSpeed = Math.PI * 2;
+ private hoveredPlayerId = -1;
+ private hoverStartTime = 0;
+ private static readonly HOVER_DURATION_MS = 5000;
+ private animationStartTime = Date.now();
+ private contestNow = 0;
+ private contestDurationTicks = 0;
+ private smoothProgress = 1;
+ private smoothEnabled = true;
+ private jfaSupported = false;
+ private jfaDisabledReason: string | null = null;
+ private jfaDirty = false;
+ private jfaHistoryInitialized = false;
+ private changeMaskDirty = false;
+ private changeMaskHistoryInitialized = false;
+ private prevStateCopySupported = false;
+ private jfaSteps: number[] = [];
+ private interpolationPair: "prevCurrent" | "olderPrev" = "prevCurrent";
+ private readonly userSettings = new UserSettings();
+ private readonly patternBytesCache = new Map();
+
+ private constructor(
+ private readonly game: GameView,
+ private readonly theme: Theme,
+ state: Uint16Array,
+ ) {
+ this.canvas = document.createElement("canvas");
+ this.mapWidth = game.width();
+ this.mapHeight = game.height();
+ this.viewWidth = this.mapWidth;
+ this.viewHeight = this.mapHeight;
+ this.canvas.width = this.viewWidth;
+ this.canvas.height = this.viewHeight;
+
+ this.state = state;
+ this.contestOwnersState = new Uint16Array(state.length * 2);
+ this.contestIdsState = new Uint16Array(state.length);
+ this.contestTimesState = new Uint16Array(1);
+ this.contestStrengthsState = new Uint16Array(1);
+
+ 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.jfaVao = null;
+ this.jfaVertexBuffer = null;
+ this.stateTexture = null;
+ this.terrainTexture = null;
+ this.paletteTexture = null;
+ this.relationTexture = null;
+ this.patternTexture = null;
+ this.contestOwnersTexture = null;
+ this.contestIdsTexture = null;
+ this.contestTimesTexture = null;
+ this.contestStrengthsTexture = null;
+ this.prevOwnerTexture = null;
+ this.olderOwnerTexture = null;
+ this.stateFramebuffer = null;
+ this.prevStateFramebuffer = null;
+ this.olderStateFramebuffer = null;
+ this.jfaTextureA = null;
+ this.jfaTextureB = null;
+ this.jfaFramebufferA = null;
+ this.jfaFramebufferB = null;
+ this.jfaResultOlderTexture = null;
+ this.jfaResultOldTexture = null;
+ this.jfaResultNewTexture = null;
+ this.jfaResultOlderFramebuffer = null;
+ this.jfaResultOldFramebuffer = null;
+ this.jfaResultNewFramebuffer = null;
+ this.jfaSeedProgram = null;
+ this.jfaProgram = null;
+ this.changeMaskProgram = null;
+ this.changeMaskTextureOlder = null;
+ this.changeMaskTextureOld = null;
+ this.changeMaskTextureNew = null;
+ this.changeMaskFramebufferOlder = null;
+ this.changeMaskFramebufferOld = null;
+ this.changeMaskFramebufferNew = null;
+ this.jfaSeedUniforms = { resolution: null, owner: null };
+ this.jfaUniforms = { resolution: null, step: null, seeds: null };
+ this.changeMaskUniforms = {
+ resolution: null,
+ oldTexture: null,
+ newTexture: null,
+ };
+ this.uniforms = {
+ mapResolution: null,
+ viewResolution: null,
+ viewScale: null,
+ viewOffset: null,
+ state: null,
+ terrain: null,
+ latestState: null,
+ palette: null,
+ relations: null,
+ patterns: null,
+ contestEnabled: null,
+ contestPatternMode: null,
+ debugDisableStaticBorders: null,
+ debugDisableAllBorders: null,
+ seedSamplingMode: null,
+ debugStripeFixedColors: null,
+ motionMode: null,
+ contestOwners: null,
+ contestIds: null,
+ contestTimes: null,
+ contestStrengths: null,
+ jfaAvailable: null,
+ contestNow: null,
+ contestDuration: null,
+ prevOwner: null,
+ jfaSeedsOld: null,
+ jfaSeedsNew: null,
+ smoothProgress: null,
+ changeMask: null,
+ smoothEnabled: null,
+ patternStride: null,
+ patternRows: 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,
+ viewerId: null,
+ darkMode: null,
+ };
+ return;
+ }
+
+ const gl = this.gl;
+ this.program = this.createProgram(gl);
+ if (!this.program) {
+ this.vao = null;
+ this.vertexBuffer = null;
+ this.jfaVao = null;
+ this.jfaVertexBuffer = null;
+ this.stateTexture = null;
+ this.terrainTexture = null;
+ this.paletteTexture = null;
+ this.relationTexture = null;
+ this.patternTexture = null;
+ this.contestOwnersTexture = null;
+ this.contestIdsTexture = null;
+ this.contestTimesTexture = null;
+ this.contestStrengthsTexture = null;
+ this.prevOwnerTexture = null;
+ this.olderOwnerTexture = null;
+ this.stateFramebuffer = null;
+ this.prevStateFramebuffer = null;
+ this.olderStateFramebuffer = null;
+ this.jfaTextureA = null;
+ this.jfaTextureB = null;
+ this.jfaFramebufferA = null;
+ this.jfaFramebufferB = null;
+ this.jfaResultOlderTexture = null;
+ this.jfaResultOldTexture = null;
+ this.jfaResultNewTexture = null;
+ this.jfaResultOlderFramebuffer = null;
+ this.jfaResultOldFramebuffer = null;
+ this.jfaResultNewFramebuffer = null;
+ this.jfaSeedProgram = null;
+ this.jfaProgram = null;
+ this.changeMaskProgram = null;
+ this.changeMaskTextureOlder = null;
+ this.changeMaskTextureOld = null;
+ this.changeMaskTextureNew = null;
+ this.changeMaskFramebufferOlder = null;
+ this.changeMaskFramebufferOld = null;
+ this.changeMaskFramebufferNew = null;
+ this.jfaSeedUniforms = { resolution: null, owner: null };
+ this.jfaUniforms = { resolution: null, step: null, seeds: null };
+ this.changeMaskUniforms = {
+ resolution: null,
+ oldTexture: null,
+ newTexture: null,
+ };
+ this.uniforms = {
+ mapResolution: null,
+ viewResolution: null,
+ viewScale: null,
+ viewOffset: null,
+ state: null,
+ terrain: null,
+ latestState: null,
+ palette: null,
+ relations: null,
+ patterns: null,
+ contestEnabled: null,
+ contestPatternMode: null,
+ debugDisableStaticBorders: null,
+ debugDisableAllBorders: null,
+ seedSamplingMode: null,
+ debugStripeFixedColors: null,
+ motionMode: null,
+ contestOwners: null,
+ contestIds: null,
+ contestTimes: null,
+ contestStrengths: null,
+ jfaAvailable: null,
+ contestNow: null,
+ contestDuration: null,
+ prevOwner: null,
+ jfaSeedsOld: null,
+ jfaSeedsNew: null,
+ smoothProgress: null,
+ changeMask: null,
+ smoothEnabled: null,
+ patternStride: null,
+ patternRows: 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,
+ viewerId: null,
+ darkMode: null,
+ };
+ return;
+ }
+
+ this.jfaSupported = !!gl.getExtension("EXT_color_buffer_float");
+ if (!this.jfaSupported) {
+ this.jfaDisabledReason = "EXT_color_buffer_float unavailable";
+ }
+ this.jfaSeedProgram = this.jfaSupported
+ ? this.createJfaSeedProgram(gl)
+ : null;
+ this.jfaProgram = this.jfaSupported ? this.createJfaProgram(gl) : null;
+ this.changeMaskProgram = this.jfaSupported
+ ? this.createChangeMaskProgram(gl)
+ : null;
+ if (!this.jfaSeedProgram || !this.jfaProgram) {
+ this.jfaSupported = false;
+ this.jfaDisabledReason ??= "JFA shaders unavailable";
+ }
+ this.jfaSeedUniforms = this.jfaSeedProgram
+ ? {
+ resolution: gl.getUniformLocation(
+ this.jfaSeedProgram,
+ "u_resolution",
+ ),
+ owner: gl.getUniformLocation(this.jfaSeedProgram, "u_ownerTexture"),
+ }
+ : { resolution: null, owner: null };
+ this.jfaUniforms = this.jfaProgram
+ ? {
+ resolution: gl.getUniformLocation(this.jfaProgram, "u_resolution"),
+ step: gl.getUniformLocation(this.jfaProgram, "u_step"),
+ seeds: gl.getUniformLocation(this.jfaProgram, "u_seeds"),
+ }
+ : { resolution: null, step: null, seeds: null };
+ this.changeMaskUniforms = this.changeMaskProgram
+ ? {
+ resolution: gl.getUniformLocation(
+ this.changeMaskProgram,
+ "u_resolution",
+ ),
+ oldTexture: gl.getUniformLocation(
+ this.changeMaskProgram,
+ "u_oldTexture",
+ ),
+ newTexture: gl.getUniformLocation(
+ this.changeMaskProgram,
+ "u_newTexture",
+ ),
+ }
+ : { resolution: null, oldTexture: null, newTexture: null };
+
+ this.uniforms = {
+ mapResolution: gl.getUniformLocation(this.program, "u_mapResolution"),
+ viewResolution: gl.getUniformLocation(this.program, "u_viewResolution"),
+ viewScale: gl.getUniformLocation(this.program, "u_viewScale"),
+ viewOffset: gl.getUniformLocation(this.program, "u_viewOffset"),
+ state: gl.getUniformLocation(this.program, "u_state"),
+ terrain: gl.getUniformLocation(this.program, "u_terrain"),
+ latestState: gl.getUniformLocation(this.program, "u_latestState"),
+ palette: gl.getUniformLocation(this.program, "u_palette"),
+ relations: gl.getUniformLocation(this.program, "u_relations"),
+ patterns: gl.getUniformLocation(this.program, "u_patterns"),
+ contestEnabled: gl.getUniformLocation(this.program, "u_contestEnabled"),
+ contestPatternMode: gl.getUniformLocation(
+ this.program,
+ "u_contestPatternMode",
+ ),
+ debugDisableStaticBorders: gl.getUniformLocation(
+ this.program,
+ "u_debugDisableStaticBorders",
+ ),
+ debugDisableAllBorders: gl.getUniformLocation(
+ this.program,
+ "u_debugDisableAllBorders",
+ ),
+ seedSamplingMode: gl.getUniformLocation(
+ this.program,
+ "u_seedSamplingMode",
+ ),
+ debugStripeFixedColors: gl.getUniformLocation(
+ this.program,
+ "u_debugStripeFixedColors",
+ ),
+ motionMode: gl.getUniformLocation(this.program, "u_motionMode"),
+ contestOwners: gl.getUniformLocation(this.program, "u_contestOwners"),
+ contestIds: gl.getUniformLocation(this.program, "u_contestIds"),
+ contestTimes: gl.getUniformLocation(this.program, "u_contestTimes"),
+ contestStrengths: gl.getUniformLocation(
+ this.program,
+ "u_contestStrengths",
+ ),
+ jfaAvailable: gl.getUniformLocation(this.program, "u_jfaAvailable"),
+ contestNow: gl.getUniformLocation(this.program, "u_contestNow"),
+ contestDuration: gl.getUniformLocation(
+ this.program,
+ "u_contestDurationTicks",
+ ),
+ prevOwner: gl.getUniformLocation(this.program, "u_prevOwner"),
+ jfaSeedsOld: gl.getUniformLocation(this.program, "u_jfaSeedsOld"),
+ jfaSeedsNew: gl.getUniformLocation(this.program, "u_jfaSeedsNew"),
+ smoothProgress: gl.getUniformLocation(this.program, "u_smoothProgress"),
+ changeMask: gl.getUniformLocation(this.program, "u_changeMask"),
+ smoothEnabled: gl.getUniformLocation(this.program, "u_smoothEnabled"),
+ patternStride: gl.getUniformLocation(this.program, "u_patternStride"),
+ patternRows: gl.getUniformLocation(this.program, "u_patternRows"),
+ 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"),
+ viewerId: gl.getUniformLocation(this.program, "u_viewerId"),
+ darkMode: gl.getUniformLocation(this.program, "u_darkMode"),
+ };
+
+ // Vertex data: two triangles covering the full view (pixel-perfect).
+ const vertices = new Float32Array([
+ 0,
+ 0,
+ this.viewWidth,
+ 0,
+ 0,
+ this.viewHeight,
+ 0,
+ this.viewHeight,
+ this.viewWidth,
+ 0,
+ this.viewWidth,
+ this.viewHeight,
+ ]);
+
+ 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);
+
+ const mapVertices = new Float32Array([
+ 0,
+ 0,
+ this.mapWidth,
+ 0,
+ 0,
+ this.mapHeight,
+ 0,
+ this.mapHeight,
+ this.mapWidth,
+ 0,
+ this.mapWidth,
+ this.mapHeight,
+ ]);
+ this.jfaVao = gl.createVertexArray();
+ this.jfaVertexBuffer = gl.createBuffer();
+ gl.bindVertexArray(this.jfaVao);
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.jfaVertexBuffer);
+ gl.bufferData(gl.ARRAY_BUFFER, mapVertices, gl.STATIC_DRAW);
+ gl.enableVertexAttribArray(posLoc);
+ gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 2 * 4, 0);
+ gl.bindVertexArray(null);
+
+ this.stateTexture = gl.createTexture();
+ this.terrainTexture = gl.createTexture();
+ this.paletteTexture = gl.createTexture();
+ this.relationTexture = gl.createTexture();
+ this.patternTexture = gl.createTexture();
+ this.contestOwnersTexture = gl.createTexture();
+ this.contestIdsTexture = gl.createTexture();
+ this.contestTimesTexture = gl.createTexture();
+ this.contestStrengthsTexture = gl.createTexture();
+ this.prevOwnerTexture = gl.createTexture();
+ this.olderOwnerTexture = gl.createTexture();
+ this.stateFramebuffer = gl.createFramebuffer();
+ this.prevStateFramebuffer = gl.createFramebuffer();
+ this.olderStateFramebuffer = gl.createFramebuffer();
+ this.jfaTextureA = this.jfaSupported ? gl.createTexture() : null;
+ this.jfaTextureB = this.jfaSupported ? gl.createTexture() : null;
+ this.jfaFramebufferA = this.jfaSupported ? gl.createFramebuffer() : null;
+ this.jfaFramebufferB = this.jfaSupported ? gl.createFramebuffer() : null;
+ this.jfaResultOlderTexture = this.jfaSupported ? gl.createTexture() : null;
+ this.jfaResultOldTexture = this.jfaSupported ? gl.createTexture() : null;
+ this.jfaResultNewTexture = this.jfaSupported ? gl.createTexture() : null;
+ this.jfaResultOlderFramebuffer = this.jfaSupported
+ ? gl.createFramebuffer()
+ : null;
+ this.jfaResultOldFramebuffer = this.jfaSupported
+ ? gl.createFramebuffer()
+ : null;
+ this.jfaResultNewFramebuffer = this.jfaSupported
+ ? gl.createFramebuffer()
+ : null;
+ this.changeMaskTextureOlder = this.jfaSupported ? gl.createTexture() : null;
+ this.changeMaskTextureOld = this.jfaSupported ? gl.createTexture() : null;
+ this.changeMaskTextureNew = this.jfaSupported ? gl.createTexture() : null;
+ this.changeMaskFramebufferOlder = this.jfaSupported
+ ? gl.createFramebuffer()
+ : null;
+ this.changeMaskFramebufferOld = this.jfaSupported
+ ? gl.createFramebuffer()
+ : null;
+ this.changeMaskFramebufferNew = this.jfaSupported
+ ? gl.createFramebuffer()
+ : null;
+
+ 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.mapWidth,
+ this.mapHeight,
+ 0,
+ gl.RED_INTEGER,
+ gl.UNSIGNED_SHORT,
+ this.state,
+ );
+
+ // Terrain texture (immutable, only uploaded once)
+ gl.activeTexture(gl.TEXTURE14);
+ gl.bindTexture(gl.TEXTURE_2D, this.terrainTexture);
+ 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.mapWidth,
+ this.mapHeight,
+ 0,
+ gl.RED_INTEGER,
+ gl.UNSIGNED_BYTE,
+ game.terrainDataView(),
+ );
+
+ this.uploadPalette();
+
+ gl.activeTexture(gl.TEXTURE4);
+ 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);
+ 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.RG16UI,
+ this.mapWidth,
+ this.mapHeight,
+ 0,
+ gl.RG_INTEGER,
+ gl.UNSIGNED_SHORT,
+ 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.mapWidth,
+ this.mapHeight,
+ 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.activeTexture(gl.TEXTURE11);
+ gl.bindTexture(gl.TEXTURE_2D, this.contestStrengthsTexture);
+ 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.contestStrengthsState.length,
+ 1,
+ 0,
+ gl.RED_INTEGER,
+ gl.UNSIGNED_SHORT,
+ this.contestStrengthsState,
+ );
+
+ gl.activeTexture(gl.TEXTURE7);
+ gl.bindTexture(gl.TEXTURE_2D, this.prevOwnerTexture);
+ 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.mapWidth,
+ this.mapHeight,
+ 0,
+ gl.RED_INTEGER,
+ gl.UNSIGNED_SHORT,
+ this.state,
+ );
+
+ gl.activeTexture(gl.TEXTURE13);
+ gl.bindTexture(gl.TEXTURE_2D, this.olderOwnerTexture);
+ 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.mapWidth,
+ this.mapHeight,
+ 0,
+ gl.RED_INTEGER,
+ gl.UNSIGNED_SHORT,
+ this.state,
+ );
+
+ if (
+ this.stateFramebuffer &&
+ this.prevStateFramebuffer &&
+ this.olderStateFramebuffer &&
+ this.stateTexture &&
+ this.prevOwnerTexture &&
+ this.olderOwnerTexture
+ ) {
+ gl.bindFramebuffer(gl.FRAMEBUFFER, this.stateFramebuffer);
+ gl.framebufferTexture2D(
+ gl.FRAMEBUFFER,
+ gl.COLOR_ATTACHMENT0,
+ gl.TEXTURE_2D,
+ this.stateTexture,
+ 0,
+ );
+ const stateStatus = gl.checkFramebufferStatus(gl.FRAMEBUFFER);
+ gl.bindFramebuffer(gl.FRAMEBUFFER, this.prevStateFramebuffer);
+ gl.framebufferTexture2D(
+ gl.FRAMEBUFFER,
+ gl.COLOR_ATTACHMENT0,
+ gl.TEXTURE_2D,
+ this.prevOwnerTexture,
+ 0,
+ );
+ const prevStatus = gl.checkFramebufferStatus(gl.FRAMEBUFFER);
+ gl.bindFramebuffer(gl.FRAMEBUFFER, this.olderStateFramebuffer);
+ gl.framebufferTexture2D(
+ gl.FRAMEBUFFER,
+ gl.COLOR_ATTACHMENT0,
+ gl.TEXTURE_2D,
+ this.olderOwnerTexture,
+ 0,
+ );
+ const olderStatus = gl.checkFramebufferStatus(gl.FRAMEBUFFER);
+ this.prevStateCopySupported =
+ stateStatus === gl.FRAMEBUFFER_COMPLETE &&
+ prevStatus === gl.FRAMEBUFFER_COMPLETE &&
+ olderStatus === gl.FRAMEBUFFER_COMPLETE;
+ gl.bindFramebuffer(gl.FRAMEBUFFER, null);
+ }
+
+ if (
+ this.jfaSupported &&
+ this.jfaTextureA &&
+ this.jfaTextureB &&
+ this.jfaFramebufferA &&
+ this.jfaFramebufferB &&
+ this.jfaResultOlderTexture &&
+ this.jfaResultOldTexture &&
+ this.jfaResultNewTexture &&
+ this.jfaResultOlderFramebuffer &&
+ this.jfaResultOldFramebuffer &&
+ this.jfaResultNewFramebuffer
+ ) {
+ gl.activeTexture(gl.TEXTURE9);
+ gl.bindTexture(gl.TEXTURE_2D, this.jfaTextureA);
+ 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.texImage2D(
+ gl.TEXTURE_2D,
+ 0,
+ gl.RG16F,
+ this.mapWidth,
+ this.mapHeight,
+ 0,
+ gl.RG,
+ gl.HALF_FLOAT,
+ null,
+ );
+
+ gl.activeTexture(gl.TEXTURE10);
+ gl.bindTexture(gl.TEXTURE_2D, this.jfaTextureB);
+ 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.texImage2D(
+ gl.TEXTURE_2D,
+ 0,
+ gl.RG16F,
+ this.mapWidth,
+ this.mapHeight,
+ 0,
+ gl.RG,
+ gl.HALF_FLOAT,
+ null,
+ );
+
+ gl.bindFramebuffer(gl.FRAMEBUFFER, this.jfaFramebufferA);
+ gl.framebufferTexture2D(
+ gl.FRAMEBUFFER,
+ gl.COLOR_ATTACHMENT0,
+ gl.TEXTURE_2D,
+ this.jfaTextureA,
+ 0,
+ );
+ gl.bindFramebuffer(gl.FRAMEBUFFER, this.jfaFramebufferB);
+ gl.framebufferTexture2D(
+ gl.FRAMEBUFFER,
+ gl.COLOR_ATTACHMENT0,
+ gl.TEXTURE_2D,
+ this.jfaTextureB,
+ 0,
+ );
+
+ gl.activeTexture(gl.TEXTURE12);
+ gl.bindTexture(gl.TEXTURE_2D, this.jfaResultOlderTexture);
+ 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.texImage2D(
+ gl.TEXTURE_2D,
+ 0,
+ gl.RG16F,
+ this.mapWidth,
+ this.mapHeight,
+ 0,
+ gl.RG,
+ gl.HALF_FLOAT,
+ null,
+ );
+
+ gl.activeTexture(gl.TEXTURE10);
+ gl.bindTexture(gl.TEXTURE_2D, this.jfaResultOldTexture);
+ 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.texImage2D(
+ gl.TEXTURE_2D,
+ 0,
+ gl.RG16F,
+ this.mapWidth,
+ this.mapHeight,
+ 0,
+ gl.RG,
+ gl.HALF_FLOAT,
+ null,
+ );
+
+ gl.activeTexture(gl.TEXTURE11);
+ gl.bindTexture(gl.TEXTURE_2D, this.jfaResultNewTexture);
+ 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.texImage2D(
+ gl.TEXTURE_2D,
+ 0,
+ gl.RG16F,
+ this.mapWidth,
+ this.mapHeight,
+ 0,
+ gl.RG,
+ gl.HALF_FLOAT,
+ null,
+ );
+
+ gl.bindFramebuffer(gl.FRAMEBUFFER, this.jfaResultOlderFramebuffer);
+ gl.framebufferTexture2D(
+ gl.FRAMEBUFFER,
+ gl.COLOR_ATTACHMENT0,
+ gl.TEXTURE_2D,
+ this.jfaResultOlderTexture,
+ 0,
+ );
+ gl.bindFramebuffer(gl.FRAMEBUFFER, this.jfaResultOldFramebuffer);
+ gl.framebufferTexture2D(
+ gl.FRAMEBUFFER,
+ gl.COLOR_ATTACHMENT0,
+ gl.TEXTURE_2D,
+ this.jfaResultOldTexture,
+ 0,
+ );
+ gl.bindFramebuffer(gl.FRAMEBUFFER, this.jfaResultNewFramebuffer);
+ gl.framebufferTexture2D(
+ gl.FRAMEBUFFER,
+ gl.COLOR_ATTACHMENT0,
+ gl.TEXTURE_2D,
+ this.jfaResultNewTexture,
+ 0,
+ );
+ gl.bindFramebuffer(gl.FRAMEBUFFER, null);
+
+ this.jfaSteps = this.buildJfaSteps(this.mapWidth, this.mapHeight);
+ this.jfaDirty = true;
+ }
+
+ if (
+ this.jfaSupported &&
+ this.changeMaskTextureOlder &&
+ this.changeMaskTextureOld &&
+ this.changeMaskTextureNew &&
+ this.changeMaskFramebufferOlder &&
+ this.changeMaskFramebufferOld &&
+ this.changeMaskFramebufferNew
+ ) {
+ const initMaskTex = (tex: WebGLTexture) => {
+ gl.bindTexture(gl.TEXTURE_2D, tex);
+ 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.texImage2D(
+ gl.TEXTURE_2D,
+ 0,
+ gl.R8UI,
+ this.mapWidth,
+ this.mapHeight,
+ 0,
+ gl.RED_INTEGER,
+ gl.UNSIGNED_BYTE,
+ null,
+ );
+ };
+
+ gl.activeTexture(gl.TEXTURE13);
+ initMaskTex(this.changeMaskTextureOlder);
+ initMaskTex(this.changeMaskTextureOld);
+ initMaskTex(this.changeMaskTextureNew);
+
+ gl.bindFramebuffer(gl.FRAMEBUFFER, this.changeMaskFramebufferOlder);
+ gl.framebufferTexture2D(
+ gl.FRAMEBUFFER,
+ gl.COLOR_ATTACHMENT0,
+ gl.TEXTURE_2D,
+ this.changeMaskTextureOlder,
+ 0,
+ );
+ gl.clearBufferuiv(gl.COLOR, 0, new Uint32Array([0, 0, 0, 0]));
+ gl.bindFramebuffer(gl.FRAMEBUFFER, this.changeMaskFramebufferOld);
+ gl.framebufferTexture2D(
+ gl.FRAMEBUFFER,
+ gl.COLOR_ATTACHMENT0,
+ gl.TEXTURE_2D,
+ this.changeMaskTextureOld,
+ 0,
+ );
+ gl.clearBufferuiv(gl.COLOR, 0, new Uint32Array([0, 0, 0, 0]));
+ gl.bindFramebuffer(gl.FRAMEBUFFER, this.changeMaskFramebufferNew);
+ gl.framebufferTexture2D(
+ gl.FRAMEBUFFER,
+ gl.COLOR_ATTACHMENT0,
+ gl.TEXTURE_2D,
+ this.changeMaskTextureNew,
+ 0,
+ );
+ gl.clearBufferuiv(gl.COLOR, 0, new Uint32Array([0, 0, 0, 0]));
+ gl.bindFramebuffer(gl.FRAMEBUFFER, null);
+
+ this.changeMaskDirty = true;
+ }
+
+ gl.useProgram(this.program);
+ gl.uniform1i(this.uniforms.state, 0);
+ if (this.uniforms.terrain) {
+ gl.uniform1i(this.uniforms.terrain, 14);
+ }
+ if (this.uniforms.latestState) {
+ gl.uniform1i(this.uniforms.latestState, 12);
+ }
+ gl.uniform1i(this.uniforms.palette, 1);
+ gl.uniform1i(this.uniforms.relations, 2);
+ gl.uniform1i(this.uniforms.patterns, 3);
+ gl.uniform1i(this.uniforms.contestOwners, 4);
+ gl.uniform1i(this.uniforms.contestIds, 5);
+ gl.uniform1i(this.uniforms.contestTimes, 6);
+ gl.uniform1i(this.uniforms.contestStrengths, 11);
+ gl.uniform1i(this.uniforms.prevOwner, 7);
+ gl.uniform1i(this.uniforms.jfaSeedsOld, 8);
+ gl.uniform1i(this.uniforms.jfaSeedsNew, 9);
+ if (this.uniforms.changeMask) {
+ gl.uniform1i(this.uniforms.changeMask, 13);
+ }
+
+ if (this.uniforms.mapResolution) {
+ gl.uniform2f(this.uniforms.mapResolution, this.mapWidth, this.mapHeight);
+ }
+ if (this.uniforms.viewResolution) {
+ gl.uniform2f(
+ this.uniforms.viewResolution,
+ this.viewWidth,
+ this.viewHeight,
+ );
+ }
+ if (this.uniforms.viewScale) {
+ gl.uniform1f(this.uniforms.viewScale, this.viewScale);
+ }
+ if (this.uniforms.viewOffset) {
+ gl.uniform2f(
+ this.uniforms.viewOffset,
+ this.viewOffsetX,
+ this.viewOffsetY,
+ );
+ }
+ 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.viewerId) {
+ const viewerId = this.game.myPlayer()?.smallID() ?? 0;
+ gl.uniform1i(this.uniforms.viewerId, viewerId);
+ }
+ if (this.uniforms.viewResolution) {
+ gl.uniform2f(
+ this.uniforms.viewResolution,
+ this.viewWidth,
+ this.viewHeight,
+ );
+ }
+ if (this.uniforms.viewScale) {
+ gl.uniform1f(this.uniforms.viewScale, this.viewScale);
+ }
+ if (this.uniforms.viewOffset) {
+ gl.uniform2f(
+ this.uniforms.viewOffset,
+ this.viewOffsetX,
+ this.viewOffsetY,
+ );
+ }
+ 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);
+ }
+ if (this.uniforms.jfaAvailable) {
+ gl.uniform1i(this.uniforms.jfaAvailable, this.jfaSupported ? 1 : 0);
+ }
+ if (this.uniforms.contestNow) {
+ gl.uniform1i(this.uniforms.contestNow, this.contestNow);
+ }
+ if (this.uniforms.contestDuration) {
+ gl.uniform1f(this.uniforms.contestDuration, this.contestDurationTicks);
+ }
+ if (this.uniforms.smoothProgress) {
+ gl.uniform1f(this.uniforms.smoothProgress, this.smoothProgress);
+ }
+ if (this.uniforms.smoothEnabled) {
+ gl.uniform1i(this.uniforms.smoothEnabled, this.smoothEnabled ? 1 : 0);
+ }
+
+ if (
+ this.jfaSupported &&
+ this.jfaResultOldTexture &&
+ this.jfaResultNewTexture
+ ) {
+ gl.activeTexture(gl.TEXTURE8);
+ gl.bindTexture(gl.TEXTURE_2D, this.jfaResultOldTexture);
+ gl.activeTexture(gl.TEXTURE9);
+ gl.bindTexture(gl.TEXTURE_2D, this.jfaResultNewTexture);
+ }
+
+ gl.enable(gl.BLEND);
+ gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
+ gl.viewport(0, 0, this.viewWidth, this.viewHeight);
+ }
+
+ static create(game: GameView, theme: Theme): TerritoryWebGLCreateResult {
+ const state = game.tileStateView();
+ const expected = game.width() * game.height();
+ if (state.length !== expected) {
+ return {
+ renderer: null,
+ reason: "Tile state buffer size mismatch; WebGL renderer disabled.",
+ };
+ }
+
+ const renderer = new TerritoryWebGLRenderer(game, theme, state);
+ if (!renderer.isValid()) {
+ return {
+ renderer: null,
+ reason: "WebGL2 not available; WebGL renderer disabled.",
+ };
+ }
+ return { renderer };
+ }
+
+ isValid(): boolean {
+ return !!this.gl && !!this.program && !!this.vao;
+ }
+
+ dispose(): void {
+ if (this.gl) {
+ this.gl.getExtension("WEBGL_lose_context")?.loseContext();
+ }
+ this.canvas.remove();
+ }
+
+ setAlternativeView(enabled: boolean) {
+ this.alternativeView = enabled;
+ }
+
+ setViewSize(width: number, height: number) {
+ const nextWidth = Math.max(1, Math.floor(width));
+ const nextHeight = Math.max(1, Math.floor(height));
+ if (nextWidth === this.viewWidth && nextHeight === this.viewHeight) {
+ return;
+ }
+ this.viewWidth = nextWidth;
+ this.viewHeight = nextHeight;
+ this.canvas.width = nextWidth;
+ this.canvas.height = nextHeight;
+ if (!this.gl || !this.vertexBuffer) {
+ return;
+ }
+ const gl = this.gl;
+ const vertices = new Float32Array([
+ 0,
+ 0,
+ this.viewWidth,
+ 0,
+ 0,
+ this.viewHeight,
+ 0,
+ this.viewHeight,
+ this.viewWidth,
+ 0,
+ this.viewWidth,
+ this.viewHeight,
+ ]);
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.vertexBuffer);
+ gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);
+ if (this.program) {
+ gl.useProgram(this.program);
+ if (this.uniforms.viewResolution) {
+ gl.uniform2f(
+ this.uniforms.viewResolution,
+ this.viewWidth,
+ this.viewHeight,
+ );
+ }
+ }
+ }
+
+ setViewTransform(scale: number, offsetX: number, offsetY: number) {
+ this.viewScale = scale;
+ this.viewOffsetX = offsetX;
+ this.viewOffsetY = offsetY;
+ }
+
+ setHoveredPlayerId(playerSmallId: number | null) {
+ const encoded = playerSmallId ?? -1;
+ if (encoded !== this.hoveredPlayerId) {
+ this.hoveredPlayerId = encoded;
+ this.hoverStartTime = encoded >= 0 ? Date.now() : 0;
+ }
+ }
+
+ 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);
+ }
+ }
+
+ setContestEnabled(enabled: boolean) {
+ if (this.contestEnabled === enabled) {
+ return;
+ }
+ this.contestEnabled = enabled;
+ if (this.contestEnabled) {
+ this.needsContestFullUpload = true;
+ this.needsContestTimesUpload = true;
+ this.needsContestStrengthsUpload = true;
+ } else {
+ this.contestDirtyRows.clear();
+ }
+ }
+
+ setContestPatternMode(mode: "blueNoise" | "checkerboard" | "bayer4x4") {
+ if (mode === "checkerboard") this.contestPatternMode = 1;
+ else if (mode === "bayer4x4") this.contestPatternMode = 2;
+ else this.contestPatternMode = 0;
+ }
+
+ setDebugDisableStaticBorders(disabled: boolean) {
+ this.debugDisableStaticBorders = disabled;
+ }
+
+ setDebugDisableAllBorders(disabled: boolean) {
+ this.debugDisableAllBorders = disabled;
+ }
+
+ setSeedSamplingMode(mode: "none" | "2x2" | "3x3") {
+ this.seedSamplingMode = mode === "none" ? 0 : mode === "2x2" ? 1 : 2;
+ }
+
+ setDebugStripeFixedColors(enabled: boolean) {
+ this.debugStripeFixedColors = enabled;
+ }
+
+ setMotionMode(mode: "euclidean" | "axisSnap" | "manhattan" | "chebyshev") {
+ if (mode === "axisSnap") this.motionMode = 1;
+ else if (mode === "manhattan") this.motionMode = 2;
+ else if (mode === "chebyshev") this.motionMode = 3;
+ else this.motionMode = 0;
+ }
+
+ markTile(tile: TileRef) {
+ if (this.needsFullUpload) {
+ return;
+ }
+ const x = tile % this.mapWidth;
+ const y = Math.floor(tile / this.mapWidth);
+ 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);
+ }
+ }
+
+ setContestTile(
+ tile: TileRef,
+ defenderOwner: number,
+ attackerOwner: number,
+ componentId: number,
+ attackerEver: boolean,
+ ) {
+ if (!this.contestEnabled) {
+ return;
+ }
+ const offset = tile * 2;
+ const defenderValue = defenderOwner & 0xffff;
+ const attackerValue = attackerOwner & 0xffff;
+ const idValue = (componentId & 0x7fff) | (attackerEver ? 0x8000 : 0);
+ if (
+ this.contestOwnersState[offset] === defenderValue &&
+ this.contestOwnersState[offset + 1] === attackerValue &&
+ this.contestIdsState[tile] === idValue
+ ) {
+ return;
+ }
+ this.contestOwnersState[offset] = defenderValue;
+ this.contestOwnersState[offset + 1] = attackerValue;
+ this.contestIdsState[tile] = idValue;
+ if (this.needsContestFullUpload) {
+ return;
+ }
+ const x = tile % this.mapWidth;
+ const y = Math.floor(tile / this.mapWidth);
+ const span = this.contestDirtyRows.get(y);
+ if (span === undefined) {
+ this.contestDirtyRows.set(y, { minX: x, maxX: x });
+ } else {
+ span.minX = Math.min(span.minX, x);
+ span.maxX = Math.max(span.maxX, x);
+ }
+ }
+
+ clearContestTile(tile: TileRef) {
+ this.setContestTile(tile, 0, 0, 0, false);
+ }
+
+ setContestTime(componentId: number, nowPacked: number) {
+ if (!this.contestEnabled) {
+ return;
+ }
+ 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;
+ }
+
+ setContestStrength(componentId: number, strength: number) {
+ if (!this.contestEnabled) {
+ return;
+ }
+ if (componentId <= 0) {
+ return;
+ }
+ this.ensureContestStrengthCapacity(componentId);
+ const clamped = Math.max(0, Math.min(1, strength));
+ const packed = Math.round(clamped * 65535) & 0xffff;
+ if (this.contestStrengthsState[componentId] === packed) {
+ return;
+ }
+ this.contestStrengthsState[componentId] = packed;
+ this.needsContestStrengthsUpload = true;
+ }
+
+ ensureContestStrengthCapacity(componentId: number) {
+ if (componentId < this.contestStrengthsState.length) {
+ return;
+ }
+ let nextLength = Math.max(1, this.contestStrengthsState.length);
+ while (nextLength <= componentId) {
+ nextLength *= 2;
+ }
+ const nextState = new Uint16Array(nextLength);
+ nextState.set(this.contestStrengthsState);
+ this.contestStrengthsState = nextState;
+ this.needsContestStrengthsUpload = true;
+ }
+
+ setContestNow(nowPacked: number, durationTicks: number) {
+ if (!this.contestEnabled) {
+ return;
+ }
+ this.contestNow = nowPacked | 0;
+ this.contestDurationTicks = Math.max(0, durationTicks);
+ }
+
+ snapshotStateForSmoothing() {
+ if (
+ !this.gl ||
+ !this.prevStateCopySupported ||
+ !this.stateFramebuffer ||
+ !this.prevStateFramebuffer ||
+ !this.olderStateFramebuffer
+ ) {
+ return;
+ }
+ const gl = this.gl;
+
+ gl.bindFramebuffer(gl.READ_FRAMEBUFFER, this.prevStateFramebuffer);
+ gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, this.olderStateFramebuffer);
+ gl.blitFramebuffer(
+ 0,
+ 0,
+ this.mapWidth,
+ this.mapHeight,
+ 0,
+ 0,
+ this.mapWidth,
+ this.mapHeight,
+ gl.COLOR_BUFFER_BIT,
+ gl.NEAREST,
+ );
+ gl.bindFramebuffer(gl.READ_FRAMEBUFFER, this.stateFramebuffer);
+ gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, this.prevStateFramebuffer);
+ gl.blitFramebuffer(
+ 0,
+ 0,
+ this.mapWidth,
+ this.mapHeight,
+ 0,
+ 0,
+ this.mapWidth,
+ this.mapHeight,
+ gl.COLOR_BUFFER_BIT,
+ gl.NEAREST,
+ );
+ gl.bindFramebuffer(gl.READ_FRAMEBUFFER, null);
+ gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, null);
+
+ if (
+ this.jfaSupported &&
+ this.jfaResultOlderFramebuffer &&
+ this.jfaResultOldFramebuffer &&
+ this.jfaResultNewFramebuffer
+ ) {
+ gl.bindFramebuffer(gl.READ_FRAMEBUFFER, this.jfaResultOldFramebuffer);
+ gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, this.jfaResultOlderFramebuffer);
+ gl.blitFramebuffer(
+ 0,
+ 0,
+ this.mapWidth,
+ this.mapHeight,
+ 0,
+ 0,
+ this.mapWidth,
+ this.mapHeight,
+ gl.COLOR_BUFFER_BIT,
+ gl.NEAREST,
+ );
+ gl.bindFramebuffer(gl.READ_FRAMEBUFFER, this.jfaResultNewFramebuffer);
+ gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, this.jfaResultOldFramebuffer);
+ gl.blitFramebuffer(
+ 0,
+ 0,
+ this.mapWidth,
+ this.mapHeight,
+ 0,
+ 0,
+ this.mapWidth,
+ this.mapHeight,
+ gl.COLOR_BUFFER_BIT,
+ gl.NEAREST,
+ );
+ gl.bindFramebuffer(gl.READ_FRAMEBUFFER, null);
+ gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, null);
+ }
+
+ if (
+ this.jfaSupported &&
+ this.changeMaskFramebufferOlder &&
+ this.changeMaskFramebufferOld &&
+ this.changeMaskFramebufferNew
+ ) {
+ gl.bindFramebuffer(gl.READ_FRAMEBUFFER, this.changeMaskFramebufferOld);
+ gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, this.changeMaskFramebufferOlder);
+ gl.blitFramebuffer(
+ 0,
+ 0,
+ this.mapWidth,
+ this.mapHeight,
+ 0,
+ 0,
+ this.mapWidth,
+ this.mapHeight,
+ gl.COLOR_BUFFER_BIT,
+ gl.NEAREST,
+ );
+ gl.bindFramebuffer(gl.READ_FRAMEBUFFER, this.changeMaskFramebufferNew);
+ gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, this.changeMaskFramebufferOld);
+ gl.blitFramebuffer(
+ 0,
+ 0,
+ this.mapWidth,
+ this.mapHeight,
+ 0,
+ 0,
+ this.mapWidth,
+ this.mapHeight,
+ gl.COLOR_BUFFER_BIT,
+ gl.NEAREST,
+ );
+ gl.bindFramebuffer(gl.READ_FRAMEBUFFER, null);
+ gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, null);
+ }
+ this.jfaDirty = true;
+ this.changeMaskDirty = true;
+ }
+
+ setSmoothProgress(progress: number) {
+ this.smoothProgress = Math.max(0, Math.min(1, progress));
+ }
+
+ setSmoothEnabled(enabled: boolean) {
+ this.smoothEnabled =
+ enabled &&
+ this.jfaSupported &&
+ this.prevStateCopySupported &&
+ !!this.changeMaskProgram &&
+ !!this.changeMaskTextureOld &&
+ !!this.changeMaskTextureNew &&
+ !!this.jfaResultOldTexture &&
+ !!this.jfaResultNewTexture;
+ }
+
+ setInterpolationPair(pair: "prevCurrent" | "olderPrev") {
+ this.interpolationPair = pair;
+ }
+
+ markAllDirty() {
+ this.needsFullUpload = true;
+ this.dirtyRows.clear();
+ this.needsContestFullUpload = true;
+ this.needsContestTimesUpload = true;
+ this.needsContestStrengthsUpload = true;
+ this.contestDirtyRows.clear();
+ this.jfaDirty = true;
+ this.changeMaskDirty = true;
+ }
+
+ 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);
+
+ if (this.contestEnabled) {
+ const uploadContestSpan = FrameProfiler.start();
+ this.uploadContestTexture();
+ FrameProfiler.end(
+ "TerritoryWebGLRenderer:uploadContests",
+ uploadContestSpan,
+ );
+
+ const uploadContestTimesSpan = FrameProfiler.start();
+ this.uploadContestTimesTexture();
+ FrameProfiler.end(
+ "TerritoryWebGLRenderer:uploadContestTimes",
+ uploadContestTimesSpan,
+ );
+
+ const uploadContestStrengthsSpan = FrameProfiler.start();
+ this.uploadContestStrengthsTexture();
+ FrameProfiler.end(
+ "TerritoryWebGLRenderer:uploadContestStrengths",
+ uploadContestStrengthsSpan,
+ );
+ }
+
+ if (this.jfaSupported) {
+ this.updateChangeMask();
+ this.updateJfa();
+ }
+
+ const renderSpan = FrameProfiler.start();
+ gl.viewport(0, 0, this.viewWidth, this.viewHeight);
+ gl.useProgram(this.program);
+ gl.bindVertexArray(this.vao);
+
+ const canUseOlderPair =
+ this.interpolationPair === "olderPrev" &&
+ !!this.prevOwnerTexture &&
+ !!this.olderOwnerTexture &&
+ !!this.jfaResultOldTexture &&
+ !!this.jfaResultOlderTexture;
+ const renderPair = canUseOlderPair ? "olderPrev" : "prevCurrent";
+
+ const toStateTexture =
+ renderPair === "olderPrev" ? this.prevOwnerTexture : this.stateTexture;
+ const fromStateTexture =
+ renderPair === "olderPrev"
+ ? this.olderOwnerTexture
+ : this.prevOwnerTexture;
+
+ if (toStateTexture) {
+ gl.activeTexture(gl.TEXTURE0);
+ gl.bindTexture(gl.TEXTURE_2D, toStateTexture);
+ }
+ if (this.paletteTexture) {
+ gl.activeTexture(gl.TEXTURE1);
+ gl.bindTexture(gl.TEXTURE_2D, this.paletteTexture);
+ }
+ if (this.relationTexture) {
+ gl.activeTexture(gl.TEXTURE2);
+ gl.bindTexture(gl.TEXTURE_2D, this.relationTexture);
+ }
+ if (this.patternTexture) {
+ gl.activeTexture(gl.TEXTURE3);
+ gl.bindTexture(gl.TEXTURE_2D, this.patternTexture);
+ }
+ if (this.contestOwnersTexture) {
+ gl.activeTexture(gl.TEXTURE4);
+ gl.bindTexture(gl.TEXTURE_2D, this.contestOwnersTexture);
+ }
+ if (this.contestIdsTexture) {
+ gl.activeTexture(gl.TEXTURE5);
+ gl.bindTexture(gl.TEXTURE_2D, this.contestIdsTexture);
+ }
+ if (this.contestTimesTexture) {
+ gl.activeTexture(gl.TEXTURE6);
+ gl.bindTexture(gl.TEXTURE_2D, this.contestTimesTexture);
+ }
+ if (fromStateTexture) {
+ gl.activeTexture(gl.TEXTURE7);
+ gl.bindTexture(gl.TEXTURE_2D, fromStateTexture);
+ }
+
+ const seedsOld =
+ renderPair === "olderPrev"
+ ? this.jfaResultOlderTexture
+ : this.jfaResultOldTexture;
+ const seedsNew =
+ renderPair === "olderPrev"
+ ? this.jfaResultOldTexture
+ : this.jfaResultNewTexture;
+ if (seedsOld) {
+ gl.activeTexture(gl.TEXTURE8);
+ gl.bindTexture(gl.TEXTURE_2D, seedsOld);
+ }
+ if (seedsNew) {
+ gl.activeTexture(gl.TEXTURE9);
+ gl.bindTexture(gl.TEXTURE_2D, seedsNew);
+ }
+
+ if (this.stateTexture) {
+ gl.activeTexture(gl.TEXTURE12);
+ gl.bindTexture(gl.TEXTURE_2D, this.stateTexture);
+ }
+ if (this.terrainTexture) {
+ gl.activeTexture(gl.TEXTURE14);
+ gl.bindTexture(gl.TEXTURE_2D, this.terrainTexture);
+ }
+
+ const changeMaskTexture =
+ renderPair === "olderPrev"
+ ? this.changeMaskTextureOld
+ : this.changeMaskTextureNew;
+ if (changeMaskTexture) {
+ gl.activeTexture(gl.TEXTURE13);
+ gl.bindTexture(gl.TEXTURE_2D, changeMaskTexture);
+ }
+ if (this.contestStrengthsTexture) {
+ gl.activeTexture(gl.TEXTURE11);
+ gl.bindTexture(gl.TEXTURE_2D, this.contestStrengthsTexture);
+ }
+ if (this.uniforms.viewResolution) {
+ gl.uniform2f(
+ this.uniforms.viewResolution,
+ this.viewWidth,
+ this.viewHeight,
+ );
+ }
+ if (this.uniforms.viewScale) {
+ gl.uniform1f(this.uniforms.viewScale, this.viewScale);
+ }
+ if (this.uniforms.viewOffset) {
+ gl.uniform2f(
+ this.uniforms.viewOffset,
+ this.viewOffsetX,
+ this.viewOffsetY,
+ );
+ }
+ if (this.uniforms.alternativeView) {
+ gl.uniform1i(this.uniforms.alternativeView, this.alternativeView ? 1 : 0);
+ }
+ if (this.uniforms.hoveredPlayerId) {
+ // Disable highlight after 5 seconds
+ const now = Date.now();
+ const elapsed = now - this.hoverStartTime;
+ const activeHoverId =
+ this.hoveredPlayerId >= 0 &&
+ elapsed < TerritoryWebGLRenderer.HOVER_DURATION_MS
+ ? this.hoveredPlayerId
+ : -1;
+ gl.uniform1f(this.uniforms.hoveredPlayerId, activeHoverId);
+ }
+ 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);
+ }
+ if (this.uniforms.viewerId) {
+ const viewerId = this.game.myPlayer()?.smallID() ?? 0;
+ gl.uniform1i(this.uniforms.viewerId, viewerId);
+ }
+ if (this.uniforms.contestEnabled) {
+ gl.uniform1i(this.uniforms.contestEnabled, this.contestEnabled ? 1 : 0);
+ }
+ if (this.uniforms.contestPatternMode) {
+ gl.uniform1i(this.uniforms.contestPatternMode, this.contestPatternMode);
+ }
+ if (this.uniforms.debugDisableStaticBorders) {
+ gl.uniform1i(
+ this.uniforms.debugDisableStaticBorders,
+ this.debugDisableStaticBorders ? 1 : 0,
+ );
+ }
+ if (this.uniforms.debugDisableAllBorders) {
+ gl.uniform1i(
+ this.uniforms.debugDisableAllBorders,
+ this.debugDisableAllBorders ? 1 : 0,
+ );
+ }
+ if (this.uniforms.seedSamplingMode) {
+ gl.uniform1i(this.uniforms.seedSamplingMode, this.seedSamplingMode);
+ }
+ if (this.uniforms.debugStripeFixedColors) {
+ gl.uniform1i(
+ this.uniforms.debugStripeFixedColors,
+ this.debugStripeFixedColors ? 1 : 0,
+ );
+ }
+ if (this.uniforms.motionMode) {
+ gl.uniform1i(this.uniforms.motionMode, this.motionMode);
+ }
+ if (this.uniforms.contestNow) {
+ gl.uniform1i(this.uniforms.contestNow, this.contestNow);
+ }
+ if (this.uniforms.contestDuration) {
+ gl.uniform1f(this.uniforms.contestDuration, this.contestDurationTicks);
+ }
+ if (this.uniforms.smoothProgress) {
+ gl.uniform1f(this.uniforms.smoothProgress, this.smoothProgress);
+ }
+ if (this.uniforms.smoothEnabled) {
+ gl.uniform1i(this.uniforms.smoothEnabled, this.smoothEnabled ? 1 : 0);
+ }
+ if (this.uniforms.darkMode) {
+ gl.uniform1i(
+ this.uniforms.darkMode,
+ this.userSettings.darkMode() ? 1 : 0,
+ );
+ }
+
+ 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);
+ }
+
+ getDebugStats() {
+ return {
+ mapWidth: this.mapWidth,
+ mapHeight: this.mapHeight,
+ viewWidth: this.viewWidth,
+ viewHeight: this.viewHeight,
+ viewScale: this.viewScale,
+ viewOffsetX: this.viewOffsetX,
+ viewOffsetY: this.viewOffsetY,
+ smoothEnabled: this.smoothEnabled,
+ smoothProgress: this.smoothProgress,
+ jfaSupported: this.jfaSupported,
+ jfaDisabledReason: this.jfaDisabledReason,
+ jfaDirty: this.jfaDirty,
+ prevStateCopySupported: this.prevStateCopySupported,
+ contestDurationTicks: this.contestDurationTicks,
+ contestNow: this.contestNow,
+ hoveredPlayerId: this.hoveredPlayerId,
+ };
+ }
+
+ 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.mapWidth,
+ this.mapHeight,
+ 0,
+ gl.RED_INTEGER,
+ gl.UNSIGNED_SHORT,
+ this.state,
+ );
+ this.needsFullUpload = false;
+ this.dirtyRows.clear();
+ rowsUploaded = this.mapHeight;
+ bytesUploaded = this.mapWidth * this.mapHeight * 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.mapWidth + 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 uploadContestTexture(): { rows: number; bytes: number } {
+ if (!this.gl || !this.contestOwnersTexture || !this.contestIdsTexture) {
+ return { rows: 0, bytes: 0 };
+ }
+ const gl = this.gl;
+ gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
+
+ const bytesPerOwnerPixel = Uint16Array.BYTES_PER_ELEMENT * 2;
+ const bytesPerIdPixel = Uint16Array.BYTES_PER_ELEMENT;
+ let rowsUploaded = 0;
+ let bytesUploaded = 0;
+
+ if (this.needsContestFullUpload) {
+ gl.activeTexture(gl.TEXTURE4);
+ gl.bindTexture(gl.TEXTURE_2D, this.contestOwnersTexture);
+ gl.texImage2D(
+ gl.TEXTURE_2D,
+ 0,
+ gl.RG16UI,
+ this.mapWidth,
+ this.mapHeight,
+ 0,
+ gl.RG_INTEGER,
+ gl.UNSIGNED_SHORT,
+ this.contestOwnersState,
+ );
+
+ gl.activeTexture(gl.TEXTURE5);
+ gl.bindTexture(gl.TEXTURE_2D, this.contestIdsTexture);
+ gl.texImage2D(
+ gl.TEXTURE_2D,
+ 0,
+ gl.R16UI,
+ this.mapWidth,
+ this.mapHeight,
+ 0,
+ gl.RED_INTEGER,
+ gl.UNSIGNED_SHORT,
+ this.contestIdsState,
+ );
+
+ this.needsContestFullUpload = false;
+ this.contestDirtyRows.clear();
+ rowsUploaded = this.mapHeight;
+ bytesUploaded =
+ this.mapWidth * this.mapHeight * (bytesPerOwnerPixel + bytesPerIdPixel);
+ return { rows: rowsUploaded, bytes: bytesUploaded };
+ }
+
+ if (this.contestDirtyRows.size === 0) {
+ return { rows: 0, bytes: 0 };
+ }
+
+ for (const [y, span] of this.contestDirtyRows) {
+ const width = span.maxX - span.minX + 1;
+ const ownerOffset = (y * this.mapWidth + 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,
+ span.minX,
+ y,
+ width,
+ 1,
+ gl.RG_INTEGER,
+ gl.UNSIGNED_SHORT,
+ ownerSlice,
+ );
+
+ const idOffset = y * this.mapWidth + 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 * (bytesPerOwnerPixel + bytesPerIdPixel);
+ }
+ 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 uploadContestStrengthsTexture(): { rows: number; bytes: number } {
+ if (!this.gl || !this.contestStrengthsTexture) {
+ return { rows: 0, bytes: 0 };
+ }
+ if (!this.needsContestStrengthsUpload) {
+ return { rows: 0, bytes: 0 };
+ }
+ const gl = this.gl;
+ gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
+ gl.activeTexture(gl.TEXTURE11);
+ gl.bindTexture(gl.TEXTURE_2D, this.contestStrengthsTexture);
+ gl.texImage2D(
+ gl.TEXTURE_2D,
+ 0,
+ gl.R16UI,
+ this.contestStrengthsState.length,
+ 1,
+ 0,
+ gl.RED_INTEGER,
+ gl.UNSIGNED_SHORT,
+ this.contestStrengthsState,
+ );
+ this.needsContestStrengthsUpload = false;
+ const bytes =
+ this.contestStrengthsState.length * Uint16Array.BYTES_PER_ELEMENT;
+ return { rows: 1, bytes };
+ }
+
+ private updateChangeMask() {
+ if (
+ !this.gl ||
+ !this.jfaSupported ||
+ !this.changeMaskDirty ||
+ !this.changeMaskProgram ||
+ !this.changeMaskFramebufferNew ||
+ !this.changeMaskFramebufferOld ||
+ !this.changeMaskFramebufferOlder ||
+ !this.prevOwnerTexture ||
+ !this.stateTexture ||
+ !this.jfaVao
+ ) {
+ return;
+ }
+
+ const gl = this.gl;
+ const prevBlend = gl.isEnabled(gl.BLEND);
+ gl.disable(gl.BLEND);
+ gl.viewport(0, 0, this.mapWidth, this.mapHeight);
+ gl.bindVertexArray(this.jfaVao);
+
+ gl.useProgram(this.changeMaskProgram);
+ if (this.changeMaskUniforms.resolution) {
+ gl.uniform2f(
+ this.changeMaskUniforms.resolution,
+ this.mapWidth,
+ this.mapHeight,
+ );
+ }
+ if (this.changeMaskUniforms.oldTexture) {
+ gl.activeTexture(gl.TEXTURE0);
+ gl.bindTexture(gl.TEXTURE_2D, this.prevOwnerTexture);
+ gl.uniform1i(this.changeMaskUniforms.oldTexture, 0);
+ }
+ if (this.changeMaskUniforms.newTexture) {
+ gl.activeTexture(gl.TEXTURE1);
+ gl.bindTexture(gl.TEXTURE_2D, this.stateTexture);
+ gl.uniform1i(this.changeMaskUniforms.newTexture, 1);
+ }
+
+ gl.bindFramebuffer(gl.FRAMEBUFFER, this.changeMaskFramebufferNew);
+ gl.drawArrays(gl.TRIANGLES, 0, 6);
+ gl.bindFramebuffer(gl.FRAMEBUFFER, null);
+
+ if (!this.changeMaskHistoryInitialized) {
+ gl.bindFramebuffer(gl.READ_FRAMEBUFFER, this.changeMaskFramebufferNew);
+ gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, this.changeMaskFramebufferOld);
+ gl.blitFramebuffer(
+ 0,
+ 0,
+ this.mapWidth,
+ this.mapHeight,
+ 0,
+ 0,
+ this.mapWidth,
+ this.mapHeight,
+ gl.COLOR_BUFFER_BIT,
+ gl.NEAREST,
+ );
+ gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, this.changeMaskFramebufferOlder);
+ gl.blitFramebuffer(
+ 0,
+ 0,
+ this.mapWidth,
+ this.mapHeight,
+ 0,
+ 0,
+ this.mapWidth,
+ this.mapHeight,
+ gl.COLOR_BUFFER_BIT,
+ gl.NEAREST,
+ );
+ gl.bindFramebuffer(gl.READ_FRAMEBUFFER, null);
+ gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, null);
+ this.changeMaskHistoryInitialized = true;
+ }
+
+ this.changeMaskDirty = false;
+
+ if (prevBlend) {
+ gl.enable(gl.BLEND);
+ }
+ }
+
+ private updateJfa() {
+ if (
+ !this.gl ||
+ !this.jfaSupported ||
+ !this.jfaSeedProgram ||
+ !this.jfaProgram ||
+ !this.jfaFramebufferA ||
+ !this.jfaFramebufferB ||
+ !this.jfaTextureA ||
+ !this.jfaTextureB ||
+ !this.stateTexture ||
+ !this.jfaResultNewFramebuffer ||
+ !this.jfaResultNewTexture ||
+ !this.jfaVao
+ ) {
+ return;
+ }
+ if (!this.jfaDirty) {
+ return;
+ }
+ const gl = this.gl;
+ const prevBlend = gl.isEnabled(gl.BLEND);
+ gl.disable(gl.BLEND);
+ gl.viewport(0, 0, this.mapWidth, this.mapHeight);
+ gl.bindVertexArray(this.jfaVao);
+
+ const runJfa = (
+ ownerTexture: WebGLTexture,
+ resultFramebuffer: WebGLFramebuffer,
+ ) => {
+ gl.useProgram(this.jfaSeedProgram);
+ if (this.jfaSeedUniforms.resolution) {
+ gl.uniform2f(
+ this.jfaSeedUniforms.resolution,
+ this.mapWidth,
+ this.mapHeight,
+ );
+ }
+ if (this.jfaSeedUniforms.owner) {
+ gl.activeTexture(gl.TEXTURE0);
+ gl.bindTexture(gl.TEXTURE_2D, ownerTexture);
+ gl.uniform1i(this.jfaSeedUniforms.owner, 0);
+ }
+ gl.bindFramebuffer(gl.FRAMEBUFFER, this.jfaFramebufferA);
+ gl.drawArrays(gl.TRIANGLES, 0, 6);
+
+ let readTex = this.jfaTextureA;
+ let readFbo = this.jfaFramebufferA;
+ let writeFbo = this.jfaFramebufferB;
+ let writeTex = this.jfaTextureB;
+ for (const step of this.jfaSteps) {
+ gl.useProgram(this.jfaProgram);
+ if (this.jfaUniforms.resolution) {
+ gl.uniform2f(
+ this.jfaUniforms.resolution,
+ this.mapWidth,
+ this.mapHeight,
+ );
+ }
+ if (this.jfaUniforms.step) {
+ gl.uniform1f(this.jfaUniforms.step, step);
+ }
+ if (this.jfaUniforms.seeds) {
+ gl.activeTexture(gl.TEXTURE0);
+ gl.bindTexture(gl.TEXTURE_2D, readTex);
+ gl.uniform1i(this.jfaUniforms.seeds, 0);
+ }
+ gl.bindFramebuffer(gl.FRAMEBUFFER, writeFbo);
+ gl.drawArrays(gl.TRIANGLES, 0, 6);
+
+ const tempTex = readTex;
+ readTex = writeTex;
+ writeTex = tempTex;
+ const tempFbo = readFbo;
+ readFbo = writeFbo;
+ writeFbo = tempFbo;
+ }
+
+ gl.bindFramebuffer(gl.READ_FRAMEBUFFER, readFbo);
+ gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, resultFramebuffer);
+ gl.blitFramebuffer(
+ 0,
+ 0,
+ this.mapWidth,
+ this.mapHeight,
+ 0,
+ 0,
+ this.mapWidth,
+ this.mapHeight,
+ gl.COLOR_BUFFER_BIT,
+ gl.NEAREST,
+ );
+ };
+
+ runJfa(this.stateTexture, this.jfaResultNewFramebuffer);
+
+ this.jfaDirty = false;
+
+ if (
+ !this.jfaHistoryInitialized &&
+ this.jfaResultOlderFramebuffer &&
+ this.jfaResultOldFramebuffer
+ ) {
+ gl.bindFramebuffer(gl.READ_FRAMEBUFFER, this.jfaResultNewFramebuffer);
+ gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, this.jfaResultOldFramebuffer);
+ gl.blitFramebuffer(
+ 0,
+ 0,
+ this.mapWidth,
+ this.mapHeight,
+ 0,
+ 0,
+ this.mapWidth,
+ this.mapHeight,
+ gl.COLOR_BUFFER_BIT,
+ gl.NEAREST,
+ );
+ gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, this.jfaResultOlderFramebuffer);
+ gl.blitFramebuffer(
+ 0,
+ 0,
+ this.mapWidth,
+ this.mapHeight,
+ 0,
+ 0,
+ this.mapWidth,
+ this.mapHeight,
+ gl.COLOR_BUFFER_BIT,
+ gl.NEAREST,
+ );
+ gl.bindFramebuffer(gl.READ_FRAMEBUFFER, null);
+ gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, null);
+ this.jfaHistoryInitialized = true;
+ }
+
+ gl.bindFramebuffer(gl.FRAMEBUFFER, null);
+ if (prevBlend) {
+ gl.enable(gl.BLEND);
+ }
+ gl.bindVertexArray(null);
+ }
+
+ private buildJfaSteps(width: number, height: number): number[] {
+ const maxDim = Math.max(width, height);
+ let step = 1;
+ while (step < maxDim) {
+ step <<= 1;
+ }
+ step >>= 1;
+ const steps: number[] = [];
+ while (step >= 1) {
+ steps.push(step);
+ step >>= 1;
+ }
+ return steps;
+ }
+
+ private uploadPalette() {
+ if (
+ !this.gl ||
+ !this.paletteTexture ||
+ !this.relationTexture ||
+ !this.patternTexture ||
+ !this.program
+ )
+ return;
+ const gl = this.gl;
+ const players = this.game.playerViews().filter((p) => p.isPlayer());
+
+ 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);
+ const relationData = new Uint8Array(this.paletteWidth * this.paletteWidth);
+ const patternData = new Uint8Array(
+ this.paletteWidth * PATTERN_STRIDE_BYTES,
+ );
+
+ const patternsEnabled = this.userSettings.territoryPatterns();
+ const defaultPatternBytes = this.getPatternBytes(
+ DefaultPattern.patternData,
+ );
+
+ for (const p of players) {
+ const id = p.smallID();
+ 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);
+
+ const borderRgba = p.borderColor().rgba;
+ 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);
+
+ const patternBytes =
+ patternsEnabled && p.cosmetics.pattern
+ ? this.getPatternBytes(p.cosmetics.pattern.patternData)
+ : defaultPatternBytes;
+ const offset = id * PATTERN_STRIDE_BYTES;
+ patternData.set(patternBytes.slice(0, PATTERN_STRIDE_BYTES), offset);
+ }
+
+ 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.useProgram(this.program);
+
+ 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,
+ 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,
+ this.paletteWidth,
+ 0,
+ gl.RED_INTEGER,
+ gl.UNSIGNED_BYTE,
+ relationData,
+ );
+
+ gl.activeTexture(gl.TEXTURE3);
+ gl.bindTexture(gl.TEXTURE_2D, this.patternTexture);
+ 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,
+ PATTERN_STRIDE_BYTES,
+ this.paletteWidth,
+ 0,
+ gl.RED_INTEGER,
+ gl.UNSIGNED_BYTE,
+ patternData,
+ );
+
+ if (this.uniforms.patternStride) {
+ gl.uniform1i(this.uniforms.patternStride, PATTERN_STRIDE_BYTES);
+ }
+ if (this.uniforms.patternRows) {
+ gl.uniform1i(this.uniforms.patternRows, this.paletteWidth);
+ }
+ }
+
+ private resolveRelationCode(
+ owner: PlayerView | null,
+ other: PlayerView | null,
+ ): number {
+ if (!owner || !other || !owner.isPlayer() || !other.isPlayer()) {
+ return 0;
+ }
+
+ let code = 0;
+ if (owner.smallID() === other.smallID()) {
+ code |= 4;
+ }
+ if (owner.isFriendly(other) || other.isFriendly(owner)) {
+ code |= 1;
+ }
+ if (owner.hasEmbargo(other)) {
+ code |= 2;
+ }
+ return code;
+ }
+
+ private safePlayerBySmallId(id: number): PlayerView | null {
+ const player = this.game.playerBySmallID(id);
+ return player instanceof PlayerView ? player : null;
+ }
+
+ private getPatternBytes(patternData: string): Uint8Array {
+ const cached = this.patternBytesCache.get(patternData);
+ if (cached) {
+ return cached;
+ }
+ try {
+ const bytes = base64url.decode(patternData);
+ this.patternBytesCache.set(patternData, bytes);
+ return bytes;
+ } catch (error) {
+ const fallback = base64url.decode(DefaultPattern.patternData);
+ this.patternBytesCache.set(patternData, fallback);
+ return fallback;
+ }
+ }
+
+ private createJfaSeedProgram(
+ gl: WebGL2RenderingContext,
+ ): WebGLProgram | null {
+ const vertexShaderSource = `#version 300 es
+ precision highp float;
+ layout(location = 0) 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 highp float;
+ precision highp usampler2D;
+
+ uniform usampler2D u_ownerTexture;
+ uniform vec2 u_resolution;
+
+ out vec2 outSeed;
+
+ uint ownerAt(ivec2 texCoord) {
+ ivec2 clamped = clamp(
+ texCoord,
+ ivec2(0, 0),
+ ivec2(int(u_resolution.x) - 1, int(u_resolution.y) - 1)
+ );
+ return texelFetch(u_ownerTexture, clamped, 0).r & 0xFFFu;
+ }
+
+ void main() {
+ ivec2 fragCoord = ivec2(gl_FragCoord.xy);
+ ivec2 texCoord = ivec2(
+ fragCoord.x,
+ int(u_resolution.y) - 1 - fragCoord.y
+ );
+
+ uint owner = ownerAt(texCoord);
+ bool isBorder = false;
+ vec2 edgeDir = vec2(0.0);
+ uint nOwner = ownerAt(texCoord + ivec2(1, 0));
+ if (nOwner != owner) { isBorder = true; edgeDir += vec2(1.0, 0.0); }
+ nOwner = ownerAt(texCoord + ivec2(-1, 0));
+ if (nOwner != owner) { isBorder = true; edgeDir += vec2(-1.0, 0.0); }
+ nOwner = ownerAt(texCoord + ivec2(0, 1));
+ if (nOwner != owner) { isBorder = true; edgeDir += vec2(0.0, 1.0); }
+ nOwner = ownerAt(texCoord + ivec2(0, -1));
+ if (nOwner != owner) { isBorder = true; edgeDir += vec2(0.0, -1.0); }
+
+ vec2 edgeOffset = vec2(
+ edgeDir.x == 0.0 ? 0.0 : (edgeDir.x > 0.0 ? 0.5 : -0.5),
+ edgeDir.y == 0.0 ? 0.0 : (edgeDir.y > 0.0 ? 0.5 : -0.5)
+ );
+
+ // Seed at the border edge (tile center +/- 0.5) so the front can move
+ // even when the border tile itself stays the same.
+ outSeed = isBorder
+ ? (vec2(texCoord) + vec2(0.5) + edgeOffset)
+ : vec2(-1.0, -1.0);
+ }
+ `;
+
+ 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] JFA seed link error",
+ gl.getProgramInfoLog(program),
+ );
+ gl.deleteProgram(program);
+ return null;
+ }
+ return program;
+ }
+
+ private createJfaProgram(gl: WebGL2RenderingContext): WebGLProgram | null {
+ const vertexShaderSource = `#version 300 es
+ precision highp float;
+ layout(location = 0) 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 highp float;
+
+ uniform sampler2D u_seeds;
+ uniform vec2 u_resolution;
+ uniform float u_step;
+
+ out vec2 outSeed;
+
+ vec2 seedAt(ivec2 coord) {
+ // coord is in texCoord space (Y-flipped from fragCoord)
+ // JFA texture was written at fragCoord positions, so flip back
+ ivec2 jfaCoord = ivec2(coord.x, int(u_resolution.y) - 1 - coord.y);
+ ivec2 clamped = clamp(
+ jfaCoord,
+ ivec2(0, 0),
+ ivec2(int(u_resolution.x) - 1, int(u_resolution.y) - 1)
+ );
+ return texelFetch(u_seeds, clamped, 0).rg;
+ }
+
+ void considerSeed(ivec2 coord, ivec2 texCoord, inout vec2 bestSeed, inout float bestDist) {
+ vec2 seed = seedAt(coord);
+ if (seed.x < 0.0) {
+ return;
+ }
+ float dist = length(seed - (vec2(texCoord) + vec2(0.5)));
+ if (dist < bestDist) {
+ bestDist = dist;
+ bestSeed = seed;
+ }
+ }
+
+ void main() {
+ ivec2 fragCoord = ivec2(gl_FragCoord.xy);
+ ivec2 texCoord = ivec2(
+ fragCoord.x,
+ int(u_resolution.y) - 1 - fragCoord.y
+ );
+ int step = int(u_step + 0.5);
+
+ vec2 bestSeed = seedAt(texCoord);
+ vec2 texPos = vec2(texCoord) + vec2(0.5);
+ float bestDist = bestSeed.x < 0.0 ? 1e20 : length(bestSeed - texPos);
+
+ considerSeed(texCoord + ivec2(-step, -step), texCoord, bestSeed, bestDist);
+ considerSeed(texCoord + ivec2(0, -step), texCoord, bestSeed, bestDist);
+ considerSeed(texCoord + ivec2(step, -step), texCoord, bestSeed, bestDist);
+ considerSeed(texCoord + ivec2(-step, 0), texCoord, bestSeed, bestDist);
+ considerSeed(texCoord + ivec2(step, 0), texCoord, bestSeed, bestDist);
+ considerSeed(texCoord + ivec2(-step, step), texCoord, bestSeed, bestDist);
+ considerSeed(texCoord + ivec2(0, step), texCoord, bestSeed, bestDist);
+ considerSeed(texCoord + ivec2(step, step), texCoord, bestSeed, bestDist);
+
+ outSeed = bestSeed;
+ }
+ `;
+
+ 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] JFA link error",
+ gl.getProgramInfoLog(program),
+ );
+ gl.deleteProgram(program);
+ return null;
+ }
+ return program;
+ }
+
+ private createChangeMaskProgram(
+ gl: WebGL2RenderingContext,
+ ): WebGLProgram | null {
+ const vertexShaderSource = `#version 300 es
+ precision highp float;
+ layout(location = 0) 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 highp float;
+ precision highp usampler2D;
+
+ uniform usampler2D u_oldTexture;
+ uniform usampler2D u_newTexture;
+ uniform vec2 u_resolution;
+
+ layout(location = 0) out uint outMask;
+
+ uint ownerAt(usampler2D tex, ivec2 texCoord) {
+ ivec2 clamped = clamp(
+ texCoord,
+ ivec2(0, 0),
+ ivec2(int(u_resolution.x) - 1, int(u_resolution.y) - 1)
+ );
+ return texelFetch(tex, clamped, 0).r & 0xFFFu;
+ }
+
+ void main() {
+ ivec2 fragCoord = ivec2(gl_FragCoord.xy);
+ ivec2 texCoord = ivec2(
+ fragCoord.x,
+ int(u_resolution.y) - 1 - fragCoord.y
+ );
+
+ bool changed = ownerAt(u_oldTexture, texCoord) != ownerAt(u_newTexture, texCoord);
+ changed = changed || (ownerAt(u_oldTexture, texCoord + ivec2(1, 0)) != ownerAt(u_newTexture, texCoord + ivec2(1, 0)));
+ changed = changed || (ownerAt(u_oldTexture, texCoord + ivec2(-1, 0)) != ownerAt(u_newTexture, texCoord + ivec2(-1, 0)));
+ changed = changed || (ownerAt(u_oldTexture, texCoord + ivec2(0, 1)) != ownerAt(u_newTexture, texCoord + ivec2(0, 1)));
+ changed = changed || (ownerAt(u_oldTexture, texCoord + ivec2(0, -1)) != ownerAt(u_newTexture, texCoord + ivec2(0, -1)));
+
+ outMask = changed ? 1u : 0u;
+ }
+ `;
+
+ 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] change mask link error",
+ gl.getProgramInfoLog(program),
+ );
+ gl.deleteProgram(program);
+ return null;
+ }
+ return program;
+ }
+
+ private createProgram(gl: WebGL2RenderingContext): WebGLProgram | null {
+ const vertexShaderSource = `#version 300 es
+ precision highp float;
+ layout(location = 0) in vec2 a_position;
+ uniform vec2 u_viewResolution;
+ void main() {
+ vec2 zeroToOne = a_position / u_viewResolution;
+ 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 highp float;
+ precision highp usampler2D;
+
+ uniform usampler2D u_state;
+ uniform usampler2D u_terrain;
+ uniform usampler2D u_latestState;
+ uniform sampler2D u_palette;
+ uniform usampler2D u_relations;
+ uniform usampler2D u_patterns;
+ uniform bool u_contestEnabled;
+ uniform int u_contestPatternMode; // 0=blueNoise(strength), 1=checkerboard(50/50), 2=bayer4x4(strength)
+ uniform bool u_debugDisableStaticBorders;
+ uniform bool u_debugDisableAllBorders;
+ uniform int u_seedSamplingMode; // 0=none(single texel), 1=2x2, 2=3x3
+ uniform bool u_debugStripeFixedColors; // Use fixed debug colors for moving stripe
+ uniform int u_motionMode; // 0=euclidean, 1=axisSnap, 2=manhattan, 3=chebyshev
+ uniform usampler2D u_contestOwners;
+ uniform usampler2D u_contestIds;
+ uniform usampler2D u_contestTimes;
+ uniform usampler2D u_contestStrengths;
+ uniform bool u_jfaAvailable;
+ uniform int u_contestNow;
+ uniform float u_contestDurationTicks;
+ uniform usampler2D u_prevOwner;
+ uniform usampler2D u_changeMask;
+ uniform sampler2D u_jfaSeedsOld;
+ uniform sampler2D u_jfaSeedsNew;
+ uniform float u_smoothProgress;
+ uniform bool u_smoothEnabled;
+ uniform int u_patternStride;
+ uniform int u_patternRows;
+ uniform int u_viewerId;
+ uniform vec2 u_mapResolution;
+ uniform vec2 u_viewResolution;
+ uniform float u_viewScale;
+ uniform vec2 u_viewOffset;
+ uniform vec4 u_fallout;
+ uniform vec4 u_altSelf;
+ uniform vec4 u_altAlly;
+ uniform vec4 u_altNeutral;
+ uniform vec4 u_altEnemy;
+ uniform float u_alpha;
+ uniform bool u_alternativeView;
+ uniform float u_hoveredPlayerId;
+ uniform vec3 u_hoverHighlightColor;
+ uniform float u_hoverHighlightStrength;
+ uniform float u_hoverPulseStrength;
+ uniform float u_hoverPulseSpeed;
+ uniform float u_time;
+ uniform bool u_darkMode;
+
+ out vec4 outColor;
+
+ uint stateAtTex(ivec2 texCoord) {
+ ivec2 clamped = clamp(
+ texCoord,
+ ivec2(0, 0),
+ ivec2(int(u_mapResolution.x) - 1, int(u_mapResolution.y) - 1)
+ );
+ return texelFetch(u_state, clamped, 0).r;
+ }
+
+ uint ownerAtTex(ivec2 texCoord) {
+ return stateAtTex(texCoord) & 0xFFFu;
+ }
+
+ // Terrain bit layout: bit7=land, bit6=shoreline, bit5=ocean, bits0-4=magnitude
+ uint terrainAtTex(ivec2 texCoord) {
+ ivec2 clamped = clamp(
+ texCoord,
+ ivec2(0, 0),
+ ivec2(int(u_mapResolution.x) - 1, int(u_mapResolution.y) - 1)
+ );
+ return texelFetch(u_terrain, clamped, 0).r;
+ }
+
+ bool isLand(uint terrain) {
+ return (terrain & 0x80u) != 0u; // bit 7
+ }
+
+ bool isShoreline(uint terrain) {
+ return (terrain & 0x40u) != 0u; // bit 6
+ }
+
+ bool isOcean(uint terrain) {
+ return (terrain & 0x20u) != 0u; // bit 5
+ }
+
+ uint getMagnitude(uint terrain) {
+ return terrain & 0x1Fu; // bits 0-4
+ }
+
+ // Compute terrain color based on type, magnitude, and theme
+ // Colors match PastelTheme (light) and PastelThemeDark exactly
+ vec3 terrainColor(uint terrain) {
+ uint mag = getMagnitude(terrain);
+ float fmag = float(mag);
+
+ if (isLand(terrain)) {
+ if (isShoreline(terrain)) {
+ // Shore/beach - land adjacent to water
+ // Light: rgb(204,203,158), Dark: rgb(134,133,88)
+ return u_darkMode
+ ? vec3(134.0/255.0, 133.0/255.0, 88.0/255.0)
+ : vec3(204.0/255.0, 203.0/255.0, 158.0/255.0);
+ }
+ if (mag < 10u) {
+ // Plains (mag 0-9)
+ // Light: rgb(190, 220-2*mag, 138), Dark: rgb(140, 170-2*mag, 88)
+ return u_darkMode
+ ? vec3(140.0/255.0, (170.0 - 2.0*fmag)/255.0, 88.0/255.0)
+ : vec3(190.0/255.0, (220.0 - 2.0*fmag)/255.0, 138.0/255.0);
+ } else if (mag < 20u) {
+ // Highland (mag 10-19)
+ // Light: rgb(200+2*mag, 183+2*mag, 138+2*mag)
+ // Dark: rgb(150+2*mag, 133+2*mag, 88+2*mag)
+ return u_darkMode
+ ? vec3((150.0 + 2.0*fmag)/255.0, (133.0 + 2.0*fmag)/255.0, (88.0 + 2.0*fmag)/255.0)
+ : vec3((200.0 + 2.0*fmag)/255.0, (183.0 + 2.0*fmag)/255.0, (138.0 + 2.0*fmag)/255.0);
+ } else {
+ // Mountain (mag 20-30)
+ // Light: rgb(230+mag/2, 230+mag/2, 230+mag/2)
+ // Dark: rgb(180+mag/2, 180+mag/2, 180+mag/2)
+ float base = u_darkMode ? 180.0 : 230.0;
+ float val = (base + fmag/2.0) / 255.0;
+ return vec3(val, val, val);
+ }
+ } else {
+ // Water
+ if (isShoreline(terrain)) {
+ // Shoreline water - lighter, adjacent to land
+ // Light: rgb(100,143,255), Dark: rgb(50,50,50)
+ return u_darkMode
+ ? vec3(50.0/255.0, 50.0/255.0, 50.0/255.0)
+ : vec3(100.0/255.0, 143.0/255.0, 255.0/255.0);
+ }
+ if (isOcean(terrain)) {
+ // Ocean - depth-adjusted
+ // Light base: rgb(70,132,180), adjusted by +1-min(mag,10)
+ // Dark base: rgb(14,11,30), adjusted by +9-mag for mag<10
+ float depthAdj = float(min(mag, 10u));
+ if (u_darkMode) {
+ // Dark: rgb(14+9-mag, 11+9-mag, 30+9-mag) for mag<10, else rgb(14,11,30)
+ if (mag < 10u) {
+ return vec3(
+ (14.0 + 9.0 - fmag)/255.0,
+ (11.0 + 9.0 - fmag)/255.0,
+ (30.0 + 9.0 - fmag)/255.0
+ );
+ }
+ return vec3(14.0/255.0, 11.0/255.0, 30.0/255.0);
+ } else {
+ // Light: rgb(70-10+11-min(mag,10), 132-10+11-min(mag,10), 180-10+11-min(mag,10))
+ // = rgb(71-depthAdj, 133-depthAdj, 181-depthAdj)
+ return vec3(
+ (71.0 - depthAdj)/255.0,
+ (133.0 - depthAdj)/255.0,
+ (181.0 - depthAdj)/255.0
+ );
+ }
+ } else {
+ // Lake - use same as shoreline water for simplicity
+ // Light: rgb(100,143,255), Dark: rgb(50,50,50)
+ return u_darkMode
+ ? vec3(50.0/255.0, 50.0/255.0, 50.0/255.0)
+ : vec3(100.0/255.0, 143.0/255.0, 255.0/255.0);
+ }
+ }
+ }
+
+ uint prevStateAtTex(ivec2 texCoord) {
+ ivec2 clamped = clamp(
+ texCoord,
+ ivec2(0, 0),
+ ivec2(int(u_mapResolution.x) - 1, int(u_mapResolution.y) - 1)
+ );
+ return texelFetch(u_prevOwner, clamped, 0).r;
+ }
+
+ uint prevOwnerAtTex(ivec2 texCoord) {
+ return prevStateAtTex(texCoord) & 0xFFFu;
+ }
+
+ vec2 jfaSeedOldAtTex(ivec2 texCoord) {
+ // JFA texture was written with fragCoord (bottom-left origin), but we're reading with
+ // texCoord (top-left origin, same as state texture). Need to flip Y to match.
+ // JFA row 0 = fragCoord.y=0 = stateTexCoord.y=height-1 = bottom of map
+ // To read data for texCoord.y=0 (top), we need JFA row height-1
+ ivec2 flipped = ivec2(texCoord.x, int(u_mapResolution.y) - 1 - texCoord.y);
+ ivec2 clamped = clamp(
+ flipped,
+ ivec2(0, 0),
+ ivec2(int(u_mapResolution.x) - 1, int(u_mapResolution.y) - 1)
+ );
+ return texelFetch(u_jfaSeedsOld, clamped, 0).rg;
+ }
+
+ vec2 jfaSeedNewAtTex(ivec2 texCoord) {
+ // JFA texture was written with fragCoord (bottom-left origin), but we're reading with
+ // texCoord (top-left origin, same as state texture). Need to flip Y to match.
+ ivec2 flipped = ivec2(texCoord.x, int(u_mapResolution.y) - 1 - texCoord.y);
+ ivec2 clamped = clamp(
+ flipped,
+ ivec2(0, 0),
+ ivec2(int(u_mapResolution.x) - 1, int(u_mapResolution.y) - 1)
+ );
+ return texelFetch(u_jfaSeedsNew, clamped, 0).rg;
+ }
+
+ // Best-of-NxN seed sampling to reduce tile-boundary discontinuities.
+ // Returns the seed (from OLD JFA) that is closest to mapCoord.
+ vec2 bestSeedOld(vec2 mapCoord) {
+ ivec2 base = ivec2(floor(mapCoord));
+ float bestDist = 1e9;
+ vec2 bestSeed = vec2(-1.0);
+
+ int radius = u_seedSamplingMode == 2 ? 1 : 0; // 3x3 vs 2x2
+ int end = u_seedSamplingMode == 2 ? 2 : 2; // 3x3: -1..+1, 2x2: 0..+1
+ int start = u_seedSamplingMode == 2 ? -1 : 0;
+
+ for (int dy = start; dy < end; dy++) {
+ for (int dx = start; dx < end; dx++) {
+ ivec2 sampleTex = base + ivec2(dx, dy);
+ vec2 seed = jfaSeedOldAtTex(sampleTex);
+ if (seed.x >= 0.0) {
+ float d = distance(mapCoord, seed);
+ if (d < bestDist) {
+ bestDist = d;
+ bestSeed = seed;
+ }
+ }
+ }
+ }
+ return bestSeed;
+ }
+
+ // Best-of-NxN seed sampling for NEW JFA.
+ vec2 bestSeedNew(vec2 mapCoord) {
+ ivec2 base = ivec2(floor(mapCoord));
+ float bestDist = 1e9;
+ vec2 bestSeed = vec2(-1.0);
+
+ int radius = u_seedSamplingMode == 2 ? 1 : 0;
+ int end = u_seedSamplingMode == 2 ? 2 : 2;
+ int start = u_seedSamplingMode == 2 ? -1 : 0;
+
+ for (int dy = start; dy < end; dy++) {
+ for (int dx = start; dx < end; dx++) {
+ ivec2 sampleTex = base + ivec2(dx, dy);
+ vec2 seed = jfaSeedNewAtTex(sampleTex);
+ if (seed.x >= 0.0) {
+ float d = distance(mapCoord, seed);
+ if (d < bestDist) {
+ bestDist = d;
+ bestSeed = seed;
+ }
+ }
+ }
+ }
+ return bestSeed;
+ }
+
+ uvec2 contestOwnersAtTex(ivec2 texCoord) {
+ ivec2 clamped = clamp(
+ texCoord,
+ ivec2(0, 0),
+ ivec2(int(u_mapResolution.x) - 1, int(u_mapResolution.y) - 1)
+ );
+ return texelFetch(u_contestOwners, clamped, 0).rg;
+ }
+
+ uint contestIdRawAtTex(ivec2 texCoord) {
+ ivec2 clamped = clamp(
+ texCoord,
+ ivec2(0, 0),
+ ivec2(int(u_mapResolution.x) - 1, int(u_mapResolution.y) - 1)
+ );
+ return texelFetch(u_contestIds, clamped, 0).r;
+ }
+
+ float contestStrength(uint contestId) {
+ if (contestId == 0u) {
+ return 0.5;
+ }
+ uint strengthRaw = texelFetch(
+ u_contestStrengths,
+ ivec2(int(contestId), 0),
+ 0
+ ).r;
+ return clamp(float(strengthRaw) / 65535.0, 0.0, 1.0);
+ }
+
+ float blueNoise(ivec2 texCoord) {
+ vec2 p = vec2(texCoord);
+ float x = fract(0.06711056 * p.x + 0.00583715 * p.y);
+ return fract(52.9829189 * x);
+ }
+
+ float bayer4x4(ivec2 texCoord) {
+ // Classic 4x4 Bayer matrix values 0..15 mapped to (0.5/16 .. 15.5/16)
+ int x = texCoord.x & 3;
+ int y = texCoord.y & 3;
+ int idx = (y << 2) | x;
+ int v = 0;
+ // Row-major:
+ // 0 8 2 10
+ // 12 4 14 6
+ // 3 11 1 9
+ // 15 7 13 5
+ if (idx == 0) v = 0;
+ else if (idx == 1) v = 8;
+ else if (idx == 2) v = 2;
+ else if (idx == 3) v = 10;
+ else if (idx == 4) v = 12;
+ else if (idx == 5) v = 4;
+ else if (idx == 6) v = 14;
+ else if (idx == 7) v = 6;
+ else if (idx == 8) v = 3;
+ else if (idx == 9) v = 11;
+ else if (idx == 10) v = 1;
+ else if (idx == 11) v = 9;
+ else if (idx == 12) v = 15;
+ else if (idx == 13) v = 7;
+ else if (idx == 14) v = 13;
+ else v = 5;
+ return (float(v) + 0.5) / 16.0;
+ }
+
+ bool contestPickAttacker(ivec2 texCoord, float strength) {
+ if (u_contestPatternMode == 1) {
+ // Checkerboard is always 50/50 (ignores strength)
+ return ((texCoord.x + texCoord.y) & 1) == 0;
+ }
+ if (u_contestPatternMode == 2) {
+ return bayer4x4(texCoord) < strength;
+ }
+ return blueNoise(texCoord) < strength;
+ }
+
+ 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;
+ }
+
+ uint patternByte(uint owner, uint offset) {
+ int x = int(offset);
+ int y = int(owner);
+ if (x < 0 || x >= u_patternStride || y < 0 || y >= u_patternRows) {
+ return 0u;
+ }
+ return texelFetch(u_patterns, ivec2(x, y), 0).r;
+ }
+
+ bool patternIsPrimary(uint owner, ivec2 texCoord) {
+ uint version = patternByte(owner, 0u);
+ if (version != 0u) {
+ return true;
+ }
+ uint b1 = patternByte(owner, 1u);
+ uint b2 = patternByte(owner, 2u);
+ uint scale = b1 & 7u;
+ uint width = (((b2 & 3u) << 5) | ((b1 >> 3) & 31u)) + 2u;
+ uint height = ((b2 >> 2) & 63u) + 2u;
+ if (width == 0u || height == 0u) {
+ return true;
+ }
+ uint px = (uint(texCoord.x) >> scale) % width;
+ uint py = (uint(texCoord.y) >> scale) % height;
+ uint idx = py * width + px;
+ uint byteIndex = idx >> 3;
+ uint bitIndex = idx & 7u;
+ uint byteVal = patternByte(owner, 3u + byteIndex);
+ 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);
+ }
+
+ vec3 applyBorderTint(vec3 color, bool hasFriendly, bool hasEmbargo) {
+ 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 (hasFriendly) {
+ color = color * (1.0 - BORDER_TINT_RATIO) +
+ FRIENDLY_TINT_TARGET * BORDER_TINT_RATIO;
+ }
+ if (hasEmbargo) {
+ color = color * (1.0 - BORDER_TINT_RATIO) +
+ EMBARGO_TINT_TARGET * BORDER_TINT_RATIO;
+ }
+ return color;
+ }
+
+ void main() {
+ // gl_FragCoord.xy is already at pixel center (0.5, 0.5 ...).
+ // Use the pixel center to avoid half-pixel snapping/offset artifacts,
+ // especially noticeable on the interpolated JFA border/front.
+ vec2 viewCoord = vec2(
+ gl_FragCoord.x - 0.5,
+ u_viewResolution.y - gl_FragCoord.y - 0.5
+ );
+ vec2 mapHalf = u_mapResolution * 0.5;
+ vec2 mapCoord = (viewCoord - mapHalf) / u_viewScale + u_viewOffset + mapHalf;
+ if (
+ mapCoord.x < 0.0 ||
+ mapCoord.y < 0.0 ||
+ mapCoord.x >= u_mapResolution.x ||
+ mapCoord.y >= u_mapResolution.y
+ ) {
+ outColor = vec4(0.0);
+ return;
+ }
+ // Tile centers are at (0.5, 1.5, 2.5, ...). Floor gives the tile index.
+ // Original ivec2(mapCoord) is equivalent but less explicit.
+ ivec2 texCoord = ivec2(mapCoord);
+
+ uint state = stateAtTex(texCoord);
+ uint owner = state & 0xFFFu;
+ bool hasFallout = (state & 0x2000u) != 0u;
+ bool isDefended = (state & 0x1000u) != 0u;
+ uint latestState = texelFetch(u_latestState, texCoord, 0).r;
+ uint latestOwner = latestState & 0xFFFu;
+ uint oldState = prevStateAtTex(texCoord);
+ uint oldOwner = oldState & 0xFFFu;
+ bool oldHasFallout = (oldState & 0x2000u) != 0u;
+ bool oldIsDefended = (oldState & 0x1000u) != 0u;
+ // ChangeMask was written with Y-flipped coords, so flip when reading
+ ivec2 changeMaskCoord = ivec2(texCoord.x, int(u_mapResolution.y) - 1 - texCoord.y);
+ uint changeMask = texelFetch(u_changeMask, changeMaskCoord, 0).r;
+
+ // Expand the animation region by 1 tile (halo) so the *outer* border edge can move smoothly.
+ // If we only animate "changed" tiles, the leading edge stays pinned to tile coordinates because
+ // neighbor pixels are still rendered from the static FROM snapshot.
+ uint affectedMask = changeMask;
+ ivec2 cm;
+ cm = ivec2(clamp(texCoord.x + 1, 0, int(u_mapResolution.x) - 1), texCoord.y);
+ affectedMask |= texelFetch(u_changeMask, ivec2(cm.x, int(u_mapResolution.y) - 1 - cm.y), 0).r;
+ cm = ivec2(clamp(texCoord.x - 1, 0, int(u_mapResolution.x) - 1), texCoord.y);
+ affectedMask |= texelFetch(u_changeMask, ivec2(cm.x, int(u_mapResolution.y) - 1 - cm.y), 0).r;
+ cm = ivec2(texCoord.x, clamp(texCoord.y + 1, 0, int(u_mapResolution.y) - 1));
+ affectedMask |= texelFetch(u_changeMask, ivec2(cm.x, int(u_mapResolution.y) - 1 - cm.y), 0).r;
+ cm = ivec2(texCoord.x, clamp(texCoord.y - 1, 0, int(u_mapResolution.y) - 1));
+ affectedMask |= texelFetch(u_changeMask, ivec2(cm.x, int(u_mapResolution.y) - 1 - cm.y), 0).r;
+ bool smoothActive = u_smoothEnabled &&
+ u_smoothProgress < 1.0 &&
+ !u_alternativeView &&
+ u_jfaAvailable &&
+ affectedMask != 0u;
+
+ uint contestIdRaw = 0u;
+ const uint CONTEST_ID_MASK = 0x7FFFu;
+ uint contestId = 0u;
+ uvec2 contestOwners = uvec2(0u);
+ uint defender = 0u;
+ bool contested = false;
+ if (u_contestEnabled) {
+ contestIdRaw = contestIdRawAtTex(texCoord);
+ contestId = contestIdRaw & CONTEST_ID_MASK;
+ contestOwners = contestOwnersAtTex(texCoord);
+ defender = contestOwners.r & 0xFFFu;
+
+ 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_contestDurationTicks;
+ }
+ }
+
+ // Border detection: check if any neighbor has a different owner.
+ bool isBorder = false;
+ bool hasFriendlyRelation = false;
+ bool hasEmbargoRelation = false;
+ if (!smoothActive) {
+ 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);
+ }
+ }
+
+ // Get terrain for background rendering (needed for both normal and alt view)
+ uint terrain = terrainAtTex(texCoord);
+ vec3 baseTerrainColor = terrainColor(terrain);
+
+ if (u_alternativeView) {
+ // Alt view: terrain + borders only, no territory fill
+ vec3 color = baseTerrainColor;
+ if (!u_debugDisableAllBorders && !u_debugDisableStaticBorders && owner != 0u && isBorder) {
+ // Only draw borders, not territory fill
+ uint relationAlt = relationCode(owner, uint(u_viewerId));
+ vec4 altColor = u_altNeutral;
+ if (isSelf(relationAlt)) {
+ altColor = u_altSelf;
+ } else if (isFriendly(relationAlt)) {
+ altColor = u_altAlly;
+ } else if (isEmbargo(relationAlt)) {
+ altColor = u_altEnemy;
+ }
+ 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, 1.0);
+ return;
+ }
+
+ // Normal view: blend territory on top of terrain
+ vec3 fillColor = baseTerrainColor;
+ vec3 borderColor = vec3(0.0);
+ float borderAlpha = 0.0;
+ vec3 ownerBase = vec3(0.0);
+ vec4 ownerBorder = vec4(0.0);
+
+ if (owner == 0u) {
+ // Unowned tile - show terrain (or fallout if irradiated)
+ if (hasFallout) {
+ // Blend fallout on top of terrain
+ fillColor = mix(baseTerrainColor, u_fallout.rgb, u_alpha);
+ }
+ // Otherwise fillColor is already baseTerrainColor
+ } else {
+ vec4 base = texelFetch(u_palette, ivec2(int(owner) * 2, 0), 0);
+ vec4 baseBorder = texelFetch(
+ u_palette,
+ ivec2(int(owner) * 2 + 1, 0),
+ 0
+ );
+ ownerBase = base.rgb;
+ ownerBorder = baseBorder;
+ bool isPrimary = patternIsPrimary(owner, texCoord);
+ vec3 patternColor = isPrimary ? base.rgb : baseBorder.rgb;
+ // Blend territory fill on top of terrain
+ fillColor = mix(baseTerrainColor, patternColor, u_alpha);
+
+ if (isBorder && !smoothActive) {
+ vec3 bColor = applyBorderTint(
+ baseBorder.rgb,
+ hasFriendlyRelation,
+ hasEmbargoRelation
+ );
+ borderColor = applyDefended(bColor, isDefended, texCoord);
+ borderAlpha = baseBorder.a;
+ }
+ }
+
+ vec3 color = fillColor;
+ bool useContestedFill = false;
+ if (contested && latestOwner != 0u) {
+ useContestedFill = true;
+ vec3 latestOwnerBase = texelFetch(
+ u_palette,
+ ivec2(int(latestOwner) * 2, 0),
+ 0
+ ).rgb;
+ vec3 defenderBase = latestOwnerBase;
+ if (defender != 0u) {
+ vec4 defenderColor = texelFetch(
+ u_palette,
+ ivec2(int(defender) * 2, 0),
+ 0
+ );
+ defenderBase = defenderColor.rgb;
+ }
+ float strength = contestStrength(contestId);
+ bool pickAttacker = contestPickAttacker(texCoord, strength);
+ vec3 contestColor = pickAttacker ? latestOwnerBase : defenderBase;
+ // Blend contested fill on top of terrain
+ color = mix(baseTerrainColor, contestColor, u_alpha);
+ }
+
+ if (!u_debugDisableAllBorders && !u_debugDisableStaticBorders && !smoothActive && isBorder && owner != 0u) {
+ // Blend border on top of the current fill
+ color = mix(color, borderColor, borderAlpha);
+ }
+
+ if (smoothActive) {
+ // DEBUG: uncomment ONE line to visualize issues
+ // color = vec3(1.0, 0.0, 1.0); outColor = vec4(color, 1.0); return; // magenta = smoothActive tiles
+ // vec2 ds = jfaSeedOldAtTex(texCoord); color = vec3(ds.x >= 0.0 ? 0.0 : 1.0, jfaSeedNewAtTex(texCoord).x >= 0.0 ? 0.0 : 1.0, 0.0); outColor = vec4(color, 1.0); return; // seed validity
+
+ // Compute old color blended on terrain
+ vec3 oldColor = baseTerrainColor;
+ if (oldOwner == 0u) {
+ if (oldHasFallout) {
+ oldColor = mix(baseTerrainColor, u_fallout.rgb, u_alpha);
+ }
+ // Otherwise oldColor is already baseTerrainColor
+ } else {
+ vec4 oldBase = texelFetch(u_palette, ivec2(int(oldOwner) * 2, 0), 0);
+ vec4 oldBorder = texelFetch(
+ u_palette,
+ ivec2(int(oldOwner) * 2 + 1, 0),
+ 0
+ );
+ bool oldPrimary = patternIsPrimary(oldOwner, texCoord);
+ vec3 oldPatternColor = oldPrimary ? oldBase.rgb : oldBorder.rgb;
+ oldColor = mix(baseTerrainColor, oldPatternColor, u_alpha);
+ }
+
+ // JFA-based animation with tile-sized pixelated look
+ // Movement is pixel-smooth but edges remain hard/blocky like stable borders
+ // Use best-of-NxN seed sampling when enabled to reduce tile-boundary discontinuities.
+ // Use seeds picked at the TILE CENTER to avoid seed flipping inside a tile
+ // (which can cause direction/timing glitches). Distances still use mapCoord
+ // for smooth within-tile variation.
+ vec2 tileCenter = floor(mapCoord) + 0.5;
+ vec2 seedOld = u_seedSamplingMode == 0
+ ? jfaSeedOldAtTex(texCoord)
+ : bestSeedOld(tileCenter);
+ vec2 seedNew = u_seedSamplingMode == 0
+ ? jfaSeedNewAtTex(texCoord)
+ : bestSeedNew(tileCenter);
+
+ bool hasOldSeed = seedOld.x >= 0.0;
+ bool hasNewSeed = seedNew.x >= 0.0;
+
+ // CORRECT MODEL (no blending, no "future"):
+ // - We are interpolating between a *pair* of snapshots (from/to), selected by "renderPair" on CPU.
+ // - u_prevOwner is the FROM snapshot (texture unit 7).
+ // - u_state is the TO snapshot (texture unit 0).
+ // - u_jfaSeedsOld/u_jfaSeedsNew + u_changeMask also match that pair.
+ //
+ // We render:
+ // 1) Old snapshot at the true map coords (static).
+ // 2) New snapshot slid in from the old border position toward the new border position.
+ // No blending: the slid-in new snapshot overwrites old ONLY where changeMask indicates change.
+
+ float t = clamp(u_smoothProgress, 0.0, 1.0);
+
+ // --- Old layer (FROM snapshot), at texCoord ---
+ uint fromState = oldState;
+ uint fromOwner = oldOwner;
+
+ // Fill for FROM owner
+ vec3 fromColor = baseTerrainColor;
+ if (fromOwner != 0u) {
+ vec4 fromBase = texelFetch(u_palette, ivec2(int(fromOwner) * 2, 0), 0);
+ vec4 fromBorderBase = texelFetch(
+ u_palette,
+ ivec2(int(fromOwner) * 2 + 1, 0),
+ 0
+ );
+ bool fromPrimary = patternIsPrimary(fromOwner, texCoord);
+ vec3 fromPatternColor = fromPrimary ? fromBase.rgb : fromBorderBase.rgb;
+ fromColor = mix(baseTerrainColor, fromPatternColor, u_alpha);
+ } else if (oldHasFallout) {
+ // preserve fallout tint when unowned
+ fromColor = mix(baseTerrainColor, u_fallout.rgb, u_alpha);
+ }
+
+ // Border for FROM owner (tile-width, stable look)
+ bool fromIsBorder = false;
+ uint fromOther = 0u;
+ uint nFrom;
+ nFrom = texelFetch(u_prevOwner, texCoord + ivec2(1, 0), 0).r & 0xFFFu;
+ if (nFrom != fromOwner) { fromIsBorder = true; if (nFrom != 0u) fromOther = nFrom; }
+ nFrom = texelFetch(u_prevOwner, texCoord + ivec2(-1, 0), 0).r & 0xFFFu;
+ if (nFrom != fromOwner) { fromIsBorder = true; if (nFrom != 0u) fromOther = nFrom; }
+ nFrom = texelFetch(u_prevOwner, texCoord + ivec2(0, 1), 0).r & 0xFFFu;
+ if (nFrom != fromOwner) { fromIsBorder = true; if (nFrom != 0u) fromOther = nFrom; }
+ nFrom = texelFetch(u_prevOwner, texCoord + ivec2(0, -1), 0).r & 0xFFFu;
+ if (nFrom != fromOwner) { fromIsBorder = true; if (nFrom != 0u) fromOther = nFrom; }
+
+ if (!u_debugDisableAllBorders && !u_debugDisableStaticBorders && fromIsBorder && fromOwner != 0u) {
+ vec4 borderBase = texelFetch(u_palette, ivec2(int(fromOwner) * 2 + 1, 0), 0);
+ bool fromFriendly = false;
+ bool fromEmbargo = false;
+ if (fromOther != 0u) {
+ uint rel = relationCode(fromOwner, fromOther);
+ fromFriendly = isFriendly(rel);
+ fromEmbargo = isEmbargo(rel);
+ }
+ vec3 bColor = applyBorderTint(
+ borderBase.rgb,
+ fromFriendly,
+ fromEmbargo
+ );
+ bColor = applyDefended(bColor, oldIsDefended, texCoord);
+ fromColor = bColor;
+ }
+
+ // Start with FROM layer
+ color = fromColor;
+
+ // Draw a *constant-width* moving border stripe between the FROM and TO snapshots.
+ // Use a planar front (not radial) that moves coherently across tiles based on
+ // the displacement direction from old->new seeds.
+ if (affectedMask != 0u && hasOldSeed && hasNewSeed) {
+ vec2 disp = seedNew - seedOld;
+ vec2 absDisp = abs(disp);
+ vec2 dispSign = vec2(disp.x >= 0.0 ? 1.0 : -1.0, disp.y >= 0.0 ? 1.0 : -1.0);
+ float dispLen = length(disp);
+ if (dispLen > 1e-4) {
+ vec2 dir = vec2(1.0, 0.0);
+ vec2 frontOrigin = seedOld;
+ float frontPos = 0.0;
+ vec2 shift = vec2(0.0);
+
+ if (u_motionMode == 1) {
+ bool xDom = absDisp.x >= absDisp.y;
+ dir = xDom ? vec2(dispSign.x, 0.0) : vec2(0.0, dispSign.y);
+ float len = xDom ? absDisp.x : absDisp.y;
+ frontOrigin = seedOld;
+ frontPos = t * len;
+ shift = dir * (len * (1.0 - t));
+ } else if (u_motionMode == 2) {
+ bool xDom = absDisp.x >= absDisp.y;
+ vec2 axisX = vec2(dispSign.x, 0.0);
+ vec2 axisY = vec2(0.0, dispSign.y);
+ vec2 axis1 = xDom ? axisX : axisY;
+ vec2 axis2 = xDom ? axisY : axisX;
+ float len1 = xDom ? absDisp.x : absDisp.y;
+ float len2 = xDom ? absDisp.y : absDisp.x;
+ float total = len1 + len2;
+ float split = total > 1e-4 ? len1 / total : 0.5;
+ if (t <= split) {
+ float t1 = split > 1e-4 ? t / split : 1.0;
+ dir = axis1;
+ frontOrigin = seedOld;
+ frontPos = t1 * len1;
+ shift = axis1 * (len1 * (1.0 - t1)) + axis2 * len2;
+ } else {
+ float t2 = (t - split) / max(1.0 - split, 1e-4);
+ dir = axis2;
+ frontOrigin = seedOld + axis1 * len1;
+ frontPos = t2 * len2;
+ shift = axis2 * (len2 * (1.0 - t2));
+ }
+ } else if (u_motionMode == 3) {
+ float maxAbs = max(absDisp.x, absDisp.y);
+ float p = t * maxAbs;
+ vec2 remaining = max(absDisp - vec2(p), vec2(0.0));
+ shift = dispSign * remaining;
+ bool xDom = absDisp.x >= absDisp.y;
+ dir = xDom ? vec2(dispSign.x, 0.0) : vec2(0.0, dispSign.y);
+ frontOrigin = seedOld;
+ frontPos = t * maxAbs;
+ } else {
+ dir = disp / dispLen;
+ frontOrigin = seedOld;
+ frontPos = t * dispLen;
+ shift = disp * (1.0 - t);
+ }
+
+ // Project mapCoord onto the displacement direction, measured from frontOrigin.
+ // This gives us a global coordinate along the motion axis.
+ // At t=0, front should be near frontOrigin (s ~ 0).
+ // At t=1, front should be near frontOrigin + dir * frontPos.
+ float s = dot(mapCoord - frontOrigin, dir);
+
+ // Signed distance from the moving front plane.
+ // Positive means the front has passed this point (new territory side).
+ float frontDist = frontPos - s;
+
+ // Compute the sliding position: sample owners at the position where the front currently is.
+ // This ensures owner checks happen at the sliding position, not static.
+ vec2 slideOffsetFront = (frontPos - s) * dir; // Offset from current position to front position
+ vec2 slideCoordFront = mapCoord + slideOffsetFront;
+ ivec2 slideTexFront = clamp(ivec2(slideCoordFront), ivec2(0), ivec2(int(u_mapResolution.x) - 1, int(u_mapResolution.y) - 1));
+
+ // Sample owners at the sliding position
+ uint slideState = texelFetch(u_state, slideTexFront, 0).r;
+ uint slideOwner = slideState & 0xFFFu;
+ bool slideHasFallout = (slideState & 0x2000u) != 0u;
+ bool slideIsDefended = (slideState & 0x1000u) != 0u;
+
+ // Check if we're on a border at the sliding position (this is where the border currently is)
+ bool slideIsBorder = false;
+ bool slideHasFriendly = false;
+ bool slideHasEmbargo = false;
+ uint slideOther = 0u;
+ uint nSlide;
+ ivec2 nSlideTex;
+ nSlideTex = clamp(slideTexFront + ivec2(1, 0), ivec2(0), ivec2(int(u_mapResolution.x) - 1, int(u_mapResolution.y) - 1));
+ nSlide = texelFetch(u_state, nSlideTex, 0).r & 0xFFFu;
+ if (nSlide != slideOwner) { slideIsBorder = true; if (nSlide != 0u) { slideOther = nSlide; uint rel = relationCode(slideOwner, nSlide); slideHasFriendly = slideHasFriendly || isFriendly(rel); slideHasEmbargo = slideHasEmbargo || isEmbargo(rel); } }
+ nSlideTex = clamp(slideTexFront + ivec2(-1, 0), ivec2(0), ivec2(int(u_mapResolution.x) - 1, int(u_mapResolution.y) - 1));
+ nSlide = texelFetch(u_state, nSlideTex, 0).r & 0xFFFu;
+ if (nSlide != slideOwner) { slideIsBorder = true; if (nSlide != 0u) { slideOther = nSlide; uint rel = relationCode(slideOwner, nSlide); slideHasFriendly = slideHasFriendly || isFriendly(rel); slideHasEmbargo = slideHasEmbargo || isEmbargo(rel); } }
+ nSlideTex = clamp(slideTexFront + ivec2(0, 1), ivec2(0), ivec2(int(u_mapResolution.x) - 1, int(u_mapResolution.y) - 1));
+ nSlide = texelFetch(u_state, nSlideTex, 0).r & 0xFFFu;
+ if (nSlide != slideOwner) { slideIsBorder = true; if (nSlide != 0u) { slideOther = nSlide; uint rel = relationCode(slideOwner, nSlide); slideHasFriendly = slideHasFriendly || isFriendly(rel); slideHasEmbargo = slideHasEmbargo || isEmbargo(rel); } }
+ nSlideTex = clamp(slideTexFront + ivec2(0, -1), ivec2(0), ivec2(int(u_mapResolution.x) - 1, int(u_mapResolution.y) - 1));
+ nSlide = texelFetch(u_state, nSlideTex, 0).r & 0xFFFu;
+ if (nSlide != slideOwner) { slideIsBorder = true; if (nSlide != 0u) { slideOther = nSlide; uint rel = relationCode(slideOwner, nSlide); slideHasFriendly = slideHasFriendly || isFriendly(rel); slideHasEmbargo = slideHasEmbargo || isEmbargo(rel); } }
+
+ // Check if we're on a border in the FROM state (retreating side)
+ uint fromSlideState = prevStateAtTex(slideTexFront);
+ uint fromSlideOwner = fromSlideState & 0xFFFu;
+ bool fromSlideDefended = (fromSlideState & 0x1000u) != 0u;
+ bool fromIsBorderAtSlide = false;
+ uint fromOtherAtSlide = 0u;
+ uint nFromSlide;
+ ivec2 nFromSlideTex;
+ nFromSlideTex = clamp(slideTexFront + ivec2(1, 0), ivec2(0), ivec2(int(u_mapResolution.x) - 1, int(u_mapResolution.y) - 1));
+ nFromSlide = texelFetch(u_prevOwner, nFromSlideTex, 0).r & 0xFFFu;
+ if (nFromSlide != fromSlideOwner) { fromIsBorderAtSlide = true; if (nFromSlide != 0u) fromOtherAtSlide = nFromSlide; }
+ nFromSlideTex = clamp(slideTexFront + ivec2(-1, 0), ivec2(0), ivec2(int(u_mapResolution.x) - 1, int(u_mapResolution.y) - 1));
+ nFromSlide = texelFetch(u_prevOwner, nFromSlideTex, 0).r & 0xFFFu;
+ if (nFromSlide != fromSlideOwner) { fromIsBorderAtSlide = true; if (nFromSlide != 0u) fromOtherAtSlide = nFromSlide; }
+ nFromSlideTex = clamp(slideTexFront + ivec2(0, 1), ivec2(0), ivec2(int(u_mapResolution.x) - 1, int(u_mapResolution.y) - 1));
+ nFromSlide = texelFetch(u_prevOwner, nFromSlideTex, 0).r & 0xFFFu;
+ if (nFromSlide != fromSlideOwner) { fromIsBorderAtSlide = true; if (nFromSlide != 0u) fromOtherAtSlide = nFromSlide; }
+ nFromSlideTex = clamp(slideTexFront + ivec2(0, -1), ivec2(0), ivec2(int(u_mapResolution.x) - 1, int(u_mapResolution.y) - 1));
+ nFromSlide = texelFetch(u_prevOwner, nFromSlideTex, 0).r & 0xFFFu;
+ if (nFromSlide != fromSlideOwner) { fromIsBorderAtSlide = true; if (nFromSlide != 0u) fromOtherAtSlide = nFromSlide; }
+
+ // Draw border stripe: check both expanding (TO) and retreating (FROM) sides
+ float stripeWidth = u_debugDisableAllBorders ? 0.0 : 0.5;
+ bool isStripe = abs(frontDist) <= stripeWidth;
+ bool drawExpandingBorder =
+ isStripe && slideIsBorder && slideOwner != 0u && frontDist > 0.0;
+ bool drawRetreatingBorder =
+ isStripe && fromIsBorderAtSlide && fromSlideOwner != 0u && frontDist <= 0.0;
+
+ if (!u_debugDisableAllBorders && (drawExpandingBorder || drawRetreatingBorder)) {
+ uint stripeOwner = drawExpandingBorder ? slideOwner : fromSlideOwner;
+ uint stripeOther = drawExpandingBorder ? slideOther : fromOtherAtSlide;
+
+ if (u_debugStripeFixedColors) {
+ // Debug mode: Use fixed colors
+ if (drawExpandingBorder) {
+ // Expanding: bright red
+ color = vec3(1.0, float(stripeOwner) / 255.0, 0.0);
+ } else {
+ // Retreating: bright blue
+ color = vec3(0.0, float(stripeOwner) / 255.0, 1.0);
+ }
+ } else {
+ // Normal mode: Use actual border colors
+ if (stripeOwner != 0u) {
+ vec4 borderBase = texelFetch(
+ u_palette,
+ ivec2(int(stripeOwner) * 2 + 1, 0),
+ 0
+ );
+ bool stripeFriendly = false;
+ bool stripeEmbargo = false;
+ if (stripeOther != 0u) {
+ uint rel = relationCode(stripeOwner, stripeOther);
+ stripeFriendly = isFriendly(rel);
+ stripeEmbargo = isEmbargo(rel);
+ }
+ bool stripeDefended = drawExpandingBorder
+ ? slideIsDefended
+ : fromSlideDefended;
+ vec3 bColor = applyBorderTint(
+ borderBase.rgb,
+ stripeFriendly,
+ stripeEmbargo
+ );
+ bColor = applyDefended(bColor, stripeDefended, slideTexFront);
+ color = bColor;
+ }
+ }
+ } else if (frontDist > stripeWidth) {
+ // Front has passed; show the new fill/border at the shifted position
+ vec2 slideCoordFill = mapCoord - shift;
+ ivec2 slideTexFill = clamp(ivec2(slideCoordFill), ivec2(0), ivec2(int(u_mapResolution.x) - 1, int(u_mapResolution.y) - 1));
+
+ uint fillState = texelFetch(u_state, slideTexFill, 0).r;
+ uint fillOwner = fillState & 0xFFFu;
+ bool fillHasFallout = (fillState & 0x2000u) != 0u;
+ bool fillIsDefended = (fillState & 0x1000u) != 0u;
+
+ bool fillIsBorder = false;
+ bool fillHasFriendly = false;
+ bool fillHasEmbargo = false;
+ uint fillOther = 0u;
+ uint nFill;
+ ivec2 nFillTex;
+ nFillTex = clamp(slideTexFill + ivec2(1, 0), ivec2(0), ivec2(int(u_mapResolution.x) - 1, int(u_mapResolution.y) - 1));
+ nFill = texelFetch(u_state, nFillTex, 0).r & 0xFFFu;
+ if (nFill != fillOwner) { fillIsBorder = true; if (nFill != 0u) { fillOther = nFill; uint rel = relationCode(fillOwner, nFill); fillHasFriendly = fillHasFriendly || isFriendly(rel); fillHasEmbargo = fillHasEmbargo || isEmbargo(rel); } }
+ nFillTex = clamp(slideTexFill + ivec2(-1, 0), ivec2(0), ivec2(int(u_mapResolution.x) - 1, int(u_mapResolution.y) - 1));
+ nFill = texelFetch(u_state, nFillTex, 0).r & 0xFFFu;
+ if (nFill != fillOwner) { fillIsBorder = true; if (nFill != 0u) { fillOther = nFill; uint rel = relationCode(fillOwner, nFill); fillHasFriendly = fillHasFriendly || isFriendly(rel); fillHasEmbargo = fillHasEmbargo || isEmbargo(rel); } }
+ nFillTex = clamp(slideTexFill + ivec2(0, 1), ivec2(0), ivec2(int(u_mapResolution.x) - 1, int(u_mapResolution.y) - 1));
+ nFill = texelFetch(u_state, nFillTex, 0).r & 0xFFFu;
+ if (nFill != fillOwner) { fillIsBorder = true; if (nFill != 0u) { fillOther = nFill; uint rel = relationCode(fillOwner, nFill); fillHasFriendly = fillHasFriendly || isFriendly(rel); fillHasEmbargo = fillHasEmbargo || isEmbargo(rel); } }
+ nFillTex = clamp(slideTexFill + ivec2(0, -1), ivec2(0), ivec2(int(u_mapResolution.x) - 1, int(u_mapResolution.y) - 1));
+ nFill = texelFetch(u_state, nFillTex, 0).r & 0xFFFu;
+ if (nFill != fillOwner) { fillIsBorder = true; if (nFill != 0u) { fillOther = nFill; uint rel = relationCode(fillOwner, nFill); fillHasFriendly = fillHasFriendly || isFriendly(rel); fillHasEmbargo = fillHasEmbargo || isEmbargo(rel); } }
+
+ vec3 toColor = baseTerrainColor;
+ if (fillOwner != 0u) {
+ vec4 toBase = texelFetch(u_palette, ivec2(int(fillOwner) * 2, 0), 0);
+ vec4 toBorderBase = texelFetch(
+ u_palette,
+ ivec2(int(fillOwner) * 2 + 1, 0),
+ 0
+ );
+ bool toPrimary = patternIsPrimary(fillOwner, slideTexFill);
+ vec3 toPatternColor = toPrimary ? toBase.rgb : toBorderBase.rgb;
+ toColor = mix(baseTerrainColor, toPatternColor, u_alpha);
+ if (!u_debugDisableAllBorders && !u_debugDisableStaticBorders && fillIsBorder) {
+ vec3 bColor = applyBorderTint(
+ toBorderBase.rgb,
+ fillHasFriendly,
+ fillHasEmbargo
+ );
+ bColor = applyDefended(bColor, fillIsDefended, slideTexFill);
+ toColor = bColor;
+ }
+ } else if (fillHasFallout) {
+ toColor = mix(baseTerrainColor, u_fallout.rgb, u_alpha);
+ }
+
+ color = toColor;
+ }
+ // If frontDist < -stripeWidth, we're ahead of the front, so keep fromColor (already set).
+ }
+ }
+
+ }
+
+ bool pendingOwnerChange = latestOwner != owner;
+ if (pendingOwnerChange && !useContestedFill && !u_alternativeView) {
+ vec3 hintColor = baseTerrainColor;
+ if (latestOwner != 0u) {
+ vec3 latestColor = texelFetch(
+ u_palette,
+ ivec2(int(latestOwner) * 2, 0),
+ 0
+ ).rgb;
+ hintColor = mix(baseTerrainColor, latestColor, u_alpha * 0.12);
+ }
+ color = mix(color, hintColor, 0.5);
+ }
+
+ 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);
+ }
+
+ // Output fully opaque since we render terrain as background
+ outColor = vec4(color, 1.0);
+ }
+ `;
+
+ 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/WebGLTerritoryBackend.ts b/src/client/graphics/layers/WebGLTerritoryBackend.ts
new file mode 100644
index 000000000..e683b2d61
--- /dev/null
+++ b/src/client/graphics/layers/WebGLTerritoryBackend.ts
@@ -0,0 +1,1669 @@
+import { Colord } from "colord";
+import { Theme } from "../../../core/configuration/Config";
+import { EventBus } from "../../../core/EventBus";
+import { ColoredTeams, PlayerType, Team } from "../../../core/game/Game";
+import { euclDistFN, TileRef } from "../../../core/game/GameMap";
+import { GameUpdateType } from "../../../core/game/GameUpdates";
+import { GameView, PlayerView } from "../../../core/game/GameView";
+import { UserSettings } from "../../../core/game/UserSettings";
+import {
+ AlternateViewEvent,
+ ContextMenuEvent,
+ MouseOverEvent,
+} from "../../InputHandler";
+import { FrameProfiler } from "../FrameProfiler";
+import { getHoverInfo } from "../HoverInfo";
+import { TransformHandler } from "../TransformHandler";
+import { TerritoryBackend } from "./TerritoryBackend";
+import { TerritoryWebGLRenderer } from "./TerritoryWebGLRenderer";
+
+const CONTEST_ID_MASK = 0x7fff;
+const CONTEST_ATTACKER_EVER_BIT = 0x8000;
+const CONTEST_TIME_WRAP = 32768;
+const DEFAULT_CONTEST_DURATION_TICKS = 2;
+const ENABLE_CONTEST_TRACKING = false;
+const CONTEST_STRENGTH_EMA_ALPHA = 0.8;
+const CONTEST_STRENGTH_MIN = 0.01;
+const CONTEST_STRENGTH_MAX = 0.95;
+const DEBUG_TERRITORY_OVERLAY = false;
+
+type ContestComponent = {
+ id: number;
+ attacker: number;
+ defender: number;
+ lastActivityPacked: number;
+ tiles: TileRef[];
+ strength: number;
+};
+
+export class WebGLTerritoryBackend implements TerritoryBackend {
+ readonly id = "webgl";
+
+ profileName(): string {
+ return "WebGLTerritoryBackend:renderLayer";
+ }
+
+ private userSettings = new UserSettings();
+ private borderAnimTime = 0;
+
+ private cachedTerritoryPatternsEnabled: boolean | undefined;
+
+ private theme: Theme;
+
+ // Used for spawn highlighting
+ private highlightCanvas: HTMLCanvasElement;
+ private highlightContext: CanvasRenderingContext2D;
+
+ private highlightedTerritory: PlayerView | null = null;
+ private territoryRenderer: TerritoryWebGLRenderer | null = null;
+
+ private alternativeView = false;
+ private lastMousePosition: { x: number; y: number } | null = null;
+
+ private lastFocusedPlayer: PlayerView | null = null;
+ private lastMyPlayerSmallId: number | null = null;
+ private lastPaletteSignature: string | null = null;
+ private contestDurationTicks = DEFAULT_CONTEST_DURATION_TICKS;
+ 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();
+ private contestTileCount = 0;
+ private contestEnabled = ENABLE_CONTEST_TRACKING;
+ private tickSnapshotPending = false;
+ private tickTimeMsCurrent = 0;
+ private tickTimeMsPrev = 0;
+ private tickTimeMsOlder = 0;
+ private tickNumberCurrent: number | null = null;
+ private tickNumberPrev: number | null = null;
+ private tickNumberOlder: number | null = null;
+ private interpolationDelayMs = 100;
+ private lastInterpolationPair: "prevCurrent" | "olderPrev" = "prevCurrent";
+
+ // Runtime debug controls (UI)
+ private tripleBufferEnabled = true;
+ private interpolationDelayMode: "ema" | "fixed50" | "fixed100" | "fixed200" =
+ "ema";
+ private tickIntervalEmaMs = 0;
+ private readonly TICK_INTERVAL_EMA_ALPHA = 0.2;
+ private smoothingDebugUi: HTMLDivElement | null = null;
+ private contestedPatternMode: "blueNoise" | "checkerboard" | "bayer4x4" =
+ "blueNoise";
+ private debugDisableStaticBorders = false;
+ private debugDisableAllBorders = false;
+ private motionMode: "euclidean" | "axisSnap" | "manhattan" | "chebyshev" =
+ "euclidean";
+ private seedSamplingMode: "none" | "2x2" | "3x3" = "2x2";
+ private debugStripeFixedColors = false;
+ private failureReason: string | null = null;
+ private readonly contextLostHandler = (event: Event) => {
+ event.preventDefault();
+ this.failureReason = "WebGL context lost.";
+ };
+
+ constructor(
+ private game: GameView,
+ private eventBus: EventBus,
+ private transformHandler: TransformHandler,
+ ) {
+ this.theme = game.config().theme();
+ this.cachedTerritoryPatternsEnabled = undefined;
+ this.lastMyPlayerSmallId = game.myPlayer()?.smallID() ?? null;
+ }
+
+ shouldTransform(): boolean {
+ return true;
+ }
+
+ tick() {
+ const tickProfile = FrameProfiler.start();
+ const now = this.nowMs();
+ const currentTheme = this.game.config().theme();
+ if (currentTheme !== this.theme) {
+ this.theme = currentTheme;
+ this.redraw();
+ }
+ if (this.game.inSpawnPhase()) {
+ this.spawnHighlight();
+ }
+
+ const patternsEnabled = this.userSettings.territoryPatterns();
+ if (this.cachedTerritoryPatternsEnabled !== patternsEnabled) {
+ this.cachedTerritoryPatternsEnabled = patternsEnabled;
+ this.redraw();
+ }
+ this.refreshPaletteIfNeeded();
+
+ const tickNumber = this.game.ticks();
+ if (this.tickNumberCurrent !== tickNumber) {
+ this.tickNumberOlder = this.tickNumberPrev;
+ this.tickNumberPrev = this.tickNumberCurrent;
+ this.tickNumberCurrent = tickNumber;
+
+ this.tickTimeMsOlder = this.tickTimeMsPrev;
+ this.tickTimeMsPrev = this.tickTimeMsCurrent;
+ this.tickTimeMsCurrent = now;
+
+ const lastInterval = this.tickTimeMsCurrent - this.tickTimeMsPrev;
+ if (lastInterval > 0) {
+ // Track tick interval EMA for stable delay at variable speeds.
+ this.tickIntervalEmaMs =
+ this.tickIntervalEmaMs <= 0
+ ? lastInterval
+ : this.tickIntervalEmaMs * (1 - this.TICK_INTERVAL_EMA_ALPHA) +
+ lastInterval * this.TICK_INTERVAL_EMA_ALPHA;
+
+ // Choose delay mode.
+ if (this.interpolationDelayMode === "fixed50") {
+ this.interpolationDelayMs = 50;
+ } else if (this.interpolationDelayMode === "fixed100") {
+ this.interpolationDelayMs = 100;
+ } else if (this.interpolationDelayMode === "fixed200") {
+ this.interpolationDelayMs = 200;
+ } else {
+ // "ema": render roughly one tick behind using the raw EMA interval.
+ // Do not clamp in EMA mode (debug requested).
+ this.interpolationDelayMs = this.tickIntervalEmaMs;
+ }
+ }
+
+ if (this.territoryRenderer) {
+ this.tickSnapshotPending = true;
+ }
+ }
+
+ this.game.recentlyUpdatedTiles().forEach((t) => this.markTile(t));
+ if (this.contestEnabled) {
+ const ownerUpdates = this.game.recentlyUpdatedOwnerTiles();
+ const nowTickPacked = this.packContestTick(this.game.ticks());
+ this.applyContestChanges(ownerUpdates, nowTickPacked);
+ this.updateContestState(nowTickPacked);
+ this.updateContestStrengths();
+ let tileCount = 0;
+ for (const component of this.contestComponents.values()) {
+ tileCount += component.tiles.length;
+ }
+ this.contestTileCount = tileCount;
+ } else {
+ this.contestTileCount = 0;
+ this.contestActive = false;
+ }
+ const updates = this.game.updatesSinceLastTick();
+
+ // Detect alliance mutations
+ const myPlayer = this.game.myPlayer();
+ if (myPlayer) {
+ updates?.[GameUpdateType.BrokeAlliance]?.forEach((update) => {
+ const territory = this.game.playerBySmallID(update.betrayedID);
+ if (territory && territory instanceof PlayerView) {
+ this.territoryRenderer?.refreshPalette();
+ }
+ });
+
+ updates?.[GameUpdateType.AllianceRequestReply]?.forEach((update) => {
+ if (
+ update.accepted &&
+ (update.request.requestorID === myPlayer.smallID() ||
+ update.request.recipientID === myPlayer.smallID())
+ ) {
+ const territoryId =
+ update.request.requestorID === myPlayer.smallID()
+ ? update.request.recipientID
+ : update.request.requestorID;
+ const territory = this.game.playerBySmallID(territoryId);
+ if (territory && territory instanceof PlayerView) {
+ this.territoryRenderer?.refreshPalette();
+ }
+ }
+ });
+ updates?.[GameUpdateType.EmbargoEvent]?.forEach((update) => {
+ const player = this.game.playerBySmallID(update.playerID) as PlayerView;
+ const embargoed = this.game.playerBySmallID(
+ update.embargoedID,
+ ) as PlayerView;
+
+ if (
+ player.id() === myPlayer?.id() ||
+ embargoed.id() === myPlayer?.id()
+ ) {
+ this.territoryRenderer?.refreshPalette();
+ }
+ });
+ }
+
+ const focusedPlayer = this.game.focusedPlayer();
+ if (focusedPlayer !== this.lastFocusedPlayer) {
+ this.redraw();
+ this.lastFocusedPlayer = focusedPlayer;
+ }
+
+ const currentMyPlayer = this.game.myPlayer()?.smallID() ?? null;
+ if (currentMyPlayer !== this.lastMyPlayerSmallId) {
+ this.redraw();
+ }
+ FrameProfiler.end("TerritoryLayer:tick", tickProfile);
+ }
+
+ private spawnHighlight() {
+ this.highlightContext.clearRect(
+ 0,
+ 0,
+ this.game.width(),
+ this.game.height(),
+ );
+
+ this.drawFocusedPlayerHighlight();
+
+ const humans = this.game
+ .playerViews()
+ .filter((p) => p.type() === PlayerType.Human);
+
+ const focusedPlayer = this.game.focusedPlayer();
+ const teamColors = Object.values(ColoredTeams);
+ for (const human of humans) {
+ if (human === focusedPlayer) {
+ continue;
+ }
+ const center = human.nameLocation();
+ if (!center) {
+ continue;
+ }
+ const centerTile = this.game.ref(center.x, center.y);
+ if (!centerTile) {
+ continue;
+ }
+ let color = this.theme.spawnHighlightColor();
+ const myPlayer = this.game.myPlayer();
+ if (myPlayer !== null && myPlayer !== human && myPlayer.team() === null) {
+ // In FFA games (when team === null), use default yellow spawn highlight color
+ color = this.theme.spawnHighlightColor();
+ } else if (myPlayer !== null && myPlayer !== human) {
+ // In Team games, the spawn highlight color becomes that player's team color
+ const team = human.team();
+ if (team !== null && teamColors.includes(team)) {
+ color = this.theme.teamColor(team);
+ } else {
+ if (myPlayer.isFriendly(human)) {
+ color = this.theme.spawnHighlightTeamColor();
+ } else {
+ color = this.theme.spawnHighlightColor();
+ }
+ }
+ }
+
+ for (const tile of this.game.bfs(
+ centerTile,
+ euclDistFN(centerTile, 9, true),
+ )) {
+ if (!this.game.hasOwner(tile)) {
+ this.paintHighlightTile(tile, color, 255);
+ }
+ }
+ }
+ }
+
+ private drawFocusedPlayerHighlight() {
+ const focusedPlayer = this.game.focusedPlayer();
+
+ if (!focusedPlayer) {
+ return;
+ }
+ const center = focusedPlayer.nameLocation();
+ if (!center) {
+ return;
+ }
+ // Breathing border animation
+ this.borderAnimTime += 0.5;
+ const minRad = 8;
+ const maxRad = 24;
+ const radius =
+ minRad + (maxRad - minRad) * (0.5 + 0.5 * Math.sin(this.borderAnimTime));
+
+ const baseColor = this.theme.spawnHighlightSelfColor();
+ let teamColor: Colord | null = null;
+
+ const team: Team | null = focusedPlayer.team();
+ if (team !== null && Object.values(ColoredTeams).includes(team)) {
+ teamColor = this.theme.teamColor(team).alpha(0.5);
+ } else {
+ teamColor = baseColor;
+ }
+
+ this.drawBreathingRing(
+ center.x,
+ center.y,
+ minRad,
+ maxRad,
+ radius,
+ baseColor,
+ teamColor,
+ );
+
+ this.drawTeammateHighlights(minRad, maxRad, radius);
+ }
+
+ private drawTeammateHighlights(
+ minRad: number,
+ maxRad: number,
+ radius: number,
+ ) {
+ const myPlayer = this.game.myPlayer();
+ if (myPlayer === null || myPlayer.team() === null) {
+ return;
+ }
+
+ const teammates = this.game
+ .playerViews()
+ .filter((p) => p !== myPlayer && myPlayer.isOnSameTeam(p));
+
+ const teammateMinRad = 5;
+ const teammateMaxRad = 14;
+ const teammateRadius =
+ teammateMinRad +
+ (teammateMaxRad - teammateMinRad) *
+ ((radius - minRad) / (maxRad - minRad));
+
+ const teamColors = Object.values(ColoredTeams);
+ for (const teammate of teammates) {
+ const center = teammate.nameLocation();
+ if (!center) {
+ continue;
+ }
+
+ const team = teammate.team();
+ let baseColor: Colord;
+ let breathingColor: Colord;
+
+ if (team !== null && teamColors.includes(team)) {
+ baseColor = this.theme.teamColor(team).alpha(0.5);
+ breathingColor = this.theme.teamColor(team).alpha(0.5);
+ } else {
+ baseColor = this.theme.spawnHighlightTeamColor();
+ breathingColor = this.theme.spawnHighlightTeamColor();
+ }
+
+ this.drawBreathingRing(
+ center.x,
+ center.y,
+ teammateMinRad,
+ teammateMaxRad,
+ teammateRadius,
+ baseColor,
+ breathingColor,
+ );
+ }
+ }
+
+ 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.redraw();
+ this.ensureSmoothingDebugUi();
+ }
+
+ getFailureReason(): string | null {
+ return this.failureReason;
+ }
+
+ dispose() {
+ this.smoothingDebugUi?.remove();
+ this.smoothingDebugUi = null;
+ this.territoryRenderer?.canvas.removeEventListener(
+ "webglcontextlost",
+ this.contextLostHandler,
+ );
+ this.territoryRenderer?.dispose();
+ this.territoryRenderer = null;
+ }
+
+ private ensureSmoothingDebugUi() {
+ if (!DEBUG_TERRITORY_OVERLAY) return;
+ if (this.smoothingDebugUi) return;
+
+ const root = document.createElement("div");
+ root.style.position = "fixed";
+ root.style.right = "10px";
+ root.style.top = "10px";
+ root.style.zIndex = "9999";
+ root.style.background = "rgba(0, 0, 0, 0.6)";
+ root.style.color = "rgba(255, 255, 255, 0.92)";
+ root.style.padding = "8px 10px";
+ root.style.borderRadius = "8px";
+ root.style.font = "12px monospace";
+ root.style.userSelect = "none";
+ root.style.touchAction = "none";
+
+ const title = document.createElement("div");
+ title.textContent = "Territory smoothing";
+ title.style.fontWeight = "700";
+ title.style.marginBottom = "6px";
+ title.style.cursor = "move";
+ root.appendChild(title);
+
+ // Restore last position (if any)
+ const POS_KEY = "debug.territorySmoothingPanelPos.v1";
+ try {
+ const raw = localStorage.getItem(POS_KEY);
+ if (raw) {
+ const parsed = JSON.parse(raw) as { left: number; top: number };
+ if (
+ typeof parsed?.left === "number" &&
+ typeof parsed?.top === "number" &&
+ Number.isFinite(parsed.left) &&
+ Number.isFinite(parsed.top)
+ ) {
+ root.style.left = `${parsed.left}px`;
+ root.style.top = `${parsed.top}px`;
+ root.style.right = "auto";
+ }
+ }
+ } catch {
+ // ignore
+ }
+
+ // Make draggable via title bar
+ let dragging = false;
+ let dragDx = 0;
+ let dragDy = 0;
+ const clampPos = (left: number, top: number) => {
+ const maxLeft = Math.max(0, window.innerWidth - root.offsetWidth);
+ const maxTop = Math.max(0, window.innerHeight - root.offsetHeight);
+ return {
+ left: Math.max(0, Math.min(maxLeft, left)),
+ top: Math.max(0, Math.min(maxTop, top)),
+ };
+ };
+
+ title.addEventListener("pointerdown", (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ dragging = true;
+ title.setPointerCapture(e.pointerId);
+ const rect = root.getBoundingClientRect();
+ dragDx = e.clientX - rect.left;
+ dragDy = e.clientY - rect.top;
+ // Switch to explicit left/top positioning
+ root.style.left = `${rect.left}px`;
+ root.style.top = `${rect.top}px`;
+ root.style.right = "auto";
+ });
+
+ title.addEventListener("pointermove", (e) => {
+ if (!dragging) return;
+ e.preventDefault();
+ e.stopPropagation();
+ const next = clampPos(e.clientX - dragDx, e.clientY - dragDy);
+ root.style.left = `${next.left}px`;
+ root.style.top = `${next.top}px`;
+ try {
+ localStorage.setItem(POS_KEY, JSON.stringify(next));
+ } catch {
+ // ignore
+ }
+ });
+
+ const endDrag = (e: PointerEvent) => {
+ if (!dragging) return;
+ e.preventDefault();
+ e.stopPropagation();
+ dragging = false;
+ try {
+ title.releasePointerCapture(e.pointerId);
+ } catch {
+ // ignore
+ }
+ };
+ title.addEventListener("pointerup", endDrag);
+ title.addEventListener("pointercancel", endDrag);
+
+ const tripleRow = document.createElement("label");
+ tripleRow.style.display = "flex";
+ tripleRow.style.alignItems = "center";
+ tripleRow.style.gap = "6px";
+ tripleRow.style.marginBottom = "6px";
+
+ const tripleCheckbox = document.createElement("input");
+ tripleCheckbox.type = "checkbox";
+ tripleCheckbox.checked = this.tripleBufferEnabled;
+ tripleCheckbox.addEventListener("change", () => {
+ this.tripleBufferEnabled = tripleCheckbox.checked;
+ });
+
+ const tripleText = document.createElement("span");
+ tripleText.textContent = "triple buffer (olderPrev)";
+ tripleRow.appendChild(tripleCheckbox);
+ tripleRow.appendChild(tripleText);
+ root.appendChild(tripleRow);
+
+ const modeRow = document.createElement("label");
+ modeRow.style.display = "flex";
+ modeRow.style.alignItems = "center";
+ modeRow.style.gap = "6px";
+ modeRow.style.marginBottom = "6px";
+
+ const modeText = document.createElement("span");
+ modeText.textContent = "delay mode:";
+
+ const modeSelect = document.createElement("select");
+ modeSelect.style.font = "12px monospace";
+ modeSelect.style.background = "rgba(0,0,0,0.35)";
+ modeSelect.style.color = "rgba(255,255,255,0.92)";
+ modeSelect.style.border = "1px solid rgba(255,255,255,0.2)";
+ modeSelect.style.borderRadius = "4px";
+ modeSelect.style.padding = "2px 4px";
+
+ const modes: Array<"ema" | "fixed200" | "fixed100" | "fixed50"> = [
+ "ema",
+ "fixed200",
+ "fixed100",
+ "fixed50",
+ ];
+ for (const m of modes) {
+ const opt = document.createElement("option");
+ opt.value = m;
+ opt.textContent = m;
+ modeSelect.appendChild(opt);
+ }
+ modeSelect.value = this.interpolationDelayMode;
+ modeSelect.addEventListener("change", () => {
+ const v = modeSelect.value as typeof this.interpolationDelayMode;
+ this.interpolationDelayMode = v;
+ // Apply immediately using current EMA if available, otherwise fall back to existing delay.
+ if (v === "fixed50") this.interpolationDelayMs = 50;
+ else if (v === "fixed100") this.interpolationDelayMs = 100;
+ else if (v === "fixed200") this.interpolationDelayMs = 200;
+ else if (this.tickIntervalEmaMs > 0) {
+ // "ema": do not clamp (debug requested)
+ this.interpolationDelayMs = this.tickIntervalEmaMs;
+ }
+ });
+
+ modeRow.appendChild(modeText);
+ modeRow.appendChild(modeSelect);
+ root.appendChild(modeRow);
+
+ // Contested drawing controls
+ const contestedRow = document.createElement("label");
+ contestedRow.style.display = "flex";
+ contestedRow.style.alignItems = "center";
+ contestedRow.style.gap = "6px";
+ contestedRow.style.marginBottom = "6px";
+
+ const contestedCheckbox = document.createElement("input");
+ contestedCheckbox.type = "checkbox";
+ contestedCheckbox.checked = this.contestEnabled;
+ contestedCheckbox.addEventListener("change", () => {
+ const enabled = contestedCheckbox.checked;
+ this.contestEnabled = enabled;
+ this.contestTileCount = 0;
+ this.contestActive = false;
+ if (enabled) {
+ this.ensureContestScratch();
+ this.syncContestStateToRenderer();
+ } else {
+ this.contestComponents.clear();
+ }
+ this.territoryRenderer?.setContestEnabled(enabled);
+ this.territoryRenderer?.markAllDirty();
+ });
+
+ const contestedText = document.createElement("span");
+ contestedText.textContent = "contested draw";
+ contestedRow.appendChild(contestedCheckbox);
+ contestedRow.appendChild(contestedText);
+ root.appendChild(contestedRow);
+
+ const contestedModeRow = document.createElement("label");
+ contestedModeRow.style.display = "flex";
+ contestedModeRow.style.alignItems = "center";
+ contestedModeRow.style.gap = "6px";
+ contestedModeRow.style.marginBottom = "0px";
+
+ const contestedModeText = document.createElement("span");
+ contestedModeText.textContent = "contested pattern:";
+
+ const contestedModeSelect = document.createElement("select");
+ contestedModeSelect.style.font = "12px monospace";
+ contestedModeSelect.style.background = "rgba(0,0,0,0.35)";
+ contestedModeSelect.style.color = "rgba(255,255,255,0.92)";
+ contestedModeSelect.style.border = "1px solid rgba(255,255,255,0.2)";
+ contestedModeSelect.style.borderRadius = "4px";
+ contestedModeSelect.style.padding = "2px 4px";
+
+ const contestedModes: Array<"blueNoise" | "checkerboard" | "bayer4x4"> = [
+ "blueNoise",
+ "checkerboard",
+ "bayer4x4",
+ ];
+ for (const m of contestedModes) {
+ const opt = document.createElement("option");
+ opt.value = m;
+ opt.textContent = m;
+ contestedModeSelect.appendChild(opt);
+ }
+ contestedModeSelect.value = this.contestedPatternMode;
+ contestedModeSelect.addEventListener("change", () => {
+ const v = contestedModeSelect.value as
+ | "blueNoise"
+ | "checkerboard"
+ | "bayer4x4";
+ this.contestedPatternMode = v;
+ this.territoryRenderer?.setContestPatternMode(v);
+ this.territoryRenderer?.markAllDirty();
+ });
+
+ contestedModeRow.appendChild(contestedModeText);
+ contestedModeRow.appendChild(contestedModeSelect);
+ root.appendChild(contestedModeRow);
+
+ // Debug: hide all borders
+ const allBordersRow = document.createElement("label");
+ allBordersRow.style.display = "flex";
+ allBordersRow.style.alignItems = "center";
+ allBordersRow.style.gap = "6px";
+ allBordersRow.style.marginTop = "6px";
+
+ const allBordersCheckbox = document.createElement("input");
+ allBordersCheckbox.type = "checkbox";
+ allBordersCheckbox.checked = this.debugDisableAllBorders;
+ allBordersCheckbox.addEventListener("change", () => {
+ const disabled = allBordersCheckbox.checked;
+ this.debugDisableAllBorders = disabled;
+ this.territoryRenderer?.setDebugDisableAllBorders(disabled);
+ this.territoryRenderer?.markAllDirty();
+ });
+
+ const allBordersText = document.createElement("span");
+ allBordersText.textContent = "hide all borders";
+ allBordersRow.appendChild(allBordersCheckbox);
+ allBordersRow.appendChild(allBordersText);
+ root.appendChild(allBordersRow);
+
+ // Debug: hide non-smoothed (static) borders
+ const staticBordersRow = document.createElement("label");
+ staticBordersRow.style.display = "flex";
+ staticBordersRow.style.alignItems = "center";
+ staticBordersRow.style.gap = "6px";
+ staticBordersRow.style.marginTop = "6px";
+
+ const staticBordersCheckbox = document.createElement("input");
+ staticBordersCheckbox.type = "checkbox";
+ staticBordersCheckbox.checked = this.debugDisableStaticBorders;
+ staticBordersCheckbox.addEventListener("change", () => {
+ const disabled = staticBordersCheckbox.checked;
+ this.debugDisableStaticBorders = disabled;
+ this.territoryRenderer?.setDebugDisableStaticBorders(disabled);
+ this.territoryRenderer?.markAllDirty();
+ });
+
+ const staticBordersText = document.createElement("span");
+ staticBordersText.textContent = "hide static borders";
+ staticBordersRow.appendChild(staticBordersCheckbox);
+ staticBordersRow.appendChild(staticBordersText);
+ root.appendChild(staticBordersRow);
+
+ // Seed sampling mode dropdown (none / 2x2 / 3x3)
+ const seedSamplingRow = document.createElement("label");
+ seedSamplingRow.style.display = "flex";
+ seedSamplingRow.style.alignItems = "center";
+ seedSamplingRow.style.gap = "6px";
+ seedSamplingRow.style.marginTop = "6px";
+
+ const seedSamplingText = document.createElement("span");
+ seedSamplingText.textContent = "seed sampling";
+
+ const seedSamplingSelect = document.createElement("select");
+ seedSamplingSelect.style.background = "rgba(0,0,0,0.5)";
+ seedSamplingSelect.style.color = "#fff";
+ seedSamplingSelect.style.border = "1px solid rgba(255,255,255,0.2)";
+ seedSamplingSelect.style.borderRadius = "4px";
+ seedSamplingSelect.style.padding = "2px 4px";
+
+ const seedModes: Array<"none" | "2x2" | "3x3"> = ["none", "2x2", "3x3"];
+ for (const m of seedModes) {
+ const opt = document.createElement("option");
+ opt.value = m;
+ opt.textContent = m;
+ seedSamplingSelect.appendChild(opt);
+ }
+ seedSamplingSelect.value = this.seedSamplingMode;
+ seedSamplingSelect.addEventListener("change", () => {
+ const v = seedSamplingSelect.value as "none" | "2x2" | "3x3";
+ this.seedSamplingMode = v;
+ this.territoryRenderer?.setSeedSamplingMode(v);
+ this.territoryRenderer?.markAllDirty();
+ });
+
+ seedSamplingRow.appendChild(seedSamplingText);
+ seedSamplingRow.appendChild(seedSamplingSelect);
+ root.appendChild(seedSamplingRow);
+
+ // Motion mode dropdown
+ const motionModeRow = document.createElement("label");
+ motionModeRow.style.display = "flex";
+ motionModeRow.style.alignItems = "center";
+ motionModeRow.style.gap = "6px";
+ motionModeRow.style.marginTop = "6px";
+
+ const motionModeText = document.createElement("span");
+ motionModeText.textContent = "motion mode";
+
+ const motionModeSelect = document.createElement("select");
+ motionModeSelect.style.background = "rgba(0,0,0,0.5)";
+ motionModeSelect.style.color = "#fff";
+ motionModeSelect.style.border = "1px solid rgba(255,255,255,0.2)";
+ motionModeSelect.style.borderRadius = "4px";
+ motionModeSelect.style.padding = "2px 4px";
+
+ const motionModes: Array<
+ "euclidean" | "axisSnap" | "manhattan" | "chebyshev"
+ > = ["euclidean", "axisSnap", "manhattan", "chebyshev"];
+ for (const m of motionModes) {
+ const opt = document.createElement("option");
+ opt.value = m;
+ opt.textContent = m;
+ motionModeSelect.appendChild(opt);
+ }
+ motionModeSelect.value = this.motionMode;
+ motionModeSelect.addEventListener("change", () => {
+ const v = motionModeSelect.value as
+ | "euclidean"
+ | "axisSnap"
+ | "manhattan"
+ | "chebyshev";
+ this.motionMode = v;
+ this.territoryRenderer?.setMotionMode(v);
+ this.territoryRenderer?.markAllDirty();
+ });
+
+ motionModeRow.appendChild(motionModeText);
+ motionModeRow.appendChild(motionModeSelect);
+ root.appendChild(motionModeRow);
+
+ // Debug: fixed stripe colors
+ const stripeColorsRow = document.createElement("label");
+ stripeColorsRow.style.display = "flex";
+ stripeColorsRow.style.alignItems = "center";
+ stripeColorsRow.style.gap = "6px";
+ stripeColorsRow.style.marginTop = "6px";
+
+ const stripeColorsCheckbox = document.createElement("input");
+ stripeColorsCheckbox.type = "checkbox";
+ stripeColorsCheckbox.checked = this.debugStripeFixedColors;
+ stripeColorsCheckbox.addEventListener("change", () => {
+ const enabled = stripeColorsCheckbox.checked;
+ this.debugStripeFixedColors = enabled;
+ this.territoryRenderer?.setDebugStripeFixedColors(enabled);
+ this.territoryRenderer?.markAllDirty();
+ });
+
+ const stripeColorsText = document.createElement("span");
+ stripeColorsText.textContent =
+ "fixed stripe colors (red=expand, blue=retreat, green=owner)";
+ stripeColorsRow.appendChild(stripeColorsCheckbox);
+ stripeColorsRow.appendChild(stripeColorsText);
+ root.appendChild(stripeColorsRow);
+
+ document.body.appendChild(root);
+ this.smoothingDebugUi = root;
+ }
+
+ onMouseOver(event: MouseOverEvent) {
+ this.lastMousePosition = { x: event.x, y: event.y };
+ this.updateHighlightedTerritory();
+ }
+
+ private updateHighlightedTerritory() {
+ if (!this.lastMousePosition || !this.territoryRenderer) {
+ return;
+ }
+
+ const cell = this.transformHandler.screenToWorldCoordinates(
+ this.lastMousePosition.x,
+ this.lastMousePosition.y,
+ );
+ const previousTerritory = this.highlightedTerritory;
+ const info = getHoverInfo(this.game, cell);
+ let territory: PlayerView | null = null;
+ if (info.player) {
+ territory = info.player;
+ } else if (info.unit) {
+ territory = info.unit.owner();
+ }
+
+ if (territory) {
+ this.highlightedTerritory = territory;
+ } else {
+ this.highlightedTerritory = null;
+ }
+
+ if (previousTerritory?.id() !== this.highlightedTerritory?.id()) {
+ this.territoryRenderer.setHoveredPlayerId(
+ this.highlightedTerritory?.smallID() ?? null,
+ );
+ }
+ }
+
+ redraw() {
+ this.lastMyPlayerSmallId = this.game.myPlayer()?.smallID() ?? null;
+ this.cachedTerritoryPatternsEnabled = this.userSettings.territoryPatterns();
+ this.configureRenderers();
+ if (this.contestEnabled) {
+ this.ensureContestScratch();
+ this.syncContestStateToRenderer();
+ } else {
+ this.contestActive = false;
+ this.contestComponents.clear();
+ this.contestFreeIds = [];
+ this.contestNextId = 1;
+ }
+
+ // Add a second canvas for highlights
+ this.highlightCanvas = document.createElement("canvas");
+ const highlightContext = this.highlightCanvas.getContext("2d", {
+ alpha: true,
+ });
+ if (highlightContext === null) throw new Error("2d context not supported");
+ this.highlightContext = highlightContext;
+ this.highlightCanvas.width = this.game.width();
+ this.highlightCanvas.height = this.game.height();
+ }
+
+ private configureRenderers() {
+ this.territoryRenderer?.canvas.removeEventListener(
+ "webglcontextlost",
+ this.contextLostHandler,
+ );
+ this.territoryRenderer?.dispose();
+
+ const { renderer, reason } = TerritoryWebGLRenderer.create(
+ this.game,
+ this.theme,
+ );
+ if (!renderer) {
+ throw new Error(reason ?? "WebGL2 is required for territory rendering.");
+ }
+
+ this.territoryRenderer = renderer;
+ this.territoryRenderer.canvas.addEventListener(
+ "webglcontextlost",
+ this.contextLostHandler,
+ );
+ this.territoryRenderer.setContestEnabled(this.contestEnabled);
+ this.territoryRenderer.setContestPatternMode(this.contestedPatternMode);
+ this.territoryRenderer.setDebugDisableStaticBorders(
+ this.debugDisableStaticBorders,
+ );
+ this.territoryRenderer.setDebugDisableAllBorders(
+ this.debugDisableAllBorders,
+ );
+ this.territoryRenderer.setSeedSamplingMode(this.seedSamplingMode);
+ this.territoryRenderer.setMotionMode(this.motionMode);
+ this.territoryRenderer.setDebugStripeFixedColors(
+ this.debugStripeFixedColors,
+ );
+ this.territoryRenderer.setAlternativeView(this.alternativeView);
+ this.territoryRenderer.markAllDirty();
+ this.territoryRenderer.refreshPalette();
+ this.territoryRenderer.setHoverHighlightOptions(
+ this.hoverHighlightOptions(),
+ );
+ this.territoryRenderer.setHoveredPlayerId(
+ this.highlightedTerritory?.smallID() ?? null,
+ );
+ this.lastPaletteSignature = this.computePaletteSignature();
+ }
+
+ private hoverHighlightOptions() {
+ const baseColor = this.theme.playerHighlightColor();
+ const rgba = baseColor.rgba;
+
+ if (this.alternativeView) {
+ return {
+ color: { r: rgba.r, g: rgba.g, b: rgba.b },
+ strength: 0.8,
+ pulseStrength: 0.45,
+ pulseSpeed: Math.PI * 2,
+ };
+ }
+
+ return {
+ color: { r: rgba.r, g: rgba.g, b: rgba.b },
+ strength: 0.6,
+ pulseStrength: 0.35,
+ pulseSpeed: Math.PI * 2,
+ };
+ }
+
+ renderLayer(context: CanvasRenderingContext2D) {
+ if (!this.territoryRenderer) {
+ return;
+ }
+ const now = this.nowMs();
+ if (this.tickSnapshotPending) {
+ this.territoryRenderer.snapshotStateForSmoothing();
+ this.tickSnapshotPending = false;
+ }
+ this.updateInterpolationState(now);
+
+ const renderTerritoryStart = FrameProfiler.start();
+ this.territoryRenderer.setViewSize(
+ context.canvas.width,
+ context.canvas.height,
+ );
+ const viewOffset = this.transformHandler.viewOffset();
+ this.territoryRenderer.setViewTransform(
+ this.transformHandler.scale,
+ viewOffset.x,
+ viewOffset.y,
+ );
+ this.territoryRenderer.render();
+ FrameProfiler.end("TerritoryLayer:renderTerritory", renderTerritoryStart);
+
+ const drawTerritoryStart = FrameProfiler.start();
+ // Draw the WebGL territory in screen space; overlays still use world space.
+ context.save();
+ context.setTransform(1, 0, 0, 1, 0, 0);
+ context.drawImage(
+ this.territoryRenderer.canvas,
+ 0,
+ 0,
+ context.canvas.width,
+ context.canvas.height,
+ );
+ context.restore();
+ FrameProfiler.end("TerritoryLayer:drawTerritoryCanvas", drawTerritoryStart);
+
+ if (this.game.inSpawnPhase()) {
+ const highlightDrawStart = FrameProfiler.start();
+ context.drawImage(
+ this.highlightCanvas,
+ -this.game.width() / 2,
+ -this.game.height() / 2,
+ this.game.width(),
+ this.game.height(),
+ );
+ FrameProfiler.end(
+ "TerritoryLayer:drawHighlightCanvas",
+ highlightDrawStart,
+ );
+ }
+
+ if (DEBUG_TERRITORY_OVERLAY) {
+ const overlayStart = FrameProfiler.start();
+ this.drawDebugOverlay(context);
+ FrameProfiler.end("TerritoryLayer:debugOverlay", overlayStart);
+ }
+ }
+
+ private markTile(tile: TileRef) {
+ this.territoryRenderer?.markTile(tile);
+ }
+
+ paintHighlightTile(tile: TileRef, color: Colord, alpha: number) {
+ const x = this.game.x(tile);
+ const y = this.game.y(tile);
+ this.highlightContext.fillStyle = color.alpha(alpha / 255).toRgbString();
+ this.highlightContext.fillRect(x, y, 1, 1);
+ }
+
+ clearHighlightTile(tile: TileRef) {
+ const x = this.game.x(tile);
+ const y = this.game.y(tile);
+ this.highlightContext.clearRect(x, y, 1, 1);
+ }
+
+ private drawBreathingRing(
+ cx: number,
+ cy: number,
+ minRad: number,
+ maxRad: number,
+ radius: number,
+ transparentColor: Colord,
+ breathingColor: Colord,
+ ) {
+ const ctx = this.highlightContext;
+ if (!ctx) return;
+
+ // Draw a semi-transparent ring around the starting location
+ ctx.beginPath();
+ const transparent = transparentColor.alpha(0);
+ const radGrad = ctx.createRadialGradient(cx, cy, minRad, cx, cy, maxRad);
+
+ radGrad.addColorStop(0, transparent.toRgbString());
+ radGrad.addColorStop(0.01, transparentColor.toRgbString());
+ radGrad.addColorStop(0.1, transparentColor.toRgbString());
+ radGrad.addColorStop(1, transparent.toRgbString());
+
+ ctx.arc(cx, cy, maxRad, 0, Math.PI * 2);
+ ctx.fillStyle = radGrad;
+ ctx.closePath();
+ ctx.fill();
+
+ const breatheInner = breathingColor.alpha(0);
+ ctx.beginPath();
+ const radGrad2 = ctx.createRadialGradient(cx, cy, minRad, cx, cy, radius);
+ radGrad2.addColorStop(0, breatheInner.toRgbString());
+ radGrad2.addColorStop(0.01, breathingColor.toRgbString());
+ radGrad2.addColorStop(1, breathingColor.toRgbString());
+
+ ctx.arc(cx, cy, radius, 0, Math.PI * 2);
+ ctx.fillStyle = radGrad2;
+ ctx.fill();
+ }
+
+ private nowMs(): number {
+ return typeof performance !== "undefined" ? performance.now() : Date.now();
+ }
+
+ private ensureContestScratch() {
+ const size = this.game.width() * this.game.height();
+ 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 updateInterpolationState(now: number) {
+ if (!this.territoryRenderer) {
+ return;
+ }
+
+ if (this.tickTimeMsPrev <= 0 || this.tickTimeMsCurrent <= 0) {
+ this.lastInterpolationPair = "prevCurrent";
+ this.territoryRenderer.setInterpolationPair("prevCurrent");
+ this.territoryRenderer.setSmoothProgress(1);
+ this.territoryRenderer.setSmoothEnabled(false);
+ return;
+ }
+
+ const renderTime = now - this.interpolationDelayMs;
+
+ let pair: "prevCurrent" | "olderPrev" = "prevCurrent";
+ let fromTime = this.tickTimeMsPrev;
+ let toTime = this.tickTimeMsCurrent;
+
+ if (
+ this.tripleBufferEnabled &&
+ this.tickTimeMsOlder > 0 &&
+ renderTime < this.tickTimeMsPrev
+ ) {
+ pair = "olderPrev";
+ fromTime = this.tickTimeMsOlder;
+ toTime = this.tickTimeMsPrev;
+ }
+
+ // Use the real tick interval so interpolation duration scales with tick speed.
+ // The previous 250ms cap caused slow tick speeds (e.g. 0.5x) to finish animations early.
+ const denom = Math.max(1, toTime - fromTime);
+ const progress = Math.max(0, Math.min(1, (renderTime - fromTime) / denom));
+
+ this.lastInterpolationPair = pair;
+ this.territoryRenderer.setInterpolationPair(pair);
+ this.territoryRenderer.setSmoothProgress(progress);
+ this.territoryRenderer.setSmoothEnabled(true);
+ }
+
+ private applyContestChanges(
+ changes: Array<{ tile: TileRef; previousOwner: number; newOwner: number }>,
+ nowTickPacked: number,
+ ) {
+ if (!this.territoryRenderer || changes.length === 0) {
+ return;
+ }
+ this.ensureContestScratch();
+
+ for (const change of changes) {
+ if (change.newOwner === change.previousOwner) {
+ continue;
+ }
+ const tile = change.tile;
+ const currentId = this.contestId(tile);
+ if (currentId === 0) {
+ this.startContestForTile(
+ tile,
+ change.previousOwner,
+ change.newOwner,
+ nowTickPacked,
+ );
+ continue;
+ }
+
+ const component = this.contestComponents.get(currentId);
+ if (!component) {
+ this.clearContestTile(tile);
+ this.startContestForTile(
+ tile,
+ change.previousOwner,
+ change.newOwner,
+ nowTickPacked,
+ );
+ 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 = nowTickPacked;
+ this.territoryRenderer.setContestTime(component.id, nowTickPacked);
+ } else {
+ this.removeTileFromComponent(tile, component);
+ this.startContestForTile(
+ tile,
+ change.previousOwner,
+ change.newOwner,
+ nowTickPacked,
+ );
+ }
+ }
+ }
+
+ private updateContestStrengths() {
+ if (!this.territoryRenderer) {
+ return;
+ }
+ if (this.contestComponents.size === 0) {
+ return;
+ }
+
+ const involvedIds = new Set();
+ for (const component of this.contestComponents.values()) {
+ involvedIds.add(component.attacker);
+ involvedIds.add(component.defender);
+ }
+ const totalTroopsById = this.buildTotalTroopsLookup(involvedIds);
+ const attackTroopsById = this.buildAttackTroopsLookup(involvedIds);
+
+ const pairStrength = new Map();
+ for (const component of this.contestComponents.values()) {
+ const key = (component.attacker << 16) | component.defender;
+ let strength = pairStrength.get(key);
+ if (strength === undefined) {
+ strength = this.computeContestStrength(
+ component.attacker,
+ component.defender,
+ totalTroopsById,
+ attackTroopsById,
+ );
+ pairStrength.set(key, strength);
+ }
+ component.strength =
+ component.strength * (1 - CONTEST_STRENGTH_EMA_ALPHA) +
+ strength * CONTEST_STRENGTH_EMA_ALPHA;
+ component.strength = Math.max(
+ CONTEST_STRENGTH_MIN,
+ Math.min(CONTEST_STRENGTH_MAX, component.strength),
+ );
+ this.territoryRenderer.setContestStrength(
+ component.id,
+ component.strength,
+ );
+ }
+ }
+
+ private buildTotalTroopsLookup(
+ involvedIds: Set,
+ ): Map {
+ const totals = new Map();
+ for (const id of involvedIds) {
+ const player = this.game.playerBySmallID(id);
+ if (player instanceof PlayerView) {
+ totals.set(id, player.troops());
+ }
+ }
+ return totals;
+ }
+
+ private buildAttackTroopsLookup(
+ involvedIds: Set,
+ ): Map> {
+ const totals = new Map>();
+ for (const id of involvedIds) {
+ const player = this.game.playerBySmallID(id);
+ if (!(player instanceof PlayerView)) {
+ continue;
+ }
+ const outgoing = player.outgoingAttacks();
+ if (outgoing.length === 0) {
+ continue;
+ }
+ for (const attack of outgoing) {
+ if (!involvedIds.has(attack.targetID)) {
+ continue;
+ }
+ let byTarget = totals.get(id);
+ if (!byTarget) {
+ byTarget = new Map();
+ totals.set(id, byTarget);
+ }
+ byTarget.set(
+ attack.targetID,
+ (byTarget.get(attack.targetID) ?? 0) + attack.troops,
+ );
+ }
+ }
+ return totals;
+ }
+
+ private computeContestStrength(
+ attackerId: number,
+ defenderId: number,
+ totalTroopsById: Map,
+ attackTroopsById: Map>,
+ ) {
+ const attackerTroops = totalTroopsById.get(attackerId);
+ const defenderTroops = totalTroopsById.get(defenderId);
+ if (attackerTroops === undefined || defenderTroops === undefined) {
+ return 0.5;
+ }
+
+ const attackerAttackTroops =
+ attackTroopsById.get(attackerId)?.get(defenderId) ?? 0;
+ const defenderAttackTroops =
+ attackTroopsById.get(defenderId)?.get(attackerId) ?? 0;
+ const attackerPower = attackerTroops + attackerAttackTroops;
+ const defenderPower = defenderTroops + defenderAttackTroops;
+ const totalPower = attackerPower + defenderPower;
+ if (totalPower <= 0) {
+ return 0.5;
+ }
+ return Math.max(0, Math.min(1, attackerPower / totalPower));
+ }
+
+ private updateContestState(nowTickPacked: number) {
+ if (!this.territoryRenderer) {
+ return;
+ }
+ this.ensureContestScratch();
+ this.territoryRenderer.setContestNow(
+ nowTickPacked,
+ this.contestDurationTicks,
+ );
+
+ if (!this.contestActive) {
+ return;
+ }
+
+ const expired: ContestComponent[] = [];
+ for (const component of this.contestComponents.values()) {
+ const elapsed = this.contestElapsed(
+ nowTickPacked,
+ component.lastActivityPacked,
+ );
+ if (elapsed >= this.contestDurationTicks) {
+ expired.push(component);
+ }
+ }
+
+ for (const component of expired) {
+ this.expireContestComponent(component);
+ }
+ }
+
+ private startContestForTile(
+ tile: TileRef,
+ defender: number,
+ attacker: number,
+ nowTickPacked: number,
+ ): ContestComponent | null {
+ if (attacker === defender || attacker === 0 || defender === 0) {
+ return null;
+ }
+ const neighbors = this.collectNeighborComponents(tile, attacker, defender);
+ let component: ContestComponent;
+ if (neighbors.length === 0) {
+ component = this.createContestComponent(
+ attacker,
+ defender,
+ nowTickPacked,
+ );
+ } else {
+ component = neighbors[0];
+ for (let i = 1; i < neighbors.length; i++) {
+ this.mergeContestComponents(component, neighbors[i]);
+ }
+ }
+
+ this.addTileToComponent(tile, component, true);
+ component.lastActivityPacked = nowTickPacked;
+ this.territoryRenderer?.setContestTime(component.id, nowTickPacked);
+ return component;
+ }
+
+ 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,
+ nowTickPacked: number,
+ ): ContestComponent {
+ const id = this.allocateContestComponentId();
+ const component: ContestComponent = {
+ id,
+ attacker,
+ defender,
+ lastActivityPacked: nowTickPacked,
+ tiles: [],
+ strength: 0.5,
+ };
+ this.contestComponents.set(id, component);
+ this.contestActive = true;
+ this.territoryRenderer?.ensureContestTimeCapacity(id);
+ this.territoryRenderer?.setContestStrength(id, 0.5);
+ 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.territoryRenderer?.setContestStrength(component.id, 0);
+ this.contestComponents.delete(component.id);
+ this.releaseContestComponentId(component.id);
+ this.contestActive = this.contestComponents.size > 0;
+ }
+ }
+
+ private mergeContestComponents(
+ target: ContestComponent,
+ source: ContestComponent,
+ ) {
+ const targetSize = target.tiles.length;
+ const sourceSize = source.tiles.length;
+ const totalSize = targetSize + sourceSize;
+ if (totalSize > 0) {
+ target.strength = Math.min(
+ 1,
+ (target.strength * targetSize + source.strength * sourceSize) /
+ totalSize,
+ );
+ }
+ 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.territoryRenderer?.setContestStrength(source.id, 0);
+ 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.territoryRenderer?.setContestStrength(component.id, 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 packContestTick(tick: number): number {
+ return Math.floor(tick) % CONTEST_TIME_WRAP;
+ }
+
+ private contestElapsed(nowPacked: number, startPacked: number): number {
+ if (nowPacked >= startPacked) {
+ return nowPacked - startPacked;
+ }
+ 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);
+ this.territoryRenderer.ensureContestStrengthCapacity(maxId);
+ }
+ for (const component of this.contestComponents.values()) {
+ this.territoryRenderer.setContestTime(
+ component.id,
+ component.lastActivityPacked,
+ );
+ this.territoryRenderer.setContestStrength(
+ component.id,
+ component.strength,
+ );
+ 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 {
+ let maxSmallId = 0;
+ for (const player of this.game.playerViews()) {
+ maxSmallId = Math.max(maxSmallId, player.smallID());
+ }
+ const patternsEnabled = this.userSettings.territoryPatterns();
+ return `${this.game.playerViews().length}:${maxSmallId}:${patternsEnabled ? 1 : 0}`;
+ }
+
+ private refreshPaletteIfNeeded() {
+ if (!this.territoryRenderer) {
+ return;
+ }
+ const signature = this.computePaletteSignature();
+ if (signature !== this.lastPaletteSignature) {
+ this.lastPaletteSignature = signature;
+ this.territoryRenderer.refreshPalette();
+ }
+ }
+
+ private drawDebugOverlay(context: CanvasRenderingContext2D) {
+ if (!this.territoryRenderer) {
+ return;
+ }
+ const stats = this.territoryRenderer.getDebugStats();
+ context.save();
+ context.setTransform(1, 0, 0, 1, 0, 0);
+ context.font = "12px monospace";
+ context.textBaseline = "top";
+ const jfaStatus = stats.jfaSupported
+ ? "on"
+ : `off (${stats.jfaDisabledReason ?? "disabled"})`;
+ const lines = [
+ `map: ${stats.mapWidth}x${stats.mapHeight}`,
+ `view: ${stats.viewWidth}x${stats.viewHeight}`,
+ `scale: ${stats.viewScale.toFixed(2)}`,
+ `offset: ${stats.viewOffsetX.toFixed(1)}, ${stats.viewOffsetY.toFixed(1)}`,
+ `smooth: ${stats.smoothEnabled ? "on" : "off"} ${stats.smoothProgress.toFixed(2)} pair ${this.lastInterpolationPair}`,
+ `tick: ${this.tickNumberCurrent ?? "-"} prev ${this.tickNumberPrev ?? "-"}`,
+ `delayMs: ${this.interpolationDelayMs.toFixed(0)}`,
+ `motionMode: ${this.motionMode}`,
+ `tripleBuf: ${this.tripleBufferEnabled ? "on" : "off"}`,
+ `delayMode: ${this.interpolationDelayMode}${this.interpolationDelayMode === "ema" ? ` (ema=${this.tickIntervalEmaMs.toFixed(0)}ms)` : ""}`,
+ `smoothPrereq: prevCopy ${stats.prevStateCopySupported ? "yes" : "no"}`,
+ `jfa: ${jfaStatus} dirty ${stats.jfaDirty ? "yes" : "no"}`,
+ `contests: ${this.contestEnabled ? "on" : "off"} comps ${this.contestComponents.size}`,
+ `contestPattern: ${this.contestedPatternMode}`,
+ `hideAllBorders: ${this.debugDisableAllBorders ? "yes" : "no"}`,
+ `hideStaticBorders: ${this.debugDisableStaticBorders ? "yes" : "no"}`,
+ `contestTiles: ${this.contestTileCount}`,
+ `contestTicks: ${this.contestDurationTicks}`,
+ `hovered: ${stats.hoveredPlayerId}`,
+ ];
+ const padding = 6;
+ const lineHeight = 14;
+ let maxWidth = 0;
+ for (const line of lines) {
+ maxWidth = Math.max(maxWidth, context.measureText(line).width);
+ }
+ const width = Math.ceil(maxWidth + padding * 2);
+ const height = padding * 2 + lines.length * lineHeight;
+ context.fillStyle = "rgba(0, 0, 0, 0.6)";
+ context.fillRect(10, 10, width, height);
+ context.fillStyle = "rgba(255, 255, 255, 0.9)";
+ let y = 10 + padding;
+ for (const line of lines) {
+ context.fillText(line, 10 + padding, y);
+ y += lineHeight;
+ }
+ context.restore();
+ }
+}
diff --git a/src/client/graphics/layers/WebGPUTerritoryBackend.ts b/src/client/graphics/layers/WebGPUTerritoryBackend.ts
new file mode 100644
index 000000000..88d7b48de
--- /dev/null
+++ b/src/client/graphics/layers/WebGPUTerritoryBackend.ts
@@ -0,0 +1,447 @@
+import { Theme } from "../../../core/configuration/Config";
+import { EventBus } from "../../../core/EventBus";
+import { UnitType } from "../../../core/game/Game";
+import { TileRef } from "../../../core/game/GameMap";
+import { GameView } from "../../../core/game/GameView";
+import { UserSettings } from "../../../core/game/UserSettings";
+import {
+ AlternateViewEvent,
+ MouseOverEvent,
+ WebGPUComputeMetricsEvent,
+} from "../../InputHandler";
+import { FrameProfiler } from "../FrameProfiler";
+import { TransformHandler } from "../TransformHandler";
+import {
+ buildTerrainShaderParams,
+ readTerrainShaderId,
+} from "../webgpu/render/TerrainShaderRegistry";
+import {
+ buildTerritoryPostSmoothingParams,
+ readTerritoryPostSmoothingId,
+} from "../webgpu/render/TerritoryPostSmoothingRegistry";
+import {
+ buildTerritoryPreSmoothingParams,
+ readTerritoryPreSmoothingId,
+} from "../webgpu/render/TerritoryPreSmoothingRegistry";
+import {
+ buildTerritoryShaderParams,
+ readTerritoryShaderId,
+} from "../webgpu/render/TerritoryShaderRegistry";
+import { TerritoryRenderer } from "../webgpu/TerritoryRenderer";
+import { TerritoryBackend } from "./TerritoryBackend";
+
+export class WebGPUTerritoryBackend implements TerritoryBackend {
+ readonly id = "webgpu";
+
+ profileName(): string {
+ return "WebGPUTerritoryBackend:renderLayer";
+ }
+
+ private attachedTerritoryCanvas: HTMLCanvasElement | null = null;
+
+ private overlayWrapper: HTMLElement | null = null;
+ private overlayResizeObserver: ResizeObserver | null = null;
+
+ private theme: Theme;
+
+ private territoryRenderer: TerritoryRenderer | null = null;
+ private alternativeView = false;
+
+ private lastPaletteSignature: string | null = null;
+ private lastDefensePostsSignature: string | null = null;
+ private lastTerrainShaderSignature: string | null = null;
+ private lastTerritoryShaderSignature: string | null = null;
+ private lastPreSmoothingSignature: string | null = null;
+ private lastPostSmoothingSignature: string | null = null;
+
+ private lastMousePosition: { x: number; y: number } | null = null;
+ private hoveredOwnerSmallId: number | null = null;
+ private lastHoverUpdateMs = 0;
+
+ constructor(
+ private game: GameView,
+ private eventBus: EventBus,
+ private transformHandler: TransformHandler,
+ private userSettings: UserSettings,
+ ) {
+ this.theme = game.config().theme();
+ }
+
+ shouldTransform(): boolean {
+ return true;
+ }
+
+ init() {
+ this.eventBus.on(AlternateViewEvent, (e) => {
+ this.alternativeView = e.alternateView;
+ this.territoryRenderer?.setAlternativeView(this.alternativeView);
+ });
+ this.eventBus.on(MouseOverEvent, (e) => {
+ this.lastMousePosition = { x: e.x, y: e.y };
+ });
+ this.redraw();
+ }
+
+ whenReady(): Promise {
+ return this.territoryRenderer?.whenReady() ?? Promise.resolve(false);
+ }
+
+ getFailureReason(): string | null {
+ return this.territoryRenderer?.getFailureReason() ?? null;
+ }
+
+ dispose() {
+ this.overlayResizeObserver?.disconnect();
+ this.overlayResizeObserver = null;
+ this.attachedTerritoryCanvas?.remove();
+ this.attachedTerritoryCanvas = null;
+ this.overlayWrapper = null;
+ this.territoryRenderer?.dispose();
+ this.territoryRenderer = null;
+ }
+
+ tick() {
+ const tickProfile = FrameProfiler.start();
+
+ const currentTheme = this.game.config().theme();
+ if (currentTheme !== this.theme) {
+ this.theme = currentTheme;
+ this.territoryRenderer?.refreshTerrain();
+ this.redraw();
+ }
+
+ this.refreshPaletteIfNeeded();
+ this.refreshDefensePostsIfNeeded();
+ this.applyTerrainShaderSettings();
+ this.applyTerritoryShaderSettings();
+ this.applyTerritorySmoothingSettings();
+
+ const updatedTiles = this.game.recentlyUpdatedTiles();
+ for (let i = 0; i < updatedTiles.length; i++) {
+ this.markTile(updatedTiles[i]);
+ }
+
+ // After collecting pending updates and handling palette/theme changes,
+ // invoke the renderer's tick() to process compute passes. This ensures
+ // compute shaders run at the simulation rate rather than every frame.
+ if (this.territoryRenderer) {
+ const start = performance.now();
+ this.territoryRenderer.tick();
+ const computeMs = performance.now() - start;
+ this.eventBus.emit(new WebGPUComputeMetricsEvent(computeMs));
+ }
+
+ FrameProfiler.end("TerritoryLayer:tick", tickProfile);
+ }
+
+ redraw() {
+ this.configureRenderer();
+ }
+
+ private configureRenderer() {
+ this.territoryRenderer?.dispose();
+ this.territoryRenderer = null;
+
+ const { renderer, reason } = TerritoryRenderer.create(
+ this.game,
+ this.theme,
+ );
+ if (!renderer) {
+ throw new Error(reason ?? "WebGPU is required for territory rendering.");
+ }
+
+ this.territoryRenderer = renderer;
+ this.territoryRenderer.setAlternativeView(this.alternativeView);
+ this.territoryRenderer.setHighlightedOwnerId(this.hoveredOwnerSmallId);
+ this.applyTerrainShaderSettings(true);
+ this.applyTerritoryShaderSettings(true);
+ this.applyTerritorySmoothingSettings(true);
+ this.territoryRenderer.markAllDirty();
+ this.territoryRenderer.refreshPalette();
+ this.lastPaletteSignature = this.computePaletteSignature();
+
+ this.lastDefensePostsSignature = this.computeDefensePostsSignature();
+ // Ensure defense posts buffer is uploaded on first tick.
+ this.territoryRenderer.markDefensePostsDirty();
+
+ // Run an initial tick to upload state and build the colour texture. Without
+ // this, the first render call may occur before the initial compute pass
+ // has been executed, resulting in undefined colours.
+ this.territoryRenderer.tick();
+ }
+
+ renderLayer(context: CanvasRenderingContext2D) {
+ if (!this.territoryRenderer) {
+ return;
+ }
+
+ // Check for theme changes in renderLayer too (for when game is paused)
+ const currentTheme = this.game.config().theme();
+ if (currentTheme !== this.theme) {
+ this.theme = currentTheme;
+ this.territoryRenderer.refreshTerrain();
+ this.redraw();
+ }
+
+ // Apply user settings even while the game is paused (settings modal).
+ this.applyTerritoryShaderSettings();
+ this.applyTerritorySmoothingSettings();
+
+ this.ensureTerritoryCanvasAttached(context.canvas);
+ this.updateHoverHighlight();
+
+ const renderTerritoryStart = FrameProfiler.start();
+ this.territoryRenderer.setViewSize(
+ context.canvas.width,
+ context.canvas.height,
+ );
+ const viewOffset = this.transformHandler.viewOffset();
+ this.territoryRenderer.setViewTransform(
+ this.transformHandler.scale,
+ viewOffset.x,
+ viewOffset.y,
+ );
+ this.territoryRenderer.render();
+ FrameProfiler.end("TerritoryLayer:renderTerritory", renderTerritoryStart);
+ }
+
+ private ensureTerritoryCanvasAttached(mainCanvas: HTMLCanvasElement) {
+ if (!this.territoryRenderer) {
+ return;
+ }
+
+ const canvas = this.territoryRenderer.canvas;
+
+ // If the renderer recreated its canvas, detach the old one.
+ if (this.attachedTerritoryCanvas !== canvas) {
+ this.attachedTerritoryCanvas?.remove();
+ this.attachedTerritoryCanvas = canvas;
+
+ // Configure overlay canvas styles once. Avoid per-frame style reads/writes.
+ canvas.style.pointerEvents = "none";
+ canvas.style.position = "absolute";
+ canvas.style.inset = "0";
+ canvas.style.width = "100%";
+ canvas.style.height = "100%";
+ canvas.style.display = "block";
+ }
+
+ const parent = mainCanvas.parentElement;
+ if (!parent) {
+ // Fallback: if the canvas isn't in the DOM yet, append to body.
+ if (!canvas.isConnected) {
+ document.body.appendChild(canvas);
+ }
+ return;
+ }
+
+ // Ensure the main canvas is wrapped in a positioned container so the
+ // territory canvas can overlay it without mirroring computed styles.
+ let wrapper: HTMLElement;
+ const currentParent = mainCanvas.parentElement;
+ if (currentParent && currentParent.dataset.territoryOverlay === "1") {
+ wrapper = currentParent;
+ } else {
+ wrapper = document.createElement("div");
+ wrapper.dataset.territoryOverlay = "1";
+ wrapper.style.position = "relative";
+ wrapper.style.display = "inline-block";
+ wrapper.style.lineHeight = "0";
+
+ // Replace mainCanvas with wrapper, then re-insert mainCanvas inside wrapper.
+ parent.replaceChild(wrapper, mainCanvas);
+ wrapper.appendChild(mainCanvas);
+ }
+
+ if (this.overlayWrapper !== wrapper) {
+ this.overlayWrapper = wrapper;
+ this.overlayResizeObserver?.disconnect();
+ this.overlayResizeObserver = new ResizeObserver(() => {
+ this.syncOverlayWrapperSize(mainCanvas, wrapper);
+ });
+ this.overlayResizeObserver.observe(mainCanvas);
+ // Kick an initial size update; further updates are handled by ResizeObserver.
+ this.syncOverlayWrapperSize(mainCanvas, wrapper);
+ }
+
+ // Ensure territory canvas is the first child so it's the lowest layer.
+ if (canvas.parentElement !== wrapper) {
+ canvas.remove();
+ wrapper.insertBefore(canvas, mainCanvas);
+ } else if (canvas !== wrapper.firstElementChild) {
+ wrapper.insertBefore(canvas, mainCanvas);
+ }
+ }
+
+ private syncOverlayWrapperSize(
+ mainCanvas: HTMLCanvasElement,
+ wrapper: HTMLElement,
+ ) {
+ // Ensure the wrapper has real layout size so the absolutely-positioned
+ // territory canvas (100% width/height) is non-zero even if the main canvas
+ // is positioned absolutely.
+ const rect = mainCanvas.getBoundingClientRect();
+ const w = rect.width > 0 ? rect.width : mainCanvas.clientWidth;
+ const h = rect.height > 0 ? rect.height : mainCanvas.clientHeight;
+ if (w > 0) wrapper.style.width = `${w}px`;
+ if (h > 0) wrapper.style.height = `${h}px`;
+ }
+
+ private markTile(tile: TileRef) {
+ this.territoryRenderer?.markTile(tile);
+ }
+
+ private updateHoverHighlight() {
+ if (!this.territoryRenderer) {
+ return;
+ }
+
+ const now = performance.now();
+ if (now - this.lastHoverUpdateMs < 100) {
+ return;
+ }
+ this.lastHoverUpdateMs = now;
+
+ let nextOwnerSmallId: number | null = null;
+ if (this.lastMousePosition) {
+ const cell = this.transformHandler.screenToWorldCoordinates(
+ this.lastMousePosition.x,
+ this.lastMousePosition.y,
+ );
+ if (this.game.isValidCoord(cell.x, cell.y)) {
+ const tile = this.game.ref(cell.x, cell.y);
+ const owner = this.game.owner(tile);
+ if (owner && owner.isPlayer()) {
+ nextOwnerSmallId = owner.smallID();
+ }
+ }
+ }
+
+ if (nextOwnerSmallId === this.hoveredOwnerSmallId) {
+ return;
+ }
+ this.hoveredOwnerSmallId = nextOwnerSmallId;
+ this.territoryRenderer.setHighlightedOwnerId(nextOwnerSmallId);
+ }
+
+ private computePaletteSignature(): string {
+ let maxSmallId = 0;
+ for (const player of this.game.playerViews()) {
+ maxSmallId = Math.max(maxSmallId, player.smallID());
+ }
+ const patternsEnabled = this.userSettings.territoryPatterns();
+ return `${this.game.playerViews().length}:${maxSmallId}:${patternsEnabled ? 1 : 0}`;
+ }
+
+ private refreshPaletteIfNeeded() {
+ if (!this.territoryRenderer) {
+ return;
+ }
+ const signature = this.computePaletteSignature();
+ if (signature !== this.lastPaletteSignature) {
+ this.lastPaletteSignature = signature;
+ this.territoryRenderer.refreshPalette();
+ }
+ }
+
+ private applyTerritoryShaderSettings(force: boolean = false) {
+ if (!this.territoryRenderer) {
+ return;
+ }
+
+ const shaderId = readTerritoryShaderId(this.userSettings);
+ const { shaderPath, params0, params1 } = buildTerritoryShaderParams(
+ this.userSettings,
+ shaderId,
+ );
+
+ const signature = `${shaderPath}:${Array.from(params0).join(",")}:${Array.from(params1).join(",")}`;
+ if (!force && signature === this.lastTerritoryShaderSignature) {
+ return;
+ }
+ this.lastTerritoryShaderSignature = signature;
+
+ this.territoryRenderer.setTerritoryShader(shaderPath);
+ this.territoryRenderer.setTerritoryShaderParams(params0, params1);
+ }
+
+ private applyTerrainShaderSettings(force: boolean = false) {
+ if (!this.territoryRenderer) {
+ return;
+ }
+
+ const terrainId = readTerrainShaderId(this.userSettings);
+ const { shaderPath, params0, params1 } = buildTerrainShaderParams(
+ this.userSettings,
+ terrainId,
+ );
+ const signature = `${shaderPath}:${Array.from(params0).join(",")}:${Array.from(params1).join(",")}`;
+ if (!force && signature === this.lastTerrainShaderSignature) {
+ return;
+ }
+ this.lastTerrainShaderSignature = signature;
+ this.territoryRenderer.setTerrainShader(shaderPath);
+ this.territoryRenderer.setTerrainShaderParams(params0, params1);
+ }
+
+ private applyTerritorySmoothingSettings(force: boolean = false) {
+ if (!this.territoryRenderer) {
+ return;
+ }
+
+ const preId = readTerritoryPreSmoothingId(this.userSettings);
+ const preParams = buildTerritoryPreSmoothingParams(
+ this.userSettings,
+ preId,
+ );
+ const preSignature = `${preId}:${Array.from(preParams.params0).join(",")}`;
+ if (force || preSignature !== this.lastPreSmoothingSignature) {
+ this.lastPreSmoothingSignature = preSignature;
+ this.territoryRenderer.setPreSmoothing(
+ preParams.enabled,
+ preParams.shaderPath,
+ preParams.params0,
+ );
+ }
+
+ const postId = readTerritoryPostSmoothingId(this.userSettings);
+ const postParams = buildTerritoryPostSmoothingParams(
+ this.userSettings,
+ postId,
+ );
+ const postSignature = `${postId}:${Array.from(postParams.params0).join(",")}`;
+ if (force || postSignature !== this.lastPostSmoothingSignature) {
+ this.lastPostSmoothingSignature = postSignature;
+ this.territoryRenderer.setPostSmoothing(
+ postParams.enabled,
+ postParams.shaderPath,
+ postParams.params0,
+ );
+ }
+ }
+
+ private computeDefensePostsSignature(): string {
+ // Active + completed posts only.
+ const parts: string[] = [];
+ for (const u of this.game.units(UnitType.DefensePost)) {
+ if (!u.isActive() || u.isUnderConstruction()) continue;
+ const tile = u.tile();
+ parts.push(
+ `${u.owner().smallID()},${this.game.x(tile)},${this.game.y(tile)}`,
+ );
+ }
+ parts.sort();
+ return parts.join("|");
+ }
+
+ private refreshDefensePostsIfNeeded() {
+ if (!this.territoryRenderer) {
+ return;
+ }
+ const signature = this.computeDefensePostsSignature();
+ if (signature !== this.lastDefensePostsSignature) {
+ this.lastDefensePostsSignature = signature;
+ this.territoryRenderer.markDefensePostsDirty();
+ }
+ }
+}
diff --git a/src/client/graphics/webgpu/TerritoryRenderer.ts b/src/client/graphics/webgpu/TerritoryRenderer.ts
index c3b0f84e6..5e6d0d0bb 100644
--- a/src/client/graphics/webgpu/TerritoryRenderer.ts
+++ b/src/client/graphics/webgpu/TerritoryRenderer.ts
@@ -30,6 +30,7 @@ export class TerritoryRenderer {
private resources: GroundTruthData | null = null;
private ready = false;
private initPromise: Promise | null = null;
+ private failureReason: string | null = null;
private territoryShaderPath = "render/territory.wgsl";
private territoryShaderParams0 = new Float32Array(4);
private territoryShaderParams1 = new Float32Array(4);
@@ -99,15 +100,25 @@ export class TerritoryRenderer {
private startInit(): void {
if (this.initPromise) return;
- this.initPromise = this.init();
+ this.initPromise = this.init().catch((error) => {
+ this.ready = false;
+ this.failureReason =
+ error instanceof Error ? error.message : String(error);
+ console.warn("[TerritoryRenderer] WebGPU init failed", error);
+ });
}
private async init(): Promise {
const webgpuDevice = await WebGPUDevice.create(this.canvas);
if (!webgpuDevice) {
+ this.failureReason = "WebGPU device initialization failed.";
return;
}
this.device = webgpuDevice;
+ void webgpuDevice.device.lost.then((info) => {
+ this.ready = false;
+ this.failureReason = `WebGPU device lost: ${info.reason}`;
+ });
const state = this.game.tileStateView();
this.resources = GroundTruthData.create(
@@ -182,6 +193,25 @@ export class TerritoryRenderer {
this.ready = true;
}
+ async whenReady(): Promise {
+ await this.initPromise;
+ return this.ready && this.failureReason === null;
+ }
+
+ getFailureReason(): string | null {
+ return this.failureReason;
+ }
+
+ dispose(): void {
+ this.ready = false;
+ try {
+ this.device?.device.destroy();
+ } catch {
+ // Ignore device cleanup failures during renderer fallback.
+ }
+ this.canvas.remove();
+ }
+
/**
* Topological sort of passes based on dependencies.
* Ensures passes run in the correct order.
diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts
index e0cacf57b..6b7e08c2d 100644
--- a/src/core/configuration/Config.ts
+++ b/src/core/configuration/Config.ts
@@ -205,6 +205,7 @@ export interface Theme {
allyColor(): Colord;
neutralColor(): Colord;
enemyColor(): Colord;
+ playerHighlightColor(): Colord;
spawnHighlightColor(): Colord;
spawnHighlightSelfColor(): Colord;
spawnHighlightTeamColor(): Colord;
diff --git a/src/core/configuration/PastelTheme.ts b/src/core/configuration/PastelTheme.ts
index 23ae4e653..354ca3dc8 100644
--- a/src/core/configuration/PastelTheme.ts
+++ b/src/core/configuration/PastelTheme.ts
@@ -35,6 +35,8 @@ export class PastelTheme implements Theme {
/** Alternate View colors for enemies, red */
private _enemyColor = colord("rgb(255,0,0)");
+ /** Hover highlight color for player territories */
+ private _playerHighlightColor = colord("rgb(221, 221, 221)");
/** Default spawn highlight colors for other players in FFA, yellow */
private _spawnHighlightColor = colord("rgb(255,213,79)");
/** Added non-default spawn highlight colors for self, full white */
@@ -209,6 +211,9 @@ export class PastelTheme implements Theme {
enemyColor(): Colord {
return this._enemyColor;
}
+ playerHighlightColor(): Colord {
+ return this._playerHighlightColor;
+ }
spawnHighlightColor(): Colord {
return this._spawnHighlightColor;
diff --git a/src/core/configuration/PastelThemeDark.ts b/src/core/configuration/PastelThemeDark.ts
index 2cff80685..d840f0fb6 100644
--- a/src/core/configuration/PastelThemeDark.ts
+++ b/src/core/configuration/PastelThemeDark.ts
@@ -8,6 +8,7 @@ export class PastelThemeDark extends PastelTheme {
private darkWater = colord("rgb(14,11,30)");
private darkShorelineWater = colord("rgb(50,50,50)");
+ private darkPlayerHighlight = colord("rgb(99, 42, 42)");
// | Terrain Type | Magnitude | Base Color Logic | Visual Description |
// | :---------------- | :-------- | :---------------------------------------------- | :-------------------- |
@@ -59,4 +60,8 @@ export class PastelThemeDark extends PastelTheme {
});
}
}
+
+ playerHighlightColor(): Colord {
+ return this.darkPlayerHighlight;
+ }
}
diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts
index 1d2c87763..349613319 100644
--- a/src/core/game/GameImpl.ts
+++ b/src/core/game/GameImpl.ts
@@ -692,6 +692,7 @@ export class GameImpl implements Game {
owner._lastTileChange = this._ticks;
this.updateBorders(tile);
this._map.setFallout(tile, false);
+ this.updateDefendedStateForTileChange(tile, owner);
this.recordTileUpdate(tile);
}
@@ -710,6 +711,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.recordTileUpdate(tile);
}
@@ -971,9 +975,18 @@ export class GameImpl implements Game {
}
}
updateUnitTile(u: Unit) {
+ if (u.type() === UnitType.DefensePost) {
+ this.updateDefendedStateForDefensePost(u.tile(), u.owner() as PlayerImpl);
+ }
this.unitGrid.updateUnitCell(u);
}
+ refreshDefensePostDefendedState(u: Unit) {
+ if (u.type() === UnitType.DefensePost) {
+ this.updateDefendedStateForDefensePost(u.tile(), u.owner() as PlayerImpl);
+ }
+ }
+
hasUnitNearby(
tile: TileRef,
searchRange: number,
@@ -1254,6 +1267,49 @@ export class GameImpl implements Game {
gold: goldCaptured,
});
}
+
+ 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.recordTileUpdate(tile);
+ }
+ }
+ }
+ }
+
+ 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);
+ }
+
+ 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:
diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts
index 59a4b49e4..09d010cf3 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,
diff --git a/src/core/game/UnitImpl.ts b/src/core/game/UnitImpl.ts
index 9444ed70b..ba9cda344 100644
--- a/src/core/game/UnitImpl.ts
+++ b/src/core/game/UnitImpl.ts
@@ -433,6 +433,9 @@ export class UnitImpl implements Unit {
setUnderConstruction(underConstruction: boolean): void {
if (this._underConstruction !== underConstruction) {
this._underConstruction = underConstruction;
+ if (this._type === UnitType.DefensePost) {
+ this.mg.refreshDefensePostDefendedState(this);
+ }
this.mg.addUpdate(this.toUpdate());
}
}
diff --git a/src/core/game/UserSettings.ts b/src/core/game/UserSettings.ts
index 180dc67db..2454176fc 100644
--- a/src/core/game/UserSettings.ts
+++ b/src/core/game/UserSettings.ts
@@ -47,6 +47,12 @@ export const COLOR_KEY = "settings.territoryColor";
export const DARK_MODE_KEY = "settings.darkMode";
export const PERFORMANCE_OVERLAY_KEY = "settings.performanceOverlay";
export const KEYBINDS_KEY = "settings.keybinds";
+export const TERRITORY_RENDERER_KEY = "settings.territoryRenderer";
+export type TerritoryRendererPreference =
+ | "auto"
+ | "classic"
+ | "webgl"
+ | "webgpu";
export class UserSettings {
private static cache = new Map();
@@ -154,7 +160,7 @@ export class UserSettings {
}
webgpuDebug(): boolean {
- return this.get("settings.webgpuDebug", true);
+ return this.get("settings.webgpuDebug", false);
}
alertFrame() {
@@ -197,6 +203,27 @@ export class UserSettings {
return this.getInt("settings.territoryBorderMode", 1);
}
+ territoryRenderer(): TerritoryRendererPreference {
+ const value = this.getString(TERRITORY_RENDERER_KEY, "auto");
+ if (
+ value === "auto" ||
+ value === "classic" ||
+ value === "webgl" ||
+ value === "webgpu"
+ ) {
+ return value;
+ }
+ return "auto";
+ }
+
+ setTerritoryRenderer(value: string): void {
+ const renderer =
+ value === "classic" || value === "webgl" || value === "webgpu"
+ ? value
+ : "auto";
+ this.setString(TERRITORY_RENDERER_KEY, renderer);
+ }
+
toggleAttackingTroopsOverlay() {
this.setBool(
"settings.attackingTroopsOverlay",
diff --git a/tests/TerritoryBackendSelection.test.ts b/tests/TerritoryBackendSelection.test.ts
new file mode 100644
index 000000000..b722f3b77
--- /dev/null
+++ b/tests/TerritoryBackendSelection.test.ts
@@ -0,0 +1,161 @@
+import { describe, expect, test } from "vitest";
+import {
+ selectTerritoryBackend,
+ type TerritoryBackendCandidate,
+ type TerritoryRendererId,
+ type TerritoryRendererPreference,
+} from "../src/client/graphics/layers/TerritoryBackend";
+
+type FakeBackendSpec = {
+ initError?: string;
+ ready?: boolean;
+ failureReason?: string;
+};
+
+type FakeBackendSpecs = Partial>;
+
+class FakeBackend implements TerritoryBackendCandidate {
+ initialized = false;
+ disposed = false;
+
+ constructor(
+ readonly id: TerritoryRendererId,
+ private readonly spec: FakeBackendSpec = {},
+ ) {}
+
+ init() {
+ this.initialized = true;
+ if (this.spec.initError) {
+ throw new Error(this.spec.initError);
+ }
+ }
+
+ async whenReady(): Promise {
+ return this.spec.ready ?? true;
+ }
+
+ getFailureReason(): string | null {
+ return this.spec.failureReason ?? null;
+ }
+
+ dispose() {
+ this.disposed = true;
+ }
+}
+
+class RendererSelectionHarness {
+ active: TerritoryRendererId | null = null;
+ readonly failed = new Set();
+ preference: TerritoryRendererPreference;
+
+ constructor(preference: TerritoryRendererPreference) {
+ this.preference = preference;
+ }
+
+ setPreference(preference: TerritoryRendererPreference) {
+ this.preference = preference;
+ this.failed.clear();
+ }
+
+ async select(specs: FakeBackendSpecs = {}) {
+ const created: FakeBackend[] = [];
+ const selection = await selectTerritoryBackend(
+ this.preference,
+ this.failed,
+ (id) => {
+ const backend = new FakeBackend(id, specs[id]);
+ created.push(backend);
+ return backend;
+ },
+ );
+
+ for (const failure of selection.failures) {
+ if (failure.id !== "classic") {
+ this.failed.add(failure.id);
+ }
+ }
+ if (selection.backend) {
+ this.active = selection.backend.id;
+ }
+
+ return { ...selection, created };
+ }
+
+ async failActiveRuntime(specs: FakeBackendSpecs = {}) {
+ if (this.active && this.active !== "classic") {
+ this.failed.add(this.active);
+ }
+ return this.select(specs);
+ }
+}
+
+describe("territory renderer backend selection", () => {
+ test("auto selects WebGPU when ready", async () => {
+ const harness = new RendererSelectionHarness("auto");
+
+ const result = await harness.select();
+
+ expect(result.backend?.id).toBe("webgpu");
+ expect(harness.active).toBe("webgpu");
+ expect(result.failures).toEqual([]);
+ expect(result.created.map((backend) => backend.id)).toEqual(["webgpu"]);
+ });
+
+ test("auto falls back to WebGL when WebGPU init fails", async () => {
+ const harness = new RendererSelectionHarness("auto");
+
+ const result = await harness.select({
+ webgpu: { initError: "navigator.gpu unavailable" },
+ });
+
+ expect(result.backend?.id).toBe("webgl");
+ expect(harness.active).toBe("webgl");
+ expect(result.failures.map((failure) => failure.id)).toEqual(["webgpu"]);
+ expect(result.created[0].disposed).toBe(true);
+ });
+
+ test("auto falls back to classic when both accelerated backends fail", async () => {
+ const harness = new RendererSelectionHarness("auto");
+
+ const result = await harness.select({
+ webgpu: { initError: "navigator.gpu unavailable" },
+ webgl: { failureReason: "WebGL2 unavailable" },
+ });
+
+ expect(result.backend?.id).toBe("classic");
+ expect(harness.active).toBe("classic");
+ expect(result.failures.map((failure) => failure.id)).toEqual([
+ "webgpu",
+ "webgl",
+ ]);
+ });
+
+ test("forced WebGPU falls back on runtime failure without changing saved setting", async () => {
+ const harness = new RendererSelectionHarness("webgpu");
+ await harness.select();
+
+ const result = await harness.failActiveRuntime();
+
+ expect(result.backend?.id).toBe("webgl");
+ expect(harness.active).toBe("webgl");
+ expect(harness.preference).toBe("webgpu");
+ expect(harness.failed.has("webgpu")).toBe(true);
+ });
+
+ test("manual setting change retries previously failed backends", async () => {
+ const harness = new RendererSelectionHarness("auto");
+ await harness.select({
+ webgpu: { initError: "navigator.gpu unavailable" },
+ });
+
+ expect(harness.active).toBe("webgl");
+ expect(harness.failed.has("webgpu")).toBe(true);
+
+ harness.setPreference("auto");
+ const retry = await harness.select();
+
+ expect(retry.backend?.id).toBe("webgpu");
+ expect(harness.active).toBe("webgpu");
+ expect(harness.failed.size).toBe(0);
+ });
+});