Merge renderer fallback branch

This commit is contained in:
scamiv
2026-05-27 14:26:01 +02:00
57 changed files with 14357 additions and 719 deletions
+1 -1
View File
@@ -77,7 +77,7 @@ ENV GIT_COMMIT="$GIT_COMMIT"
RUN <<'EOF' tee /usr/local/bin/start.sh
#!/bin/sh
if [ "$DOMAIN" = openfront.dev ] && [ "$SUBDOMAIN" != main ]; then
exec timeout 25h /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf
exec timeout 720h /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf
else
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf
fi
+2
View File
@@ -343,7 +343,9 @@
<chat-modal></chat-modal>
<multi-tab-modal></multi-tab-modal>
<game-left-sidebar></game-left-sidebar>
<renderer-status-panel></renderer-status-panel>
<performance-overlay></performance-overlay>
<webgpu-debug-overlay></webgpu-debug-overlay>
<player-info-overlay></player-info-overlay>
<leader-board></leader-board>
<team-stats></team-stats>
+4
View File
@@ -703,6 +703,10 @@
"coordinate_grid_desc": "Toggle the alphanumeric grid overlay",
"attacking_troops_overlay_label": "Attacking Troops Overlay",
"attacking_troops_overlay_desc": "Show attacker vs defender troop counts on active front lines.",
"territory_border_mode_label": "Territory Borders",
"territory_border_mode_desc": "Select border rendering style (visual only)",
"renderer_label": "Renderer",
"renderer_desc": "Choose territory rendering backend. Auto uses WebGPU, then WebGL, then Classic.",
"performance_overlay_label": "Performance Overlay",
"performance_overlay_desc": "Toggle the performance overlay. When enabled, the performance overlay will be displayed. Press shift-D during game to toggle.",
"easter_writing_speed_label": "Writing Speed Multiplier",
+4
View File
@@ -193,6 +193,10 @@ export class TickMetricsEvent implements GameEvent {
) {}
}
export class WebGPUComputeMetricsEvent implements GameEvent {
constructor(public readonly computeMs: number) {}
}
export class InputHandler {
private lastPointerX: number = 0;
private lastPointerY: number = 0;
+47
View File
@@ -300,6 +300,24 @@ export class UserSettingModal extends BaseModal {
this.requestUpdate();
}
private changeTerritoryBorderMode(
e: CustomEvent<{ value: number | string }>,
) {
const rawValue = e.detail?.value;
const value =
typeof rawValue === "number" ? rawValue : parseInt(String(rawValue), 10);
if (!Number.isFinite(value)) return;
this.userSettings.setInt("settings.territoryBorderMode", Math.round(value));
this.requestUpdate();
}
private changeTerritoryRenderer(e: CustomEvent<{ value: number | string }>) {
const value = String(e.detail?.value ?? "auto");
this.userSettings.setTerritoryRenderer(value);
this.requestUpdate();
}
private toggleTerritoryPatterns() {
this.userSettings.toggleTerritoryPatterns();
@@ -752,6 +770,35 @@ export class UserSettingModal extends BaseModal {
></setting-toggle>
<!-- 😊 Emojis -->
<setting-select
label="${translateText("user_setting.territory_border_mode_label")}"
description="${translateText(
"user_setting.territory_border_mode_desc",
)}"
id="territory-border-mode-select"
.value=${String(this.userSettings.territoryBorderMode())}
.options=${[
{ value: 0, label: "Off" },
{ value: 1, label: "Simple" },
{ value: 2, label: "Glow" },
]}
@change=${this.changeTerritoryBorderMode}
></setting-select>
<setting-select
label="${translateText("user_setting.renderer_label")}"
description="${translateText("user_setting.renderer_desc")}"
id="territory-renderer-select"
.value=${this.userSettings.territoryRenderer()}
.options=${[
{ value: "auto", label: "Auto" },
{ value: "classic", label: "Classic" },
{ value: "webgl", label: "WebGL" },
{ value: "webgpu", label: "WebGPU" },
]}
@change=${this.changeTerritoryRenderer}
></setting-select>
<setting-toggle
label="${translateText("user_setting.emojis_label")}"
description="${translateText("user_setting.emojis_desc")}"
@@ -1,7 +1,7 @@
import { LitElement, html } from "lit";
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators.js";
type SelectOption = {
export type SettingSelectOption = {
value: number | string;
label: string;
};
@@ -10,8 +10,10 @@ type SelectOption = {
export class SettingSelect extends LitElement {
@property() label = "Setting";
@property() description = "";
@property({ type: Array }) options: SelectOption[] = [];
@property() id = "setting-select-input";
@property({ type: Array }) options: SettingSelectOption[] = [];
@property({ type: String }) value = "";
@property({ type: Boolean }) easter = false;
createRenderRoot() {
return this;
@@ -35,14 +37,18 @@ export class SettingSelect extends LitElement {
}
render() {
const rainbowClass = this.easter
? "bg-[linear-gradient(270deg,#990033,#996600,#336600,#008080,#1c3f99,#5e0099,#990033)] bg-[length:1400%_1400%] animate-rainbow-bg text-white hover:bg-[linear-gradient(270deg,#990033,#996600,#336600,#008080,#1c3f99,#5e0099,#990033)]"
: "";
return html`
<div
class="flex flex-col w-full p-4 bg-white/5 border border-white/10 rounded-xl hover:bg-white/10 transition-all gap-3"
class="flex flex-col w-full p-4 bg-white/5 border border-white/10 rounded-xl hover:bg-white/10 transition-all gap-3 ${rainbowClass}"
>
<div class="flex flex-col min-w-0">
<label
class="text-white font-bold text-base block mb-1"
for="setting-select-input"
for=${this.id}
>${this.label}</label
>
<div class="text-white/50 text-sm leading-snug">
@@ -51,7 +57,7 @@ export class SettingSelect extends LitElement {
</div>
<div class="relative w-full">
<select
id="setting-select-input"
id=${this.id}
class="w-full appearance-none py-2 pl-3 pr-9 border border-white/20 rounded-lg bg-black/40 text-white font-mono text-sm focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 transition-all"
.value=${String(this.value)}
@change=${this.handleChange}
+31 -11
View File
@@ -33,6 +33,7 @@ import { PerformanceOverlay } from "./layers/PerformanceOverlay";
import { PlayerInfoOverlay } from "./layers/PlayerInfoOverlay";
import { PlayerPanel } from "./layers/PlayerPanel";
import { RailroadLayer } from "./layers/RailroadLayer";
import { RendererStatusPanel } from "./layers/RendererStatusPanel";
import { ReplayPanel } from "./layers/ReplayPanel";
import { SAMRadiusLayer } from "./layers/SAMRadiusLayer";
import { SettingsModal } from "./layers/SettingsModal";
@@ -40,11 +41,11 @@ import { SpawnTimer } from "./layers/SpawnTimer";
import { StructureIconsLayer } from "./layers/StructureIconsLayer";
import { StructureLayer } from "./layers/StructureLayer";
import { TeamStats } from "./layers/TeamStats";
import { TerrainLayer } from "./layers/TerrainLayer";
import { TerritoryLayer } from "./layers/TerritoryLayer";
import { UILayer } from "./layers/UILayer";
import { UnitDisplay } from "./layers/UnitDisplay";
import { UnitLayer } from "./layers/UnitLayer";
import { WebGPUDebugOverlay } from "./layers/WebGPUDebugOverlay";
import { WinModal } from "./layers/WinModal";
export function createRenderer(
@@ -242,6 +243,25 @@ export function createRenderer(
performanceOverlay.eventBus = eventBus;
performanceOverlay.userSettings = userSettings;
const webgpuDebugOverlay = document.querySelector(
"webgpu-debug-overlay",
) as WebGPUDebugOverlay;
if (!(webgpuDebugOverlay instanceof WebGPUDebugOverlay)) {
console.error("webgpu debug overlay not found");
}
webgpuDebugOverlay.eventBus = eventBus;
webgpuDebugOverlay.userSettings = userSettings;
webgpuDebugOverlay.requestUpdate();
const rendererStatusPanel = document.querySelector(
"renderer-status-panel",
) as RendererStatusPanel;
if (!(rendererStatusPanel instanceof RendererStatusPanel)) {
console.error("renderer status panel not found");
}
rendererStatusPanel.userSettings = userSettings;
rendererStatusPanel.requestUpdate();
const alertFrame = document.querySelector("alert-frame") as AlertFrame;
if (!(alertFrame instanceof AlertFrame)) {
console.error("alert frame not found");
@@ -275,8 +295,8 @@ export function createRenderer(
// Try to group layers by the return value of shouldTransform.
// Not grouping the layers may cause excessive calls to context.save() and context.restore().
const layers: Layer[] = [
new TerrainLayer(game, transformHandler),
new TerritoryLayer(game, eventBus, transformHandler),
rendererStatusPanel,
new TerritoryLayer(game, eventBus, transformHandler, userSettings),
new RailroadLayer(game, eventBus, transformHandler, uiState),
new CoordinateGridLayer(game, eventBus, transformHandler),
structureLayer,
@@ -320,6 +340,7 @@ export function createRenderer(
inGamePromo,
alertFrame,
performanceOverlay,
webgpuDebugOverlay,
];
return new GameRenderer(
@@ -330,6 +351,7 @@ export function createRenderer(
uiState,
layers,
performanceOverlay,
webgpuDebugOverlay,
);
}
@@ -347,8 +369,10 @@ export class GameRenderer {
public uiState: UIState,
private layers: Layer[],
private performanceOverlay: PerformanceOverlay,
private webgpuDebugOverlay: WebGPUDebugOverlay,
) {
const context = canvas.getContext("2d", { alpha: false });
// Keep the main canvas transparent; the WebGPU territory canvas renders the background.
const context = canvas.getContext("2d", { alpha: true });
if (context === null) throw new Error("2d context not supported");
this.context = context;
}
@@ -399,13 +423,8 @@ export class GameRenderer {
FrameProfiler.clear();
}
const start = performance.now();
// Set background
this.context.fillStyle = this.game
.config()
.theme()
.backgroundColor()
.toHex();
this.context.fillRect(0, 0, this.canvas.width, this.canvas.height);
// Clear overlay canvas to transparent; the territory WebGPU canvas draws the base.
this.context.clearRect(0, 0, this.canvas.width, this.canvas.height);
const handleTransformState = (
needsTransform: boolean,
@@ -457,6 +476,7 @@ export class GameRenderer {
}
this.performanceOverlay.updateFrameMetrics(duration, layerDurations);
}
this.webgpuDebugOverlay.updateFrameMetrics(duration);
if (duration > 50) {
console.warn(
+73
View File
@@ -0,0 +1,73 @@
import { UnitType } from "../../core/game/Game";
import { TileRef } from "../../core/game/GameMap";
import { GameView, PlayerView, UnitView } from "../../core/game/GameView";
export type HoverInfo = {
player: PlayerView | null;
unit: UnitView | null;
isWilderness: boolean;
isIrradiatedWilderness: boolean;
};
function euclideanDistWorld(
coord: { x: number; y: number },
tileRef: TileRef,
game: GameView,
): number {
const x = game.x(tileRef);
const y = game.y(tileRef);
const dx = coord.x - x;
const dy = coord.y - y;
return Math.sqrt(dx * dx + dy * dy);
}
function distSortUnitWorld(coord: { x: number; y: number }, game: GameView) {
return (a: UnitView, b: UnitView) => {
const distA = euclideanDistWorld(coord, a.tile(), game);
const distB = euclideanDistWorld(coord, b.tile(), game);
return distA - distB;
};
}
export function getHoverInfo(
game: GameView,
worldCoord: { x: number; y: number },
): HoverInfo {
const info: HoverInfo = {
player: null,
unit: null,
isWilderness: false,
isIrradiatedWilderness: false,
};
if (!game.isValidCoord(worldCoord.x, worldCoord.y)) {
return info;
}
const tile = game.ref(worldCoord.x, worldCoord.y);
const owner = game.owner(tile);
if (owner && owner.isPlayer()) {
info.player = owner as PlayerView;
return info;
}
if (owner && !owner.isPlayer() && game.isLand(tile)) {
info.isIrradiatedWilderness = game.hasFallout(tile);
info.isWilderness = !info.isIrradiatedWilderness;
return info;
}
if (!game.isLand(tile)) {
const units = game
.units(UnitType.Warship, UnitType.TradeShip, UnitType.TransportShip)
.filter((u) => euclideanDistWorld(worldCoord, u.tile(), game) < 50)
.sort(distSortUnitWorld(worldCoord, game));
if (units.length > 0) {
info.unit = units[0];
}
}
return info;
}
+4
View File
@@ -59,6 +59,10 @@ export class TransformHandler {
return this._boundingRect;
}
viewOffset(): { x: number; y: number } {
return { x: this.offsetX, y: this.offsetY };
}
width(): number {
return this.boundingRect().width;
}
@@ -0,0 +1,709 @@
import { PriorityQueue } from "@datastructures-js/priority-queue";
import { Colord } from "colord";
import { Theme } from "../../../core/configuration/Config";
import { EventBus } from "../../../core/EventBus";
import {
Cell,
ColoredTeams,
PlayerType,
Team,
UnitType,
} 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 { PseudoRandom } from "../../../core/PseudoRandom";
import {
AlternateViewEvent,
DragEvent,
MouseOverEvent,
} from "../../InputHandler";
import { FrameProfiler } from "../FrameProfiler";
import { TransformHandler } from "../TransformHandler";
import { Layer } from "./Layer";
export class ClassicCanvasTerritoryLayer implements Layer {
private canvas: HTMLCanvasElement;
private context: CanvasRenderingContext2D;
private imageData: ImageData;
private alternativeImageData: ImageData;
private borderAnimTime = 0;
private cachedTerritoryPatternsEnabled: boolean | undefined;
private tileToRenderQueue: PriorityQueue<{
tile: TileRef;
lastUpdate: number;
}> = new PriorityQueue((a, b) => {
return a.lastUpdate - b.lastUpdate;
});
private random = new PseudoRandom(123);
private theme: Theme;
// Used for spawn highlighting
private highlightCanvas: HTMLCanvasElement;
private highlightContext: CanvasRenderingContext2D;
private highlightedTerritory: PlayerView | null = null;
private alternativeView = false;
private lastDragTime = 0;
private nodrawDragDuration = 200;
private lastMousePosition: { x: number; y: number } | null = null;
private refreshRate = 10; //refresh every 10ms
private lastRefresh = 0;
private lastFocusedPlayer: PlayerView | null = null;
constructor(
private game: GameView,
private eventBus: EventBus,
private transformHandler: TransformHandler,
) {
this.theme = game.config().theme();
this.cachedTerritoryPatternsEnabled = undefined;
}
shouldTransform(): boolean {
return true;
}
async paintPlayerBorder(player: PlayerView) {
const tiles = await player.borderTiles();
tiles.borderTiles.forEach((tile: TileRef) => {
this.paintTerritory(tile, true); // Immediately paint the tile instead of enqueueing
});
}
tick() {
if (this.game.inSpawnPhase()) {
this.spawnHighlight();
}
this.game.recentlyUpdatedTiles().forEach((t) => {
this.enqueueTile(t);
// Immediately clear territory overlay for water tiles so old
// borders/territory don't persist visually (e.g. after nuke turns land to water)
if (this.game.isWater(t)) {
this.clearTile(t);
}
});
const updates = this.game.updatesSinceLastTick();
const unitUpdates = updates !== null ? updates[GameUpdateType.Unit] : [];
unitUpdates.forEach((update) => {
if (update.unitType === UnitType.DefensePost) {
// Only update borders if the defense post is not under construction
if (update.underConstruction) {
return; // Skip barrier creation while under construction
}
const tile = update.pos;
this.game
.bfs(tile, euclDistFN(tile, this.game.config().defensePostRange()))
.forEach((t) => {
if (
this.game.isBorder(t) &&
(this.game.ownerID(t) === update.ownerID ||
this.game.ownerID(t) === update.lastOwnerID)
) {
this.enqueueTile(t);
}
});
}
});
// Detect alliance mutations
const myPlayer = this.game.myPlayer();
if (myPlayer) {
updates?.[GameUpdateType.BrokeAlliance]?.forEach((update) => {
const territory = this.game.playerBySmallID(update.betrayedID);
if (territory && territory instanceof PlayerView) {
this.redrawBorder(territory);
}
});
updates?.[GameUpdateType.AllianceRequestReply]?.forEach((update) => {
if (
update.accepted &&
(update.request.requestorID === myPlayer.smallID() ||
update.request.recipientID === myPlayer.smallID())
) {
const territoryId =
update.request.requestorID === myPlayer.smallID()
? update.request.recipientID
: update.request.requestorID;
const territory = this.game.playerBySmallID(territoryId);
if (territory && territory instanceof PlayerView) {
this.redrawBorder(territory);
}
}
});
updates?.[GameUpdateType.EmbargoEvent]?.forEach((update) => {
const player = this.game.playerBySmallID(update.playerID) as PlayerView;
const embargoed = this.game.playerBySmallID(
update.embargoedID,
) as PlayerView;
if (
player.id() === myPlayer?.id() ||
embargoed.id() === myPlayer?.id()
) {
this.redrawBorder(player, embargoed);
}
});
}
const focusedPlayer = this.game.focusedPlayer();
if (focusedPlayer !== this.lastFocusedPlayer) {
if (this.lastFocusedPlayer) {
this.paintPlayerBorder(this.lastFocusedPlayer);
}
if (focusedPlayer) {
this.paintPlayerBorder(focusedPlayer);
}
this.lastFocusedPlayer = focusedPlayer;
}
}
private spawnHighlight() {
this.highlightContext.clearRect(
0,
0,
this.game.width(),
this.game.height(),
);
this.drawFocusedPlayerHighlight();
const humans = this.game
.playerViews()
.filter((p) => p.type() === PlayerType.Human);
const focusedPlayer = this.game.focusedPlayer();
const teamColors = Object.values(ColoredTeams);
for (const human of humans) {
if (human === focusedPlayer) {
continue;
}
const center = human.nameLocation();
if (!center) {
continue;
}
const centerTile = this.game.ref(center.x, center.y);
if (!centerTile) {
continue;
}
let color = this.theme.spawnHighlightColor();
const myPlayer = this.game.myPlayer();
if (myPlayer !== null && myPlayer !== human && myPlayer.team() === null) {
// In FFA games (when team === null), use default yellow spawn highlight color
color = this.theme.spawnHighlightColor();
} else if (myPlayer !== null && myPlayer !== human) {
// In Team games, the spawn highlight color becomes that player's team color
// Optionally, this could be broken down to teammate or enemy and simplified to green and red, respectively
const team = human.team();
if (team !== null && teamColors.includes(team)) {
color = this.theme.teamColor(team);
} else {
if (myPlayer.isFriendly(human)) {
color = this.theme.spawnHighlightTeamColor();
} else {
color = this.theme.spawnHighlightColor();
}
}
}
for (const tile of this.game.bfs(
centerTile,
euclDistFN(centerTile, 9, true),
)) {
if (!this.game.hasOwner(tile)) {
this.paintHighlightTile(tile, color, 255);
}
}
}
}
private drawFocusedPlayerHighlight() {
const focusedPlayer = this.game.focusedPlayer();
if (!focusedPlayer) {
return;
}
const center = focusedPlayer.nameLocation();
if (!center) {
return;
}
// Breathing border animation
this.borderAnimTime += 0.5;
const minRad = 8;
const maxRad = 24;
// Range: [minPadding..maxPadding]
const radius =
minRad + (maxRad - minRad) * (0.5 + 0.5 * Math.sin(this.borderAnimTime));
const baseColor = this.theme.spawnHighlightSelfColor(); //white
let teamColor: Colord | null = null;
const team: Team | null = focusedPlayer.team();
if (team !== null && Object.values(ColoredTeams).includes(team)) {
teamColor = this.theme.teamColor(team).alpha(0.5);
} else {
teamColor = baseColor;
}
this.drawBreathingRing(
center.x,
center.y,
minRad,
maxRad,
radius,
baseColor, // Always draw white static semi-transparent ring
teamColor, // Pass the breathing ring color. White for FFA, Duos, Trios, Quads. Transparent team color for TEAM games.
);
// Draw breathing rings for teammates in team games (helps colorblind players identify teammates)
this.drawTeammateHighlights(minRad, maxRad, radius);
}
private drawTeammateHighlights(
minRad: number,
maxRad: number,
radius: number,
) {
const myPlayer = this.game.myPlayer();
if (myPlayer === null || myPlayer.team() === null) {
return;
}
const teammates = this.game
.playerViews()
.filter((p) => p !== myPlayer && myPlayer.isOnSameTeam(p));
// Smaller radius for teammates (more subtle than self highlight)
const teammateMinRad = 5;
const teammateMaxRad = 14;
const teammateRadius =
teammateMinRad +
(teammateMaxRad - teammateMinRad) *
((radius - minRad) / (maxRad - minRad));
const teamColors = Object.values(ColoredTeams);
for (const teammate of teammates) {
const center = teammate.nameLocation();
if (!center) {
continue;
}
const team = teammate.team();
let baseColor: Colord;
let breathingColor: Colord;
if (team !== null && teamColors.includes(team)) {
baseColor = this.theme.teamColor(team).alpha(0.5);
breathingColor = this.theme.teamColor(team).alpha(0.5);
} else {
baseColor = this.theme.spawnHighlightTeamColor();
breathingColor = this.theme.spawnHighlightTeamColor();
}
this.drawBreathingRing(
center.x,
center.y,
teammateMinRad,
teammateMaxRad,
teammateRadius,
baseColor,
breathingColor,
);
}
}
init() {
this.eventBus.on(MouseOverEvent, (e) => this.onMouseOver(e));
this.eventBus.on(AlternateViewEvent, (e) => {
this.alternativeView = e.alternateView;
});
this.eventBus.on(DragEvent, (e) => {
// TODO: consider re-enabling this on mobile or low end devices for smoother dragging.
// this.lastDragTime = Date.now();
});
this.redraw();
}
onMouseOver(event: MouseOverEvent) {
this.lastMousePosition = { x: event.x, y: event.y };
this.updateHighlightedTerritory();
}
private updateHighlightedTerritory() {
if (!this.alternativeView) {
return;
}
if (!this.lastMousePosition) {
return;
}
const cell = this.transformHandler.screenToWorldCoordinates(
this.lastMousePosition.x,
this.lastMousePosition.y,
);
if (!this.game.isValidCoord(cell.x, cell.y)) {
return;
}
const previousTerritory = this.highlightedTerritory;
const territory = this.getTerritoryAtCell(cell);
if (territory) {
this.highlightedTerritory = territory;
} else {
this.highlightedTerritory = null;
}
if (previousTerritory?.id() !== this.highlightedTerritory?.id()) {
const territories: PlayerView[] = [];
if (previousTerritory) {
territories.push(previousTerritory);
}
if (this.highlightedTerritory) {
territories.push(this.highlightedTerritory);
}
this.redrawBorder(...territories);
}
}
private getTerritoryAtCell(cell: { x: number; y: number }) {
const tile = this.game.ref(cell.x, cell.y);
if (!tile) {
return null;
}
// If the tile has no owner, it is either a fallout tile or a terra nullius tile.
if (!this.game.hasOwner(tile)) {
return null;
}
const owner = this.game.owner(tile);
return owner instanceof PlayerView ? owner : null;
}
redraw() {
console.log("redrew territory layer");
this.canvas = document.createElement("canvas");
const context = this.canvas.getContext("2d");
if (context === null) throw new Error("2d context not supported");
this.context = context;
this.canvas.width = this.game.width();
this.canvas.height = this.game.height();
this.imageData = this.context.getImageData(
0,
0,
this.canvas.width,
this.canvas.height,
);
this.alternativeImageData = this.context.getImageData(
0,
0,
this.canvas.width,
this.canvas.height,
);
this.initImageData();
this.context.putImageData(
this.alternativeView ? this.alternativeImageData : this.imageData,
0,
0,
);
// Add a second canvas for highlights
this.highlightCanvas = document.createElement("canvas");
const highlightContext = this.highlightCanvas.getContext("2d", {
alpha: true,
});
if (highlightContext === null) throw new Error("2d context not supported");
this.highlightContext = highlightContext;
this.highlightCanvas.width = this.game.width();
this.highlightCanvas.height = this.game.height();
this.game.forEachTile((t) => {
this.paintTerritory(t);
});
}
redrawBorder(...players: PlayerView[]) {
return Promise.all(
players.map(async (player) => {
const tiles = await player.borderTiles();
tiles.borderTiles.forEach((tile: TileRef) => {
this.paintTerritory(tile, true);
});
}),
);
}
initImageData() {
this.game.forEachTile((tile) => {
const cell = new Cell(this.game.x(tile), this.game.y(tile));
const index = cell.y * this.game.width() + cell.x;
const offset = index * 4;
this.imageData.data[offset + 3] = 0;
this.alternativeImageData.data[offset + 3] = 0;
});
}
renderLayer(context: CanvasRenderingContext2D) {
const now = Date.now();
if (
now > this.lastDragTime + this.nodrawDragDuration &&
now > this.lastRefresh + this.refreshRate
) {
this.lastRefresh = now;
const renderTerritoryStart = FrameProfiler.start();
this.renderTerritory();
FrameProfiler.end("TerritoryLayer:renderTerritory", renderTerritoryStart);
const [topLeft, bottomRight] = this.transformHandler.screenBoundingRect();
const vx0 = Math.max(0, topLeft.x);
const vy0 = Math.max(0, topLeft.y);
const vx1 = Math.min(this.game.width() - 1, bottomRight.x);
const vy1 = Math.min(this.game.height() - 1, bottomRight.y);
const w = vx1 - vx0 + 1;
const h = vy1 - vy0 + 1;
if (w > 0 && h > 0) {
const putImageStart = FrameProfiler.start();
this.context.putImageData(
this.alternativeView ? this.alternativeImageData : this.imageData,
0,
0,
vx0,
vy0,
w,
h,
);
FrameProfiler.end("TerritoryLayer:putImageData", putImageStart);
}
}
const drawCanvasStart = FrameProfiler.start();
context.drawImage(
this.canvas,
-this.game.width() / 2,
-this.game.height() / 2,
this.game.width(),
this.game.height(),
);
FrameProfiler.end("TerritoryLayer:drawCanvas", drawCanvasStart);
if (this.game.inSpawnPhase()) {
const highlightDrawStart = FrameProfiler.start();
context.drawImage(
this.highlightCanvas,
-this.game.width() / 2,
-this.game.height() / 2,
this.game.width(),
this.game.height(),
);
FrameProfiler.end(
"TerritoryLayer:drawHighlightCanvas",
highlightDrawStart,
);
}
}
renderTerritory() {
let numToRender = Math.floor(this.tileToRenderQueue.size() / 10);
if (numToRender === 0 || this.game.inSpawnPhase()) {
numToRender = this.tileToRenderQueue.size();
}
while (numToRender > 0) {
numToRender--;
const entry = this.tileToRenderQueue.pop();
if (!entry) {
break;
}
const tile = entry.tile;
this.paintTerritory(tile);
for (const neighbor of this.game.neighbors(tile)) {
this.paintTerritory(neighbor, true);
}
}
}
paintTerritory(tile: TileRef, isBorder: boolean = false) {
if (isBorder && !this.game.hasOwner(tile)) {
return;
}
if (!this.game.hasOwner(tile)) {
if (this.game.hasFallout(tile)) {
this.paintTile(this.imageData, tile, this.theme.falloutColor(), 150);
this.paintTile(
this.alternativeImageData,
tile,
this.theme.falloutColor(),
150,
);
return;
}
this.clearTile(tile);
return;
}
const owner = this.game.owner(tile) as PlayerView;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const isHighlighted =
this.highlightedTerritory &&
this.highlightedTerritory.id() === owner.id();
const myPlayer = this.game.myPlayer();
if (this.game.isBorder(tile)) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const playerIsFocused = owner && this.game.focusedPlayer() === owner;
if (myPlayer) {
const alternativeColor = this.alternateViewColor(owner);
this.paintTile(this.alternativeImageData, tile, alternativeColor, 255);
}
const isDefended = this.game.hasUnitNearby(
tile,
this.game.config().defensePostRange(),
UnitType.DefensePost,
owner.id(),
);
this.paintTile(
this.imageData,
tile,
owner.borderColor(tile, isDefended),
255,
);
} else {
// Alternative view only shows borders.
this.clearAlternativeTile(tile);
this.paintTile(this.imageData, tile, owner.territoryColor(tile), 150);
}
}
alternateViewColor(other: PlayerView): Colord {
const myPlayer = this.game.myPlayer();
if (!myPlayer) {
return this.theme.neutralColor();
}
if (other.smallID() === myPlayer.smallID()) {
return this.theme.selfColor();
}
if (other.isFriendly(myPlayer)) {
return this.theme.allyColor();
}
if (!other.hasEmbargo(myPlayer)) {
return this.theme.neutralColor();
}
return this.theme.enemyColor();
}
paintAlternateViewTile(tile: TileRef, other: PlayerView) {
const color = this.alternateViewColor(other);
this.paintTile(this.alternativeImageData, tile, color, 255);
}
paintTile(imageData: ImageData, tile: TileRef, color: Colord, alpha: number) {
const offset = tile * 4;
imageData.data[offset] = color.rgba.r;
imageData.data[offset + 1] = color.rgba.g;
imageData.data[offset + 2] = color.rgba.b;
imageData.data[offset + 3] = alpha;
}
clearTile(tile: TileRef) {
const offset = tile * 4;
this.imageData.data[offset + 3] = 0; // Set alpha to 0 (fully transparent)
this.alternativeImageData.data[offset + 3] = 0; // Set alpha to 0 (fully transparent)
}
clearAlternativeTile(tile: TileRef) {
const offset = tile * 4;
this.alternativeImageData.data[offset + 3] = 0; // Set alpha to 0 (fully transparent)
}
enqueueTile(tile: TileRef) {
this.tileToRenderQueue.push({
tile: tile,
lastUpdate: this.game.ticks() + this.random.nextFloat(0, 0.5),
});
}
async enqueuePlayerBorder(player: PlayerView) {
const playerBorderTiles = await player.borderTiles();
playerBorderTiles.borderTiles.forEach((tile: TileRef) => {
this.enqueueTile(tile);
});
}
paintHighlightTile(tile: TileRef, color: Colord, alpha: number) {
this.clearTile(tile);
const x = this.game.x(tile);
const y = this.game.y(tile);
this.highlightContext.fillStyle = color.alpha(alpha / 255).toRgbString();
this.highlightContext.fillRect(x, y, 1, 1);
}
clearHighlightTile(tile: TileRef) {
const x = this.game.x(tile);
const y = this.game.y(tile);
this.highlightContext.clearRect(x, y, 1, 1);
}
private drawBreathingRing(
cx: number,
cy: number,
minRad: number,
maxRad: number,
radius: number,
transparentColor: Colord,
breathingColor: Colord,
) {
const ctx = this.highlightContext;
if (!ctx) return;
// Draw a semi-transparent ring around the starting location
ctx.beginPath();
// Transparency matches the highlight color provided
const transparent = transparentColor.alpha(0);
const radGrad = ctx.createRadialGradient(cx, cy, minRad, cx, cy, maxRad);
// Pixels with radius < minRad are transparent
radGrad.addColorStop(0, transparent.toRgbString());
// The ring then starts with solid highlight color
radGrad.addColorStop(0.01, transparentColor.toRgbString());
radGrad.addColorStop(0.1, transparentColor.toRgbString());
// The outer edge of the ring is transparent
radGrad.addColorStop(1, transparent.toRgbString());
// Draw an arc at the max radius and fill with the created radial gradient
ctx.arc(cx, cy, maxRad, 0, Math.PI * 2);
ctx.fillStyle = radGrad;
ctx.closePath();
ctx.fill();
const breatheInner = breathingColor.alpha(0);
// Draw a solid ring around the starting location with outer radius = the breathing radius
ctx.beginPath();
const radGrad2 = ctx.createRadialGradient(cx, cy, minRad, cx, cy, radius);
// Pixels with radius < minRad are transparent
radGrad2.addColorStop(0, breatheInner.toRgbString());
// The ring then starts with solid highlight color
radGrad2.addColorStop(0.01, breathingColor.toRgbString());
// The ring is solid throughout
radGrad2.addColorStop(1, breathingColor.toRgbString());
// Draw an arc at the current breathing radius and fill with the created "gradient"
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
ctx.fillStyle = radGrad2;
ctx.fill();
}
}
@@ -0,0 +1,59 @@
import { EventBus } from "../../../core/EventBus";
import { GameView } from "../../../core/game/GameView";
import { TransformHandler } from "../TransformHandler";
import { ClassicCanvasTerritoryLayer } from "./ClassicCanvasTerritoryLayer";
import { TerrainLayer } from "./TerrainLayer";
import { TerritoryBackend } from "./TerritoryBackend";
export class ClassicTerritoryBackend implements TerritoryBackend {
readonly id = "classic";
private readonly terrainLayer: TerrainLayer;
private readonly territoryLayer: ClassicCanvasTerritoryLayer;
constructor(
game: GameView,
eventBus: EventBus,
transformHandler: TransformHandler,
) {
this.terrainLayer = new TerrainLayer(game, transformHandler);
this.territoryLayer = new ClassicCanvasTerritoryLayer(
game,
eventBus,
transformHandler,
);
}
profileName(): string {
return "ClassicTerritoryBackend:renderLayer";
}
shouldTransform(): boolean {
return true;
}
init() {
this.terrainLayer.init?.();
this.territoryLayer.init?.();
}
tick() {
this.terrainLayer.tick?.();
this.territoryLayer.tick?.();
}
redraw() {
this.terrainLayer.redraw?.();
this.territoryLayer.redraw?.();
}
renderLayer(context: CanvasRenderingContext2D) {
this.terrainLayer.renderLayer?.(context);
this.territoryLayer.renderLayer?.(context);
}
dispose() {
// Classic layers own only offscreen canvases and event-bus listeners.
// The event bus does not currently expose unsubscribe hooks.
}
}
+28 -42
View File
@@ -6,10 +6,8 @@ import {
PlayerProfile,
PlayerType,
Relation,
Unit,
UnitType,
} from "../../../core/game/Game";
import { TileRef } from "../../../core/game/GameMap";
import { AllianceView } from "../../../core/game/GameUpdates";
import { GameView, PlayerView, UnitView } from "../../../core/game/GameView";
import {
@@ -24,6 +22,7 @@ import {
renderTroops,
translateText,
} from "../../Utils";
import { getHoverInfo } from "../HoverInfo";
import {
EMOJI_ICON_KIND,
getFirstPlacePlayer,
@@ -47,26 +46,6 @@ const portIcon = assetUrl("images/PortIcon.svg");
const samLauncherIcon = assetUrl("images/SamLauncherIconWhite.svg");
const soldierIcon = assetUrl("images/SoldierIcon.svg");
function euclideanDistWorld(
coord: { x: number; y: number },
tileRef: TileRef,
game: GameView,
): number {
const x = game.x(tileRef);
const y = game.y(tileRef);
const dx = coord.x - x;
const dy = coord.y - y;
return Math.sqrt(dx * dx + dy * dy);
}
function distSortUnitWorld(coord: { x: number; y: number }, game: GameView) {
return (a: Unit | UnitView, b: Unit | UnitView) => {
const distA = euclideanDistWorld(coord, a.tile(), game);
const distB = euclideanDistWorld(coord, b.tile(), game);
return distA - distB;
};
}
@customElement("player-info-overlay")
export class PlayerInfoOverlay extends LitElement implements Layer {
@property({ type: Object })
@@ -87,6 +66,12 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
@state()
private unit: UnitView | null = null;
@state()
private isWilderness: boolean = false;
@state()
private isIrradiatedWilderness: boolean = false;
@state()
private _isInfoVisible: boolean = false;
@@ -134,36 +119,28 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
this.setVisible(false);
this.unit = null;
this.player = null;
this.isWilderness = false;
this.isIrradiatedWilderness = false;
}
public maybeShow(x: number, y: number) {
this.hide();
const worldCoord = this.transform.screenToWorldCoordinates(x, y);
if (!this.game.isValidCoord(worldCoord.x, worldCoord.y)) {
return;
}
const info = getHoverInfo(this.game, worldCoord);
const tile = this.game.ref(worldCoord.x, worldCoord.y);
if (!tile) return;
const owner = this.game.owner(tile);
if (owner && owner.isPlayer()) {
this.player = owner as PlayerView;
if (info.player) {
this.player = info.player;
this.player.profile().then((p) => {
this.playerProfile = p;
});
this.setVisible(true);
} else if (!this.game.isLand(tile)) {
const units = this.game
.units(UnitType.Warship, UnitType.TradeShip, UnitType.TransportShip)
.filter((u) => euclideanDistWorld(worldCoord, u.tile(), this.game) < 50)
.sort(distSortUnitWorld(worldCoord, this.game));
if (units.length > 0) {
this.unit = units[0];
this.setVisible(true);
}
} else if (info.isWilderness || info.isIrradiatedWilderness) {
this.isWilderness = info.isWilderness;
this.isIrradiatedWilderness = info.isIrradiatedWilderness;
this.setVisible(true);
} else if (info.unit) {
this.unit = info.unit;
this.setVisible(true);
}
}
@@ -506,6 +483,15 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
<div
class="bg-gray-800/92 backdrop-blur-sm shadow-xs min-[1200px]:rounded-lg sm:rounded-b-lg shadow-lg text-white text-lg lg:text-base w-full sm:w-[500px] overflow-hidden ${containerClasses}"
>
${this.isWilderness || this.isIrradiatedWilderness
? html`<div class="p-2 font-bold">
${translateText(
this.isIrradiatedWilderness
? "player_info_overlay.irradiated_wilderness_title"
: "player_info_overlay.wilderness_title",
)}
</div>`
: ""}
${this.player !== null ? this.renderPlayerInfo(this.player) : ""}
${this.unit !== null ? this.renderUnitInfo(this.unit) : ""}
</div>
@@ -0,0 +1,383 @@
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import {
TERRITORY_RENDERER_KEY,
USER_SETTINGS_CHANGED_EVENT,
UserSettings,
} from "../../../core/game/UserSettings";
import { Layer } from "./Layer";
import {
TERRITORY_RENDERER_OPTIONS,
TERRITORY_RENDERER_STATUS_EVENT,
TerritoryRendererId,
TerritoryRendererPreference,
TerritoryRendererStatus,
} from "./TerritoryBackend";
@customElement("renderer-status-panel")
export class RendererStatusPanel extends LitElement implements Layer {
@property({ type: Object })
public userSettings!: UserSettings;
@state()
private activeRenderer: TerritoryRendererId | null = null;
@state()
private preference: TerritoryRendererPreference = "auto";
@state()
private failedBackends: TerritoryRendererId[] = [];
@state()
private message: string | null = null;
@state()
private position: { x: number; y: number } | null = null;
@state()
private isDragging = false;
private dragState: {
pointerId: number;
offsetX: number;
offsetY: number;
} | null = null;
private readonly positionStorageKey = "rendererStatusPanel.position.v1";
static styles = css`
.panel {
position: fixed;
left: 16px;
bottom: 16px;
z-index: 9998;
width: min(280px, calc(100vw - 32px));
box-sizing: border-box;
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: 8px;
background: rgba(13, 16, 20, 0.86);
color: rgba(255, 255, 255, 0.92);
font-family:
Inter,
ui-sans-serif,
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
sans-serif;
font-size: 12px;
line-height: 1.35;
pointer-events: auto;
user-select: none;
box-shadow: 0 14px 32px rgba(0, 0, 0, 0.24);
backdrop-filter: blur(10px);
}
.panel.dragging {
opacity: 0.72;
}
.title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px 10px 6px;
cursor: grab;
touch-action: none;
font-weight: 700;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.panel.dragging .title {
cursor: grabbing;
}
.body {
display: grid;
gap: 7px;
padding: 8px 10px 10px;
}
.row {
display: grid;
grid-template-columns: 72px 1fr;
align-items: center;
gap: 8px;
}
.label {
color: rgba(255, 255, 255, 0.62);
}
.value {
min-width: 0;
color: rgba(255, 255, 255, 0.94);
font-weight: 650;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.active {
display: inline-flex;
align-items: center;
gap: 6px;
}
.dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: rgb(67, 214, 142);
box-shadow: 0 0 0 3px rgba(67, 214, 142, 0.16);
}
select {
width: 100%;
min-width: 0;
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: 6px;
background: rgba(0, 0, 0, 0.38);
color: rgba(255, 255, 255, 0.94);
padding: 5px 7px;
font: inherit;
outline: none;
}
.note {
color: rgba(255, 255, 255, 0.66);
overflow-wrap: anywhere;
}
`;
init() {
this.preference = this.userSettings.territoryRenderer();
this.restorePosition();
globalThis.addEventListener(
TERRITORY_RENDERER_STATUS_EVENT,
this.handleRendererStatus,
);
globalThis.addEventListener(
`${USER_SETTINGS_CHANGED_EVENT}:${TERRITORY_RENDERER_KEY}`,
this.handlePreferenceChanged,
);
this.requestUpdate();
}
disconnectedCallback(): void {
super.disconnectedCallback();
this.endDrag();
globalThis.removeEventListener(
TERRITORY_RENDERER_STATUS_EVENT,
this.handleRendererStatus,
);
globalThis.removeEventListener(
`${USER_SETTINGS_CHANGED_EVENT}:${TERRITORY_RENDERER_KEY}`,
this.handlePreferenceChanged,
);
}
private readonly handleRendererStatus = (event: Event) => {
const detail = (event as CustomEvent<TerritoryRendererStatus>).detail;
if (!detail) {
return;
}
this.activeRenderer = detail.active;
this.preference = detail.preference;
this.failedBackends = detail.failedBackends;
this.message = detail.message;
};
private readonly handlePreferenceChanged = () => {
if (!this.userSettings) {
return;
}
this.preference = this.userSettings.territoryRenderer();
this.message = null;
};
private changeRenderer(event: Event) {
const value = (event.target as HTMLSelectElement).value;
this.userSettings.setTerritoryRenderer(value);
this.preference = this.userSettings.territoryRenderer();
}
private rendererLabel(id: TerritoryRendererId | TerritoryRendererPreference) {
if (id === "webgpu") return "WebGPU";
if (id === "webgl") return "WebGL";
if (id === "classic") return "Classic";
return "Auto";
}
private statusNote() {
if (this.failedBackends.length > 0) {
return `Skipped this cycle: ${this.failedBackends
.map((id) => this.rendererLabel(id))
.join(", ")}`;
}
if (
this.activeRenderer &&
this.preference !== "auto" &&
this.activeRenderer !== this.preference
) {
return `Fallback from ${this.rendererLabel(this.preference)}`;
}
return this.message;
}
private restorePosition() {
try {
const raw = localStorage.getItem(this.positionStorageKey);
if (!raw) {
return;
}
const parsed = JSON.parse(raw) as { x: unknown; y: unknown };
if (
typeof parsed.x === "number" &&
typeof parsed.y === "number" &&
Number.isFinite(parsed.x) &&
Number.isFinite(parsed.y)
) {
this.position = this.clampPosition(parsed.x, parsed.y);
}
} catch {
// Keep the default docked position.
}
}
private savePosition() {
if (!this.position) {
return;
}
try {
localStorage.setItem(
this.positionStorageKey,
JSON.stringify(this.position),
);
} catch {
// Position persistence is best-effort.
}
}
private clampPosition(x: number, y: number) {
const panel = this.renderRoot.querySelector(".panel") as HTMLElement | null;
const width = panel?.offsetWidth ?? 280;
const height = panel?.offsetHeight ?? 120;
const margin = 8;
return {
x: Math.max(margin, Math.min(window.innerWidth - width - margin, x)),
y: Math.max(margin, Math.min(window.innerHeight - height - margin, y)),
};
}
private panelStyle() {
if (!this.position) {
return "";
}
return `left: ${this.position.x}px; top: ${this.position.y}px; bottom: auto;`;
}
private stopPointerEvent(event: PointerEvent) {
event.stopPropagation();
}
private handleDragPointerDown(event: PointerEvent) {
event.preventDefault();
event.stopPropagation();
const panel = this.renderRoot.querySelector(".panel") as HTMLElement | null;
if (!panel) {
return;
}
const rect = panel.getBoundingClientRect();
this.position = { x: rect.left, y: rect.top };
this.isDragging = true;
this.dragState = {
pointerId: event.pointerId,
offsetX: event.clientX - rect.left,
offsetY: event.clientY - rect.top,
};
globalThis.addEventListener("pointermove", this.handleDragPointerMove);
globalThis.addEventListener("pointerup", this.handleDragPointerUp);
globalThis.addEventListener("pointercancel", this.handleDragPointerUp);
}
private readonly handleDragPointerMove = (event: PointerEvent) => {
if (!this.dragState || event.pointerId !== this.dragState.pointerId) {
return;
}
event.preventDefault();
event.stopPropagation();
this.position = this.clampPosition(
event.clientX - this.dragState.offsetX,
event.clientY - this.dragState.offsetY,
);
};
private readonly handleDragPointerUp = (event: PointerEvent) => {
if (!this.dragState || event.pointerId !== this.dragState.pointerId) {
return;
}
event.preventDefault();
event.stopPropagation();
this.savePosition();
this.endDrag();
};
private endDrag() {
globalThis.removeEventListener("pointermove", this.handleDragPointerMove);
globalThis.removeEventListener("pointerup", this.handleDragPointerUp);
globalThis.removeEventListener("pointercancel", this.handleDragPointerUp);
this.dragState = null;
this.isDragging = false;
}
render() {
if (!this.userSettings) {
return null;
}
const note = this.statusNote();
return html`
<div
class="panel ${this.isDragging ? "dragging" : ""}"
style=${this.panelStyle()}
@pointerdown=${this.stopPointerEvent}
>
<div class="title" @pointerdown=${this.handleDragPointerDown}>
<span>Renderer</span>
</div>
<div class="body">
<div class="row">
<div class="label">Active</div>
<div class="value active">
<span class="dot"></span>
${this.activeRenderer
? this.rendererLabel(this.activeRenderer)
: "Pending"}
</div>
</div>
<div class="row">
<label class="label" for="renderer-select">Saved</label>
<select
id="renderer-select"
.value=${this.preference}
@change=${this.changeRenderer}
>
${TERRITORY_RENDERER_OPTIONS.map(
(option) =>
html`<option value=${option}>
${this.rendererLabel(option)}
</option>`,
)}
</select>
</div>
${note ? html`<div class="note">${note}</div>` : null}
</div>
</div>
`;
}
}
@@ -174,6 +174,11 @@ export class SettingsModal extends LitElement implements Layer {
this.requestUpdate();
}
private onToggleWebgpuDebugOverlayButtonClick() {
this.userSettings.toggleWebgpuDebug();
this.requestUpdate();
}
private onExitButtonClick() {
// redirect to the home page
window.location.href = "/";
@@ -526,6 +531,29 @@ export class SettingsModal extends LitElement implements Layer {
</div>
</button>
<button
class="flex gap-3 items-center w-full text-left p-3 hover:bg-slate-700 rounded-sm text-white transition-colors"
@click="${this.onToggleWebgpuDebugOverlayButtonClick}"
>
<img
src=${settingsIcon}
alt="webgpuDebugIcon"
width="20"
height="20"
/>
<div class="flex-1">
<div class="font-medium">WebGPU Debug</div>
<div class="text-sm text-slate-400">
Territory shader selection + options
</div>
</div>
<div class="text-sm text-slate-400">
${this.userSettings.webgpuDebug()
? translateText("user_setting.on")
: translateText("user_setting.off")}
</div>
</button>
<div class="border-t border-slate-600 pt-3 mt-4">
<button
class="flex gap-3 items-center w-full text-left p-3 hover:bg-red-600/20 rounded-sm text-red-400 transition-colors"
@@ -0,0 +1,143 @@
import { Layer } from "./Layer";
export type TerritoryRendererId = "classic" | "webgl" | "webgpu";
export type TerritoryRendererPreference = "auto" | TerritoryRendererId;
export const TERRITORY_RENDERER_STATUS_EVENT =
"event:territory-renderer-status";
export const TERRITORY_RENDERER_OPTIONS: TerritoryRendererPreference[] = [
"auto",
"classic",
"webgl",
"webgpu",
];
export interface TerritoryBackend extends Layer {
readonly id: TerritoryRendererId;
dispose?: () => void;
getFailureReason?: () => string | null;
whenReady?: () => Promise<boolean>;
}
export interface TerritoryRendererStatus {
active: TerritoryRendererId | null;
preference: TerritoryRendererPreference;
failedBackends: TerritoryRendererId[];
message: string | null;
}
export interface TerritoryBackendCandidate {
readonly id: TerritoryRendererId;
init?: () => void | Promise<void>;
dispose?: () => void;
getFailureReason?: () => string | null;
whenReady?: () => Promise<boolean>;
}
export interface TerritoryBackendSelectionFailure {
id: TerritoryRendererId;
reason: string;
error?: unknown;
}
export interface TerritoryBackendSelection<
T extends TerritoryBackendCandidate,
> {
backend: T | null;
failures: TerritoryBackendSelectionFailure[];
cancelled: boolean;
}
export function normalizeTerritoryRendererPreference(
value: string | null | undefined,
): TerritoryRendererPreference {
if (
value === "classic" ||
value === "webgl" ||
value === "webgpu" ||
value === "auto"
) {
return value;
}
return "auto";
}
export function territoryRendererOrder(
preference: TerritoryRendererPreference,
failedBackends: ReadonlySet<TerritoryRendererId> = new Set(),
): TerritoryRendererId[] {
const preferredOrder: TerritoryRendererId[] =
preference === "classic"
? ["classic"]
: preference === "webgl"
? ["webgl", "classic"]
: ["webgpu", "webgl", "classic"];
return preferredOrder.filter(
(id) => id === "classic" || !failedBackends.has(id),
);
}
export async function selectTerritoryBackend<
T extends TerritoryBackendCandidate,
>(
preference: TerritoryRendererPreference,
failedBackends: ReadonlySet<TerritoryRendererId>,
createBackend: (id: TerritoryRendererId) => T,
shouldContinue: () => boolean = () => true,
): Promise<TerritoryBackendSelection<T>> {
const failures: TerritoryBackendSelectionFailure[] = [];
for (const id of territoryRendererOrder(preference, failedBackends)) {
if (!shouldContinue()) {
return { backend: null, failures, cancelled: true };
}
const backend = createBackend(id);
try {
await backend.init?.();
if (!shouldContinue()) {
backend.dispose?.();
return { backend: null, failures, cancelled: true };
}
let reason = backend.getFailureReason?.() ?? null;
if (reason !== null) {
backend.dispose?.();
failures.push({ id, reason });
continue;
}
if (backend.whenReady) {
const ready = await backend.whenReady();
if (!shouldContinue()) {
backend.dispose?.();
return { backend: null, failures, cancelled: true };
}
reason = backend.getFailureReason?.() ?? null;
if (!ready || reason !== null) {
backend.dispose?.();
failures.push({
id,
reason: reason ?? "initialization failed",
});
continue;
}
}
return { backend, failures, cancelled: false };
} catch (error) {
backend.dispose?.();
failures.push({
id,
reason: error instanceof Error ? error.message : String(error),
error,
});
}
}
return { backend: null, failures, cancelled: false };
}
+217 -656
View File
@@ -1,709 +1,270 @@
import { PriorityQueue } from "@datastructures-js/priority-queue";
import { Colord } from "colord";
import { Theme } from "../../../core/configuration/Config";
import { EventBus } from "../../../core/EventBus";
import { GameView } from "../../../core/game/GameView";
import {
Cell,
ColoredTeams,
PlayerType,
Team,
UnitType,
} 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 { PseudoRandom } from "../../../core/PseudoRandom";
import {
AlternateViewEvent,
DragEvent,
MouseOverEvent,
} from "../../InputHandler";
import { FrameProfiler } from "../FrameProfiler";
TERRITORY_RENDERER_KEY,
USER_SETTINGS_CHANGED_EVENT,
UserSettings,
} from "../../../core/game/UserSettings";
import { TransformHandler } from "../TransformHandler";
import { Layer } from "./Layer";
import { ClassicTerritoryBackend } from "./ClassicTerritoryBackend";
import {
TERRITORY_RENDERER_STATUS_EVENT,
TerritoryBackend,
TerritoryRendererId,
TerritoryRendererStatus,
selectTerritoryBackend,
territoryRendererOrder,
} from "./TerritoryBackend";
import { WebGLTerritoryBackend } from "./WebGLTerritoryBackend";
import { WebGPUTerritoryBackend } from "./WebGPUTerritoryBackend";
export class TerritoryLayer implements Layer {
private canvas: HTMLCanvasElement;
private context: CanvasRenderingContext2D;
private imageData: ImageData;
private alternativeImageData: ImageData;
private borderAnimTime = 0;
export class TerritoryLayer implements TerritoryBackend {
readonly id = "classic";
private cachedTerritoryPatternsEnabled: boolean | undefined;
private tileToRenderQueue: PriorityQueue<{
tile: TileRef;
lastUpdate: number;
}> = new PriorityQueue((a, b) => {
return a.lastUpdate - b.lastUpdate;
});
private random = new PseudoRandom(123);
private theme: Theme;
// Used for spawn highlighting
private highlightCanvas: HTMLCanvasElement;
private highlightContext: CanvasRenderingContext2D;
private highlightedTerritory: PlayerView | null = null;
private alternativeView = false;
private lastDragTime = 0;
private nodrawDragDuration = 200;
private lastMousePosition: { x: number; y: number } | null = null;
private refreshRate = 10; //refresh every 10ms
private lastRefresh = 0;
private lastFocusedPlayer: PlayerView | null = null;
private activeBackend: TerritoryBackend | null = null;
private failedBackends = new Set<TerritoryRendererId>();
private selectionToken = 0;
private initialized = false;
private readonly settingsChanged = () => {
this.failedBackends.clear();
this.publishStatus("Retrying renderer selection");
void this.selectConfiguredBackend();
};
constructor(
private game: GameView,
private eventBus: EventBus,
private transformHandler: TransformHandler,
) {
this.theme = game.config().theme();
this.cachedTerritoryPatternsEnabled = undefined;
private userSettings: UserSettings,
) {}
profileName(): string {
return "TerritoryLayer:renderLayer";
}
shouldTransform(): boolean {
return true;
}
async paintPlayerBorder(player: PlayerView) {
const tiles = await player.borderTiles();
tiles.borderTiles.forEach((tile: TileRef) => {
this.paintTerritory(tile, true); // Immediately paint the tile instead of enqueueing
});
init() {
this.initialized = true;
globalThis.addEventListener?.(
`${USER_SETTINGS_CHANGED_EVENT}:${TERRITORY_RENDERER_KEY}`,
this.settingsChanged,
);
// Keep the map visible while accelerated renderers initialize.
this.activateBackend(
this.createBackend("classic"),
"Using Classic while accelerated renderer initializes",
);
void this.selectConfiguredBackend();
}
tick() {
if (this.game.inSpawnPhase()) {
this.spawnHighlight();
}
this.game.recentlyUpdatedTiles().forEach((t) => {
this.enqueueTile(t);
// Immediately clear territory overlay for water tiles so old
// borders/territory don't persist visually (e.g. after nuke turns land to water)
if (this.game.isWater(t)) {
this.clearTile(t);
}
});
const updates = this.game.updatesSinceLastTick();
const unitUpdates = updates !== null ? updates[GameUpdateType.Unit] : [];
unitUpdates.forEach((update) => {
if (update.unitType === UnitType.DefensePost) {
// Only update borders if the defense post is not under construction
if (update.underConstruction) {
return; // Skip barrier creation while under construction
}
const tile = update.pos;
this.game
.bfs(tile, euclDistFN(tile, this.game.config().defensePostRange()))
.forEach((t) => {
if (
this.game.isBorder(t) &&
(this.game.ownerID(t) === update.ownerID ||
this.game.ownerID(t) === update.lastOwnerID)
) {
this.enqueueTile(t);
}
});
}
});
// Detect alliance mutations
const myPlayer = this.game.myPlayer();
if (myPlayer) {
updates?.[GameUpdateType.BrokeAlliance]?.forEach((update) => {
const territory = this.game.playerBySmallID(update.betrayedID);
if (territory && territory instanceof PlayerView) {
this.redrawBorder(territory);
}
});
updates?.[GameUpdateType.AllianceRequestReply]?.forEach((update) => {
if (
update.accepted &&
(update.request.requestorID === myPlayer.smallID() ||
update.request.recipientID === myPlayer.smallID())
) {
const territoryId =
update.request.requestorID === myPlayer.smallID()
? update.request.recipientID
: update.request.requestorID;
const territory = this.game.playerBySmallID(territoryId);
if (territory && territory instanceof PlayerView) {
this.redrawBorder(territory);
}
}
});
updates?.[GameUpdateType.EmbargoEvent]?.forEach((update) => {
const player = this.game.playerBySmallID(update.playerID) as PlayerView;
const embargoed = this.game.playerBySmallID(
update.embargoedID,
) as PlayerView;
if (
player.id() === myPlayer?.id() ||
embargoed.id() === myPlayer?.id()
) {
this.redrawBorder(player, embargoed);
}
});
}
const focusedPlayer = this.game.focusedPlayer();
if (focusedPlayer !== this.lastFocusedPlayer) {
if (this.lastFocusedPlayer) {
this.paintPlayerBorder(this.lastFocusedPlayer);
}
if (focusedPlayer) {
this.paintPlayerBorder(focusedPlayer);
}
this.lastFocusedPlayer = focusedPlayer;
}
}
private spawnHighlight() {
this.highlightContext.clearRect(
0,
0,
this.game.width(),
this.game.height(),
);
this.drawFocusedPlayerHighlight();
const humans = this.game
.playerViews()
.filter((p) => p.type() === PlayerType.Human);
const focusedPlayer = this.game.focusedPlayer();
const teamColors = Object.values(ColoredTeams);
for (const human of humans) {
if (human === focusedPlayer) {
continue;
}
const center = human.nameLocation();
if (!center) {
continue;
}
const centerTile = this.game.ref(center.x, center.y);
if (!centerTile) {
continue;
}
let color = this.theme.spawnHighlightColor();
const myPlayer = this.game.myPlayer();
if (myPlayer !== null && myPlayer !== human && myPlayer.team() === null) {
// In FFA games (when team === null), use default yellow spawn highlight color
color = this.theme.spawnHighlightColor();
} else if (myPlayer !== null && myPlayer !== human) {
// In Team games, the spawn highlight color becomes that player's team color
// Optionally, this could be broken down to teammate or enemy and simplified to green and red, respectively
const team = human.team();
if (team !== null && teamColors.includes(team)) {
color = this.theme.teamColor(team);
} else {
if (myPlayer.isFriendly(human)) {
color = this.theme.spawnHighlightTeamColor();
} else {
color = this.theme.spawnHighlightColor();
}
}
}
for (const tile of this.game.bfs(
centerTile,
euclDistFN(centerTile, 9, true),
)) {
if (!this.game.hasOwner(tile)) {
this.paintHighlightTile(tile, color, 255);
}
}
}
}
private drawFocusedPlayerHighlight() {
const focusedPlayer = this.game.focusedPlayer();
if (!focusedPlayer) {
return;
}
const center = focusedPlayer.nameLocation();
if (!center) {
return;
}
// Breathing border animation
this.borderAnimTime += 0.5;
const minRad = 8;
const maxRad = 24;
// Range: [minPadding..maxPadding]
const radius =
minRad + (maxRad - minRad) * (0.5 + 0.5 * Math.sin(this.borderAnimTime));
const baseColor = this.theme.spawnHighlightSelfColor(); //white
let teamColor: Colord | null = null;
const team: Team | null = focusedPlayer.team();
if (team !== null && Object.values(ColoredTeams).includes(team)) {
teamColor = this.theme.teamColor(team).alpha(0.5);
} else {
teamColor = baseColor;
}
this.drawBreathingRing(
center.x,
center.y,
minRad,
maxRad,
radius,
baseColor, // Always draw white static semi-transparent ring
teamColor, // Pass the breathing ring color. White for FFA, Duos, Trios, Quads. Transparent team color for TEAM games.
);
// Draw breathing rings for teammates in team games (helps colorblind players identify teammates)
this.drawTeammateHighlights(minRad, maxRad, radius);
}
private drawTeammateHighlights(
minRad: number,
maxRad: number,
radius: number,
) {
const myPlayer = this.game.myPlayer();
if (myPlayer === null || myPlayer.team() === null) {
return;
}
const teammates = this.game
.playerViews()
.filter((p) => p !== myPlayer && myPlayer.isOnSameTeam(p));
// Smaller radius for teammates (more subtle than self highlight)
const teammateMinRad = 5;
const teammateMaxRad = 14;
const teammateRadius =
teammateMinRad +
(teammateMaxRad - teammateMinRad) *
((radius - minRad) / (maxRad - minRad));
const teamColors = Object.values(ColoredTeams);
for (const teammate of teammates) {
const center = teammate.nameLocation();
if (!center) {
continue;
}
const team = teammate.team();
let baseColor: Colord;
let breathingColor: Colord;
if (team !== null && teamColors.includes(team)) {
baseColor = this.theme.teamColor(team).alpha(0.5);
breathingColor = this.theme.teamColor(team).alpha(0.5);
} else {
baseColor = this.theme.spawnHighlightTeamColor();
breathingColor = this.theme.spawnHighlightTeamColor();
}
this.drawBreathingRing(
center.x,
center.y,
teammateMinRad,
teammateMaxRad,
teammateRadius,
baseColor,
breathingColor,
);
}
}
init() {
this.eventBus.on(MouseOverEvent, (e) => this.onMouseOver(e));
this.eventBus.on(AlternateViewEvent, (e) => {
this.alternativeView = e.alternateView;
});
this.eventBus.on(DragEvent, (e) => {
// TODO: consider re-enabling this on mobile or low end devices for smoother dragging.
// this.lastDragTime = Date.now();
});
this.redraw();
}
onMouseOver(event: MouseOverEvent) {
this.lastMousePosition = { x: event.x, y: event.y };
this.updateHighlightedTerritory();
}
private updateHighlightedTerritory() {
if (!this.alternativeView) {
return;
}
if (!this.lastMousePosition) {
return;
}
const cell = this.transformHandler.screenToWorldCoordinates(
this.lastMousePosition.x,
this.lastMousePosition.y,
);
if (!this.game.isValidCoord(cell.x, cell.y)) {
return;
}
const previousTerritory = this.highlightedTerritory;
const territory = this.getTerritoryAtCell(cell);
if (territory) {
this.highlightedTerritory = territory;
} else {
this.highlightedTerritory = null;
}
if (previousTerritory?.id() !== this.highlightedTerritory?.id()) {
const territories: PlayerView[] = [];
if (previousTerritory) {
territories.push(previousTerritory);
}
if (this.highlightedTerritory) {
territories.push(this.highlightedTerritory);
}
this.redrawBorder(...territories);
}
}
private getTerritoryAtCell(cell: { x: number; y: number }) {
const tile = this.game.ref(cell.x, cell.y);
if (!tile) {
return null;
}
// If the tile has no owner, it is either a fallout tile or a terra nullius tile.
if (!this.game.hasOwner(tile)) {
return null;
}
const owner = this.game.owner(tile);
return owner instanceof PlayerView ? owner : null;
this.runActive("tick", (backend) => backend.tick?.());
}
redraw() {
console.log("redrew territory layer");
this.canvas = document.createElement("canvas");
const context = this.canvas.getContext("2d");
if (context === null) throw new Error("2d context not supported");
this.context = context;
this.canvas.width = this.game.width();
this.canvas.height = this.game.height();
this.imageData = this.context.getImageData(
0,
0,
this.canvas.width,
this.canvas.height,
);
this.alternativeImageData = this.context.getImageData(
0,
0,
this.canvas.width,
this.canvas.height,
);
this.initImageData();
this.context.putImageData(
this.alternativeView ? this.alternativeImageData : this.imageData,
0,
0,
);
// Add a second canvas for highlights
this.highlightCanvas = document.createElement("canvas");
const highlightContext = this.highlightCanvas.getContext("2d", {
alpha: true,
});
if (highlightContext === null) throw new Error("2d context not supported");
this.highlightContext = highlightContext;
this.highlightCanvas.width = this.game.width();
this.highlightCanvas.height = this.game.height();
this.game.forEachTile((t) => {
this.paintTerritory(t);
});
}
redrawBorder(...players: PlayerView[]) {
return Promise.all(
players.map(async (player) => {
const tiles = await player.borderTiles();
tiles.borderTiles.forEach((tile: TileRef) => {
this.paintTerritory(tile, true);
});
}),
);
}
initImageData() {
this.game.forEachTile((tile) => {
const cell = new Cell(this.game.x(tile), this.game.y(tile));
const index = cell.y * this.game.width() + cell.x;
const offset = index * 4;
this.imageData.data[offset + 3] = 0;
this.alternativeImageData.data[offset + 3] = 0;
});
if (!this.initialized) {
return;
}
this.runActive("redraw", (backend) => backend.redraw?.());
void this.selectConfiguredBackend();
}
renderLayer(context: CanvasRenderingContext2D) {
const now = Date.now();
if (
now > this.lastDragTime + this.nodrawDragDuration &&
now > this.lastRefresh + this.refreshRate
) {
this.lastRefresh = now;
const renderTerritoryStart = FrameProfiler.start();
this.renderTerritory();
FrameProfiler.end("TerritoryLayer:renderTerritory", renderTerritoryStart);
const [topLeft, bottomRight] = this.transformHandler.screenBoundingRect();
const vx0 = Math.max(0, topLeft.x);
const vy0 = Math.max(0, topLeft.y);
const vx1 = Math.min(this.game.width() - 1, bottomRight.x);
const vy1 = Math.min(this.game.height() - 1, bottomRight.y);
const w = vx1 - vx0 + 1;
const h = vy1 - vy0 + 1;
if (w > 0 && h > 0) {
const putImageStart = FrameProfiler.start();
this.context.putImageData(
this.alternativeView ? this.alternativeImageData : this.imageData,
0,
0,
vx0,
vy0,
w,
h,
);
FrameProfiler.end("TerritoryLayer:putImageData", putImageStart);
}
if (!this.activeBackend) {
return;
}
const drawCanvasStart = FrameProfiler.start();
context.drawImage(
this.canvas,
-this.game.width() / 2,
-this.game.height() / 2,
this.game.width(),
this.game.height(),
if (this.activeBackend.id !== "webgpu") {
this.fillBackground(context);
}
this.runActive("renderLayer", (backend) => backend.renderLayer?.(context));
}
dispose() {
globalThis.removeEventListener?.(
`${USER_SETTINGS_CHANGED_EVENT}:${TERRITORY_RENDERER_KEY}`,
this.settingsChanged,
);
FrameProfiler.end("TerritoryLayer:drawCanvas", drawCanvasStart);
if (this.game.inSpawnPhase()) {
const highlightDrawStart = FrameProfiler.start();
context.drawImage(
this.highlightCanvas,
-this.game.width() / 2,
-this.game.height() / 2,
this.game.width(),
this.game.height(),
);
FrameProfiler.end(
"TerritoryLayer:drawHighlightCanvas",
highlightDrawStart,
);
}
this.activeBackend?.dispose?.();
this.activeBackend = null;
}
renderTerritory() {
let numToRender = Math.floor(this.tileToRenderQueue.size() / 10);
if (numToRender === 0 || this.game.inSpawnPhase()) {
numToRender = this.tileToRenderQueue.size();
}
while (numToRender > 0) {
numToRender--;
const entry = this.tileToRenderQueue.pop();
if (!entry) {
break;
}
const tile = entry.tile;
this.paintTerritory(tile);
for (const neighbor of this.game.neighbors(tile)) {
this.paintTerritory(neighbor, true);
}
}
}
paintTerritory(tile: TileRef, isBorder: boolean = false) {
if (isBorder && !this.game.hasOwner(tile)) {
private async selectConfiguredBackend() {
const token = ++this.selectionToken;
const preference = this.userSettings.territoryRenderer();
const order = territoryRendererOrder(preference, this.failedBackends);
if (
this.activeBackend?.id === order[0] &&
!this.activeBackend.getFailureReason?.()
) {
return;
}
if (!this.game.hasOwner(tile)) {
if (this.game.hasFallout(tile)) {
this.paintTile(this.imageData, tile, this.theme.falloutColor(), 150);
this.paintTile(
this.alternativeImageData,
tile,
this.theme.falloutColor(),
150,
);
return;
}
this.clearTile(tile);
const selection = await selectTerritoryBackend(
preference,
this.failedBackends,
(id) => this.createBackend(id),
() => token === this.selectionToken,
);
if (selection.cancelled) {
return;
}
const owner = this.game.owner(tile) as PlayerView;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const isHighlighted =
this.highlightedTerritory &&
this.highlightedTerritory.id() === owner.id();
const myPlayer = this.game.myPlayer();
if (this.game.isBorder(tile)) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const playerIsFocused = owner && this.game.focusedPlayer() === owner;
if (myPlayer) {
const alternativeColor = this.alternateViewColor(owner);
this.paintTile(this.alternativeImageData, tile, alternativeColor, 255);
for (const failure of selection.failures) {
console.warn(
`[TerritoryLayer] ${failure.id} renderer unavailable: ${failure.reason}`,
failure.error ?? "",
);
if (failure.id !== "classic") {
this.failedBackends.add(failure.id);
}
const isDefended = this.game.hasUnitNearby(
tile,
this.game.config().defensePostRange(),
UnitType.DefensePost,
owner.id(),
);
}
this.paintTile(
this.imageData,
tile,
owner.borderColor(tile, isDefended),
255,
);
if (selection.backend !== null) {
this.activateBackend(selection.backend);
} else {
// Alternative view only shows borders.
this.clearAlternativeTile(tile);
this.paintTile(this.imageData, tile, owner.territoryColor(tile), 150);
this.publishStatus("No territory renderer is currently available");
}
}
alternateViewColor(other: PlayerView): Colord {
const myPlayer = this.game.myPlayer();
if (!myPlayer) {
return this.theme.neutralColor();
private async initializeCandidate(
backend: TerritoryBackend,
token: number,
): Promise<boolean> {
try {
await backend.init?.();
if (token !== this.selectionToken) {
return false;
}
if (backend.getFailureReason?.()) {
console.warn(
`[TerritoryLayer] ${backend.id} renderer unavailable: ${backend.getFailureReason()}`,
);
return false;
}
if (backend.whenReady) {
const ready = await backend.whenReady();
if (!ready || backend.getFailureReason?.()) {
console.warn(
`[TerritoryLayer] ${backend.id} renderer unavailable: ${
backend.getFailureReason?.() ?? "initialization failed"
}`,
);
return false;
}
}
return true;
} catch (error) {
console.warn(
`[TerritoryLayer] ${backend.id} renderer failed init`,
error,
);
return false;
}
if (other.smallID() === myPlayer.smallID()) {
return this.theme.selfColor();
}
if (other.isFriendly(myPlayer)) {
return this.theme.allyColor();
}
if (!other.hasEmbargo(myPlayer)) {
return this.theme.neutralColor();
}
return this.theme.enemyColor();
}
paintAlternateViewTile(tile: TileRef, other: PlayerView) {
const color = this.alternateViewColor(other);
this.paintTile(this.alternativeImageData, tile, color, 255);
}
paintTile(imageData: ImageData, tile: TileRef, color: Colord, alpha: number) {
const offset = tile * 4;
imageData.data[offset] = color.rgba.r;
imageData.data[offset + 1] = color.rgba.g;
imageData.data[offset + 2] = color.rgba.b;
imageData.data[offset + 3] = alpha;
}
clearTile(tile: TileRef) {
const offset = tile * 4;
this.imageData.data[offset + 3] = 0; // Set alpha to 0 (fully transparent)
this.alternativeImageData.data[offset + 3] = 0; // Set alpha to 0 (fully transparent)
}
clearAlternativeTile(tile: TileRef) {
const offset = tile * 4;
this.alternativeImageData.data[offset + 3] = 0; // Set alpha to 0 (fully transparent)
}
enqueueTile(tile: TileRef) {
this.tileToRenderQueue.push({
tile: tile,
lastUpdate: this.game.ticks() + this.random.nextFloat(0, 0.5),
});
}
async enqueuePlayerBorder(player: PlayerView) {
const playerBorderTiles = await player.borderTiles();
playerBorderTiles.borderTiles.forEach((tile: TileRef) => {
this.enqueueTile(tile);
});
}
paintHighlightTile(tile: TileRef, color: Colord, alpha: number) {
this.clearTile(tile);
const x = this.game.x(tile);
const y = this.game.y(tile);
this.highlightContext.fillStyle = color.alpha(alpha / 255).toRgbString();
this.highlightContext.fillRect(x, y, 1, 1);
}
clearHighlightTile(tile: TileRef) {
const x = this.game.x(tile);
const y = this.game.y(tile);
this.highlightContext.clearRect(x, y, 1, 1);
}
private drawBreathingRing(
cx: number,
cy: number,
minRad: number,
maxRad: number,
radius: number,
transparentColor: Colord,
breathingColor: Colord,
private activateBackend(
backend: TerritoryBackend,
message: string | null = null,
) {
const ctx = this.highlightContext;
if (!ctx) return;
if (this.activeBackend === backend) {
return;
}
const previous = this.activeBackend;
this.activeBackend = backend;
previous?.dispose?.();
console.info(`[TerritoryLayer] active renderer: ${backend.id}`);
this.publishStatus(message);
}
// Draw a semi-transparent ring around the starting location
ctx.beginPath();
// Transparency matches the highlight color provided
const transparent = transparentColor.alpha(0);
const radGrad = ctx.createRadialGradient(cx, cy, minRad, cx, cy, maxRad);
private runActive(
operation: "tick" | "redraw" | "renderLayer",
run: (backend: TerritoryBackend) => void,
) {
const backend = this.activeBackend;
if (!backend) {
return;
}
// Pixels with radius < minRad are transparent
radGrad.addColorStop(0, transparent.toRgbString());
// The ring then starts with solid highlight color
radGrad.addColorStop(0.01, transparentColor.toRgbString());
radGrad.addColorStop(0.1, transparentColor.toRgbString());
// The outer edge of the ring is transparent
radGrad.addColorStop(1, transparent.toRgbString());
try {
run(backend);
const reason = backend.getFailureReason?.();
if (reason) {
this.handleBackendFailure(backend, `${operation}: ${reason}`);
}
} catch (error) {
this.handleBackendFailure(backend, `${operation}: ${String(error)}`);
}
}
// Draw an arc at the max radius and fill with the created radial gradient
ctx.arc(cx, cy, maxRad, 0, Math.PI * 2);
ctx.fillStyle = radGrad;
ctx.closePath();
ctx.fill();
private handleBackendFailure(backend: TerritoryBackend, reason: string) {
console.warn(`[TerritoryLayer] ${backend.id} renderer failed: ${reason}`);
if (backend.id !== "classic") {
this.failedBackends.add(backend.id);
}
this.publishStatus(`${backend.id} failed: ${reason}`);
if (this.activeBackend === backend) {
this.activeBackend = null;
backend.dispose?.();
const classic = this.createBackend("classic");
void this.initializeCandidate(classic, ++this.selectionToken).then(
(ready) => {
if (ready) {
this.activateBackend(classic);
void this.selectConfiguredBackend();
}
},
);
}
}
const breatheInner = breathingColor.alpha(0);
// Draw a solid ring around the starting location with outer radius = the breathing radius
ctx.beginPath();
const radGrad2 = ctx.createRadialGradient(cx, cy, minRad, cx, cy, radius);
// Pixels with radius < minRad are transparent
radGrad2.addColorStop(0, breatheInner.toRgbString());
// The ring then starts with solid highlight color
radGrad2.addColorStop(0.01, breathingColor.toRgbString());
// The ring is solid throughout
radGrad2.addColorStop(1, breathingColor.toRgbString());
private createBackend(id: TerritoryRendererId): TerritoryBackend {
if (id === "webgpu") {
return new WebGPUTerritoryBackend(
this.game,
this.eventBus,
this.transformHandler,
this.userSettings,
);
}
if (id === "webgl") {
return new WebGLTerritoryBackend(
this.game,
this.eventBus,
this.transformHandler,
);
}
return new ClassicTerritoryBackend(
this.game,
this.eventBus,
this.transformHandler,
);
}
// Draw an arc at the current breathing radius and fill with the created "gradient"
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
ctx.fillStyle = radGrad2;
ctx.fill();
private fillBackground(context: CanvasRenderingContext2D) {
context.save();
context.setTransform(1, 0, 0, 1, 0, 0);
context.fillStyle = this.game.config().theme().backgroundColor().toHex();
context.fillRect(0, 0, context.canvas.width, context.canvas.height);
context.restore();
}
private publishStatus(message: string | null = null) {
const detail: TerritoryRendererStatus = {
active: this.activeBackend?.id ?? null,
preference: this.userSettings.territoryRenderer(),
failedBackends: Array.from(this.failedBackends),
message,
};
globalThis.dispatchEvent?.(
new CustomEvent(TERRITORY_RENDERER_STATUS_EVENT, { detail }),
);
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,602 @@
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { live } from "lit/directives/live.js";
import { EventBus } from "../../../core/EventBus";
import {
USER_SETTINGS_CHANGED_EVENT,
UserSettings,
WEBGPU_DEBUG_KEY,
} from "../../../core/game/UserSettings";
import { WebGPUComputeMetricsEvent } from "../../InputHandler";
import {
TERRAIN_SHADER_KEY,
TERRAIN_SHADERS,
terrainShaderIdFromInt,
terrainShaderIntFromId,
TerrainShaderOption,
} from "../webgpu/render/TerrainShaderRegistry";
import {
TERRITORY_POST_SMOOTHING,
TERRITORY_POST_SMOOTHING_KEY,
territoryPostSmoothingIdFromInt,
territoryPostSmoothingIntFromId,
} from "../webgpu/render/TerritoryPostSmoothingRegistry";
import {
TERRITORY_PRE_SMOOTHING,
TERRITORY_PRE_SMOOTHING_KEY,
territoryPreSmoothingIdFromInt,
territoryPreSmoothingIntFromId,
} from "../webgpu/render/TerritoryPreSmoothingRegistry";
import {
TERRITORY_SHADER_KEY,
TERRITORY_SHADERS,
territoryShaderIdFromInt,
territoryShaderIntFromId,
TerritoryShaderOption,
} from "../webgpu/render/TerritoryShaderRegistry";
import { Layer } from "./Layer";
type ShaderOption = TerrainShaderOption | TerritoryShaderOption;
@customElement("webgpu-debug-overlay")
export class WebGPUDebugOverlay extends LitElement implements Layer {
@property({ type: Object })
public eventBus!: EventBus;
@property({ type: Object })
public userSettings!: UserSettings;
@state()
private renderFps: number = 0;
@state()
private tickComputeMs: number = 0;
@state()
private position: { x: number; y: number } | null = null;
@state()
private isDragging = false;
private frameTimes: number[] = [];
private dragState: {
pointerId: number;
offsetX: number;
offsetY: number;
} | null = null;
private readonly positionStorageKey = "webgpuDebugOverlay.position.v1";
static styles = css`
.overlay {
position: fixed;
top: 16px;
left: 16px;
z-index: 9999;
min-width: 340px;
max-width: 420px;
background: rgba(0, 0, 0, 0.82);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 8px;
padding: 10px 12px;
color: rgba(255, 255, 255, 0.92);
font-family:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
"Liberation Mono", "Courier New", monospace;
font-size: 12px;
pointer-events: auto;
user-select: none;
}
.overlay.dragging {
opacity: 0.72;
}
.title {
font-weight: 700;
margin-bottom: 8px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
cursor: grab;
touch-action: none;
}
.overlay.dragging .title {
cursor: grabbing;
}
.metrics {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px 10px;
margin-bottom: 10px;
}
.metric {
display: flex;
justify-content: space-between;
gap: 10px;
white-space: nowrap;
}
.label {
color: rgba(255, 255, 255, 0.7);
}
.value {
color: rgba(255, 255, 255, 0.95);
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin: 6px 0;
user-select: none;
}
.sectionTitle {
margin-top: 10px;
font-weight: 700;
letter-spacing: 0.02em;
color: rgba(255, 255, 255, 0.85);
text-transform: uppercase;
font-size: 11px;
}
select,
input[type="range"] {
width: 170px;
}
select {
background: rgba(0, 0, 0, 0.6);
color: rgba(255, 255, 255, 0.92);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 6px;
padding: 4px 6px;
font-size: 12px;
}
input[type="checkbox"] {
transform: translateY(1px);
}
.range {
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
gap: 10px;
}
.rangeValue {
min-width: 54px;
text-align: right;
color: rgba(255, 255, 255, 0.8);
font-variant-numeric: tabular-nums;
}
`;
init() {
this.restorePosition();
globalThis.addEventListener?.(
`${USER_SETTINGS_CHANGED_EVENT}:${WEBGPU_DEBUG_KEY}`,
this.handleDebugSettingChanged,
);
this.eventBus.on(WebGPUComputeMetricsEvent, (e) => {
if (typeof e.computeMs === "number" && Number.isFinite(e.computeMs)) {
this.tickComputeMs = e.computeMs;
this.requestUpdate();
}
});
this.requestUpdate();
}
disconnectedCallback(): void {
super.disconnectedCallback();
this.endDrag();
globalThis.removeEventListener?.(
`${USER_SETTINGS_CHANGED_EVENT}:${WEBGPU_DEBUG_KEY}`,
this.handleDebugSettingChanged,
);
}
private readonly handleDebugSettingChanged = () => {
this.requestUpdate();
};
updateFrameMetrics(frameDurationMs: number): void {
if (!this.userSettings || !this.userSettings.webgpuDebug()) {
return;
}
if (!Number.isFinite(frameDurationMs) || frameDurationMs <= 0) {
return;
}
this.frameTimes.push(frameDurationMs);
if (this.frameTimes.length > 60) {
this.frameTimes.shift();
}
const avgMs =
this.frameTimes.reduce((a, b) => a + b, 0) / this.frameTimes.length;
this.renderFps = Math.round(1000 / Math.max(1e-6, avgMs));
this.requestUpdate();
}
private selectedShaderId() {
const selected = this.userSettings.getInt(TERRITORY_SHADER_KEY, 0);
return territoryShaderIdFromInt(selected);
}
private setSelectedShaderId(id: "classic" | "retro") {
this.userSettings.setInt(
TERRITORY_SHADER_KEY,
territoryShaderIntFromId(id),
);
this.requestUpdate();
}
private selectedTerrainShaderId() {
const selected = this.userSettings.getInt(TERRAIN_SHADER_KEY, 0);
return terrainShaderIdFromInt(selected);
}
private setSelectedTerrainShaderId(
id: "classic" | "improved-lite" | "improved-heavy",
) {
this.userSettings.setInt(TERRAIN_SHADER_KEY, terrainShaderIntFromId(id));
this.requestUpdate();
}
private selectedPreSmoothingId() {
const selected = this.userSettings.getInt(TERRITORY_PRE_SMOOTHING_KEY, 0);
return territoryPreSmoothingIdFromInt(selected);
}
private setSelectedPreSmoothingId(id: "off" | "dissolve" | "budget") {
this.userSettings.setInt(
TERRITORY_PRE_SMOOTHING_KEY,
territoryPreSmoothingIntFromId(id),
);
this.requestUpdate();
}
private selectedPostSmoothingId() {
const selected = this.userSettings.getInt(TERRITORY_POST_SMOOTHING_KEY, 0);
return territoryPostSmoothingIdFromInt(selected);
}
private setSelectedPostSmoothingId(id: "off" | "fade" | "dissolve") {
this.userSettings.setInt(
TERRITORY_POST_SMOOTHING_KEY,
territoryPostSmoothingIntFromId(id),
);
this.requestUpdate();
}
private renderOptionControl(option: ShaderOption) {
if (option.kind === "boolean") {
const enabled = this.userSettings.get(option.key, option.defaultValue);
return html`
<div class="row">
<div class="label">${option.label}</div>
<input
type="checkbox"
.checked=${live(enabled)}
@change=${(e: Event) => {
const checked = (e.target as HTMLInputElement).checked;
this.userSettings.set(option.key, checked);
this.requestUpdate();
}}
/>
</div>
`;
}
if (option.kind === "enum") {
const value = this.userSettings.getInt(option.key, option.defaultValue);
return html`
<div class="row">
<div class="label">${option.label}</div>
<select
.value=${live(String(value))}
@change=${(e: Event) => {
const raw = (e.target as HTMLSelectElement).value;
const next = Number.parseInt(raw, 10);
if (!Number.isFinite(next)) return;
this.userSettings.setInt(option.key, next);
this.requestUpdate();
}}
>
${option.options.map(
(o) => html`<option value=${String(o.value)}>${o.label}</option>`,
)}
</select>
</div>
`;
}
const value = this.userSettings.getFloat(option.key, option.defaultValue);
return html`
<div class="row">
<div class="label">${option.label}</div>
<div class="range">
<input
type="range"
min=${String(option.min)}
max=${String(option.max)}
step=${String(option.step)}
.value=${live(String(value))}
@input=${(e: Event) => {
const raw = (e.target as HTMLInputElement).value;
const next = Number.parseFloat(raw);
if (!Number.isFinite(next)) return;
this.userSettings.setFloat(option.key, next);
this.requestUpdate();
}}
/>
<div class="rangeValue">${value.toFixed(2)}</div>
</div>
</div>
`;
}
private restorePosition() {
try {
const raw = localStorage.getItem(this.positionStorageKey);
if (!raw) {
return;
}
const parsed = JSON.parse(raw) as { x: unknown; y: unknown };
if (
typeof parsed.x === "number" &&
typeof parsed.y === "number" &&
Number.isFinite(parsed.x) &&
Number.isFinite(parsed.y)
) {
this.position = this.clampPosition(parsed.x, parsed.y);
}
} catch {
// Keep the default position.
}
}
private savePosition() {
if (!this.position) {
return;
}
try {
localStorage.setItem(
this.positionStorageKey,
JSON.stringify(this.position),
);
} catch {
// Position persistence is best-effort.
}
}
private clampPosition(x: number, y: number) {
const overlay = this.renderRoot.querySelector(
".overlay",
) as HTMLElement | null;
const width = overlay?.offsetWidth ?? 340;
const height = overlay?.offsetHeight ?? 420;
const margin = 8;
return {
x: Math.max(margin, Math.min(window.innerWidth - width - margin, x)),
y: Math.max(margin, Math.min(window.innerHeight - height - margin, y)),
};
}
private overlayStyle() {
if (!this.position) {
return "";
}
return `left: ${this.position.x}px; top: ${this.position.y}px;`;
}
private stopPointerEvent(event: PointerEvent) {
event.stopPropagation();
}
private handleDragPointerDown(event: PointerEvent) {
event.preventDefault();
event.stopPropagation();
const overlay = this.renderRoot.querySelector(
".overlay",
) as HTMLElement | null;
if (!overlay) {
return;
}
const rect = overlay.getBoundingClientRect();
this.position = { x: rect.left, y: rect.top };
this.isDragging = true;
this.dragState = {
pointerId: event.pointerId,
offsetX: event.clientX - rect.left,
offsetY: event.clientY - rect.top,
};
globalThis.addEventListener("pointermove", this.handleDragPointerMove);
globalThis.addEventListener("pointerup", this.handleDragPointerUp);
globalThis.addEventListener("pointercancel", this.handleDragPointerUp);
}
private readonly handleDragPointerMove = (event: PointerEvent) => {
if (!this.dragState || event.pointerId !== this.dragState.pointerId) {
return;
}
event.preventDefault();
event.stopPropagation();
this.position = this.clampPosition(
event.clientX - this.dragState.offsetX,
event.clientY - this.dragState.offsetY,
);
};
private readonly handleDragPointerUp = (event: PointerEvent) => {
if (!this.dragState || event.pointerId !== this.dragState.pointerId) {
return;
}
event.preventDefault();
event.stopPropagation();
this.savePosition();
this.endDrag();
};
private endDrag() {
globalThis.removeEventListener("pointermove", this.handleDragPointerMove);
globalThis.removeEventListener("pointerup", this.handleDragPointerUp);
globalThis.removeEventListener("pointercancel", this.handleDragPointerUp);
this.dragState = null;
this.isDragging = false;
}
render() {
if (!this.userSettings || !this.userSettings.webgpuDebug()) {
return null;
}
const shaderId = this.selectedShaderId();
const shader =
TERRITORY_SHADERS.find((s) => s.id === shaderId) ?? TERRITORY_SHADERS[0];
const terrainShaderId = this.selectedTerrainShaderId();
const terrainShader =
TERRAIN_SHADERS.find((s) => s.id === terrainShaderId) ??
TERRAIN_SHADERS[0];
const preId = this.selectedPreSmoothingId();
const pre =
TERRITORY_PRE_SMOOTHING.find((s) => s.id === preId) ??
TERRITORY_PRE_SMOOTHING[0];
const postId = this.selectedPostSmoothingId();
const post =
TERRITORY_POST_SMOOTHING.find((s) => s.id === postId) ??
TERRITORY_POST_SMOOTHING[0];
return html`
<div
class="overlay ${this.isDragging ? "dragging" : ""}"
style=${this.overlayStyle()}
@pointerdown=${this.stopPointerEvent}
>
<div class="title" @pointerdown=${this.handleDragPointerDown}>
<div>WebGPU Debug</div>
</div>
<div class="metrics">
<div class="metric">
<div class="label">tick ms compute</div>
<div class="value">${this.tickComputeMs.toFixed(2)}</div>
</div>
<div class="metric">
<div class="label">render fps</div>
<div class="value">${this.renderFps}</div>
</div>
</div>
<div class="sectionTitle">Terrain</div>
<div class="row">
<div class="label">Terrain Shader</div>
<select
.value=${live(String(terrainShaderIntFromId(terrainShaderId)))}
@change=${(e: Event) => {
const raw = (e.target as HTMLSelectElement).value;
const next = terrainShaderIdFromInt(Number.parseInt(raw, 10));
this.setSelectedTerrainShaderId(next);
}}
>
${TERRAIN_SHADERS.map(
(s) =>
html`<option value=${String(terrainShaderIntFromId(s.id))}>
${s.label}
</option>`,
)}
</select>
</div>
${terrainShader.options.map((opt) => this.renderOptionControl(opt))}
<div class="sectionTitle">Territory</div>
<div class="row">
<div class="label">Territory Shader</div>
<select
.value=${live(String(territoryShaderIntFromId(shaderId)))}
@change=${(e: Event) => {
const raw = (e.target as HTMLSelectElement).value;
const next = territoryShaderIdFromInt(Number.parseInt(raw, 10));
this.setSelectedShaderId(next);
}}
>
${TERRITORY_SHADERS.map(
(s) =>
html`<option value=${String(territoryShaderIntFromId(s.id))}>
${s.label}
</option>`,
)}
</select>
</div>
${shader.options.map((opt) => this.renderOptionControl(opt))}
<div class="sectionTitle">Temporal</div>
<div class="row">
<div class="label">Post Compute</div>
<select
.value=${live(String(territoryPreSmoothingIntFromId(preId)))}
@change=${(e: Event) => {
const raw = (e.target as HTMLSelectElement).value;
const next = territoryPreSmoothingIdFromInt(
Number.parseInt(raw, 10),
);
this.setSelectedPreSmoothingId(next);
}}
>
${TERRITORY_PRE_SMOOTHING.map(
(s) =>
html`<option
value=${String(territoryPreSmoothingIntFromId(s.id))}
>
${s.label}
</option>`,
)}
</select>
</div>
${pre.options.map((opt) => this.renderOptionControl(opt))}
<div class="row">
<div class="label">Post Render</div>
<select
.value=${live(String(territoryPostSmoothingIntFromId(postId)))}
@change=${(e: Event) => {
const raw = (e.target as HTMLSelectElement).value;
const next = territoryPostSmoothingIdFromInt(
Number.parseInt(raw, 10),
);
this.setSelectedPostSmoothingId(next);
}}
>
${TERRITORY_POST_SMOOTHING.map(
(s) =>
html`<option
value=${String(territoryPostSmoothingIntFromId(s.id))}
>
${s.label}
</option>`,
)}
</select>
</div>
${post.options.map((opt) => this.renderOptionControl(opt))}
</div>
`;
}
}
@@ -0,0 +1,447 @@
import { Theme } from "../../../core/configuration/Config";
import { EventBus } from "../../../core/EventBus";
import { UnitType } from "../../../core/game/Game";
import { TileRef } from "../../../core/game/GameMap";
import { GameView } from "../../../core/game/GameView";
import { UserSettings } from "../../../core/game/UserSettings";
import {
AlternateViewEvent,
MouseOverEvent,
WebGPUComputeMetricsEvent,
} from "../../InputHandler";
import { FrameProfiler } from "../FrameProfiler";
import { TransformHandler } from "../TransformHandler";
import {
buildTerrainShaderParams,
readTerrainShaderId,
} from "../webgpu/render/TerrainShaderRegistry";
import {
buildTerritoryPostSmoothingParams,
readTerritoryPostSmoothingId,
} from "../webgpu/render/TerritoryPostSmoothingRegistry";
import {
buildTerritoryPreSmoothingParams,
readTerritoryPreSmoothingId,
} from "../webgpu/render/TerritoryPreSmoothingRegistry";
import {
buildTerritoryShaderParams,
readTerritoryShaderId,
} from "../webgpu/render/TerritoryShaderRegistry";
import { TerritoryRenderer } from "../webgpu/TerritoryRenderer";
import { TerritoryBackend } from "./TerritoryBackend";
export class WebGPUTerritoryBackend implements TerritoryBackend {
readonly id = "webgpu";
profileName(): string {
return "WebGPUTerritoryBackend:renderLayer";
}
private attachedTerritoryCanvas: HTMLCanvasElement | null = null;
private overlayWrapper: HTMLElement | null = null;
private overlayResizeObserver: ResizeObserver | null = null;
private theme: Theme;
private territoryRenderer: TerritoryRenderer | null = null;
private alternativeView = false;
private lastPaletteSignature: string | null = null;
private lastDefensePostsSignature: string | null = null;
private lastTerrainShaderSignature: string | null = null;
private lastTerritoryShaderSignature: string | null = null;
private lastPreSmoothingSignature: string | null = null;
private lastPostSmoothingSignature: string | null = null;
private lastMousePosition: { x: number; y: number } | null = null;
private hoveredOwnerSmallId: number | null = null;
private lastHoverUpdateMs = 0;
constructor(
private game: GameView,
private eventBus: EventBus,
private transformHandler: TransformHandler,
private userSettings: UserSettings,
) {
this.theme = game.config().theme();
}
shouldTransform(): boolean {
return true;
}
init() {
this.eventBus.on(AlternateViewEvent, (e) => {
this.alternativeView = e.alternateView;
this.territoryRenderer?.setAlternativeView(this.alternativeView);
});
this.eventBus.on(MouseOverEvent, (e) => {
this.lastMousePosition = { x: e.x, y: e.y };
});
this.redraw();
}
whenReady(): Promise<boolean> {
return this.territoryRenderer?.whenReady() ?? Promise.resolve(false);
}
getFailureReason(): string | null {
return this.territoryRenderer?.getFailureReason() ?? null;
}
dispose() {
this.overlayResizeObserver?.disconnect();
this.overlayResizeObserver = null;
this.attachedTerritoryCanvas?.remove();
this.attachedTerritoryCanvas = null;
this.overlayWrapper = null;
this.territoryRenderer?.dispose();
this.territoryRenderer = null;
}
tick() {
const tickProfile = FrameProfiler.start();
const currentTheme = this.game.config().theme();
if (currentTheme !== this.theme) {
this.theme = currentTheme;
this.territoryRenderer?.refreshTerrain();
this.redraw();
}
this.refreshPaletteIfNeeded();
this.refreshDefensePostsIfNeeded();
this.applyTerrainShaderSettings();
this.applyTerritoryShaderSettings();
this.applyTerritorySmoothingSettings();
const updatedTiles = this.game.recentlyUpdatedTiles();
for (let i = 0; i < updatedTiles.length; i++) {
this.markTile(updatedTiles[i]);
}
// After collecting pending updates and handling palette/theme changes,
// invoke the renderer's tick() to process compute passes. This ensures
// compute shaders run at the simulation rate rather than every frame.
if (this.territoryRenderer) {
const start = performance.now();
this.territoryRenderer.tick();
const computeMs = performance.now() - start;
this.eventBus.emit(new WebGPUComputeMetricsEvent(computeMs));
}
FrameProfiler.end("TerritoryLayer:tick", tickProfile);
}
redraw() {
this.configureRenderer();
}
private configureRenderer() {
this.territoryRenderer?.dispose();
this.territoryRenderer = null;
const { renderer, reason } = TerritoryRenderer.create(
this.game,
this.theme,
);
if (!renderer) {
throw new Error(reason ?? "WebGPU is required for territory rendering.");
}
this.territoryRenderer = renderer;
this.territoryRenderer.setAlternativeView(this.alternativeView);
this.territoryRenderer.setHighlightedOwnerId(this.hoveredOwnerSmallId);
this.applyTerrainShaderSettings(true);
this.applyTerritoryShaderSettings(true);
this.applyTerritorySmoothingSettings(true);
this.territoryRenderer.markAllDirty();
this.territoryRenderer.refreshPalette();
this.lastPaletteSignature = this.computePaletteSignature();
this.lastDefensePostsSignature = this.computeDefensePostsSignature();
// Ensure defense posts buffer is uploaded on first tick.
this.territoryRenderer.markDefensePostsDirty();
// Run an initial tick to upload state and build the colour texture. Without
// this, the first render call may occur before the initial compute pass
// has been executed, resulting in undefined colours.
this.territoryRenderer.tick();
}
renderLayer(context: CanvasRenderingContext2D) {
if (!this.territoryRenderer) {
return;
}
// Check for theme changes in renderLayer too (for when game is paused)
const currentTheme = this.game.config().theme();
if (currentTheme !== this.theme) {
this.theme = currentTheme;
this.territoryRenderer.refreshTerrain();
this.redraw();
}
// Apply user settings even while the game is paused (settings modal).
this.applyTerritoryShaderSettings();
this.applyTerritorySmoothingSettings();
this.ensureTerritoryCanvasAttached(context.canvas);
this.updateHoverHighlight();
const renderTerritoryStart = FrameProfiler.start();
this.territoryRenderer.setViewSize(
context.canvas.width,
context.canvas.height,
);
const viewOffset = this.transformHandler.viewOffset();
this.territoryRenderer.setViewTransform(
this.transformHandler.scale,
viewOffset.x,
viewOffset.y,
);
this.territoryRenderer.render();
FrameProfiler.end("TerritoryLayer:renderTerritory", renderTerritoryStart);
}
private ensureTerritoryCanvasAttached(mainCanvas: HTMLCanvasElement) {
if (!this.territoryRenderer) {
return;
}
const canvas = this.territoryRenderer.canvas;
// If the renderer recreated its canvas, detach the old one.
if (this.attachedTerritoryCanvas !== canvas) {
this.attachedTerritoryCanvas?.remove();
this.attachedTerritoryCanvas = canvas;
// Configure overlay canvas styles once. Avoid per-frame style reads/writes.
canvas.style.pointerEvents = "none";
canvas.style.position = "absolute";
canvas.style.inset = "0";
canvas.style.width = "100%";
canvas.style.height = "100%";
canvas.style.display = "block";
}
const parent = mainCanvas.parentElement;
if (!parent) {
// Fallback: if the canvas isn't in the DOM yet, append to body.
if (!canvas.isConnected) {
document.body.appendChild(canvas);
}
return;
}
// Ensure the main canvas is wrapped in a positioned container so the
// territory canvas can overlay it without mirroring computed styles.
let wrapper: HTMLElement;
const currentParent = mainCanvas.parentElement;
if (currentParent && currentParent.dataset.territoryOverlay === "1") {
wrapper = currentParent;
} else {
wrapper = document.createElement("div");
wrapper.dataset.territoryOverlay = "1";
wrapper.style.position = "relative";
wrapper.style.display = "inline-block";
wrapper.style.lineHeight = "0";
// Replace mainCanvas with wrapper, then re-insert mainCanvas inside wrapper.
parent.replaceChild(wrapper, mainCanvas);
wrapper.appendChild(mainCanvas);
}
if (this.overlayWrapper !== wrapper) {
this.overlayWrapper = wrapper;
this.overlayResizeObserver?.disconnect();
this.overlayResizeObserver = new ResizeObserver(() => {
this.syncOverlayWrapperSize(mainCanvas, wrapper);
});
this.overlayResizeObserver.observe(mainCanvas);
// Kick an initial size update; further updates are handled by ResizeObserver.
this.syncOverlayWrapperSize(mainCanvas, wrapper);
}
// Ensure territory canvas is the first child so it's the lowest layer.
if (canvas.parentElement !== wrapper) {
canvas.remove();
wrapper.insertBefore(canvas, mainCanvas);
} else if (canvas !== wrapper.firstElementChild) {
wrapper.insertBefore(canvas, mainCanvas);
}
}
private syncOverlayWrapperSize(
mainCanvas: HTMLCanvasElement,
wrapper: HTMLElement,
) {
// Ensure the wrapper has real layout size so the absolutely-positioned
// territory canvas (100% width/height) is non-zero even if the main canvas
// is positioned absolutely.
const rect = mainCanvas.getBoundingClientRect();
const w = rect.width > 0 ? rect.width : mainCanvas.clientWidth;
const h = rect.height > 0 ? rect.height : mainCanvas.clientHeight;
if (w > 0) wrapper.style.width = `${w}px`;
if (h > 0) wrapper.style.height = `${h}px`;
}
private markTile(tile: TileRef) {
this.territoryRenderer?.markTile(tile);
}
private updateHoverHighlight() {
if (!this.territoryRenderer) {
return;
}
const now = performance.now();
if (now - this.lastHoverUpdateMs < 100) {
return;
}
this.lastHoverUpdateMs = now;
let nextOwnerSmallId: number | null = null;
if (this.lastMousePosition) {
const cell = this.transformHandler.screenToWorldCoordinates(
this.lastMousePosition.x,
this.lastMousePosition.y,
);
if (this.game.isValidCoord(cell.x, cell.y)) {
const tile = this.game.ref(cell.x, cell.y);
const owner = this.game.owner(tile);
if (owner && owner.isPlayer()) {
nextOwnerSmallId = owner.smallID();
}
}
}
if (nextOwnerSmallId === this.hoveredOwnerSmallId) {
return;
}
this.hoveredOwnerSmallId = nextOwnerSmallId;
this.territoryRenderer.setHighlightedOwnerId(nextOwnerSmallId);
}
private computePaletteSignature(): string {
let maxSmallId = 0;
for (const player of this.game.playerViews()) {
maxSmallId = Math.max(maxSmallId, player.smallID());
}
const patternsEnabled = this.userSettings.territoryPatterns();
return `${this.game.playerViews().length}:${maxSmallId}:${patternsEnabled ? 1 : 0}`;
}
private refreshPaletteIfNeeded() {
if (!this.territoryRenderer) {
return;
}
const signature = this.computePaletteSignature();
if (signature !== this.lastPaletteSignature) {
this.lastPaletteSignature = signature;
this.territoryRenderer.refreshPalette();
}
}
private applyTerritoryShaderSettings(force: boolean = false) {
if (!this.territoryRenderer) {
return;
}
const shaderId = readTerritoryShaderId(this.userSettings);
const { shaderPath, params0, params1 } = buildTerritoryShaderParams(
this.userSettings,
shaderId,
);
const signature = `${shaderPath}:${Array.from(params0).join(",")}:${Array.from(params1).join(",")}`;
if (!force && signature === this.lastTerritoryShaderSignature) {
return;
}
this.lastTerritoryShaderSignature = signature;
this.territoryRenderer.setTerritoryShader(shaderPath);
this.territoryRenderer.setTerritoryShaderParams(params0, params1);
}
private applyTerrainShaderSettings(force: boolean = false) {
if (!this.territoryRenderer) {
return;
}
const terrainId = readTerrainShaderId(this.userSettings);
const { shaderPath, params0, params1 } = buildTerrainShaderParams(
this.userSettings,
terrainId,
);
const signature = `${shaderPath}:${Array.from(params0).join(",")}:${Array.from(params1).join(",")}`;
if (!force && signature === this.lastTerrainShaderSignature) {
return;
}
this.lastTerrainShaderSignature = signature;
this.territoryRenderer.setTerrainShader(shaderPath);
this.territoryRenderer.setTerrainShaderParams(params0, params1);
}
private applyTerritorySmoothingSettings(force: boolean = false) {
if (!this.territoryRenderer) {
return;
}
const preId = readTerritoryPreSmoothingId(this.userSettings);
const preParams = buildTerritoryPreSmoothingParams(
this.userSettings,
preId,
);
const preSignature = `${preId}:${Array.from(preParams.params0).join(",")}`;
if (force || preSignature !== this.lastPreSmoothingSignature) {
this.lastPreSmoothingSignature = preSignature;
this.territoryRenderer.setPreSmoothing(
preParams.enabled,
preParams.shaderPath,
preParams.params0,
);
}
const postId = readTerritoryPostSmoothingId(this.userSettings);
const postParams = buildTerritoryPostSmoothingParams(
this.userSettings,
postId,
);
const postSignature = `${postId}:${Array.from(postParams.params0).join(",")}`;
if (force || postSignature !== this.lastPostSmoothingSignature) {
this.lastPostSmoothingSignature = postSignature;
this.territoryRenderer.setPostSmoothing(
postParams.enabled,
postParams.shaderPath,
postParams.params0,
);
}
}
private computeDefensePostsSignature(): string {
// Active + completed posts only.
const parts: string[] = [];
for (const u of this.game.units(UnitType.DefensePost)) {
if (!u.isActive() || u.isUnderConstruction()) continue;
const tile = u.tile();
parts.push(
`${u.owner().smallID()},${this.game.x(tile)},${this.game.y(tile)}`,
);
}
parts.sort();
return parts.join("|");
}
private refreshDefensePostsIfNeeded() {
if (!this.territoryRenderer) {
return;
}
const signature = this.computeDefensePostsSignature();
if (signature !== this.lastDefensePostsSignature) {
this.lastDefensePostsSignature = signature;
this.territoryRenderer.markDefensePostsDirty();
}
}
}
@@ -0,0 +1,657 @@
import { Theme } from "../../../core/configuration/Config";
import { TileRef } from "../../../core/game/GameMap";
import { GameView } from "../../../core/game/GameView";
import { createCanvas } from "../../Utils";
import { ComputePass } from "./compute/ComputePass";
import { DefendedStrengthFullPass } from "./compute/DefendedStrengthFullPass";
import { DefendedStrengthPass } from "./compute/DefendedStrengthPass";
import { StateUpdatePass } from "./compute/StateUpdatePass";
import { TerrainComputePass } from "./compute/TerrainComputePass";
import { VisualStateSmoothingPass } from "./compute/VisualStateSmoothingPass";
import { GroundTruthData } from "./core/GroundTruthData";
import { WebGPUDevice } from "./core/WebGPUDevice";
import { RenderPass } from "./render/RenderPass";
import { TemporalResolvePass } from "./render/TemporalResolvePass";
import { TerritoryRenderPass } from "./render/TerritoryRenderPass";
export interface TerritoryWebGLCreateResult {
renderer: TerritoryRenderer | null;
reason?: string;
}
/**
* Main orchestrator for WebGPU territory rendering.
* Manages compute passes (tick-based) and render passes (frame-based).
*/
export class TerritoryRenderer {
public readonly canvas: HTMLCanvasElement;
private device: WebGPUDevice | null = null;
private resources: GroundTruthData | null = null;
private ready = false;
private initPromise: Promise<void> | null = null;
private failureReason: string | null = null;
private territoryShaderPath = "render/territory.wgsl";
private territoryShaderParams0 = new Float32Array(4);
private territoryShaderParams1 = new Float32Array(4);
private terrainShaderPath = "compute/terrain-compute.wgsl";
private terrainShaderParams0 = new Float32Array(4);
private terrainShaderParams1 = new Float32Array(4);
private preSmoothingShaderPath = "compute/visual-state-smoothing.wgsl";
private preSmoothingParams0 = new Float32Array(4);
private postSmoothingShaderPath = "render/temporal-resolve.wgsl";
private postSmoothingParams0 = new Float32Array(4);
// Compute passes
private computePasses: ComputePass[] = [];
private computePassOrder: ComputePass[] = [];
private frameComputePasses: ComputePass[] = [];
// Render passes
private renderPasses: RenderPass[] = [];
private renderPassOrder: RenderPass[] = [];
// Pass instances
private terrainComputePass: TerrainComputePass | null = null;
private stateUpdatePass: StateUpdatePass | null = null;
private defendedStrengthFullPass: DefendedStrengthFullPass | null = null;
private defendedStrengthPass: DefendedStrengthPass | null = null;
private visualStateSmoothingPass: VisualStateSmoothingPass | null = null;
private territoryRenderPass: TerritoryRenderPass | null = null;
private temporalResolvePass: TemporalResolvePass | null = null;
private readonly defensePostRange: number;
private preSmoothingEnabled = false;
private postSmoothingEnabled = false;
private constructor(
private readonly game: GameView,
private readonly theme: Theme,
) {
this.canvas = createCanvas();
this.canvas.style.pointerEvents = "none";
this.canvas.width = 1;
this.canvas.height = 1;
this.defensePostRange = game.config().defensePostRange();
}
static create(game: GameView, theme: Theme): TerritoryWebGLCreateResult {
const state = game.tileStateView();
const expected = game.width() * game.height();
if (state.length !== expected) {
return {
renderer: null,
reason: "Tile state buffer size mismatch; GPU renderer disabled.",
};
}
const nav = globalThis.navigator as any;
if (!nav?.gpu || typeof nav.gpu.requestAdapter !== "function") {
return {
renderer: null,
reason: "WebGPU not available; GPU renderer disabled.",
};
}
const renderer = new TerritoryRenderer(game, theme);
renderer.startInit();
return { renderer };
}
private startInit(): void {
if (this.initPromise) return;
this.initPromise = this.init().catch((error) => {
this.ready = false;
this.failureReason =
error instanceof Error ? error.message : String(error);
console.warn("[TerritoryRenderer] WebGPU init failed", error);
});
}
private async init(): Promise<void> {
const webgpuDevice = await WebGPUDevice.create(this.canvas);
if (!webgpuDevice) {
this.failureReason = "WebGPU device initialization failed.";
return;
}
this.device = webgpuDevice;
void webgpuDevice.device.lost.then((info) => {
this.ready = false;
this.failureReason = `WebGPU device lost: ${info.reason}`;
});
const state = this.game.tileStateView();
this.resources = GroundTruthData.create(
webgpuDevice.device,
this.game,
this.theme,
state,
);
this.resources.setTerritoryShaderParams(
this.territoryShaderParams0,
this.territoryShaderParams1,
);
this.resources.setTerrainShaderParams(
this.terrainShaderParams0,
this.terrainShaderParams1,
);
// Upload terrain data and params (terrain colors will be computed on GPU)
this.resources.uploadTerrainData();
this.resources.uploadTerrainParams();
// Create compute passes (terrain compute should run first)
this.terrainComputePass = new TerrainComputePass();
void this.terrainComputePass.setShader(this.terrainShaderPath);
this.stateUpdatePass = new StateUpdatePass();
this.defendedStrengthFullPass = new DefendedStrengthFullPass();
this.defendedStrengthPass = new DefendedStrengthPass();
this.visualStateSmoothingPass = new VisualStateSmoothingPass();
this.computePasses = [
this.terrainComputePass,
this.stateUpdatePass,
this.defendedStrengthFullPass,
this.defendedStrengthPass,
];
this.frameComputePasses = [this.visualStateSmoothingPass];
// Create render passes
this.territoryRenderPass = new TerritoryRenderPass();
this.temporalResolvePass = new TemporalResolvePass();
this.renderPasses = [this.territoryRenderPass, this.temporalResolvePass];
// Initialize all passes
for (const pass of this.computePasses) {
await pass.init(webgpuDevice.device, this.resources);
}
for (const pass of this.frameComputePasses) {
await pass.init(webgpuDevice.device, this.resources);
}
for (const pass of this.renderPasses) {
await pass.init(
webgpuDevice.device,
this.resources,
webgpuDevice.canvasFormat,
);
}
if (this.territoryRenderPass) {
await this.territoryRenderPass.setShader(this.territoryShaderPath);
}
this.applyPreSmoothingConfig();
this.applyPostSmoothingConfig();
// Compute dependency order (topological sort)
this.computePassOrder = this.topologicalSort(this.computePasses);
this.renderPassOrder = this.topologicalSort(this.renderPasses);
this.ready = true;
}
async whenReady(): Promise<boolean> {
await this.initPromise;
return this.ready && this.failureReason === null;
}
getFailureReason(): string | null {
return this.failureReason;
}
dispose(): void {
this.ready = false;
try {
this.device?.device.destroy();
} catch {
// Ignore device cleanup failures during renderer fallback.
}
this.canvas.remove();
}
/**
* Topological sort of passes based on dependencies.
* Ensures passes run in the correct order.
*/
private topologicalSort<T extends { name: string; dependencies: string[] }>(
passes: T[],
): T[] {
const passMap = new Map<string, T>();
for (const pass of passes) {
passMap.set(pass.name, pass);
}
const visited = new Set<string>();
const visiting = new Set<string>();
const result: T[] = [];
const visit = (pass: T): void => {
if (visiting.has(pass.name)) {
console.warn(
`Circular dependency detected involving pass: ${pass.name}`,
);
return;
}
if (visited.has(pass.name)) {
return;
}
visiting.add(pass.name);
for (const depName of pass.dependencies) {
const dep = passMap.get(depName);
if (dep) {
visit(dep);
}
}
visiting.delete(pass.name);
visited.add(pass.name);
result.push(pass);
};
for (const pass of passes) {
if (!visited.has(pass.name)) {
visit(pass);
}
}
return result;
}
setViewSize(width: number, height: number): void {
if (!this.resources || !this.device) {
return;
}
const nextWidth = Math.max(1, Math.floor(width));
const nextHeight = Math.max(1, Math.floor(height));
if (nextWidth === this.canvas.width && nextHeight === this.canvas.height) {
return;
}
this.canvas.width = nextWidth;
this.canvas.height = nextHeight;
this.resources.setViewSize(nextWidth, nextHeight);
this.device.reconfigure();
if (this.postSmoothingEnabled && this.resources) {
this.resources.ensurePostSmoothingTextures(
nextWidth,
nextHeight,
this.device.canvasFormat,
);
this.resources.invalidateHistory();
}
}
setViewTransform(scale: number, offsetX: number, offsetY: number): void {
if (!this.resources) {
return;
}
this.resources.setViewTransform(scale, offsetX, offsetY);
}
setAlternativeView(enabled: boolean): void {
if (!this.resources) {
return;
}
this.resources.setAlternativeView(enabled);
}
setHighlightedOwnerId(ownerSmallId: number | null): void {
if (!this.resources) {
return;
}
this.resources.setHighlightedOwnerId(ownerSmallId);
}
setTerritoryShader(shaderPath: string): void {
this.territoryShaderPath = shaderPath;
if (this.territoryRenderPass) {
void this.territoryRenderPass.setShader(shaderPath);
}
this.resources?.invalidateHistory();
}
setTerrainShader(shaderPath: string): void {
this.terrainShaderPath = shaderPath;
if (!this.terrainComputePass) {
return;
}
void this.terrainComputePass.setShader(shaderPath).then(() => {
this.refreshTerrain();
});
}
setTerritoryShaderParams(
params0: Float32Array | number[],
params1: Float32Array | number[],
): void {
for (let i = 0; i < 4; i++) {
this.territoryShaderParams0[i] = Number(params0[i] ?? 0);
this.territoryShaderParams1[i] = Number(params1[i] ?? 0);
}
if (!this.resources) {
return;
}
this.resources.setTerritoryShaderParams(
this.territoryShaderParams0,
this.territoryShaderParams1,
);
this.resources.invalidateHistory();
}
setTerrainShaderParams(
params0: Float32Array | number[],
params1: Float32Array | number[],
): void {
for (let i = 0; i < 4; i++) {
this.terrainShaderParams0[i] = Number(params0[i] ?? 0);
this.terrainShaderParams1[i] = Number(params1[i] ?? 0);
}
if (!this.resources) {
return;
}
this.resources.setTerrainShaderParams(
this.terrainShaderParams0,
this.terrainShaderParams1,
);
this.refreshTerrain();
}
setPreSmoothing(
enabled: boolean,
shaderPath: string,
params0: Float32Array | number[],
): void {
this.preSmoothingEnabled = enabled;
if (shaderPath) {
this.preSmoothingShaderPath = shaderPath;
}
for (let i = 0; i < 4; i++) {
this.preSmoothingParams0[i] = Number(params0[i] ?? 0);
}
this.applyPreSmoothingConfig();
}
setPostSmoothing(
enabled: boolean,
shaderPath: string,
params0: Float32Array | number[],
): void {
this.postSmoothingEnabled = enabled;
if (shaderPath) {
this.postSmoothingShaderPath = shaderPath;
}
for (let i = 0; i < 4; i++) {
this.postSmoothingParams0[i] = Number(params0[i] ?? 0);
}
this.applyPostSmoothingConfig();
}
private applyPreSmoothingConfig(): void {
if (!this.resources || !this.visualStateSmoothingPass) {
return;
}
this.resources.setUseVisualStateTexture(this.preSmoothingEnabled);
if (this.preSmoothingEnabled) {
this.resources.ensureVisualStateTexture();
void this.visualStateSmoothingPass.setShader(this.preSmoothingShaderPath);
this.visualStateSmoothingPass.setParams(this.preSmoothingParams0);
} else {
this.visualStateSmoothingPass.setParams(new Float32Array(4));
this.resources.releaseVisualStateTexture();
}
this.resources.invalidateHistory();
}
private applyPostSmoothingConfig(): void {
if (!this.resources || !this.temporalResolvePass || !this.device) {
return;
}
if (this.postSmoothingEnabled) {
void this.temporalResolvePass.setShader(this.postSmoothingShaderPath);
this.temporalResolvePass.setParams(this.postSmoothingParams0);
this.temporalResolvePass.setEnabled(true);
this.resources.ensurePostSmoothingTextures(
this.canvas.width,
this.canvas.height,
this.device.canvasFormat,
);
} else {
this.temporalResolvePass.setEnabled(false);
this.resources.releasePostSmoothingTextures();
}
this.resources.invalidateHistory();
}
markTile(tile: TileRef): void {
if (this.stateUpdatePass) {
this.stateUpdatePass.markTile(tile);
}
}
markAllDirty(): void {
this.resources?.markDefensePostsDirty();
}
refreshPalette(): void {
if (!this.resources) {
return;
}
this.resources.markPaletteDirty();
}
markDefensePostsDirty(): void {
if (!this.resources) {
return;
}
this.resources.markDefensePostsDirty();
}
refreshTerrain(): void {
if (!this.resources || !this.device) {
return;
}
this.resources.markTerrainParamsDirty();
if (this.terrainComputePass) {
this.terrainComputePass.markDirty();
// Immediately compute terrain to avoid blank rendering
this.computeTerrainImmediate();
}
}
/**
* Immediately execute terrain compute pass (for theme changes).
* This ensures terrain is recomputed before the next render.
*/
private computeTerrainImmediate(): void {
if (
!this.ready ||
!this.device ||
!this.resources ||
!this.terrainComputePass
) {
return;
}
// Upload terrain params if needed
this.resources.uploadTerrainParams();
if (!this.terrainComputePass.needsUpdate()) {
return;
}
const encoder = this.device.device.createCommandEncoder();
this.terrainComputePass.execute(encoder, this.resources);
this.device.device.queue.submit([encoder.finish()]);
// Rebuild render pass bind group to ensure it uses the updated terrain texture
// This will be called again in render(), but doing it here ensures it's ready
if (this.territoryRenderPass) {
(this.territoryRenderPass as any).rebuildBindGroup?.();
}
}
/**
* Perform one simulation tick.
* Runs compute passes to update ground truth data.
*/
tick(): void {
if (!this.ready || !this.device || !this.resources) {
return;
}
this.resources.updateTickTiming(performance.now() / 1000);
if (this.game.config().defensePostRange() !== this.defensePostRange) {
throw new Error("defensePostRange changed at runtime; unsupported.");
}
// Upload palette if needed
this.resources.uploadPalette();
// Upload diplomacy relations (used by retro shader / debug modes)
this.resources.uploadRelations();
// Upload defense posts if needed (also produces defended dirty tiles on changes)
this.resources.uploadDefensePosts();
// Initial state upload
this.resources.uploadState();
const stateUpdatesPending = this.stateUpdatePass?.needsUpdate() ?? false;
if (!stateUpdatesPending) {
this.resources.setLastStateUpdateCount(0);
}
const needsCompute =
(this.terrainComputePass?.needsUpdate() ?? false) ||
stateUpdatesPending ||
(this.defendedStrengthFullPass?.needsUpdate() ?? false) ||
(this.defendedStrengthPass?.needsUpdate() ?? false);
if (!needsCompute) {
return;
}
const encoder = this.device.device.createCommandEncoder();
if (this.preSmoothingEnabled && stateUpdatesPending) {
this.resources.ensureVisualStateTexture();
const visualStateTexture = this.resources.getVisualStateTexture();
if (visualStateTexture) {
encoder.copyTextureToTexture(
{ texture: this.resources.stateTexture },
{ texture: visualStateTexture },
{
width: this.resources.getMapWidth(),
height: this.resources.getMapHeight(),
depthOrArrayLayers: 1,
},
);
this.resources.consumeVisualStateSyncNeeded();
}
}
// Execute compute passes in dependency order (clear will run before update if needed)
for (const pass of this.computePassOrder) {
if (!pass.needsUpdate()) {
continue;
}
pass.execute(encoder, this.resources);
}
this.device.device.queue.submit([encoder.finish()]);
}
/**
* Render one frame.
* Runs render passes to draw to the canvas.
*/
render(): void {
if (
!this.ready ||
!this.device ||
!this.resources ||
!this.territoryRenderPass
) {
return;
}
const nowSec = performance.now() / 1000;
this.resources.writeTemporalUniformBuffer(nowSec);
// If terrain needs recomputation, trigger it asynchronously (no blocking)
// It will be ready for the next frame, acceptable trade-off for performance
if (this.terrainComputePass?.needsUpdate()) {
this.resources.uploadTerrainParams();
const computeEncoder = this.device.device.createCommandEncoder();
this.terrainComputePass.execute(computeEncoder, this.resources);
this.device.device.queue.submit([computeEncoder.finish()]);
// Continue with render - may show stale terrain for one frame, but better performance
}
const encoder = this.device.device.createCommandEncoder();
const swapchainView = this.device.context.getCurrentTexture().createView();
if (
this.preSmoothingEnabled &&
this.resources.consumeVisualStateSyncNeeded()
) {
const visualStateTexture = this.resources.getVisualStateTexture();
if (visualStateTexture) {
encoder.copyTextureToTexture(
{ texture: this.resources.stateTexture },
{ texture: visualStateTexture },
{
width: this.resources.getMapWidth(),
height: this.resources.getMapHeight(),
depthOrArrayLayers: 1,
},
);
}
}
for (const pass of this.frameComputePasses) {
if (!pass.needsUpdate()) {
continue;
}
pass.execute(encoder, this.resources);
}
// Execute render passes in dependency order
for (const pass of this.renderPassOrder) {
if (!pass.needsUpdate()) {
continue;
}
if (pass === this.territoryRenderPass && this.postSmoothingEnabled) {
if (!this.resources.getCurrentColorTexture()) {
this.resources.ensurePostSmoothingTextures(
this.canvas.width,
this.canvas.height,
this.device.canvasFormat,
);
}
const currentTexture = this.resources.getCurrentColorTexture();
if (currentTexture) {
pass.execute(encoder, this.resources, currentTexture.createView());
}
continue;
}
pass.execute(encoder, this.resources, swapchainView);
}
this.device.device.queue.submit([encoder.finish()]);
}
}
@@ -0,0 +1,37 @@
import { GroundTruthData } from "../core/GroundTruthData";
/**
* Base interface for compute passes.
* Compute passes run during tick() (simulation rate) to update ground truth data.
*/
export interface ComputePass {
/** Unique name of this pass (used for dependency resolution) */
name: string;
/** Names of passes that must run before this one */
dependencies: string[];
/**
* Initialize the pass with device and resources.
* Called once during renderer initialization.
*/
init(device: GPUDevice, resources: GroundTruthData): Promise<void>;
/**
* Check if this pass needs to run this tick.
* Performance optimization: return false to skip execution.
*/
needsUpdate(): boolean;
/**
* Execute the compute pass.
* @param encoder Command encoder for recording GPU commands
* @param resources Ground truth data (read/write access)
*/
execute(encoder: GPUCommandEncoder, resources: GroundTruthData): void;
/**
* Clean up resources when the pass is no longer needed.
*/
dispose(): void;
}
@@ -0,0 +1,159 @@
import { GroundTruthData } from "../core/GroundTruthData";
import { loadShader } from "../core/ShaderLoader";
import { ComputePass } from "./ComputePass";
/**
* Full defended strength recompute across the entire map.
* Used on initial upload or when post diffs are too large for a tile list.
*/
export class DefendedStrengthFullPass implements ComputePass {
name = "defended-strength-full";
dependencies: string[] = ["state-update"];
private pipeline: GPUComputePipeline | null = null;
private bindGroupLayout: GPUBindGroupLayout | null = null;
private bindGroup: GPUBindGroup | null = null;
private device: GPUDevice | null = null;
private resources: GroundTruthData | null = null;
private boundPostsByOwnerBuffer: GPUBuffer | null = null;
async init(device: GPUDevice, resources: GroundTruthData): Promise<void> {
this.device = device;
this.resources = resources;
const shaderCode = await loadShader("compute/defended-strength-full.wgsl");
const shaderModule = device.createShaderModule({ code: shaderCode });
this.bindGroupLayout = device.createBindGroupLayout({
entries: [
{
binding: 0,
visibility: 4 /* COMPUTE */,
buffer: { type: "uniform" },
},
{
binding: 1,
visibility: 4 /* COMPUTE */,
texture: { sampleType: "uint" },
},
{
binding: 2,
visibility: 4 /* COMPUTE */,
storageTexture: { format: "rgba8unorm" },
},
{
binding: 3,
visibility: 4 /* COMPUTE */,
buffer: { type: "read-only-storage" },
},
{
binding: 4,
visibility: 4 /* COMPUTE */,
buffer: { type: "read-only-storage" },
},
],
});
this.pipeline = device.createComputePipeline({
layout: device.createPipelineLayout({
bindGroupLayouts: [this.bindGroupLayout],
}),
compute: {
module: shaderModule,
entryPoint: "main",
},
});
}
needsUpdate(): boolean {
return this.resources?.needsDefendedFullRecompute() ?? false;
}
execute(encoder: GPUCommandEncoder, resources: GroundTruthData): void {
if (!this.device || !this.pipeline) {
return;
}
if (!resources.needsDefendedFullRecompute()) {
return;
}
resources.writeDefendedStrengthParamsBuffer(0);
const postsByOwnerBuffer = resources.defensePostsByOwnerBuffer;
if (
!this.bindGroup ||
this.boundPostsByOwnerBuffer !== postsByOwnerBuffer
) {
this.rebuildBindGroup();
}
if (!this.bindGroup) {
return;
}
const mapWidth = resources.getMapWidth();
const mapHeight = resources.getMapHeight();
const workgroupCountX = Math.ceil(mapWidth / 8);
const workgroupCountY = Math.ceil(mapHeight / 8);
const pass = encoder.beginComputePass();
pass.setPipeline(this.pipeline);
pass.setBindGroup(0, this.bindGroup);
pass.dispatchWorkgroups(workgroupCountX, workgroupCountY);
pass.end();
resources.clearDefendedFullRecompute();
}
private rebuildBindGroup(): void {
if (
!this.device ||
!this.bindGroupLayout ||
!this.resources ||
!this.resources.defendedStrengthParamsBuffer ||
!this.resources.stateTexture ||
!this.resources.defendedStrengthTexture ||
!this.resources.defenseOwnerOffsetsBuffer ||
!this.resources.defensePostsByOwnerBuffer
) {
this.bindGroup = null;
return;
}
this.bindGroup = this.device.createBindGroup({
layout: this.bindGroupLayout,
entries: [
{
binding: 0,
resource: { buffer: this.resources.defendedStrengthParamsBuffer },
},
{
binding: 1,
resource: this.resources.stateTexture.createView(),
},
{
binding: 2,
resource: this.resources.defendedStrengthTexture.createView(),
},
{
binding: 3,
resource: { buffer: this.resources.defenseOwnerOffsetsBuffer },
},
{
binding: 4,
resource: { buffer: this.resources.defensePostsByOwnerBuffer },
},
],
});
this.boundPostsByOwnerBuffer = this.resources.defensePostsByOwnerBuffer;
}
dispose(): void {
this.pipeline = null;
this.bindGroupLayout = null;
this.bindGroup = null;
this.device = null;
this.resources = null;
}
}
@@ -0,0 +1,172 @@
import { GroundTruthData } from "../core/GroundTruthData";
import { loadShader } from "../core/ShaderLoader";
import { ComputePass } from "./ComputePass";
/**
* Recomputes defended strength for a list of dirty tiles.
* Dirty tiles are produced when defense posts are added/removed/moved.
*/
export class DefendedStrengthPass implements ComputePass {
name = "defended-strength";
dependencies: string[] = ["state-update"];
private pipeline: GPUComputePipeline | null = null;
private bindGroupLayout: GPUBindGroupLayout | null = null;
private bindGroup: GPUBindGroup | null = null;
private device: GPUDevice | null = null;
private resources: GroundTruthData | null = null;
private boundDirtyTilesBuffer: GPUBuffer | null = null;
private boundPostsByOwnerBuffer: GPUBuffer | null = null;
async init(device: GPUDevice, resources: GroundTruthData): Promise<void> {
this.device = device;
this.resources = resources;
const shaderCode = await loadShader("compute/defended-strength.wgsl");
const shaderModule = device.createShaderModule({ code: shaderCode });
this.bindGroupLayout = device.createBindGroupLayout({
entries: [
{
binding: 0,
visibility: 4 /* COMPUTE */,
buffer: { type: "uniform" },
},
{
binding: 1,
visibility: 4 /* COMPUTE */,
buffer: { type: "read-only-storage" },
},
{
binding: 2,
visibility: 4 /* COMPUTE */,
texture: { sampleType: "uint" },
},
{
binding: 3,
visibility: 4 /* COMPUTE */,
storageTexture: { format: "rgba8unorm" },
},
{
binding: 4,
visibility: 4 /* COMPUTE */,
buffer: { type: "read-only-storage" },
},
{
binding: 5,
visibility: 4 /* COMPUTE */,
buffer: { type: "read-only-storage" },
},
],
});
this.pipeline = device.createComputePipeline({
layout: device.createPipelineLayout({
bindGroupLayouts: [this.bindGroupLayout],
}),
compute: {
module: shaderModule,
entryPoint: "main",
},
});
}
needsUpdate(): boolean {
return (this.resources?.getDefendedDirtyTilesCount() ?? 0) > 0;
}
execute(encoder: GPUCommandEncoder, resources: GroundTruthData): void {
if (!this.device || !this.pipeline) {
return;
}
const dirtyCount = resources.getDefendedDirtyTilesCount();
if (dirtyCount === 0) {
return;
}
resources.writeDefendedStrengthParamsBuffer(dirtyCount);
const dirtyTilesBuffer = resources.defendedDirtyTilesBuffer;
const postsByOwnerBuffer = resources.defensePostsByOwnerBuffer;
const shouldRebuildBindGroup =
!this.bindGroup ||
this.boundDirtyTilesBuffer !== dirtyTilesBuffer ||
this.boundPostsByOwnerBuffer !== postsByOwnerBuffer;
if (shouldRebuildBindGroup) {
this.rebuildBindGroup();
}
if (!this.bindGroup) {
return;
}
const pass = encoder.beginComputePass();
pass.setPipeline(this.pipeline);
pass.setBindGroup(0, this.bindGroup);
const workgroupCount = Math.ceil(dirtyCount / 64);
pass.dispatchWorkgroups(workgroupCount);
pass.end();
resources.clearDefendedDirtyTiles();
}
private rebuildBindGroup(): void {
if (
!this.device ||
!this.bindGroupLayout ||
!this.resources ||
!this.resources.defendedStrengthParamsBuffer ||
!this.resources.defendedDirtyTilesBuffer ||
!this.resources.stateTexture ||
!this.resources.defendedStrengthTexture ||
!this.resources.defenseOwnerOffsetsBuffer ||
!this.resources.defensePostsByOwnerBuffer
) {
this.bindGroup = null;
return;
}
this.bindGroup = this.device.createBindGroup({
layout: this.bindGroupLayout,
entries: [
{
binding: 0,
resource: { buffer: this.resources.defendedStrengthParamsBuffer },
},
{
binding: 1,
resource: { buffer: this.resources.defendedDirtyTilesBuffer },
},
{
binding: 2,
resource: this.resources.stateTexture.createView(),
},
{
binding: 3,
resource: this.resources.defendedStrengthTexture.createView(),
},
{
binding: 4,
resource: { buffer: this.resources.defenseOwnerOffsetsBuffer },
},
{
binding: 5,
resource: { buffer: this.resources.defensePostsByOwnerBuffer },
},
],
});
this.boundDirtyTilesBuffer = this.resources.defendedDirtyTilesBuffer;
this.boundPostsByOwnerBuffer = this.resources.defensePostsByOwnerBuffer;
}
dispose(): void {
this.pipeline = null;
this.bindGroupLayout = null;
this.bindGroup = null;
this.device = null;
this.resources = null;
}
}
@@ -0,0 +1,197 @@
import { GroundTruthData } from "../core/GroundTruthData";
import { loadShader } from "../core/ShaderLoader";
import { ComputePass } from "./ComputePass";
/**
* Compute pass that scatters tile state updates into the state texture.
*/
export class StateUpdatePass implements ComputePass {
name = "state-update";
dependencies: string[] = [];
private pipeline: GPUComputePipeline | null = null;
private bindGroupLayout: GPUBindGroupLayout | null = null;
private bindGroup: GPUBindGroup | null = null;
private device: GPUDevice | null = null;
private resources: GroundTruthData | null = null;
private readonly pendingTiles: Set<number> = new Set();
private boundUpdatesBuffer: GPUBuffer | null = null;
private boundPostsByOwnerBuffer: GPUBuffer | null = null;
async init(device: GPUDevice, resources: GroundTruthData): Promise<void> {
this.device = device;
this.resources = resources;
const shaderCode = await loadShader("compute/state-update.wgsl");
const shaderModule = device.createShaderModule({ code: shaderCode });
this.bindGroupLayout = device.createBindGroupLayout({
entries: [
{
binding: 0,
visibility: 4 /* COMPUTE */,
buffer: { type: "uniform" },
},
{
binding: 1,
visibility: 4 /* COMPUTE */,
buffer: { type: "read-only-storage" },
},
{
binding: 2,
visibility: 4 /* COMPUTE */,
storageTexture: { format: "r32uint" },
},
{
binding: 3,
visibility: 4 /* COMPUTE */,
storageTexture: { format: "rgba8unorm" },
},
{
binding: 4,
visibility: 4 /* COMPUTE */,
buffer: { type: "read-only-storage" },
},
{
binding: 5,
visibility: 4 /* COMPUTE */,
buffer: { type: "read-only-storage" },
},
],
});
this.pipeline = device.createComputePipeline({
layout: device.createPipelineLayout({
bindGroupLayouts: [this.bindGroupLayout],
}),
compute: {
module: shaderModule,
entryPoint: "main",
},
});
this.rebuildBindGroup();
}
needsUpdate(): boolean {
return this.pendingTiles.size > 0;
}
execute(encoder: GPUCommandEncoder, resources: GroundTruthData): void {
if (!this.device || !this.pipeline) {
return;
}
const numUpdates = this.pendingTiles.size;
if (numUpdates === 0) {
return;
}
resources.setLastStateUpdateCount(numUpdates);
const updatesBuffer = resources.ensureUpdatesBuffer(numUpdates);
resources.writeStateUpdateParamsBuffer(numUpdates);
const staging = resources.getUpdatesStaging();
const state = resources.getState();
// Prepare staging data
let idx = 0;
for (const tile of this.pendingTiles) {
const stateValue = state[tile];
staging[idx * 2] = tile;
staging[idx * 2 + 1] = stateValue;
idx++;
}
// Upload to GPU
this.device.queue.writeBuffer(
updatesBuffer,
0,
staging.subarray(0, numUpdates * 2),
);
const postsByOwnerBuffer = resources.defensePostsByOwnerBuffer;
const shouldRebuildBindGroup =
!this.bindGroup ||
this.boundUpdatesBuffer !== updatesBuffer ||
this.boundPostsByOwnerBuffer !== postsByOwnerBuffer;
if (shouldRebuildBindGroup) {
this.rebuildBindGroup();
}
if (!this.bindGroup) {
return;
}
const pass = encoder.beginComputePass();
pass.setPipeline(this.pipeline);
pass.setBindGroup(0, this.bindGroup);
const workgroupCount = Math.ceil(numUpdates / 64);
pass.dispatchWorkgroups(workgroupCount);
pass.end();
this.pendingTiles.clear();
}
private rebuildBindGroup(): void {
if (
!this.device ||
!this.bindGroupLayout ||
!this.resources ||
!this.resources.stateUpdateParamsBuffer ||
!this.resources.updatesBuffer ||
!this.resources.stateTexture ||
!this.resources.defendedStrengthTexture ||
!this.resources.defenseOwnerOffsetsBuffer ||
!this.resources.defensePostsByOwnerBuffer
) {
this.bindGroup = null;
return;
}
this.bindGroup = this.device.createBindGroup({
layout: this.bindGroupLayout,
entries: [
{
binding: 0,
resource: { buffer: this.resources.stateUpdateParamsBuffer },
},
{ binding: 1, resource: { buffer: this.resources.updatesBuffer } },
{
binding: 2,
resource: this.resources.stateTexture.createView(),
},
{
binding: 3,
resource: this.resources.defendedStrengthTexture.createView(),
},
{
binding: 4,
resource: { buffer: this.resources.defenseOwnerOffsetsBuffer },
},
{
binding: 5,
resource: { buffer: this.resources.defensePostsByOwnerBuffer },
},
],
});
this.boundUpdatesBuffer = this.resources.updatesBuffer;
this.boundPostsByOwnerBuffer = this.resources.defensePostsByOwnerBuffer;
}
markTile(tile: number): void {
this.pendingTiles.add(tile);
}
dispose(): void {
// Resources are managed by GroundTruthData
this.pipeline = null;
this.bindGroupLayout = null;
this.bindGroup = null;
this.device = null;
this.resources = null;
}
}
@@ -0,0 +1,145 @@
import { GroundTruthData } from "../core/GroundTruthData";
import { loadShader } from "../core/ShaderLoader";
import { ComputePass } from "./ComputePass";
/**
* Compute pass that generates terrain colors from terrain data.
* Runs once at initialization or when theme changes.
*/
export class TerrainComputePass implements ComputePass {
name = "terrain-compute";
dependencies: string[] = [];
private pipeline: GPUComputePipeline | null = null;
private bindGroupLayout: GPUBindGroupLayout | null = null;
private bindGroup: GPUBindGroup | null = null;
private device: GPUDevice | null = null;
private resources: GroundTruthData | null = null;
private needsCompute = true;
private shaderPath = "compute/terrain-compute.wgsl";
async init(device: GPUDevice, resources: GroundTruthData): Promise<void> {
this.device = device;
this.resources = resources;
this.ensureBindGroupLayout();
await this.setShader(this.shaderPath);
this.rebuildBindGroup();
}
async setShader(shaderPath: string): Promise<void> {
this.shaderPath = shaderPath;
if (!this.device || !this.bindGroupLayout) {
return;
}
const shaderCode = await loadShader(shaderPath);
const shaderModule = this.device.createShaderModule({ code: shaderCode });
this.pipeline = this.device.createComputePipeline({
layout: this.device.createPipelineLayout({
bindGroupLayouts: [this.bindGroupLayout],
}),
compute: {
module: shaderModule,
entryPoint: "main",
},
});
this.needsCompute = true;
}
needsUpdate(): boolean {
return this.needsCompute;
}
execute(encoder: GPUCommandEncoder, resources: GroundTruthData): void {
if (!this.device || !this.pipeline || !this.bindGroup) {
return;
}
const mapWidth = resources.getMapWidth();
const mapHeight = resources.getMapHeight();
const workgroupCountX = Math.ceil(mapWidth / 8);
const workgroupCountY = Math.ceil(mapHeight / 8);
const pass = encoder.beginComputePass();
pass.setPipeline(this.pipeline);
pass.setBindGroup(0, this.bindGroup);
pass.dispatchWorkgroups(workgroupCountX, workgroupCountY);
pass.end();
this.needsCompute = false;
}
private rebuildBindGroup(): void {
if (
!this.device ||
!this.bindGroupLayout ||
!this.resources ||
!this.resources.terrainParamsBuffer ||
!this.resources.terrainDataTexture ||
!this.resources.terrainTexture
) {
return;
}
this.bindGroup = this.device.createBindGroup({
layout: this.bindGroupLayout,
entries: [
{
binding: 0,
resource: { buffer: this.resources.terrainParamsBuffer },
},
{
binding: 1,
resource: this.resources.terrainDataTexture.createView(),
},
{
binding: 2,
resource: this.resources.terrainTexture.createView(),
},
],
});
}
private ensureBindGroupLayout(): void {
if (!this.device || this.bindGroupLayout) {
return;
}
this.bindGroupLayout = this.device.createBindGroupLayout({
entries: [
{
binding: 0,
visibility: 4 /* COMPUTE */,
buffer: { type: "uniform" },
},
{
binding: 1,
visibility: 4 /* COMPUTE */,
texture: { sampleType: "uint" },
},
{
binding: 2,
visibility: 4 /* COMPUTE */,
storageTexture: { format: "rgba8unorm" },
},
],
});
}
markDirty(): void {
this.needsCompute = true;
// Rebuild bind group in case terrain params buffer was recreated
this.rebuildBindGroup();
}
dispose(): void {
this.pipeline = null;
this.bindGroupLayout = null;
this.bindGroup = null;
this.device = null;
this.resources = null;
}
}
@@ -0,0 +1,203 @@
import { GroundTruthData } from "../core/GroundTruthData";
import { loadShader } from "../core/ShaderLoader";
import { ComputePass } from "./ComputePass";
/**
* Per-frame compute pass that updates the visual state texture.
* Supports dissolve and budgeted reveal modes.
*/
export class VisualStateSmoothingPass implements ComputePass {
name = "visual-state-smoothing";
dependencies: string[] = [];
private pipeline: GPUComputePipeline | null = null;
private bindGroupLayout: GPUBindGroupLayout | null = null;
private bindGroup: GPUBindGroup | null = null;
private device: GPUDevice | null = null;
private resources: GroundTruthData | null = null;
private paramsBuffer: GPUBuffer | null = null;
private paramsData = new Float32Array(8);
private enabled = false;
private shaderPath = "compute/visual-state-smoothing.wgsl";
private mode = 0;
private curveExp = 1;
private boundUpdatesBuffer: GPUBuffer | null = null;
private boundVisualStateTexture: GPUTexture | null = null;
async init(device: GPUDevice, resources: GroundTruthData): Promise<void> {
this.device = device;
this.resources = resources;
const GPUBufferUsage = (globalThis as any).GPUBufferUsage;
const UNIFORM = GPUBufferUsage?.UNIFORM ?? 0x40;
const COPY_DST = GPUBufferUsage?.COPY_DST ?? 0x8;
this.paramsBuffer = device.createBuffer({
size: 32,
usage: UNIFORM | COPY_DST,
});
await this.setShader(this.shaderPath);
this.rebuildBindGroup();
}
async setShader(shaderPath: string): Promise<void> {
this.shaderPath = shaderPath;
if (!this.device) {
return;
}
const shaderCode = await loadShader(shaderPath);
const shaderModule = this.device.createShaderModule({ code: shaderCode });
this.bindGroupLayout = this.device.createBindGroupLayout({
entries: [
{
binding: 0,
visibility: 4 /* COMPUTE */,
buffer: { type: "uniform" },
},
{
binding: 1,
visibility: 4 /* COMPUTE */,
buffer: { type: "uniform" },
},
{
binding: 2,
visibility: 4 /* COMPUTE */,
buffer: { type: "read-only-storage" },
},
{
binding: 3,
visibility: 4 /* COMPUTE */,
storageTexture: { format: "r32uint" },
},
],
});
this.pipeline = this.device.createComputePipeline({
layout: this.device.createPipelineLayout({
bindGroupLayouts: [this.bindGroupLayout],
}),
compute: {
module: shaderModule,
entryPoint: "main",
},
});
this.rebuildBindGroup();
}
setParams(params0: Float32Array | number[]): void {
this.mode = Number(params0[0] ?? 0);
this.curveExp = Number(params0[1] ?? 1);
this.enabled = this.mode > 0;
}
needsUpdate(): boolean {
if (!this.enabled || !this.resources) {
return false;
}
return this.resources.getLastStateUpdateCount() > 0;
}
execute(encoder: GPUCommandEncoder, resources: GroundTruthData): void {
if (!this.device || !this.pipeline || !this.paramsBuffer) {
return;
}
const updateCount = resources.getLastStateUpdateCount();
if (updateCount <= 0) {
return;
}
const updatesBuffer = resources.updatesBuffer;
const visualStateTexture = resources.getVisualStateTexture();
if (!updatesBuffer || !visualStateTexture) {
return;
}
this.paramsData[0] = this.mode;
this.paramsData[1] = this.curveExp;
this.paramsData[2] = 0;
this.paramsData[3] = 0;
this.paramsData[4] = updateCount;
this.paramsData[5] = 0;
this.paramsData[6] = 0;
this.paramsData[7] = 0;
this.device.queue.writeBuffer(this.paramsBuffer, 0, this.paramsData);
const shouldRebuild =
!this.bindGroup ||
this.boundUpdatesBuffer !== updatesBuffer ||
this.boundVisualStateTexture !== visualStateTexture;
if (shouldRebuild) {
this.rebuildBindGroup();
}
if (!this.bindGroup) {
return;
}
const pass = encoder.beginComputePass();
pass.setPipeline(this.pipeline);
pass.setBindGroup(0, this.bindGroup);
const workgroupCount = Math.ceil(updateCount / 64);
pass.dispatchWorkgroups(workgroupCount);
pass.end();
}
private rebuildBindGroup(): void {
if (
!this.device ||
!this.bindGroupLayout ||
!this.resources ||
!this.resources.temporalUniformBuffer ||
!this.paramsBuffer ||
!this.resources.updatesBuffer ||
!this.resources.getVisualStateTexture()
) {
this.bindGroup = null;
return;
}
const visualStateTexture = this.resources.getVisualStateTexture();
if (!visualStateTexture) {
this.bindGroup = null;
return;
}
this.bindGroup = this.device.createBindGroup({
layout: this.bindGroupLayout,
entries: [
{
binding: 0,
resource: { buffer: this.resources.temporalUniformBuffer },
},
{
binding: 1,
resource: { buffer: this.paramsBuffer },
},
{
binding: 2,
resource: { buffer: this.resources.updatesBuffer },
},
{
binding: 3,
resource: visualStateTexture.createView(),
},
],
});
this.boundUpdatesBuffer = this.resources.updatesBuffer;
this.boundVisualStateTexture = visualStateTexture;
}
dispose(): void {
this.pipeline = null;
this.bindGroupLayout = null;
this.bindGroup = null;
this.device = null;
this.resources = null;
this.paramsBuffer = null;
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,19 @@
/**
* Utility for loading WGSL shader sources bundled by Vite.
* Uses a static glob so production builds reliably include all shaders.
*/
const shaderSources = import.meta.glob("../shaders/**/*.wgsl", {
query: "?raw",
import: "default",
eager: true,
}) as Record<string, string>;
export async function loadShader(path: string): Promise<string> {
const key = `../shaders/${path}`;
const src = shaderSources[key];
if (!src) {
throw new Error(`Missing WGSL shader source: ${key}`);
}
return src;
}
@@ -0,0 +1,66 @@
/**
* Manages WebGPU device initialization and canvas context configuration.
*/
export class WebGPUDevice {
public readonly device: GPUDevice;
public readonly context: GPUCanvasContext;
public readonly canvasFormat: GPUTextureFormat;
private constructor(
device: GPUDevice,
context: GPUCanvasContext,
canvasFormat: GPUTextureFormat,
) {
this.device = device;
this.context = context;
this.canvasFormat = canvasFormat;
}
/**
* Initialize WebGPU device and canvas context.
* @param canvas Canvas element to configure
* @returns WebGPUDevice instance or null if WebGPU is not available
*/
static async create(canvas: HTMLCanvasElement): Promise<WebGPUDevice | null> {
const nav = globalThis.navigator as any;
if (!nav?.gpu || typeof nav.gpu.requestAdapter !== "function") {
return null;
}
const adapter = await nav.gpu.requestAdapter();
if (!adapter) {
return null;
}
const device = await adapter.requestDevice();
const context = canvas.getContext("webgpu");
if (!context) {
return null;
}
const canvasFormat =
typeof nav.gpu.getPreferredCanvasFormat === "function"
? nav.gpu.getPreferredCanvasFormat()
: "bgra8unorm";
context.configure({
device,
format: canvasFormat,
alphaMode: "opaque",
});
return new WebGPUDevice(device, context, canvasFormat);
}
/**
* Reconfigure the canvas context (e.g., when canvas size changes).
*/
reconfigure(): void {
this.context.configure({
device: this.device,
format: this.canvasFormat,
alphaMode: "opaque",
});
}
}
@@ -0,0 +1,46 @@
import { GroundTruthData } from "../core/GroundTruthData";
/**
* Base interface for render passes.
* Render passes run during render() (frame rate) to draw to the canvas.
*/
export interface RenderPass {
/** Unique name of this pass (used for dependency resolution) */
name: string;
/** Names of render passes that must run before this one */
dependencies: string[];
/**
* Initialize the pass with device, resources, and canvas format.
* Called once during renderer initialization.
*/
init(
device: GPUDevice,
resources: GroundTruthData,
canvasFormat: GPUTextureFormat,
): Promise<void>;
/**
* Check if this pass needs to run this frame.
* Performance optimization: return false to skip execution.
*/
needsUpdate(): boolean;
/**
* Execute the render pass.
* @param encoder Command encoder for recording GPU commands
* @param resources Ground truth data (read-only access)
* @param target Target texture view to render to
*/
execute(
encoder: GPUCommandEncoder,
resources: GroundTruthData,
target: GPUTextureView,
): void;
/**
* Clean up resources when the pass is no longer needed.
*/
dispose(): void;
}
@@ -0,0 +1,218 @@
import { GroundTruthData } from "../core/GroundTruthData";
import { loadShader } from "../core/ShaderLoader";
import { RenderPass } from "./RenderPass";
/**
* Post-render temporal resolve pass. Blends current and history frames.
*/
export class TemporalResolvePass implements RenderPass {
name = "temporal-resolve";
dependencies: string[] = ["territory"];
private pipeline: GPURenderPipeline | null = null;
private bindGroupLayout: GPUBindGroupLayout | null = null;
private bindGroup: GPUBindGroup | null = null;
private device: GPUDevice | null = null;
private resources: GroundTruthData | null = null;
private canvasFormat: GPUTextureFormat | null = null;
private paramsBuffer: GPUBuffer | null = null;
private paramsData = new Float32Array(4);
private enabled = false;
private boundCurrentTexture: GPUTexture | null = null;
private boundHistoryTexture: GPUTexture | null = null;
async init(
device: GPUDevice,
resources: GroundTruthData,
canvasFormat: GPUTextureFormat,
): Promise<void> {
this.device = device;
this.resources = resources;
this.canvasFormat = canvasFormat;
const GPUBufferUsage = (globalThis as any).GPUBufferUsage;
const UNIFORM = GPUBufferUsage?.UNIFORM ?? 0x40;
const COPY_DST = GPUBufferUsage?.COPY_DST ?? 0x8;
this.paramsBuffer = device.createBuffer({
size: 16,
usage: UNIFORM | COPY_DST,
});
this.bindGroupLayout = device.createBindGroupLayout({
entries: [
{
binding: 0,
visibility: 2 /* FRAGMENT */,
buffer: { type: "uniform" },
},
{
binding: 1,
visibility: 2 /* FRAGMENT */,
buffer: { type: "uniform" },
},
{
binding: 2,
visibility: 2 /* FRAGMENT */,
texture: { sampleType: "float" },
},
{
binding: 3,
visibility: 2 /* FRAGMENT */,
texture: { sampleType: "float" },
},
],
});
await this.setShader("render/temporal-resolve.wgsl");
this.rebuildBindGroup();
}
async setShader(shaderPath: string): Promise<void> {
if (!this.device || !this.bindGroupLayout || !this.canvasFormat) {
return;
}
const shaderCode = await loadShader(shaderPath);
const shaderModule = this.device.createShaderModule({ code: shaderCode });
this.pipeline = this.device.createRenderPipeline({
layout: this.device.createPipelineLayout({
bindGroupLayouts: [this.bindGroupLayout],
}),
vertex: { module: shaderModule, entryPoint: "vsMain" },
fragment: {
module: shaderModule,
entryPoint: "fsMain",
targets: [{ format: this.canvasFormat }, { format: this.canvasFormat }],
},
primitive: { topology: "triangle-list" },
});
}
setParams(params0: Float32Array | number[]): void {
this.paramsData[0] = Number(params0[0] ?? 0);
this.paramsData[1] = Number(params0[1] ?? 1);
this.paramsData[2] = Number(params0[2] ?? 0.08);
this.paramsData[3] = 0;
this.enabled = this.paramsData[0] > 0;
}
setEnabled(enabled: boolean): void {
this.enabled = enabled;
}
needsUpdate(): boolean {
return this.enabled;
}
execute(
encoder: GPUCommandEncoder,
resources: GroundTruthData,
target: GPUTextureView,
): void {
if (!this.device || !this.pipeline || !this.paramsBuffer) {
return;
}
if (!this.enabled) {
return;
}
const currentTexture = resources.getCurrentColorTexture();
const historyRead = resources.getHistoryReadTexture();
const historyWrite = resources.getHistoryWriteTexture();
if (!currentTexture || !historyRead || !historyWrite) {
return;
}
this.device.queue.writeBuffer(this.paramsBuffer, 0, this.paramsData);
const shouldRebuild =
!this.bindGroup ||
this.boundCurrentTexture !== currentTexture ||
this.boundHistoryTexture !== historyRead;
if (shouldRebuild) {
this.rebuildBindGroup();
}
if (!this.bindGroup) {
return;
}
const pass = encoder.beginRenderPass({
colorAttachments: [
{
view: target,
loadOp: "clear",
storeOp: "store",
clearValue: { r: 0, g: 0, b: 0, a: 1 },
},
{
view: historyWrite.createView(),
loadOp: "clear",
storeOp: "store",
clearValue: { r: 0, g: 0, b: 0, a: 1 },
},
],
});
pass.setPipeline(this.pipeline);
pass.setBindGroup(0, this.bindGroup);
pass.draw(3);
pass.end();
resources.swapHistoryTextures();
resources.markHistoryValid();
}
rebuildBindGroup(): void {
if (
!this.device ||
!this.bindGroupLayout ||
!this.resources ||
!this.resources.temporalUniformBuffer ||
!this.paramsBuffer
) {
return;
}
const currentTexture = this.resources.getCurrentColorTexture();
const historyRead = this.resources.getHistoryReadTexture();
if (!currentTexture || !historyRead) {
return;
}
this.bindGroup = this.device.createBindGroup({
layout: this.bindGroupLayout,
entries: [
{
binding: 0,
resource: { buffer: this.resources.temporalUniformBuffer },
},
{
binding: 1,
resource: { buffer: this.paramsBuffer },
},
{
binding: 2,
resource: currentTexture.createView(),
},
{
binding: 3,
resource: historyRead.createView(),
},
],
});
this.boundCurrentTexture = currentTexture;
this.boundHistoryTexture = historyRead;
}
dispose(): void {
this.pipeline = null;
this.bindGroupLayout = null;
this.bindGroup = null;
this.device = null;
this.resources = null;
this.paramsBuffer = null;
}
}
@@ -0,0 +1,284 @@
export type TerrainShaderId = "classic" | "improved-lite" | "improved-heavy";
export type TerrainShaderOption =
| {
kind: "boolean";
key: string;
label: string;
defaultValue: boolean;
}
| {
kind: "range";
key: string;
label: string;
defaultValue: number;
min: number;
max: number;
step: number;
}
| {
kind: "enum";
key: string;
label: string;
defaultValue: number;
options: Array<{ value: number; label: string }>;
};
export interface TerrainShaderDefinition {
id: TerrainShaderId;
label: string;
wgslPath: string;
options: TerrainShaderOption[];
}
export const TERRAIN_SHADER_KEY = "settings.webgpu.terrain.shader";
export const TERRAIN_SHADERS: TerrainShaderDefinition[] = [
{
id: "classic",
label: "Classic",
wgslPath: "compute/terrain-compute.wgsl",
options: [],
},
{
id: "improved-lite",
label: "Improved (Lite)",
wgslPath: "compute/terrain-compute-improved-lite.wgsl",
options: [
{
kind: "range",
key: "settings.webgpu.terrain.improvedLite.noiseStrength",
label: "Noise Strength",
defaultValue: 0.005,
min: 0,
max: 0.08,
step: 0.005,
},
{
kind: "range",
key: "settings.webgpu.terrain.improvedLite.blendWidth",
label: "Biome Blend Width",
defaultValue: 5,
min: 0.5,
max: 5,
step: 0.25,
},
{
kind: "range",
key: "settings.webgpu.terrain.improvedLite.waterBlurStrength",
label: "Water Blur Strength",
defaultValue: 1,
min: 0,
max: 1,
step: 0.05,
},
],
},
{
id: "improved-heavy",
label: "Improved (Heavy)",
wgslPath: "compute/terrain-compute-improved-heavy.wgsl",
options: [
{
kind: "range",
key: "settings.webgpu.terrain.improvedHeavy.noiseStrength",
label: "Noise Strength",
defaultValue: 0.01,
min: 0,
max: 0.1,
step: 0.005,
},
{
kind: "range",
key: "settings.webgpu.terrain.improvedHeavy.detailNoiseStrength",
label: "Detail Noise Strength",
defaultValue: 0.01,
min: 0,
max: 0.08,
step: 0.005,
},
{
kind: "range",
key: "settings.webgpu.terrain.improvedHeavy.blendWidth",
label: "Biome Blend Width",
defaultValue: 4.5,
min: 0.5,
max: 6,
step: 0.25,
},
{
kind: "range",
key: "settings.webgpu.terrain.improvedHeavy.waterDepthStrength",
label: "Water Depth Strength",
defaultValue: 0.35,
min: 0,
max: 1,
step: 0.05,
},
{
kind: "range",
key: "settings.webgpu.terrain.improvedHeavy.waterDepthCurve",
label: "Water Depth Curve",
defaultValue: 2,
min: 0.5,
max: 4,
step: 0.25,
},
{
kind: "range",
key: "settings.webgpu.terrain.improvedHeavy.waterDepthBlur",
label: "Water Depth Blur",
defaultValue: 0.6,
min: 0,
max: 1,
step: 0.05,
},
{
kind: "range",
key: "settings.webgpu.terrain.improvedHeavy.lightingStrength",
label: "Lighting Strength",
defaultValue: 0.3,
min: 0,
max: 1,
step: 0.05,
},
{
kind: "range",
key: "settings.webgpu.terrain.improvedHeavy.cavityStrength",
label: "Cavity Strength",
defaultValue: 0.15,
min: 0,
max: 1,
step: 0.05,
},
],
},
];
export function getTerrainShaderById(
id: TerrainShaderId,
): TerrainShaderDefinition {
const found = TERRAIN_SHADERS.find((s) => s.id === id);
if (!found) {
throw new Error(`Unknown terrain shader: ${id}`);
}
return found;
}
export function terrainShaderIdFromInt(value: number): TerrainShaderId {
if (value === 1) return "improved-lite";
if (value === 2) return "improved-heavy";
return "classic";
}
export function terrainShaderIntFromId(id: TerrainShaderId): number {
if (id === "improved-lite") return 1;
if (id === "improved-heavy") return 2;
return 0;
}
export function readTerrainShaderId(userSettings: {
getInt: (key: string, defaultValue: number) => number;
}): TerrainShaderId {
return terrainShaderIdFromInt(userSettings.getInt(TERRAIN_SHADER_KEY, 0));
}
export function buildTerrainShaderParams(
userSettings: {
getFloat: (key: string, defaultValue: number) => number;
},
shaderId: TerrainShaderId,
): { shaderPath: string; params0: Float32Array; params1: Float32Array } {
const waterDepthStrengthDefault = 0.4;
const waterDepthCurveDefault = 2;
const waterDepthBlurDefault = 0.6;
if (shaderId === "improved-lite") {
const noiseStrength = userSettings.getFloat(
"settings.webgpu.terrain.improvedLite.noiseStrength",
0.005,
);
const blendWidth = userSettings.getFloat(
"settings.webgpu.terrain.improvedLite.blendWidth",
5,
);
const waterBlurStrength = userSettings.getFloat(
"settings.webgpu.terrain.improvedLite.waterBlurStrength",
1,
);
const params0 = new Float32Array([
noiseStrength,
blendWidth,
waterBlurStrength,
0,
]);
const params1 = new Float32Array([0, 0, 0, 0]);
return {
shaderPath: "compute/terrain-compute-improved-lite.wgsl",
params0,
params1,
};
}
if (shaderId === "improved-heavy") {
const noiseStrength = userSettings.getFloat(
"settings.webgpu.terrain.improvedHeavy.noiseStrength",
0.01,
);
const detailNoiseStrength = userSettings.getFloat(
"settings.webgpu.terrain.improvedHeavy.detailNoiseStrength",
0.01,
);
const blendWidth = userSettings.getFloat(
"settings.webgpu.terrain.improvedHeavy.blendWidth",
4.5,
);
const waterDepthStrength = userSettings.getFloat(
"settings.webgpu.terrain.improvedHeavy.waterDepthStrength",
0.35,
);
const waterDepthCurve = userSettings.getFloat(
"settings.webgpu.terrain.improvedHeavy.waterDepthCurve",
2,
);
const waterDepthBlur = userSettings.getFloat(
"settings.webgpu.terrain.improvedHeavy.waterDepthBlur",
0.6,
);
const lightingStrength = userSettings.getFloat(
"settings.webgpu.terrain.improvedHeavy.lightingStrength",
0.3,
);
const cavityStrength = userSettings.getFloat(
"settings.webgpu.terrain.improvedHeavy.cavityStrength",
0.15,
);
const params0 = new Float32Array([
noiseStrength,
blendWidth,
waterDepthStrength,
waterDepthCurve,
]);
const params1 = new Float32Array([
detailNoiseStrength,
lightingStrength,
cavityStrength,
waterDepthBlur,
]);
return {
shaderPath: "compute/terrain-compute-improved-heavy.wgsl",
params0,
params1,
};
}
const params0 = new Float32Array([
0,
2.5,
waterDepthStrengthDefault,
waterDepthCurveDefault,
]);
const params1 = new Float32Array([waterDepthBlurDefault, 0, 0, 0]);
return { shaderPath: "compute/terrain-compute.wgsl", params0, params1 };
}
@@ -0,0 +1,128 @@
import { TerritoryShaderOption } from "./TerritoryShaderRegistry";
export type TerritoryPostSmoothingId = "off" | "fade" | "dissolve";
export interface TerritoryPostSmoothingDefinition {
id: TerritoryPostSmoothingId;
label: string;
wgslPath: string;
options: TerritoryShaderOption[];
}
export const TERRITORY_POST_SMOOTHING_KEY =
"settings.webgpu.territory.smoothing.post";
export const TERRITORY_POST_SMOOTHING: TerritoryPostSmoothingDefinition[] = [
{
id: "off",
label: "Off",
wgslPath: "",
options: [],
},
{
id: "fade",
label: "Fade",
wgslPath: "render/temporal-resolve.wgsl",
options: [
{
kind: "range",
key: "settings.webgpu.territory.postSmoothing.blendStrength",
label: "Blend Strength",
defaultValue: 0.2,
min: 0.01,
max: 1,
step: 0.01,
},
],
},
{
id: "dissolve",
label: "Dissolve",
wgslPath: "render/temporal-resolve.wgsl",
options: [
{
kind: "range",
key: "settings.webgpu.territory.postSmoothing.blendStrength",
label: "Blend Strength",
defaultValue: 1,
min: 0,
max: 1,
step: 0.01,
},
{
kind: "range",
key: "settings.webgpu.territory.postSmoothing.dissolveWidth",
label: "Dissolve Width",
defaultValue: 0.08,
min: 0.01,
max: 0.4,
step: 0.01,
},
],
},
];
export function territoryPostSmoothingIdFromInt(
value: number,
): TerritoryPostSmoothingId {
if (value === 1) return "fade";
if (value === 2) return "dissolve";
return "off";
}
export function territoryPostSmoothingIntFromId(
id: TerritoryPostSmoothingId,
): number {
if (id === "fade") return 1;
if (id === "dissolve") return 2;
return 0;
}
export function readTerritoryPostSmoothingId(userSettings: {
getInt: (key: string, defaultValue: number) => number;
}): TerritoryPostSmoothingId {
return territoryPostSmoothingIdFromInt(
userSettings.getInt(TERRITORY_POST_SMOOTHING_KEY, 0),
);
}
export function buildTerritoryPostSmoothingParams(
userSettings: {
getFloat: (key: string, defaultValue: number) => number;
},
smoothingId: TerritoryPostSmoothingId,
): {
enabled: boolean;
shaderPath: string;
params0: Float32Array;
params1: Float32Array;
} {
if (smoothingId === "off") {
return {
enabled: false,
shaderPath: "",
params0: new Float32Array(4),
params1: new Float32Array(4),
};
}
const blendStrength = userSettings.getFloat(
"settings.webgpu.territory.postSmoothing.blendStrength",
0.2,
);
const dissolveWidth = userSettings.getFloat(
"settings.webgpu.territory.postSmoothing.dissolveWidth",
0.08,
);
const mode = smoothingId === "fade" ? 1 : 2;
const params0 = new Float32Array([mode, blendStrength, dissolveWidth, 0]);
const params1 = new Float32Array([0, 0, 0, 0]);
return {
enabled: true,
shaderPath: "render/temporal-resolve.wgsl",
params0,
params1,
};
}
@@ -0,0 +1,114 @@
import { TerritoryShaderOption } from "./TerritoryShaderRegistry";
export type TerritoryPreSmoothingId = "off" | "dissolve" | "budget";
export interface TerritoryPreSmoothingDefinition {
id: TerritoryPreSmoothingId;
label: string;
wgslPath: string;
options: TerritoryShaderOption[];
}
export const TERRITORY_PRE_SMOOTHING_KEY =
"settings.webgpu.territory.smoothing.pre";
export const TERRITORY_PRE_SMOOTHING: TerritoryPreSmoothingDefinition[] = [
{
id: "off",
label: "Off",
wgslPath: "",
options: [],
},
{
id: "dissolve",
label: "Dissolve",
wgslPath: "compute/visual-state-smoothing.wgsl",
options: [
{
kind: "range",
key: "settings.webgpu.territory.preSmoothing.curveExp",
label: "Reveal Curve",
defaultValue: 1,
min: 0.25,
max: 3,
step: 0.05,
},
],
},
{
id: "budget",
label: "Budgeted Reveal",
wgslPath: "compute/visual-state-smoothing.wgsl",
options: [
{
kind: "range",
key: "settings.webgpu.territory.preSmoothing.curveExp",
label: "Reveal Curve",
defaultValue: 1,
min: 0.25,
max: 3,
step: 0.05,
},
],
},
];
export function territoryPreSmoothingIdFromInt(
value: number,
): TerritoryPreSmoothingId {
if (value === 1) return "dissolve";
if (value === 2) return "budget";
return "off";
}
export function territoryPreSmoothingIntFromId(
id: TerritoryPreSmoothingId,
): number {
if (id === "dissolve") return 1;
if (id === "budget") return 2;
return 0;
}
export function readTerritoryPreSmoothingId(userSettings: {
getInt: (key: string, defaultValue: number) => number;
}): TerritoryPreSmoothingId {
return territoryPreSmoothingIdFromInt(
userSettings.getInt(TERRITORY_PRE_SMOOTHING_KEY, 0),
);
}
export function buildTerritoryPreSmoothingParams(
userSettings: {
getFloat: (key: string, defaultValue: number) => number;
},
smoothingId: TerritoryPreSmoothingId,
): {
enabled: boolean;
shaderPath: string;
params0: Float32Array;
params1: Float32Array;
} {
if (smoothingId === "off") {
return {
enabled: false,
shaderPath: "",
params0: new Float32Array(4),
params1: new Float32Array(4),
};
}
const curveExp = userSettings.getFloat(
"settings.webgpu.territory.preSmoothing.curveExp",
1,
);
const mode = smoothingId === "dissolve" ? 1 : 2;
const params0 = new Float32Array([mode, curveExp, 0, 0]);
const params1 = new Float32Array([0, 0, 0, 0]);
return {
enabled: true,
shaderPath: "compute/visual-state-smoothing.wgsl",
params0,
params1,
};
}
@@ -0,0 +1,210 @@
import { GroundTruthData } from "../core/GroundTruthData";
import { loadShader } from "../core/ShaderLoader";
import { RenderPass } from "./RenderPass";
/**
* Main territory rendering pass.
* Renders territory colors, defended tiles, fallout, and hover highlights.
*/
export class TerritoryRenderPass implements RenderPass {
name = "territory";
dependencies: string[] = [];
private pipeline: GPURenderPipeline | null = null;
private bindGroupLayout: GPUBindGroupLayout | null = null;
private bindGroup: GPUBindGroup | null = null;
private device: GPUDevice | null = null;
private resources: GroundTruthData | null = null;
private canvasFormat: GPUTextureFormat | null = null;
private shaderPath = "render/territory.wgsl";
private clearR = 0;
private clearG = 0;
private clearB = 0;
async init(
device: GPUDevice,
resources: GroundTruthData,
canvasFormat: GPUTextureFormat,
): Promise<void> {
this.device = device;
this.resources = resources;
this.canvasFormat = canvasFormat;
this.bindGroupLayout = device.createBindGroupLayout({
entries: [
{
binding: 0,
visibility: 2 /* FRAGMENT */,
buffer: { type: "uniform" },
},
{
binding: 1,
visibility: 2 /* FRAGMENT */,
texture: { sampleType: "uint" },
},
{
binding: 2,
visibility: 2 /* FRAGMENT */,
texture: { sampleType: "float" },
},
{
binding: 3,
visibility: 2 /* FRAGMENT */,
texture: { sampleType: "float" },
},
{
binding: 4,
visibility: 2 /* FRAGMENT */,
texture: { sampleType: "float" },
},
{
binding: 5,
visibility: 2 /* FRAGMENT */,
texture: { sampleType: "uint" },
},
{
binding: 6,
visibility: 2 /* FRAGMENT */,
texture: { sampleType: "uint" },
},
],
});
await this.setShader(this.shaderPath);
this.rebuildBindGroup();
// Extract clear color from theme
const bg = resources.getTheme().backgroundColor().rgba;
this.clearR = bg.r / 255;
this.clearG = bg.g / 255;
this.clearB = bg.b / 255;
}
async setShader(shaderPath: string): Promise<void> {
this.shaderPath = shaderPath;
if (!this.device || !this.bindGroupLayout || !this.canvasFormat) {
return;
}
const shaderCode = await loadShader(shaderPath);
const shaderModule = this.device.createShaderModule({ code: shaderCode });
this.pipeline = this.device.createRenderPipeline({
layout: this.device.createPipelineLayout({
bindGroupLayouts: [this.bindGroupLayout],
}),
vertex: { module: shaderModule, entryPoint: "vsMain" },
fragment: {
module: shaderModule,
entryPoint: "fsMain",
targets: [{ format: this.canvasFormat }],
},
primitive: { topology: "triangle-list" },
});
}
needsUpdate(): boolean {
// Always run every frame (can be optimized later if needed)
return true;
}
execute(
encoder: GPUCommandEncoder,
resources: GroundTruthData,
target: GPUTextureView,
): void {
if (!this.device || !this.pipeline) {
return;
}
// Rebuild bind group if needed (e.g., after texture recreation)
this.rebuildBindGroup();
if (!this.bindGroup) {
return;
}
// Update uniforms
resources.writeUniformBuffer(performance.now() / 1000);
const pass = encoder.beginRenderPass({
colorAttachments: [
{
view: target,
loadOp: "clear",
storeOp: "store",
clearValue: {
r: this.clearR,
g: this.clearG,
b: this.clearB,
a: 1,
},
},
],
});
pass.setPipeline(this.pipeline);
pass.setBindGroup(0, this.bindGroup);
pass.draw(3);
pass.end();
}
rebuildBindGroup(): void {
if (
!this.device ||
!this.bindGroupLayout ||
!this.resources ||
!this.resources.uniformBuffer ||
!this.resources.defendedStrengthTexture ||
!this.resources.paletteTexture ||
!this.resources.terrainTexture ||
!this.resources.ownerIndexTexture ||
!this.resources.relationsTexture
) {
return;
}
const stateTexture = this.resources.getRenderStateTexture();
this.bindGroup = this.device.createBindGroup({
layout: this.bindGroupLayout,
entries: [
{ binding: 0, resource: { buffer: this.resources.uniformBuffer } },
{
binding: 1,
resource: stateTexture.createView(),
},
{
binding: 2,
resource: this.resources.defendedStrengthTexture.createView(),
},
{
binding: 3,
resource: this.resources.paletteTexture.createView(),
},
{
binding: 4,
resource: this.resources.terrainTexture.createView(),
},
{
binding: 5,
resource: this.resources.ownerIndexTexture.createView(),
},
{
binding: 6,
resource: this.resources.relationsTexture.createView(),
},
],
});
}
dispose(): void {
this.pipeline = null;
this.bindGroupLayout = null;
this.bindGroup = null;
this.device = null;
this.resources = null;
}
}
@@ -0,0 +1,353 @@
export type TerritoryShaderId = "classic" | "retro";
export type TerritoryShaderOption =
| {
kind: "boolean";
key: string;
label: string;
defaultValue: boolean;
}
| {
kind: "range";
key: string;
label: string;
defaultValue: number;
min: number;
max: number;
step: number;
}
| {
kind: "enum";
key: string;
label: string;
defaultValue: number;
options: Array<{ value: number; label: string }>;
};
export interface TerritoryShaderDefinition {
id: TerritoryShaderId;
label: string;
wgslPath: string;
options: TerritoryShaderOption[];
}
export const TERRITORY_SHADER_KEY = "settings.webgpu.territory.shader";
export const TERRITORY_SHADERS: TerritoryShaderDefinition[] = [
{
id: "classic",
label: "Simple",
wgslPath: "render/territory.wgsl",
options: [
{
kind: "enum",
key: "settings.webgpu.territory.classic.borderMode",
label: "Border Mode",
defaultValue: 1,
options: [
{ value: 0, label: "Off" },
{ value: 1, label: "Simple" },
{ value: 2, label: "Glow" },
],
},
{
kind: "range",
key: "settings.webgpu.territory.classic.thicknessPx",
label: "Thickness (px)",
defaultValue: 1,
min: 0.5,
max: 8,
step: 0.5,
},
{
kind: "range",
key: "settings.webgpu.territory.classic.borderStrength",
label: "Border Strength",
defaultValue: 0.64,
min: 0,
max: 1,
step: 0.01,
},
{
kind: "range",
key: "settings.webgpu.territory.classic.glowStrength",
label: "Glow Strength",
defaultValue: 0.42,
min: 0,
max: 1,
step: 0.01,
},
{
kind: "range",
key: "settings.webgpu.territory.classic.glowRadiusMul",
label: "Glow Radius",
defaultValue: 1,
min: 1,
max: 12,
step: 0.25,
},
{
kind: "boolean",
key: "settings.webgpu.territory.classic.drawDefendedRadius",
label: "Draw Defended Radius",
defaultValue: false,
},
{
kind: "boolean",
key: "settings.webgpu.territory.classic.disableDefendedTint",
label: "Disable Defended Tint",
defaultValue: false,
},
],
},
{
id: "retro",
label: "Retro",
wgslPath: "render/retro.wgsl",
options: [
{
kind: "boolean",
key: "settings.webgpu.territory.retro.colorByRelations",
label: "Color By Player Relations",
defaultValue: true,
},
{
kind: "boolean",
key: "settings.webgpu.territory.retro.patternWhenDefended",
label: "Pattern When In Defended Range",
defaultValue: true,
},
{
kind: "boolean",
key: "settings.webgpu.territory.retro.splitBorder",
label: "Split Border",
defaultValue: true,
},
{
kind: "boolean",
key: "settings.webgpu.territory.retro.drawDefendedRadius",
label: "Draw Defended Radius",
defaultValue: true,
},
{
kind: "boolean",
key: "settings.webgpu.territory.retro.disableDefendedTint",
label: "Disable Defended Tint",
defaultValue: true,
},
{
kind: "range",
key: "settings.webgpu.territory.retro.thicknessPx",
label: "Thickness (px)",
defaultValue: 6,
min: 0.5,
max: 12,
step: 0.5,
},
{
kind: "range",
key: "settings.webgpu.territory.retro.borderStrength",
label: "Border Strength",
defaultValue: 1,
min: 0,
max: 1,
step: 0.01,
},
{
kind: "range",
key: "settings.webgpu.territory.retro.glowStrength",
label: "Glow Strength",
defaultValue: 0,
min: 0,
max: 1,
step: 0.01,
},
{
kind: "range",
key: "settings.webgpu.territory.retro.glowRadiusMul",
label: "Glow Radius",
defaultValue: 1,
min: 1,
max: 16,
step: 0.25,
},
{
kind: "range",
key: "settings.webgpu.territory.retro.relationTintStrength",
label: "Relation Tint Strength",
defaultValue: 1,
min: 0,
max: 1,
step: 0.01,
},
{
kind: "range",
key: "settings.webgpu.territory.retro.defendedPatternStrength",
label: "Defended Pattern Strength",
defaultValue: 0.5,
min: 0,
max: 1,
step: 0.01,
},
{
kind: "range",
key: "settings.webgpu.territory.retro.defendedThreshold",
label: "Defended Threshold",
defaultValue: 0.01,
min: 0.01,
max: 1,
step: 0.01,
},
],
},
];
export function getTerritoryShaderById(
id: TerritoryShaderId,
): TerritoryShaderDefinition {
const found = TERRITORY_SHADERS.find((s) => s.id === id);
if (!found) {
throw new Error(`Unknown territory shader: ${id}`);
}
return found;
}
export function territoryShaderIdFromInt(value: number): TerritoryShaderId {
return value === 1 ? "retro" : "classic";
}
export function territoryShaderIntFromId(id: TerritoryShaderId): number {
return id === "retro" ? 1 : 0;
}
export function readTerritoryShaderId(userSettings: {
getInt: (key: string, defaultValue: number) => number;
}): TerritoryShaderId {
return territoryShaderIdFromInt(userSettings.getInt(TERRITORY_SHADER_KEY, 0));
}
export function buildTerritoryShaderParams(
userSettings: {
get: (key: string, defaultValue: boolean) => boolean;
getFloat: (key: string, defaultValue: number) => number;
getInt: (key: string, defaultValue: number) => number;
},
shaderId: TerritoryShaderId,
): { shaderPath: string; params0: Float32Array; params1: Float32Array } {
if (shaderId === "retro") {
const thicknessPx = userSettings.getFloat(
"settings.webgpu.territory.retro.thicknessPx",
6,
);
const borderStrength = userSettings.getFloat(
"settings.webgpu.territory.retro.borderStrength",
1,
);
const glowStrength = userSettings.getFloat(
"settings.webgpu.territory.retro.glowStrength",
0,
);
const glowRadiusMul = userSettings.getFloat(
"settings.webgpu.territory.retro.glowRadiusMul",
1,
);
const colorByRelations = userSettings.get(
"settings.webgpu.territory.retro.colorByRelations",
true,
);
const patternWhenDefended = userSettings.get(
"settings.webgpu.territory.retro.patternWhenDefended",
true,
);
const splitBorder = userSettings.get(
"settings.webgpu.territory.retro.splitBorder",
true,
);
const drawDefendedRadius = userSettings.get(
"settings.webgpu.territory.retro.drawDefendedRadius",
true,
);
const disableDefendedTint = userSettings.get(
"settings.webgpu.territory.retro.disableDefendedTint",
true,
);
const relationTintStrength = userSettings.getFloat(
"settings.webgpu.territory.retro.relationTintStrength",
1,
);
const defendedPatternStrength = userSettings.getFloat(
"settings.webgpu.territory.retro.defendedPatternStrength",
0.5,
);
const defendedThreshold = userSettings.getFloat(
"settings.webgpu.territory.retro.defendedThreshold",
0.01,
);
let flags = 0;
if (colorByRelations) flags |= 1 << 0;
if (patternWhenDefended) flags |= 1 << 1;
if (splitBorder) flags |= 1 << 2;
if (drawDefendedRadius) flags |= 1 << 3;
if (disableDefendedTint) flags |= 1 << 4;
const params0 = new Float32Array([
thicknessPx,
borderStrength,
glowStrength,
glowRadiusMul,
]);
const params1 = new Float32Array([
flags,
relationTintStrength,
defendedPatternStrength,
defendedThreshold,
]);
return { shaderPath: "render/retro.wgsl", params0, params1 };
}
const borderMode = userSettings.getInt(
"settings.webgpu.territory.classic.borderMode",
1,
);
const thicknessPx = userSettings.getFloat(
"settings.webgpu.territory.classic.thicknessPx",
1,
);
const borderStrength = userSettings.getFloat(
"settings.webgpu.territory.classic.borderStrength",
0.64,
);
const glowStrength = userSettings.getFloat(
"settings.webgpu.territory.classic.glowStrength",
0.42,
);
const glowRadiusMul = userSettings.getFloat(
"settings.webgpu.territory.classic.glowRadiusMul",
1,
);
const drawDefendedRadius = userSettings.get(
"settings.webgpu.territory.classic.drawDefendedRadius",
false,
);
const disableDefendedTint = userSettings.get(
"settings.webgpu.territory.classic.disableDefendedTint",
false,
);
const params0 = new Float32Array([
borderMode,
thicknessPx,
borderStrength,
glowStrength,
]);
const params1 = new Float32Array([
glowRadiusMul,
drawDefendedRadius ? 1 : 0,
disableDefendedTint ? 1 : 0,
0,
]);
return { shaderPath: "render/territory.wgsl", params0, params1 };
}
@@ -0,0 +1,65 @@
struct Params {
_dirtyCount: u32,
range: u32,
_pad0: u32,
_pad1: u32,
};
@group(0) @binding(0) var<uniform> p: Params;
@group(0) @binding(1) var stateTex: texture_2d<u32>;
@group(0) @binding(2) var defendedStrengthTex: texture_storage_2d<rgba8unorm, write>;
@group(0) @binding(3) var<storage, read> ownerOffsets: array<vec2u>;
@group(0) @binding(4) var<storage, read> postsByOwner: array<vec2u>;
@compute @workgroup_size(8, 8)
fn main(@builtin(global_invocation_id) globalId: vec3<u32>) {
let dims = textureDimensions(stateTex);
if (globalId.x >= dims.x || globalId.y >= dims.y) {
return;
}
let x = i32(globalId.x);
let y = i32(globalId.y);
let state = textureLoad(stateTex, vec2i(x, y), 0).x;
let owner = state & 0xFFFu;
let range = i32(p.range);
if (owner == 0u || range <= 0) {
textureStore(defendedStrengthTex, vec2i(x, y), vec4f(0.0, 0.0, 0.0, 1.0));
return;
}
let off = ownerOffsets[owner];
let start = off.x;
let count = off.y;
if (count == 0u) {
textureStore(defendedStrengthTex, vec2i(x, y), vec4f(0.0, 0.0, 0.0, 1.0));
return;
}
let rx = f32(range);
let r2 = range * range;
var bestDist2: i32 = 0x7FFFFFFF;
var i: u32 = 0u;
loop {
if (i >= count) { break; }
let pos = postsByOwner[start + i];
let dx = i32(pos.x) - x;
let dy = i32(pos.y) - y;
let d2 = dx * dx + dy * dy;
if (d2 < bestDist2) {
bestDist2 = d2;
}
i = i + 1u;
}
if (bestDist2 > r2) {
textureStore(defendedStrengthTex, vec2i(x, y), vec4f(0.0, 0.0, 0.0, 1.0));
return;
}
let dist = sqrt(f32(bestDist2));
let strength = clamp(1.0 - (dist / rx), 0.0, 1.0);
textureStore(defendedStrengthTex, vec2i(x, y), vec4f(strength, 0.0, 0.0, 1.0));
}
@@ -0,0 +1,69 @@
struct Params {
dirtyCount: u32,
range: u32,
_pad0: u32,
_pad1: u32,
};
@group(0) @binding(0) var<uniform> p: Params;
@group(0) @binding(1) var<storage, read> dirtyTiles: array<u32>;
@group(0) @binding(2) var stateTex: texture_2d<u32>;
@group(0) @binding(3) var defendedStrengthTex: texture_storage_2d<rgba8unorm, write>;
@group(0) @binding(4) var<storage, read> ownerOffsets: array<vec2u>;
@group(0) @binding(5) var<storage, read> postsByOwner: array<vec2u>;
@compute @workgroup_size(64)
fn main(@builtin(global_invocation_id) globalId: vec3<u32>) {
let idx = globalId.x;
if (idx >= p.dirtyCount) {
return;
}
let tileIndex = dirtyTiles[idx];
let dims = textureDimensions(stateTex);
let mapWidth = dims.x;
let x = i32(tileIndex % mapWidth);
let y = i32(tileIndex / mapWidth);
let state = textureLoad(stateTex, vec2i(x, y), 0).x;
let owner = state & 0xFFFu;
let range = i32(p.range);
if (owner == 0u || range <= 0) {
textureStore(defendedStrengthTex, vec2i(x, y), vec4f(0.0, 0.0, 0.0, 1.0));
return;
}
let off = ownerOffsets[owner];
let start = off.x;
let count = off.y;
if (count == 0u) {
textureStore(defendedStrengthTex, vec2i(x, y), vec4f(0.0, 0.0, 0.0, 1.0));
return;
}
let rx = f32(range);
let r2 = range * range;
var bestDist2: i32 = 0x7FFFFFFF;
var i: u32 = 0u;
loop {
if (i >= count) { break; }
let pos = postsByOwner[start + i];
let dx = i32(pos.x) - x;
let dy = i32(pos.y) - y;
let d2 = dx * dx + dy * dy;
if (d2 < bestDist2) {
bestDist2 = d2;
}
i = i + 1u;
}
if (bestDist2 > r2) {
textureStore(defendedStrengthTex, vec2i(x, y), vec4f(0.0, 0.0, 0.0, 1.0));
return;
}
let dist = sqrt(f32(bestDist2));
let strength = clamp(1.0 - (dist / rx), 0.0, 1.0);
textureStore(defendedStrengthTex, vec2i(x, y), vec4f(strength, 0.0, 0.0, 1.0));
}
@@ -0,0 +1,73 @@
struct Update {
tileIndex: u32,
newState: u32,
};
struct Params {
updateCount: u32,
range: u32,
_pad0: u32,
_pad1: u32,
};
@group(0) @binding(0) var<uniform> p: Params;
@group(0) @binding(1) var<storage, read> updates: array<Update>;
@group(0) @binding(2) var stateTex: texture_storage_2d<r32uint, write>;
@group(0) @binding(3) var defendedStrengthTex: texture_storage_2d<rgba8unorm, write>;
@group(0) @binding(4) var<storage, read> ownerOffsets: array<vec2u>;
@group(0) @binding(5) var<storage, read> postsByOwner: array<vec2u>;
@compute @workgroup_size(64)
fn main(@builtin(global_invocation_id) globalId: vec3<u32>) {
let idx = globalId.x;
if (idx >= p.updateCount) {
return;
}
let update = updates[idx];
let dims = textureDimensions(stateTex);
let mapWidth = dims.x;
let x = i32(update.tileIndex % mapWidth);
let y = i32(update.tileIndex / mapWidth);
textureStore(stateTex, vec2i(x, y), vec4u(update.newState, 0u, 0u, 0u));
// Update defended strength for this tile based on the new owner.
let owner = update.newState & 0xFFFu;
let range = i32(p.range);
if (owner == 0u || range <= 0) {
textureStore(defendedStrengthTex, vec2i(x, y), vec4f(0.0, 0.0, 0.0, 1.0));
return;
}
let off = ownerOffsets[owner];
let start = off.x;
let count = off.y;
if (count == 0u) {
textureStore(defendedStrengthTex, vec2i(x, y), vec4f(0.0, 0.0, 0.0, 1.0));
return;
}
let rx = f32(range);
let r2 = range * range;
var bestDist2: i32 = 0x7FFFFFFF;
var i: u32 = 0u;
loop {
if (i >= count) { break; }
let pos = postsByOwner[start + i];
let dx = i32(pos.x) - x;
let dy = i32(pos.y) - y;
let d2 = dx * dx + dy * dy;
if (d2 < bestDist2) {
bestDist2 = d2;
}
i = i + 1u;
}
if (bestDist2 > r2) {
textureStore(defendedStrengthTex, vec2i(x, y), vec4f(0.0, 0.0, 0.0, 1.0));
return;
}
let dist = sqrt(f32(bestDist2));
let strength = clamp(1.0 - (dist / rx), 0.0, 1.0);
textureStore(defendedStrengthTex, vec2i(x, y), vec4f(strength, 0.0, 0.0, 1.0));
}
@@ -0,0 +1,201 @@
struct TerrainParams {
shoreColor: vec4f, // Shore (land adjacent to water)
waterColor: vec4f, // Deep water base color
shorelineWaterColor: vec4f, // Water near shore
plainsBaseColor: vec4f, // Plains base RGB (magnitude 0)
highlandBaseColor: vec4f, // Highland base RGB (magnitude 10)
mountainBaseColor: vec4f, // Mountain base RGB (magnitude 20)
tuning0: vec4f, // x=noiseStrength, y=blendWidth, z=waterDepthStrength, w=waterDepthCurve
tuning1: vec4f, // x=detailNoise, y=lightingStrength, z=cavityStrength, w=waterDepthBlur
};
@group(0) @binding(0) var<uniform> params: TerrainParams;
@group(0) @binding(1) var terrainDataTex: texture_2d<u32>;
@group(0) @binding(2) var terrainTex: texture_storage_2d<rgba8unorm, write>;
// Terrain bit constants (matching GameMapImpl)
const IS_LAND_BIT: u32 = 7u;
const SHORELINE_BIT: u32 = 6u;
const MAGNITUDE_MASK: u32 = 0x1fu;
fn hash21(p: vec2u) -> f32 {
var n = p.x * 0x9e3779b9u + p.y * 0x7f4a7c15u;
n ^= n >> 16u;
n *= 0x85ebca6bu;
n ^= n >> 13u;
n *= 0xc2b2ae35u;
n ^= n >> 16u;
return f32(n & 0x00ffffffu) / 16777215.0;
}
fn clampCoord(coord: vec2i, dims: vec2u) -> vec2i {
let maxX = i32(dims.x) - 1;
let maxY = i32(dims.y) - 1;
return vec2i(clamp(coord.x, 0, maxX), clamp(coord.y, 0, maxY));
}
fn sampleTerrainData(coord: vec2i, dims: vec2u) -> u32 {
let c = clampCoord(coord, dims);
return textureLoad(terrainDataTex, c, 0).x;
}
fn computeLandColor(
mag: f32,
noise: f32,
noiseStrength: f32,
blendWidth: f32,
) -> vec3f {
let plainsG = max(params.plainsBaseColor.g - (2.0 * mag) / 255.0, 0.0);
let plains = vec3f(params.plainsBaseColor.r, plainsG, params.plainsBaseColor.b);
let highlandMag = clamp(mag - 10.0, 0.0, 9.0);
let highland = vec3f(
min(params.highlandBaseColor.r + (2.0 * highlandMag) / 255.0, 1.0),
min(params.highlandBaseColor.g + (2.0 * highlandMag) / 255.0, 1.0),
min(params.highlandBaseColor.b + (2.0 * highlandMag) / 255.0, 1.0),
);
let mountainMag = max(mag - 20.0, 0.0);
let gray = min(params.mountainBaseColor.r + (mountainMag / 2.0) / 255.0, 1.0);
let mountain = vec3f(gray, gray, gray);
let tHigh = smoothstep(10.0 - blendWidth, 10.0 + blendWidth, mag);
let tMount = smoothstep(20.0 - blendWidth, 20.0 + blendWidth, mag);
var land = mix(plains, highland, tHigh);
land = mix(land, mountain, tMount);
let noiseBias = (noise - 0.5) * noiseStrength;
return clamp(land + vec3f(noiseBias), vec3f(0.0), vec3f(1.0));
}
@compute @workgroup_size(8, 8)
fn main(@builtin(global_invocation_id) globalId: vec3<u32>) {
let x = i32(globalId.x);
let y = i32(globalId.y);
let dims = textureDimensions(terrainDataTex);
if (x < 0 || y < 0 || u32(x) >= dims.x || u32(y) >= dims.y) {
return;
}
let texCoord = vec2i(x, y);
let terrainData = textureLoad(terrainDataTex, texCoord, 0).x;
let isLand = (terrainData & (1u << IS_LAND_BIT)) != 0u;
let isShoreline = (terrainData & (1u << SHORELINE_BIT)) != 0u;
let magnitude = terrainData & MAGNITUDE_MASK;
let mag = f32(magnitude);
let noise = hash21(vec2u(texCoord));
let noiseFine = hash21(vec2u(texCoord) * 3u + vec2u(17u, 29u));
let noiseStrength = max(params.tuning0.x, 0.0);
let blendWidth = max(params.tuning0.y, 0.1);
let waterDepthStrength = clamp(params.tuning0.z, 0.0, 1.0);
let waterDepthCurve = max(params.tuning0.w, 0.1);
let detailNoiseStrength = max(params.tuning1.x, 0.0);
let lightingStrength = clamp(params.tuning1.y, 0.0, 1.0);
let cavityStrength = clamp(params.tuning1.z, 0.0, 1.0);
let waterDepthBlur = clamp(params.tuning1.w, 0.0, 1.0);
let shoreMixLand = 0.6;
let shoreMixWater = 0.55;
let specularStrength = 0.05;
let hC = mag / 31.0;
let dataL = sampleTerrainData(texCoord + vec2i(-1, 0), dims);
let dataR = sampleTerrainData(texCoord + vec2i(1, 0), dims);
let dataD = sampleTerrainData(texCoord + vec2i(0, -1), dims);
let dataU = sampleTerrainData(texCoord + vec2i(0, 1), dims);
let magL = f32(dataL & MAGNITUDE_MASK);
let magR = f32(dataR & MAGNITUDE_MASK);
let magD = f32(dataD & MAGNITUDE_MASK);
let magU = f32(dataU & MAGNITUDE_MASK);
let hL = magL / 31.0;
let hR = magR / 31.0;
let hD = magD / 31.0;
let hU = magU / 31.0;
let dx = hR - hL;
let dy = hU - hD;
let normal = normalize(vec3f(-dx * 2.2, -dy * 2.2, 1.0));
let lightDir = normalize(vec3f(0.55, 0.45, 1.0));
let diffuse = clamp(dot(normal, lightDir), 0.0, 1.0);
let baseLighting = 0.55 + 0.45 * diffuse;
let lighting = mix(1.0, baseLighting, lightingStrength);
let slope = length(vec2f(dx, dy));
let rockiness = smoothstep(0.08, 0.28, slope);
let cavity = clamp(((hL + hR + hD + hU) * 0.25 - hC) * 2.0, 0.0, 0.25);
var color: vec4f;
if (isLand) {
var land = computeLandColor(mag, noise, noiseStrength, blendWidth);
if (isShoreline) {
land = mix(land, params.shoreColor.rgb, shoreMixLand);
}
land = mix(land, params.mountainBaseColor.rgb, rockiness * 0.6);
land = clamp(land * lighting, vec3f(0.0), vec3f(1.0));
land = clamp(land * (1.0 - cavity * cavityStrength), vec3f(0.0), vec3f(1.0));
land = clamp(
land + vec3f((noiseFine - 0.5) * detailNoiseStrength),
vec3f(0.0),
vec3f(1.0),
);
color = vec4f(land, 1.0);
} else {
var sum = mag;
var count = 1.0;
if ((dataL & (1u << IS_LAND_BIT)) == 0u) {
sum = sum + magL;
count = count + 1.0;
}
if ((dataR & (1u << IS_LAND_BIT)) == 0u) {
sum = sum + magR;
count = count + 1.0;
}
if ((dataD & (1u << IS_LAND_BIT)) == 0u) {
sum = sum + magD;
count = count + 1.0;
}
if ((dataU & (1u << IS_LAND_BIT)) == 0u) {
sum = sum + magU;
count = count + 1.0;
}
let avgMag = sum / count;
let smoothMag = mix(mag, avgMag, waterDepthBlur);
let depth01 = clamp(smoothMag / 10.0, 0.0, 1.0);
let depth = clamp(pow(depth01, waterDepthCurve), 0.0, 1.0);
let depthColor = mix(
params.shorelineWaterColor.rgb,
params.waterColor.rgb,
depth,
);
var water = mix(params.waterColor.rgb, depthColor, waterDepthStrength);
let noiseBias = (noise - 0.5) * (noiseStrength * 0.6);
water = clamp(water + vec3f(noiseBias), vec3f(0.0), vec3f(1.0));
if (isShoreline) {
water = mix(water, params.shorelineWaterColor.rgb, shoreMixWater);
}
let viewDir = vec3f(0.0, 0.0, 1.0);
let spec = pow(max(dot(reflect(-lightDir, normal), viewDir), 0.0), 24.0);
water = clamp(
water + vec3f(spec * specularStrength),
vec3f(0.0),
vec3f(1.0),
);
color = vec4f(water, 1.0);
}
textureStore(terrainTex, texCoord, color);
}
@@ -0,0 +1,134 @@
struct TerrainParams {
shoreColor: vec4f, // Shore (land adjacent to water)
waterColor: vec4f, // Deep water base color
shorelineWaterColor: vec4f, // Water near shore
plainsBaseColor: vec4f, // Plains base RGB (magnitude 0)
highlandBaseColor: vec4f, // Highland base RGB (magnitude 10)
mountainBaseColor: vec4f, // Mountain base RGB (magnitude 20)
tuning0: vec4f, // x=noiseStrength, y=blendWidth, z=waterBlurStrength, w=unused
};
@group(0) @binding(0) var<uniform> params: TerrainParams;
@group(0) @binding(1) var terrainDataTex: texture_2d<u32>;
@group(0) @binding(2) var terrainTex: texture_storage_2d<rgba8unorm, write>;
// Terrain bit constants (matching GameMapImpl)
const IS_LAND_BIT: u32 = 7u;
const SHORELINE_BIT: u32 = 6u;
const MAGNITUDE_MASK: u32 = 0x1fu;
fn hash21(p: vec2u) -> f32 {
var n = p.x * 0x9e3779b9u + p.y * 0x7f4a7c15u;
n ^= n >> 16u;
n *= 0x85ebca6bu;
n ^= n >> 13u;
n *= 0xc2b2ae35u;
n ^= n >> 16u;
return f32(n & 0x00ffffffu) / 16777215.0;
}
fn clampCoord(coord: vec2i, dims: vec2u) -> vec2i {
let maxX = i32(dims.x) - 1;
let maxY = i32(dims.y) - 1;
return vec2i(clamp(coord.x, 0, maxX), clamp(coord.y, 0, maxY));
}
fn computeLandColor(mag: f32, noise: f32, noiseStrength: f32, blendWidth: f32) -> vec3f {
let plainsG = max(params.plainsBaseColor.g - (2.0 * mag) / 255.0, 0.0);
let plains = vec3f(params.plainsBaseColor.r, plainsG, params.plainsBaseColor.b);
let highlandMag = clamp(mag - 10.0, 0.0, 9.0);
let highland = vec3f(
min(params.highlandBaseColor.r + (2.0 * highlandMag) / 255.0, 1.0),
min(params.highlandBaseColor.g + (2.0 * highlandMag) / 255.0, 1.0),
min(params.highlandBaseColor.b + (2.0 * highlandMag) / 255.0, 1.0),
);
let mountainMag = max(mag - 20.0, 0.0);
let gray = min(params.mountainBaseColor.r + (mountainMag / 2.0) / 255.0, 1.0);
let mountain = vec3f(gray, gray, gray);
let tHigh = smoothstep(10.0 - blendWidth, 10.0 + blendWidth, mag);
let tMount = smoothstep(20.0 - blendWidth, 20.0 + blendWidth, mag);
var land = mix(plains, highland, tHigh);
land = mix(land, mountain, tMount);
let noiseBias = (noise - 0.5) * noiseStrength;
return clamp(land + vec3f(noiseBias), vec3f(0.0), vec3f(1.0));
}
@compute @workgroup_size(8, 8)
fn main(@builtin(global_invocation_id) globalId: vec3<u32>) {
let x = i32(globalId.x);
let y = i32(globalId.y);
let dims = textureDimensions(terrainDataTex);
if (x < 0 || y < 0 || u32(x) >= dims.x || u32(y) >= dims.y) {
return;
}
let texCoord = vec2i(x, y);
let terrainData = textureLoad(terrainDataTex, texCoord, 0).x;
let isLand = (terrainData & (1u << IS_LAND_BIT)) != 0u;
let isShoreline = (terrainData & (1u << SHORELINE_BIT)) != 0u;
let magnitude = terrainData & MAGNITUDE_MASK;
let mag = f32(magnitude);
let noise = hash21(vec2u(texCoord));
let noiseStrength = max(params.tuning0.x, 0.0);
let blendWidth = max(params.tuning0.y, 0.1);
let waterDepthBlur = clamp(params.tuning0.z, 0.0, 1.0);
let shoreMixLand = 0.6;
var color: vec4f;
if (isLand) {
var land = computeLandColor(mag, noise, noiseStrength, blendWidth);
if (isShoreline) {
land = mix(land, params.shoreColor.rgb, shoreMixLand);
}
color = vec4f(land, 1.0);
} else {
if (isShoreline) {
color = vec4f(params.shorelineWaterColor.rgb, 1.0);
textureStore(terrainTex, texCoord, color);
return;
}
var sum = mag;
var count = 1.0;
let dataL = textureLoad(terrainDataTex, clampCoord(texCoord + vec2i(-1, 0), dims), 0).x;
if ((dataL & (1u << IS_LAND_BIT)) == 0u) {
sum = sum + f32(dataL & MAGNITUDE_MASK);
count = count + 1.0;
}
let dataR = textureLoad(terrainDataTex, clampCoord(texCoord + vec2i(1, 0), dims), 0).x;
if ((dataR & (1u << IS_LAND_BIT)) == 0u) {
sum = sum + f32(dataR & MAGNITUDE_MASK);
count = count + 1.0;
}
let dataD = textureLoad(terrainDataTex, clampCoord(texCoord + vec2i(0, -1), dims), 0).x;
if ((dataD & (1u << IS_LAND_BIT)) == 0u) {
sum = sum + f32(dataD & MAGNITUDE_MASK);
count = count + 1.0;
}
let dataU = textureLoad(terrainDataTex, clampCoord(texCoord + vec2i(0, 1), dims), 0).x;
if ((dataU & (1u << IS_LAND_BIT)) == 0u) {
sum = sum + f32(dataU & MAGNITUDE_MASK);
count = count + 1.0;
}
let avgMag = sum / count;
let smoothMag = mix(mag, avgMag, waterDepthBlur);
let magClamped = min(smoothMag, 10.0);
let adjustment = (1.0 - magClamped) / 255.0;
let water = vec3f(
max(params.waterColor.r + adjustment, 0.0),
max(params.waterColor.g + adjustment, 0.0),
max(params.waterColor.b + adjustment, 0.0),
);
color = vec4f(water, 1.0);
}
textureStore(terrainTex, texCoord, color);
}
@@ -0,0 +1,104 @@
struct TerrainParams {
shoreColor: vec4f, // Shore (land adjacent to water)
waterColor: vec4f, // Deep water base color
shorelineWaterColor: vec4f, // Water near shore
plainsBaseColor: vec4f, // Plains base RGB (magnitude 0)
highlandBaseColor: vec4f, // Highland base RGB (magnitude 10)
mountainBaseColor: vec4f, // Mountain base RGB (magnitude 20)
tuning0: vec4f, // Shader tuning params (unused in classic)
tuning1: vec4f, // Shader tuning params (unused in classic)
};
@group(0) @binding(0) var<uniform> params: TerrainParams;
@group(0) @binding(1) var terrainDataTex: texture_2d<u32>;
@group(0) @binding(2) var terrainTex: texture_storage_2d<rgba8unorm, write>;
// Terrain bit constants (matching GameMapImpl)
const IS_LAND_BIT: u32 = 7u;
const SHORELINE_BIT: u32 = 6u;
const OCEAN_BIT: u32 = 5u;
const MAGNITUDE_MASK: u32 = 0x1fu;
@compute @workgroup_size(8, 8)
fn main(@builtin(global_invocation_id) globalId: vec3<u32>) {
let x = i32(globalId.x);
let y = i32(globalId.y);
let dims = textureDimensions(terrainDataTex);
if (x < 0 || y < 0 || u32(x) >= dims.x || u32(y) >= dims.y) {
return;
}
let texCoord = vec2i(x, y);
let terrainData = textureLoad(terrainDataTex, texCoord, 0).x;
// Extract terrain bits
let isLand = (terrainData & (1u << IS_LAND_BIT)) != 0u;
let isShoreline = (terrainData & (1u << SHORELINE_BIT)) != 0u;
let isOcean = (terrainData & (1u << OCEAN_BIT)) != 0u;
let magnitude = terrainData & MAGNITUDE_MASK;
let mag = f32(magnitude);
var color: vec4f;
// Check if shore (land adjacent to water)
if (isLand && isShoreline) {
color = params.shoreColor;
} else if (!isLand) {
// Water tile
if (isShoreline) {
color = params.shorelineWaterColor;
} else {
// Deep water - color varies by magnitude
// CPU formula: waterColor - 10 + (11 - min(mag, 10))
// In normalized space: waterColor + (-10 + (11 - min(mag, 10))) / 255.0
// Simplified: waterColor + (1 - min(mag, 10)) / 255.0
let magClamped = min(mag, 10.0);
let adjustment = (1.0 - magClamped) / 255.0;
color = vec4f(
max(params.waterColor.r + adjustment, 0.0),
max(params.waterColor.g + adjustment, 0.0),
max(params.waterColor.b + adjustment, 0.0),
1.0
);
}
} else {
// Land tile - determine terrain type from magnitude
// CPU formulas:
// Plains: rgb(190, 220 - 2*mag, 138) for mag 0-9
// Highland: rgb(200 + 2*mag, 183 + 2*mag, 138 + 2*mag) for mag 10-19
// Mountain: rgb(230 + mag/2, 230 + mag/2, 230 + mag/2) for mag >= 20
//
// We sampled plains at mag 0, so plainsBaseColor = rgb(190, 220, 138) / 255
// We sampled highland at some mag 10-19, need to compute from mag 10
if (magnitude < 10u) {
// Plains: rgb(190, 220 - 2*mag, 138)
color = vec4f(
params.plainsBaseColor.r, // 190/255
max(params.plainsBaseColor.g - (2.0 * mag) / 255.0, 0.0), // (220 - 2*mag)/255
params.plainsBaseColor.b, // 138/255
1.0
);
} else if (magnitude < 20u) {
// Highland: CPU formula is rgb(200 + 2*mag, 183 + 2*mag, 138 + 2*mag)
// We sampled highlandBaseColor at mag 10, so it's rgb(220, 203, 158) / 255
// For any mag 10-19: highlandBaseColor + 2*(mag - 10) / 255
let highlandMag = mag - 10.0;
color = vec4f(
min(params.highlandBaseColor.r + (2.0 * highlandMag) / 255.0, 1.0),
min(params.highlandBaseColor.g + (2.0 * highlandMag) / 255.0, 1.0),
min(params.highlandBaseColor.b + (2.0 * highlandMag) / 255.0, 1.0),
1.0
);
} else {
// Mountain: CPU formula is rgb(230 + mag/2, 230 + mag/2, 230 + mag/2)
// We sampled mountainBaseColor at mag 20, so it's rgb(240, 240, 240) / 255 for pastel
// For any mag >= 20: mountainBaseColor + (mag - 20) / 2 / 255
let mountainMag = mag - 20.0;
let gray = min(params.mountainBaseColor.r + (mountainMag / 2.0) / 255.0, 1.0);
color = vec4f(gray, gray, gray, 1.0);
}
}
textureStore(terrainTex, texCoord, color);
}
@@ -0,0 +1,76 @@
struct Temporal {
nowSec: f32,
lastTickSec: f32,
tickDtSec: f32,
tickDtEmaSec: f32,
tickAlpha: f32,
tickCount: f32,
historyValid: f32,
_pad0: f32,
};
struct Params {
params0: vec4f, // x=mode, y=curveExp
params1: vec4f, // x=updateCount
};
struct Update {
tileIndex: u32,
newState: u32,
};
@group(0) @binding(0) var<uniform> t: Temporal;
@group(0) @binding(1) var<uniform> p: Params;
@group(0) @binding(2) var<storage, read> updates: array<Update>;
@group(0) @binding(3) var visualStateTex: texture_storage_2d<r32uint, write>;
fn hashUint(x: u32) -> u32 {
var h = x * 1664525u + 1013904223u;
h ^= h >> 16u;
h *= 2246822519u;
h ^= h >> 13u;
h *= 3266489917u;
h ^= h >> 16u;
return h;
}
fn hashToUnitFloat(x: u32) -> f32 {
return f32(x & 0x00FFFFFFu) / 16777216.0;
}
@compute @workgroup_size(64)
fn main(@builtin(global_invocation_id) globalId: vec3<u32>) {
let idx = globalId.x;
let updateCount = u32(max(0.0, p.params1.x) + 0.5);
if (idx >= updateCount) {
return;
}
let mode = u32(max(0.0, p.params0.x) + 0.5);
let curveExp = max(0.001, p.params0.y);
let alpha = clamp(pow(clamp(t.tickAlpha, 0.0, 1.0), curveExp), 0.0, 1.0);
let update = updates[idx];
if (mode == 1u) {
let tickSeed = u32(max(0.0, t.tickCount) + 0.5);
let h = hashUint(update.tileIndex ^ (tickSeed * 2654435761u));
let r = hashToUnitFloat(h);
if (r > alpha) {
return;
}
} else if (mode == 2u) {
let targetCount = u32(floor(f32(updateCount) * alpha));
if (idx >= targetCount) {
return;
}
} else {
return;
}
let dims = textureDimensions(visualStateTex);
let mapWidth = dims.x;
let x = i32(update.tileIndex % mapWidth);
let y = i32(update.tileIndex / mapWidth);
textureStore(visualStateTex, vec2i(x, y), vec4u(update.newState, 0u, 0u, 0u));
}
@@ -0,0 +1,303 @@
struct Uniforms {
mapResolution_viewScale_time: vec4f, // x=mapW, y=mapH, z=viewScale, w=timeSec
viewOffset_alt_highlight: vec4f, // x=offX, y=offY, z=alternativeView, w=highlightOwnerId
viewSize_pad: vec4f, // x=viewW, y=viewH, z=myPlayerSmallId, w unused
shaderParams0: vec4f, // x=thicknessPx, y=borderStrength, z=glowStrength, w=glowRadiusMul
shaderParams1: vec4f, // x=flags, y=relationTintStrength, z=defendedPatternStrength, w=defendedThreshold
};
@group(0) @binding(0) var<uniform> u: Uniforms;
@group(0) @binding(1) var stateTex: texture_2d<u32>;
@group(0) @binding(2) var defendedStrengthTex: texture_2d<f32>;
@group(0) @binding(3) var paletteTex: texture_2d<f32>;
@group(0) @binding(4) var terrainTex: texture_2d<f32>;
@group(0) @binding(5) var ownerIndexTex: texture_2d<u32>;
@group(0) @binding(6) var relationsTex: texture_2d<u32>;
@vertex
fn vsMain(@builtin(vertex_index) vi: u32) -> @builtin(position) vec4f {
var pos = array<vec2f, 3>(
vec2f(-1.0, -1.0),
vec2f(3.0, -1.0),
vec2f(-1.0, 3.0),
);
let p = pos[vi];
return vec4f(p, 0.0, 1.0);
}
fn hasFlag(flags: u32, bit: u32) -> bool {
return (flags & (1u << bit)) != 0u;
}
fn relationCode(ownerA: u32, ownerB: u32) -> u32 {
if (ownerA == 0u || ownerB == 0u) {
return 0u;
}
let aDense = textureLoad(ownerIndexTex, vec2i(i32(ownerA), 0), 0).x;
let bDense = textureLoad(ownerIndexTex, vec2i(i32(ownerB), 0), 0).x;
if (aDense == 0u || bDense == 0u) {
return 0u;
}
return textureLoad(relationsTex, vec2i(i32(aDense), i32(bDense)), 0).x;
}
fn applyDefendedPattern(
baseRgb: vec3f,
strength: f32,
texCoord: vec2i,
) -> vec3f {
let parity = (u32(texCoord.x) ^ u32(texCoord.y)) & 1u;
let factor = select(0.75, 1.25, parity == 1u);
let patterned = clamp(baseRgb * factor, vec3f(0.0), vec3f(1.0));
return mix(baseRgb, patterned, clamp(strength, 0.0, 1.0));
}
@fragment
fn fsMain(@builtin(position) pos: vec4f) -> @location(0) vec4f {
let mapRes = u.mapResolution_viewScale_time.xy;
let viewScale = u.mapResolution_viewScale_time.z;
let timeSec = u.mapResolution_viewScale_time.w;
let viewOffset = u.viewOffset_alt_highlight.xy;
let altView = u.viewOffset_alt_highlight.z;
let highlightId = u.viewOffset_alt_highlight.w;
let myPlayerSmallId = u.viewSize_pad.z;
let thicknessPx = u.shaderParams0.x;
let borderStrength = u.shaderParams0.y;
let glowStrength = u.shaderParams0.z;
let glowRadiusMul = u.shaderParams0.w;
let flags = u32(max(0.0, u.shaderParams1.x) + 0.5);
let relationTintStrength = u.shaderParams1.y;
let defendedPatternStrength = u.shaderParams1.z;
let defendedThreshold = u.shaderParams1.w;
let enableRelations = hasFlag(flags, 0u);
let enableDefendedPattern = hasFlag(flags, 1u);
let enableSplit = hasFlag(flags, 2u);
let drawDefendedRadius = hasFlag(flags, 3u);
let disableDefendedTint = hasFlag(flags, 4u);
// WebGPU fragment position is top-left origin and at pixel centers (0.5, 1.5, ...).
let viewCoord = vec2f(pos.x - 0.5, pos.y - 0.5);
let mapHalf = mapRes * 0.5;
let mapCoord = (viewCoord - mapHalf) / viewScale + viewOffset + mapHalf;
if (
mapCoord.x < 0.0 ||
mapCoord.y < 0.0 ||
mapCoord.x >= mapRes.x ||
mapCoord.y >= mapRes.y
) {
discard;
}
let texCoord = vec2i(mapCoord);
let state = textureLoad(stateTex, texCoord, 0).x;
let owner = state & 0xFFFu;
let hasFallout = (state & 0x2000u) != 0u;
let terrain = textureLoad(terrainTex, texCoord, 0);
let defendedStrength = textureLoad(defendedStrengthTex, texCoord, 0).x;
var outColor = terrain;
if (owner != 0u) {
// Player colors start at index 10
let c = textureLoad(paletteTex, vec2i(i32(owner) + 10, 0), 0);
var territoryRgb = c.rgb;
if (!disableDefendedTint) {
let defendedTint = select(
0.0,
clamp(0.8 * defendedStrength, 0.1, 0.35),
defendedStrength > 0.001,
);
territoryRgb = mix(
territoryRgb,
vec3f(1.0, 0.0, 1.0),
defendedTint,
);
}
if (hasFallout) {
// Fallout color is at index 0
let falloutColor = textureLoad(paletteTex, vec2i(0, 0), 0).rgb;
territoryRgb = mix(territoryRgb, falloutColor, 0.5);
}
outColor = vec4f(mix(terrain.rgb, territoryRgb, 0.65), 1.0);
} else if (hasFallout) {
let falloutColor = textureLoad(paletteTex, vec2i(0, 0), 0).rgb;
outColor = vec4f(mix(terrain.rgb, falloutColor, 0.5), 1.0);
}
// In alt view we show only borders on top of terrain.
if (altView > 0.5) {
outColor = terrain;
}
if (owner != 0u) {
let fx = fract(mapCoord.x);
let fy = fract(mapCoord.y);
var bestDist = 1e9;
var otherOwner = 0u;
var otherCoord = texCoord;
// Only border against other non-zero owners.
if (texCoord.x > 0) {
let o = textureLoad(stateTex, texCoord + vec2i(-1, 0), 0).x & 0xFFFu;
if (o != 0u && o != owner) {
let d = fx;
if (d < bestDist) {
bestDist = d;
otherOwner = o;
otherCoord = texCoord + vec2i(-1, 0);
}
}
}
if (texCoord.x + 1 < i32(mapRes.x)) {
let o = textureLoad(stateTex, texCoord + vec2i(1, 0), 0).x & 0xFFFu;
if (o != 0u && o != owner) {
let d = 1.0 - fx;
if (d < bestDist) {
bestDist = d;
otherOwner = o;
otherCoord = texCoord + vec2i(1, 0);
}
}
}
if (texCoord.y > 0) {
let o = textureLoad(stateTex, texCoord + vec2i(0, -1), 0).x & 0xFFFu;
if (o != 0u && o != owner) {
let d = fy;
if (d < bestDist) {
bestDist = d;
otherOwner = o;
otherCoord = texCoord + vec2i(0, -1);
}
}
}
if (texCoord.y + 1 < i32(mapRes.y)) {
let o = textureLoad(stateTex, texCoord + vec2i(0, 1), 0).x & 0xFFFu;
if (o != 0u && o != owner) {
let d = 1.0 - fy;
if (d < bestDist) {
bestDist = d;
otherOwner = o;
otherCoord = texCoord + vec2i(0, 1);
}
}
}
if (otherOwner != 0u) {
let pxPerTile = max(viewScale, 0.001);
let aaTiles = 1.0 / pxPerTile;
let thicknessTiles = max(0.1, thicknessPx) / pxPerTile;
let line = 1.0 - smoothstep(thicknessTiles, thicknessTiles + aaTiles, bestDist);
let glowTiles = (max(0.1, thicknessPx) * max(0.1, glowRadiusMul)) / pxPerTile;
let glow = 1.0 - smoothstep(glowTiles, glowTiles + aaTiles * 3.0, bestDist);
var baseBorderRgb = textureLoad(paletteTex, vec2i(i32(owner) + 10, 1), 0).rgb;
if (!enableSplit) {
let otherBorderRgb = textureLoad(paletteTex, vec2i(i32(otherOwner) + 10, 1), 0).rgb;
baseBorderRgb = 0.5 * (baseBorderRgb + otherBorderRgb);
}
var edgeDefendedStrength = defendedStrength;
if (!enableSplit) {
let otherDef = textureLoad(defendedStrengthTex, otherCoord, 0).x;
edgeDefendedStrength = max(edgeDefendedStrength, otherDef);
}
// Determine relation color (normal: between owners, altView: relation to viewer).
var rel = 0u;
if (enableRelations) {
if (altView > 0.5) {
rel = relationCode(owner, u32(max(0.0, myPlayerSmallId) + 0.5));
} else {
rel = relationCode(owner, otherOwner);
}
}
var borderRgb = baseBorderRgb;
if (rel != 0u) {
let tintTarget = select(vec3f(0.0, 1.0, 0.0), vec3f(1.0, 0.0, 0.0), rel == 2u);
let tint = clamp(0.35 * relationTintStrength, 0.0, 1.0);
borderRgb = mix(borderRgb, tintTarget, tint);
}
if (enableDefendedPattern && edgeDefendedStrength >= defendedThreshold) {
borderRgb = applyDefendedPattern(borderRgb, defendedPatternStrength, texCoord);
}
outColor = vec4f(
mix(outColor.rgb, borderRgb, clamp(line * borderStrength, 0.0, 1.0)),
outColor.a,
);
outColor = vec4f(
mix(outColor.rgb, borderRgb, clamp(glow * glowStrength, 0.0, 1.0)),
outColor.a,
);
}
}
if (drawDefendedRadius && defendedStrength > 0.001 && owner != 0u) {
let fx = fract(mapCoord.x);
let fy = fract(mapCoord.y);
var dist = 1e9;
if (texCoord.x > 0) {
let s = textureLoad(defendedStrengthTex, texCoord + vec2i(-1, 0), 0).x;
if (s <= 0.001) {
dist = min(dist, fx);
}
}
if (texCoord.x + 1 < i32(mapRes.x)) {
let s = textureLoad(defendedStrengthTex, texCoord + vec2i(1, 0), 0).x;
if (s <= 0.001) {
dist = min(dist, 1.0 - fx);
}
}
if (texCoord.y > 0) {
let s = textureLoad(defendedStrengthTex, texCoord + vec2i(0, -1), 0).x;
if (s <= 0.001) {
dist = min(dist, fy);
}
}
if (texCoord.y + 1 < i32(mapRes.y)) {
let s = textureLoad(defendedStrengthTex, texCoord + vec2i(0, 1), 0).x;
if (s <= 0.001) {
dist = min(dist, 1.0 - fy);
}
}
if (dist < 1e8) {
let pxPerTile = max(viewScale, 0.001);
let aaTiles = 1.0 / pxPerTile;
let thicknessTiles = 1.5 / pxPerTile;
let line = 1.0 - smoothstep(thicknessTiles, thicknessTiles + aaTiles, dist);
let baseBorderRgb = textureLoad(paletteTex, vec2i(i32(owner) + 10, 1), 0).rgb;
let ringRgb = mix(baseBorderRgb, vec3f(1.0, 1.0, 1.0), 0.5);
outColor = vec4f(
mix(outColor.rgb, ringRgb, clamp(line * 0.65, 0.0, 1.0)),
outColor.a,
);
}
}
// Apply hover highlight if needed
if (highlightId > 0.5) {
let alpha = select(0.65, 0.0, altView > 0.5);
if (alpha > 0.0 && owner != 0u && abs(f32(owner) - highlightId) < 0.5) {
let pulse = 0.5 + 0.5 * sin(timeSec * 6.2831853);
let strength = 0.15 + 0.15 * pulse;
let highlightedRgb = mix(outColor.rgb, vec3f(1.0, 1.0, 1.0), strength);
outColor = vec4f(highlightedRgb, outColor.a);
}
}
return outColor;
}
@@ -0,0 +1,81 @@
struct Temporal {
nowSec: f32,
lastTickSec: f32,
tickDtSec: f32,
tickDtEmaSec: f32,
tickAlpha: f32,
tickCount: f32,
historyValid: f32,
_pad0: f32,
};
struct Params {
params0: vec4f, // x=mode, y=blendStrength, z=dissolveWidth
};
@group(0) @binding(0) var<uniform> t: Temporal;
@group(0) @binding(1) var<uniform> p: Params;
@group(0) @binding(2) var currentTex: texture_2d<f32>;
@group(0) @binding(3) var historyTex: texture_2d<f32>;
struct FragOutput {
@location(0) color: vec4f,
@location(1) history: vec4f,
};
@vertex
fn vsMain(@builtin(vertex_index) vi: u32) -> @builtin(position) vec4f {
var pos = array<vec2f, 3>(
vec2f(-1.0, -1.0),
vec2f(3.0, -1.0),
vec2f(-1.0, 3.0),
);
let p = pos[vi];
return vec4f(p, 0.0, 1.0);
}
fn hashUint(x: u32) -> u32 {
var h = x * 1664525u + 1013904223u;
h ^= h >> 16u;
h *= 2246822519u;
h ^= h >> 13u;
h *= 3266489917u;
h ^= h >> 16u;
return h;
}
fn hashToUnitFloat(x: u32) -> f32 {
return f32(x & 0x00FFFFFFu) / 16777216.0;
}
@fragment
fn fsMain(@builtin(position) pos: vec4f) -> FragOutput {
let texCoord = vec2i(pos.xy);
let curr = textureLoad(currentTex, texCoord, 0);
let hist = textureLoad(historyTex, texCoord, 0);
let mode = u32(max(0.0, p.params0.x) + 0.5);
let strength = clamp(p.params0.y, 0.0, 1.0);
let width = max(0.001, p.params0.z);
var alpha = clamp(t.tickAlpha * strength, 0.0, 1.0);
if (t.historyValid < 0.5) {
alpha = 1.0;
}
if (mode == 1u) {
let outColor = mix(hist, curr, alpha);
return FragOutput(outColor, outColor);
}
if (mode == 2u) {
let seed = (u32(texCoord.x) * 73856093u) ^ (u32(texCoord.y) * 19349663u);
let tickSeed = u32(max(0.0, t.tickCount) + 0.5);
let r = hashToUnitFloat(hashUint(seed ^ (tickSeed * 2654435761u)));
let mask = smoothstep(alpha - width, alpha + width, r);
let outColor = mix(hist, curr, mask);
return FragOutput(outColor, outColor);
}
return FragOutput(curr, curr);
}
@@ -0,0 +1,218 @@
struct Uniforms {
mapResolution_viewScale_time: vec4f, // x=mapW, y=mapH, z=viewScale, w=timeSec
viewOffset_alt_highlight: vec4f, // x=offX, y=offY, z=alternativeView, w=highlightOwnerId
viewSize_pad: vec4f, // x=viewW, y=viewH, z=myPlayerSmallId, w unused
shaderParams0: vec4f,
shaderParams1: vec4f,
};
@group(0) @binding(0) var<uniform> u: Uniforms;
@group(0) @binding(1) var stateTex: texture_2d<u32>;
@group(0) @binding(2) var defendedStrengthTex: texture_2d<f32>;
@group(0) @binding(3) var paletteTex: texture_2d<f32>;
@group(0) @binding(4) var terrainTex: texture_2d<f32>;
@group(0) @binding(5) var ownerIndexTex: texture_2d<u32>;
@group(0) @binding(6) var relationsTex: texture_2d<u32>;
@vertex
fn vsMain(@builtin(vertex_index) vi: u32) -> @builtin(position) vec4f {
var pos = array<vec2f, 3>(
vec2f(-1.0, -1.0),
vec2f(3.0, -1.0),
vec2f(-1.0, 3.0),
);
let p = pos[vi];
return vec4f(p, 0.0, 1.0);
}
@fragment
fn fsMain(@builtin(position) pos: vec4f) -> @location(0) vec4f {
let mapRes = u.mapResolution_viewScale_time.xy;
let viewScale = u.mapResolution_viewScale_time.z;
let timeSec = u.mapResolution_viewScale_time.w;
let viewOffset = u.viewOffset_alt_highlight.xy;
let altView = u.viewOffset_alt_highlight.z;
let highlightId = u.viewOffset_alt_highlight.w;
let viewSize = u.viewSize_pad.xy;
let borderMode = u.shaderParams0.x;
let thicknessPx = u.shaderParams0.y;
let borderStrength = u.shaderParams0.z;
let glowStrength = u.shaderParams0.w;
let glowRadiusMul = u.shaderParams1.x;
let drawDefendedRadius = u.shaderParams1.y;
let disableDefendedTint = u.shaderParams1.z;
// WebGPU fragment position is top-left origin and at pixel centers (0.5, 1.5, ...).
let viewCoord = vec2f(pos.x - 0.5, pos.y - 0.5);
let mapHalf = mapRes * 0.5;
// Match TransformHandler.screenToWorldCoordinates formula:
// gameX = (canvasX - game.width() / 2) / scale + offsetX + game.width() / 2
let mapCoord = (viewCoord - mapHalf) / viewScale + viewOffset + mapHalf;
if (mapCoord.x < 0.0 || mapCoord.y < 0.0 || mapCoord.x >= mapRes.x || mapCoord.y >= mapRes.y) {
discard;
}
let texCoord = vec2i(mapCoord);
let state = textureLoad(stateTex, texCoord, 0).x;
let owner = state & 0xFFFu;
let hasFallout = (state & 0x2000u) != 0u;
let terrain = textureLoad(terrainTex, texCoord, 0);
let defendedStrength = textureLoad(defendedStrengthTex, texCoord, 0).x;
var outColor = terrain;
if (owner != 0u) {
// Player colors start at index 10
let c = textureLoad(paletteTex, vec2i(i32(owner) + 10, 0), 0);
var territoryRgb = c.rgb;
if (disableDefendedTint <= 0.5) {
let defendedTint = select(
0.0,
clamp(0.8 * defendedStrength, 0.1, 0.35),
defendedStrength > 0.001,
);
territoryRgb = mix(
territoryRgb,
vec3f(1.0, 0.0, 1.0),
defendedTint,
);
}
if (hasFallout) {
// Fallout color is at index 0
let falloutColor = textureLoad(paletteTex, vec2i(0, 0), 0).rgb;
territoryRgb = mix(territoryRgb, falloutColor, 0.5);
}
outColor = vec4f(mix(terrain.rgb, territoryRgb, 0.65), 1.0);
} else if (hasFallout) {
// Fallout color is at index 0
let falloutColor = textureLoad(paletteTex, vec2i(0, 0), 0).rgb;
outColor = vec4f(mix(terrain.rgb, falloutColor, 0.5), 1.0);
}
// Apply alternative view (hide territory by showing terrain only)
if (altView > 0.5 && owner != 0u) {
outColor = terrain;
}
// Borders (purely visual): render a stable-pixel-width line at ownership edges.
if (borderMode > 0.5 && altView <= 0.5 && owner != 0u) {
let fx = fract(mapCoord.x);
let fy = fract(mapCoord.y);
var dist = 1e9;
// Only border against other non-zero owners.
if (texCoord.x > 0) {
let o = textureLoad(stateTex, texCoord + vec2i(-1, 0), 0).x & 0xFFFu;
if (o != 0u && o != owner) {
dist = min(dist, fx);
}
}
if (texCoord.x + 1 < i32(mapRes.x)) {
let o = textureLoad(stateTex, texCoord + vec2i(1, 0), 0).x & 0xFFFu;
if (o != 0u && o != owner) {
dist = min(dist, 1.0 - fx);
}
}
if (texCoord.y > 0) {
let o = textureLoad(stateTex, texCoord + vec2i(0, -1), 0).x & 0xFFFu;
if (o != 0u && o != owner) {
dist = min(dist, fy);
}
}
if (texCoord.y + 1 < i32(mapRes.y)) {
let o = textureLoad(stateTex, texCoord + vec2i(0, 1), 0).x & 0xFFFu;
if (o != 0u && o != owner) {
dist = min(dist, 1.0 - fy);
}
}
if (dist < 1e8) {
let pxPerTile = max(viewScale, 0.001);
let aaTiles = 1.0 / pxPerTile;
// Mode 1: thin black border.
// Mode 2: thicker black border + obvious tinted glow.
let isGlow = borderMode > 1.5;
let thicknessTiles = thicknessPx / pxPerTile;
let line = 1.0 - smoothstep(thicknessTiles, thicknessTiles + aaTiles, dist);
outColor = vec4f(
mix(outColor.rgb, vec3f(0.0, 0.0, 0.0), clamp(line * borderStrength, 0.0, 1.0)),
outColor.a,
);
if (isGlow) {
let glowTiles = (thicknessPx * glowRadiusMul) / pxPerTile;
let glow = 1.0 - smoothstep(glowTiles, glowTiles + aaTiles * 3.0, dist);
let ownerRgb = textureLoad(paletteTex, vec2i(i32(owner) + 10, 0), 0).rgb;
let glowColor = mix(vec3f(1.0, 1.0, 1.0), ownerRgb, 0.85);
outColor = vec4f(
mix(outColor.rgb, glowColor, clamp(glow * glowStrength, 0.0, 1.0)),
outColor.a,
);
}
}
}
// Debug: defended radius boundary (based on defendedStrengthTex coverage).
if (drawDefendedRadius > 0.5 && defendedStrength > 0.001 && owner != 0u) {
let fx = fract(mapCoord.x);
let fy = fract(mapCoord.y);
var dist = 1e9;
if (texCoord.x > 0) {
let s = textureLoad(defendedStrengthTex, texCoord + vec2i(-1, 0), 0).x;
if (s <= 0.001) {
dist = min(dist, fx);
}
}
if (texCoord.x + 1 < i32(mapRes.x)) {
let s = textureLoad(defendedStrengthTex, texCoord + vec2i(1, 0), 0).x;
if (s <= 0.001) {
dist = min(dist, 1.0 - fx);
}
}
if (texCoord.y > 0) {
let s = textureLoad(defendedStrengthTex, texCoord + vec2i(0, -1), 0).x;
if (s <= 0.001) {
dist = min(dist, fy);
}
}
if (texCoord.y + 1 < i32(mapRes.y)) {
let s = textureLoad(defendedStrengthTex, texCoord + vec2i(0, 1), 0).x;
if (s <= 0.001) {
dist = min(dist, 1.0 - fy);
}
}
if (dist < 1e8) {
let pxPerTile = max(viewScale, 0.001);
let aaTiles = 1.0 / pxPerTile;
let thicknessTiles = 1.5 / pxPerTile;
let line = 1.0 - smoothstep(thicknessTiles, thicknessTiles + aaTiles, dist);
let borderRgb = textureLoad(paletteTex, vec2i(i32(owner) + 10, 1), 0).rgb;
let ringRgb = mix(borderRgb, vec3f(1.0, 1.0, 1.0), 0.5);
outColor = vec4f(
mix(outColor.rgb, ringRgb, clamp(line * 0.65, 0.0, 1.0)),
outColor.a,
);
}
}
// Apply hover highlight if needed
if (highlightId > 0.5) {
let alpha = select(0.65, 0.0, altView > 0.5);
if (alpha > 0.0 && owner != 0u && abs(f32(owner) - highlightId) < 0.5) {
let pulse = 0.5 + 0.5 * sin(timeSec * 6.2831853);
let strength = 0.15 + 0.15 * pulse;
let highlightedRgb = mix(outColor.rgb, vec3f(1.0, 1.0, 1.0), strength);
outColor = vec4f(highlightedRgb, outColor.a);
}
}
return outColor;
}
+10
View File
@@ -34,3 +34,13 @@ declare module "*.webp" {
const webpContent: string;
export default webpContent;
}
declare module "*.svg?url" {
const svgUrl: string;
export default svgUrl;
}
declare module "*.wgsl?raw" {
const content: string;
export default content;
}
+1
View File
@@ -205,6 +205,7 @@ export interface Theme {
allyColor(): Colord;
neutralColor(): Colord;
enemyColor(): Colord;
playerHighlightColor(): Colord;
spawnHighlightColor(): Colord;
spawnHighlightSelfColor(): Colord;
spawnHighlightTeamColor(): Colord;
+5
View File
@@ -35,6 +35,8 @@ export class PastelTheme implements Theme {
/** Alternate View colors for enemies, red */
private _enemyColor = colord("rgb(255,0,0)");
/** Hover highlight color for player territories */
private _playerHighlightColor = colord("rgb(221, 221, 221)");
/** Default spawn highlight colors for other players in FFA, yellow */
private _spawnHighlightColor = colord("rgb(255,213,79)");
/** Added non-default spawn highlight colors for self, full white */
@@ -209,6 +211,9 @@ export class PastelTheme implements Theme {
enemyColor(): Colord {
return this._enemyColor;
}
playerHighlightColor(): Colord {
return this._playerHighlightColor;
}
spawnHighlightColor(): Colord {
return this._spawnHighlightColor;
@@ -8,6 +8,7 @@ export class PastelThemeDark extends PastelTheme {
private darkWater = colord("rgb(14,11,30)");
private darkShorelineWater = colord("rgb(50,50,50)");
private darkPlayerHighlight = colord("rgb(99, 42, 42)");
// | Terrain Type | Magnitude | Base Color Logic | Visual Description |
// | :---------------- | :-------- | :---------------------------------------------- | :-------------------- |
@@ -59,4 +60,8 @@ export class PastelThemeDark extends PastelTheme {
});
}
}
playerHighlightColor(): Colord {
return this.darkPlayerHighlight;
}
}
+68 -1
View File
@@ -692,6 +692,7 @@ export class GameImpl implements Game {
owner._lastTileChange = this._ticks;
this.updateBorders(tile);
this._map.setFallout(tile, false);
this.updateDefendedStateForTileChange(tile, owner);
this.recordTileUpdate(tile);
}
@@ -710,6 +711,9 @@ export class GameImpl implements Game {
this._map.setOwnerID(tile, 0);
this.updateBorders(tile);
if (this._map.isDefended(tile)) {
this._map.setDefended(tile, false);
}
this.recordTileUpdate(tile);
}
@@ -958,7 +962,6 @@ export class GameImpl implements Game {
playerID: id,
});
}
addUnit(u: Unit) {
this.unitGrid.addUnit(u);
this._unitMap.set(u.id(), u);
@@ -972,9 +975,18 @@ export class GameImpl implements Game {
}
}
updateUnitTile(u: Unit) {
if (u.type() === UnitType.DefensePost) {
this.updateDefendedStateForDefensePost(u.tile(), u.owner() as PlayerImpl);
}
this.unitGrid.updateUnitCell(u);
}
refreshDefensePostDefendedState(u: Unit) {
if (u.type() === UnitType.DefensePost) {
this.updateDefendedStateForDefensePost(u.tile(), u.owner() as PlayerImpl);
}
}
hasUnitNearby(
tile: TileRef,
searchRange: number,
@@ -1097,6 +1109,12 @@ export class GameImpl implements Game {
hasFallout(ref: TileRef): boolean {
return this._map.hasFallout(ref);
}
isDefended(ref: TileRef): boolean {
return this._map.isDefended(ref);
}
setDefended(ref: TileRef, value: boolean): void {
this._map.setDefended(ref, value);
}
isBorder(ref: TileRef): boolean {
return this._map.isBorder(ref);
}
@@ -1155,6 +1173,12 @@ export class GameImpl implements Game {
updateTile(tile: TileRef, state: number): boolean {
return this._map.updateTile(tile, state);
}
tileStateView(): Uint16Array {
return this._map.tileStateView();
}
terrainDataView(): Uint8Array {
return this._map.terrainDataView();
}
numTilesWithFallout(): number {
return this._map.numTilesWithFallout();
}
@@ -1243,6 +1267,49 @@ export class GameImpl implements Game {
gold: goldCaptured,
});
}
private updateDefendedStateForDefensePost(
center: TileRef,
owner: PlayerImpl,
) {
const range = this.config().defensePostRange();
const rangeSq = range * range;
for (const tile of owner._borderTiles) {
if (this._map.euclideanDistSquared(center, tile) <= rangeSq) {
const wasDefended = this._map.isDefended(tile);
const isDefended = this.unitGrid.hasUnitNearby(
tile,
range,
UnitType.DefensePost,
owner.id(),
);
if (wasDefended !== isDefended) {
this._map.setDefended(tile, isDefended);
this.recordTileUpdate(tile);
}
}
}
}
private updateDefendedStateForTileChange(tile: TileRef, owner: PlayerImpl) {
const wasDefended = this._map.isDefended(tile);
const isDefended = this.unitGrid.hasUnitNearby(
tile,
this.config().defensePostRange(),
UnitType.DefensePost,
owner.id(),
);
if (wasDefended !== isDefended) {
this._map.setDefended(tile, isDefended);
}
if (
this.unitGrid.hasUnitNearby(tile, 0, UnitType.DefensePost, owner.id())
) {
this.updateDefendedStateForDefensePost(tile, owner);
}
}
}
// Or a more dynamic approach that will catch new enum values:
+25
View File
@@ -33,6 +33,10 @@ export interface GameMap {
setOwnerID(ref: TileRef, playerId: number): void;
hasFallout(ref: TileRef): boolean;
setFallout(ref: TileRef, value: boolean): void;
isDefended(ref: TileRef): boolean;
setDefended(ref: TileRef, value: boolean): void;
tileStateView(): Uint16Array;
terrainDataView(): Uint8Array;
isOnEdgeOfMap(ref: TileRef): boolean;
isBorder(ref: TileRef): boolean;
neighbors(ref: TileRef): TileRef[];
@@ -96,6 +100,7 @@ export class GameMapImpl implements GameMap {
// State bits (Uint16Array)
private static readonly PLAYER_ID_MASK = 0xfff;
private static readonly DEFENDED_BIT = 12;
private static readonly FALLOUT_BIT = 13;
private static readonly DEFENSE_BONUS_BIT = 14;
// Bit 15 still reserved
@@ -266,6 +271,26 @@ export class GameMapImpl implements GameMap {
}
}
isDefended(ref: TileRef): boolean {
return Boolean(this.state[ref] & (1 << GameMapImpl.DEFENDED_BIT));
}
setDefended(ref: TileRef, value: boolean): void {
if (value) {
this.state[ref] |= 1 << GameMapImpl.DEFENDED_BIT;
} else {
this.state[ref] &= ~(1 << GameMapImpl.DEFENDED_BIT);
}
}
tileStateView(): Uint16Array {
return this.state;
}
terrainDataView(): Uint8Array {
return this.terrain;
}
isOnEdgeOfMap(ref: TileRef): boolean {
const x = this.x(ref);
const y = this.y(ref);
+35
View File
@@ -669,6 +669,11 @@ export class GameView implements GameMap {
private _units = new Map<number, UnitView>();
private updatedTiles: TileRef[] = [];
private updatedTerrainTiles: TileRef[] = [];
private updatedOwnerChanges: Array<{
tile: TileRef;
previousOwner: number;
newOwner: number;
}> = [];
private _myPlayer: PlayerView | null = null;
@@ -780,15 +785,25 @@ export class GameView implements GameMap {
this.updatedTiles = [];
this.updatedTerrainTiles = [];
this.updatedOwnerChanges = [];
const packed = this.lastUpdate.packedTileUpdates;
for (let i = 0; i + 1 < packed.length; i += 2) {
const tile = packed[i];
const state = packed[i + 1];
const previousOwner = this._map.ownerID(tile);
const terrainChanged = this.updateTile(tile, state);
this.updatedTiles.push(tile);
if (terrainChanged) {
this.updatedTerrainTiles.push(tile);
}
const newOwner = this._map.ownerID(tile);
if (previousOwner !== newOwner) {
this.updatedOwnerChanges.push({
tile,
previousOwner,
newOwner,
});
}
}
if (gu.packedMotionPlans) {
@@ -1107,6 +1122,14 @@ export class GameView implements GameMap {
return this.updatedTerrainTiles;
}
recentlyUpdatedOwnerTiles(): Array<{
tile: TileRef;
previousOwner: number;
newOwner: number;
}> {
return this.updatedOwnerChanges;
}
nearbyUnits(
tile: TileRef,
searchRange: number,
@@ -1323,6 +1346,18 @@ export class GameView implements GameMap {
setFallout(ref: TileRef, value: boolean): void {
return this._map.setFallout(ref, value);
}
isDefended(ref: TileRef): boolean {
return this._map.isDefended(ref);
}
setDefended(ref: TileRef, value: boolean): void {
return this._map.setDefended(ref, value);
}
tileStateView(): Uint16Array {
return this._map.tileStateView();
}
terrainDataView(): Uint8Array {
return this._map.terrainDataView();
}
isBorder(ref: TileRef): boolean {
return this._map.isBorder(ref);
}
+3
View File
@@ -433,6 +433,9 @@ export class UnitImpl implements Unit {
setUnderConstruction(underConstruction: boolean): void {
if (this._underConstruction !== underConstruction) {
this._underConstruction = underConstruction;
if (this._type === UnitType.DefensePost) {
this.mg.refreshDefensePostDefendedState(this);
}
this.mg.addUpdate(this.toUpdate());
}
}
+79 -2
View File
@@ -47,6 +47,14 @@ export const COLOR_KEY = "settings.territoryColor";
export const DARK_MODE_KEY = "settings.darkMode";
export const PERFORMANCE_OVERLAY_KEY = "settings.performanceOverlay";
export const KEYBINDS_KEY = "settings.keybinds";
export const TERRITORY_RENDERER_KEY = "settings.territoryRenderer";
export const WEBGL_DEBUG_KEY = "settings.webglDebug";
export const WEBGPU_DEBUG_KEY = "settings.webgpuDebug";
export type TerritoryRendererPreference =
| "auto"
| "classic"
| "webgl"
| "webgpu";
export class UserSettings {
private static cache = new Map<string, string | null>();
@@ -110,7 +118,15 @@ export class UserSettings {
this.setCached(key, value);
}
private getFloat(key: string, defaultValue: number): number {
get(key: string, defaultValue: boolean): boolean {
return this.getBool(key, defaultValue);
}
set(key: string, value: boolean): void {
this.setBool(key, value);
}
getFloat(key: string, defaultValue: number): number {
const value = this.getCached(key);
if (!value) return defaultValue;
@@ -119,10 +135,24 @@ export class UserSettings {
return floatValue;
}
private setFloat(key: string, value: number) {
setFloat(key: string, value: number) {
this.setCached(key, value.toString());
}
getInt(key: string, defaultValue: number): number {
const value = localStorage.getItem(key);
if (!value) return defaultValue;
const intValue = parseInt(value, 10);
if (!Number.isFinite(intValue)) return defaultValue;
return intValue;
}
setInt(key: string, value: number): void {
localStorage.setItem(key, Math.trunc(value).toString());
}
emojis() {
return this.getBool("settings.emojis", true);
}
@@ -131,6 +161,22 @@ export class UserSettings {
return this.getBool(PERFORMANCE_OVERLAY_KEY, false);
}
webgpuDebug(): boolean {
return this.get(WEBGPU_DEBUG_KEY, false);
}
webglDebug(): boolean {
return this.get(WEBGL_DEBUG_KEY, false);
}
setWebgpuDebug(value: boolean): void {
this.set(WEBGPU_DEBUG_KEY, value);
}
setWebglDebug(value: boolean): void {
this.set(WEBGL_DEBUG_KEY, value);
}
alertFrame() {
return this.getBool("settings.alertFrame", true);
}
@@ -167,6 +213,33 @@ export class UserSettings {
return this.getBool("settings.attackingTroopsOverlay", true);
}
territoryBorderMode(): number {
return this.getInt("settings.territoryBorderMode", 1);
}
territoryRenderer(): TerritoryRendererPreference {
const value = this.getString(TERRITORY_RENDERER_KEY, "auto");
if (
value === "auto" ||
value === "classic" ||
value === "webgl" ||
value === "webgpu"
) {
return value;
}
return "auto";
}
setTerritoryRenderer(value: string): void {
const renderer =
value === "classic" || value === "webgl" || value === "webgpu"
? value
: "auto";
this.setWebglDebug(renderer === "webgl");
this.setWebgpuDebug(renderer === "webgpu");
this.setString(TERRITORY_RENDERER_KEY, renderer);
}
toggleAttackingTroopsOverlay() {
this.setBool(
"settings.attackingTroopsOverlay",
@@ -196,6 +269,10 @@ export class UserSettings {
this.setBool(PERFORMANCE_OVERLAY_KEY, !this.performanceOverlay());
}
toggleWebgpuDebug() {
this.setWebgpuDebug(!this.webgpuDebug());
}
toggleAlertFrame() {
this.setBool("settings.alertFrame", !this.alertFrame());
}
+161
View File
@@ -0,0 +1,161 @@
import { describe, expect, test } from "vitest";
import {
selectTerritoryBackend,
type TerritoryBackendCandidate,
type TerritoryRendererId,
type TerritoryRendererPreference,
} from "../src/client/graphics/layers/TerritoryBackend";
type FakeBackendSpec = {
initError?: string;
ready?: boolean;
failureReason?: string;
};
type FakeBackendSpecs = Partial<Record<TerritoryRendererId, FakeBackendSpec>>;
class FakeBackend implements TerritoryBackendCandidate {
initialized = false;
disposed = false;
constructor(
readonly id: TerritoryRendererId,
private readonly spec: FakeBackendSpec = {},
) {}
init() {
this.initialized = true;
if (this.spec.initError) {
throw new Error(this.spec.initError);
}
}
async whenReady(): Promise<boolean> {
return this.spec.ready ?? true;
}
getFailureReason(): string | null {
return this.spec.failureReason ?? null;
}
dispose() {
this.disposed = true;
}
}
class RendererSelectionHarness {
active: TerritoryRendererId | null = null;
readonly failed = new Set<TerritoryRendererId>();
preference: TerritoryRendererPreference;
constructor(preference: TerritoryRendererPreference) {
this.preference = preference;
}
setPreference(preference: TerritoryRendererPreference) {
this.preference = preference;
this.failed.clear();
}
async select(specs: FakeBackendSpecs = {}) {
const created: FakeBackend[] = [];
const selection = await selectTerritoryBackend(
this.preference,
this.failed,
(id) => {
const backend = new FakeBackend(id, specs[id]);
created.push(backend);
return backend;
},
);
for (const failure of selection.failures) {
if (failure.id !== "classic") {
this.failed.add(failure.id);
}
}
if (selection.backend) {
this.active = selection.backend.id;
}
return { ...selection, created };
}
async failActiveRuntime(specs: FakeBackendSpecs = {}) {
if (this.active && this.active !== "classic") {
this.failed.add(this.active);
}
return this.select(specs);
}
}
describe("territory renderer backend selection", () => {
test("auto selects WebGPU when ready", async () => {
const harness = new RendererSelectionHarness("auto");
const result = await harness.select();
expect(result.backend?.id).toBe("webgpu");
expect(harness.active).toBe("webgpu");
expect(result.failures).toEqual([]);
expect(result.created.map((backend) => backend.id)).toEqual(["webgpu"]);
});
test("auto falls back to WebGL when WebGPU init fails", async () => {
const harness = new RendererSelectionHarness("auto");
const result = await harness.select({
webgpu: { initError: "navigator.gpu unavailable" },
});
expect(result.backend?.id).toBe("webgl");
expect(harness.active).toBe("webgl");
expect(result.failures.map((failure) => failure.id)).toEqual(["webgpu"]);
expect(result.created[0].disposed).toBe(true);
});
test("auto falls back to classic when both accelerated backends fail", async () => {
const harness = new RendererSelectionHarness("auto");
const result = await harness.select({
webgpu: { initError: "navigator.gpu unavailable" },
webgl: { failureReason: "WebGL2 unavailable" },
});
expect(result.backend?.id).toBe("classic");
expect(harness.active).toBe("classic");
expect(result.failures.map((failure) => failure.id)).toEqual([
"webgpu",
"webgl",
]);
});
test("forced WebGPU falls back on runtime failure without changing saved setting", async () => {
const harness = new RendererSelectionHarness("webgpu");
await harness.select();
const result = await harness.failActiveRuntime();
expect(result.backend?.id).toBe("webgl");
expect(harness.active).toBe("webgl");
expect(harness.preference).toBe("webgpu");
expect(harness.failed.has("webgpu")).toBe(true);
});
test("manual setting change retries previously failed backends", async () => {
const harness = new RendererSelectionHarness("auto");
await harness.select({
webgpu: { initError: "navigator.gpu unavailable" },
});
expect(harness.active).toBe("webgl");
expect(harness.failed.has("webgpu")).toBe(true);
harness.setPreference("auto");
const retry = await harness.select();
expect(retry.backend?.id).toBe("webgpu");
expect(harness.active).toBe("webgpu");
expect(harness.failed.size).toBe(0);
});
});