mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-29 18:52:11 +00:00
1722 lines
55 KiB
TypeScript
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();
|
|
}
|
|
}
|