Files
OpenFrontIO/src/client/graphics/layers/WebGLTerritoryBackend.ts
T

1722 lines
55 KiB
TypeScript

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 {
USER_SETTINGS_CHANGED_EVENT,
UserSettings,
WEBGL_DEBUG_KEY,
} 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;
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<number, ContestComponent>();
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.";
};
private readonly debugSettingChanged = () => this.syncSmoothingDebugUi();
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(),
);
});
globalThis.addEventListener?.(
`${USER_SETTINGS_CHANGED_EVENT}:${WEBGL_DEBUG_KEY}`,
this.debugSettingChanged,
);
this.redraw();
this.syncSmoothingDebugUi();
}
getFailureReason(): string | null {
return this.failureReason;
}
dispose() {
globalThis.removeEventListener?.(
`${USER_SETTINGS_CHANGED_EVENT}:${WEBGL_DEBUG_KEY}`,
this.debugSettingChanged,
);
this.smoothingDebugUi?.remove();
this.smoothingDebugUi = null;
this.territoryRenderer?.canvas.removeEventListener(
"webglcontextlost",
this.contextLostHandler,
);
this.territoryRenderer?.dispose();
this.territoryRenderer = null;
}
private syncSmoothingDebugUi() {
if (!this.userSettings.webglDebug()) {
this.smoothingDebugUi?.remove();
this.smoothingDebugUi = null;
return;
}
this.ensureSmoothingDebugUi();
}
private ensureSmoothingDebugUi() {
if (!this.userSettings.webglDebug()) 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.style.fontWeight = "700";
title.style.marginBottom = "6px";
title.style.cursor = "move";
title.style.display = "flex";
title.style.alignItems = "center";
title.style.justifyContent = "space-between";
title.style.gap = "10px";
const titleText = document.createElement("span");
titleText.textContent = "Territory smoothing";
title.appendChild(titleText);
const closeButton = document.createElement("button");
closeButton.type = "button";
closeButton.textContent = "x";
closeButton.title = "Close";
closeButton.style.width = "22px";
closeButton.style.height = "22px";
closeButton.style.border = "1px solid rgba(255,255,255,0.18)";
closeButton.style.borderRadius = "5px";
closeButton.style.background = "rgba(255,255,255,0.08)";
closeButton.style.color = "rgba(255,255,255,0.88)";
closeButton.style.font = "12px monospace";
closeButton.style.lineHeight = "1";
closeButton.style.cursor = "pointer";
closeButton.addEventListener("pointerdown", (e) => {
e.preventDefault();
e.stopPropagation();
});
closeButton.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
this.userSettings.setWebglDebug(false);
});
title.appendChild(closeButton);
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 (this.userSettings.webglDebug()) {
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<number>();
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<number, number>();
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<number>,
): Map<number, number> {
const totals = new Map<number, number>();
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<number>,
): Map<number, Map<number, number>> {
const totals = new Map<number, Map<number, number>>();
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<number, number>();
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<number, number>,
attackTroopsById: Map<number, Map<number, number>>,
) {
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<number>();
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();
}
}