mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 11:20:45 +00:00
Add territory renderer fallback controller
This commit is contained in:
@@ -705,6 +705,8 @@
|
||||
"attacking_troops_overlay_desc": "Show attacker vs defender troop counts on active front lines.",
|
||||
"territory_border_mode_label": "Territory Borders",
|
||||
"territory_border_mode_desc": "Select border rendering style (visual only)",
|
||||
"renderer_label": "Renderer",
|
||||
"renderer_desc": "Choose territory rendering backend. Auto uses WebGPU, then WebGL, then Classic.",
|
||||
"performance_overlay_label": "Performance Overlay",
|
||||
"performance_overlay_desc": "Toggle the performance overlay. When enabled, the performance overlay will be displayed. Press shift-D during game to toggle.",
|
||||
"easter_writing_speed_label": "Writing Speed Multiplier",
|
||||
|
||||
@@ -300,7 +300,9 @@ export class UserSettingModal extends BaseModal {
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private changeTerritoryBorderMode(e: CustomEvent<{ value: number | string }>) {
|
||||
private changeTerritoryBorderMode(
|
||||
e: CustomEvent<{ value: number | string }>,
|
||||
) {
|
||||
const rawValue = e.detail?.value;
|
||||
const value =
|
||||
typeof rawValue === "number" ? rawValue : parseInt(String(rawValue), 10);
|
||||
@@ -310,6 +312,12 @@ export class UserSettingModal extends BaseModal {
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private changeTerritoryRenderer(e: CustomEvent<{ value: number | string }>) {
|
||||
const value = String(e.detail?.value ?? "auto");
|
||||
this.userSettings.setTerritoryRenderer(value);
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private toggleTerritoryPatterns() {
|
||||
this.userSettings.toggleTerritoryPatterns();
|
||||
|
||||
@@ -777,6 +785,20 @@ export class UserSettingModal extends BaseModal {
|
||||
@change=${this.changeTerritoryBorderMode}
|
||||
></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")}"
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { Config, Theme } from "../../../core/configuration/Config";
|
||||
import { GameView } from "../../../core/game/GameView";
|
||||
import { TransformHandler } from "../TransformHandler";
|
||||
import { Layer } from "./Layer";
|
||||
|
||||
export class TerrainLayer implements Layer {
|
||||
private canvas: HTMLCanvasElement;
|
||||
private context: CanvasRenderingContext2D;
|
||||
private imageData: ImageData;
|
||||
private theme: Theme;
|
||||
private config: Config;
|
||||
|
||||
constructor(
|
||||
private game: GameView,
|
||||
private transformHandler: TransformHandler,
|
||||
) {
|
||||
this.config = this.game.config();
|
||||
}
|
||||
shouldTransform(): boolean {
|
||||
return true;
|
||||
}
|
||||
tick() {
|
||||
if (this.config.theme() !== this.theme) {
|
||||
this.redraw();
|
||||
return;
|
||||
}
|
||||
// Repaint terrain for tiles whose terrain changed (e.g. nuke
|
||||
// turning land to water).
|
||||
const updatedTiles = this.game.recentlyUpdatedTerrainTiles();
|
||||
if (updatedTiles.length > 0) {
|
||||
let dirty = false;
|
||||
for (const tile of updatedTiles) {
|
||||
const terrainColor = this.theme.terrainColor(this.game, tile);
|
||||
const offset = tile * 4;
|
||||
const r = terrainColor.rgba.r;
|
||||
const g = terrainColor.rgba.g;
|
||||
const b = terrainColor.rgba.b;
|
||||
if (
|
||||
this.imageData.data[offset] !== r ||
|
||||
this.imageData.data[offset + 1] !== g ||
|
||||
this.imageData.data[offset + 2] !== b
|
||||
) {
|
||||
this.imageData.data[offset] = r;
|
||||
this.imageData.data[offset + 1] = g;
|
||||
this.imageData.data[offset + 2] = b;
|
||||
dirty = true;
|
||||
}
|
||||
}
|
||||
if (dirty) {
|
||||
this.context.putImageData(this.imageData, 0, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init() {
|
||||
console.log("redrew terrain layer");
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
redraw(): void {
|
||||
this.canvas = document.createElement("canvas");
|
||||
this.canvas.width = this.game.width();
|
||||
this.canvas.height = this.game.height();
|
||||
|
||||
const context = this.canvas.getContext("2d", { alpha: false });
|
||||
if (context === null) throw new Error("2d context not supported");
|
||||
this.context = context;
|
||||
|
||||
this.imageData = this.context.createImageData(
|
||||
this.canvas.width,
|
||||
this.canvas.height,
|
||||
);
|
||||
|
||||
this.initImageData();
|
||||
this.context.putImageData(this.imageData, 0, 0);
|
||||
}
|
||||
|
||||
initImageData() {
|
||||
this.theme = this.config.theme();
|
||||
this.game.forEachTile((tile) => {
|
||||
const terrainColor = this.theme.terrainColor(this.game, tile);
|
||||
// TODO: isn't tileref and index the same?
|
||||
const index = this.game.y(tile) * this.game.width() + this.game.x(tile);
|
||||
const offset = index * 4;
|
||||
this.imageData.data[offset] = terrainColor.rgba.r;
|
||||
this.imageData.data[offset + 1] = terrainColor.rgba.g;
|
||||
this.imageData.data[offset + 2] = terrainColor.rgba.b;
|
||||
this.imageData.data[offset + 3] = 255;
|
||||
});
|
||||
}
|
||||
|
||||
renderLayer(context: CanvasRenderingContext2D) {
|
||||
if (this.transformHandler.scale < 1) {
|
||||
context.imageSmoothingEnabled = true;
|
||||
context.imageSmoothingQuality = "low";
|
||||
} else {
|
||||
context.imageSmoothingEnabled = false;
|
||||
}
|
||||
context.drawImage(
|
||||
this.canvas,
|
||||
-this.game.width() / 2,
|
||||
-this.game.height() / 2,
|
||||
this.game.width(),
|
||||
this.game.height(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { Layer } from "./Layer";
|
||||
|
||||
export type TerritoryRendererId = "classic" | "webgl" | "webgpu";
|
||||
export type TerritoryRendererPreference = "auto" | TerritoryRendererId;
|
||||
|
||||
export const TERRITORY_RENDERER_OPTIONS: TerritoryRendererPreference[] = [
|
||||
"auto",
|
||||
"classic",
|
||||
"webgl",
|
||||
"webgpu",
|
||||
];
|
||||
|
||||
export interface TerritoryBackend extends Layer {
|
||||
readonly id: TerritoryRendererId;
|
||||
dispose?: () => void;
|
||||
getFailureReason?: () => string | null;
|
||||
whenReady?: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
@@ -1,68 +1,42 @@
|
||||
import { Theme } from "../../../core/configuration/Config";
|
||||
import { EventBus } from "../../../core/EventBus";
|
||||
import { UnitType } from "../../../core/game/Game";
|
||||
import { TileRef } from "../../../core/game/GameMap";
|
||||
import { GameView } from "../../../core/game/GameView";
|
||||
import { UserSettings } from "../../../core/game/UserSettings";
|
||||
import {
|
||||
AlternateViewEvent,
|
||||
MouseOverEvent,
|
||||
WebGPUComputeMetricsEvent,
|
||||
} from "../../InputHandler";
|
||||
import { FrameProfiler } from "../FrameProfiler";
|
||||
TERRITORY_RENDERER_KEY,
|
||||
USER_SETTINGS_CHANGED_EVENT,
|
||||
UserSettings,
|
||||
} from "../../../core/game/UserSettings";
|
||||
import { TransformHandler } from "../TransformHandler";
|
||||
import { ClassicTerritoryBackend } from "./ClassicTerritoryBackend";
|
||||
import {
|
||||
buildTerrainShaderParams,
|
||||
readTerrainShaderId,
|
||||
} from "../webgpu/render/TerrainShaderRegistry";
|
||||
import {
|
||||
buildTerritoryPostSmoothingParams,
|
||||
readTerritoryPostSmoothingId,
|
||||
} from "../webgpu/render/TerritoryPostSmoothingRegistry";
|
||||
import {
|
||||
buildTerritoryPreSmoothingParams,
|
||||
readTerritoryPreSmoothingId,
|
||||
} from "../webgpu/render/TerritoryPreSmoothingRegistry";
|
||||
import {
|
||||
buildTerritoryShaderParams,
|
||||
readTerritoryShaderId,
|
||||
} from "../webgpu/render/TerritoryShaderRegistry";
|
||||
import { TerritoryRenderer } from "../webgpu/TerritoryRenderer";
|
||||
import { Layer } from "./Layer";
|
||||
TerritoryBackend,
|
||||
TerritoryRendererId,
|
||||
selectTerritoryBackend,
|
||||
territoryRendererOrder,
|
||||
} from "./TerritoryBackend";
|
||||
import { WebGLTerritoryBackend } from "./WebGLTerritoryBackend";
|
||||
import { WebGPUTerritoryBackend } from "./WebGPUTerritoryBackend";
|
||||
|
||||
export class TerritoryLayer implements Layer {
|
||||
profileName(): string {
|
||||
return "TerritoryLayer:renderLayer";
|
||||
}
|
||||
export class TerritoryLayer implements TerritoryBackend {
|
||||
readonly id = "classic";
|
||||
|
||||
private attachedTerritoryCanvas: HTMLCanvasElement | null = null;
|
||||
|
||||
private overlayWrapper: HTMLElement | null = null;
|
||||
private overlayResizeObserver: ResizeObserver | null = null;
|
||||
|
||||
private theme: Theme;
|
||||
|
||||
private territoryRenderer: TerritoryRenderer | null = null;
|
||||
private alternativeView = false;
|
||||
|
||||
private lastPaletteSignature: string | null = null;
|
||||
private lastDefensePostsSignature: string | null = null;
|
||||
private lastTerrainShaderSignature: string | null = null;
|
||||
private lastTerritoryShaderSignature: string | null = null;
|
||||
private lastPreSmoothingSignature: string | null = null;
|
||||
private lastPostSmoothingSignature: string | null = null;
|
||||
|
||||
private lastMousePosition: { x: number; y: number } | null = null;
|
||||
private hoveredOwnerSmallId: number | null = null;
|
||||
private lastHoverUpdateMs = 0;
|
||||
private activeBackend: TerritoryBackend | null = null;
|
||||
private failedBackends = new Set<TerritoryRendererId>();
|
||||
private selectionToken = 0;
|
||||
private initialized = false;
|
||||
private readonly settingsChanged = () => {
|
||||
this.failedBackends.clear();
|
||||
void this.selectConfiguredBackend();
|
||||
};
|
||||
|
||||
constructor(
|
||||
private game: GameView,
|
||||
private eventBus: EventBus,
|
||||
private transformHandler: TransformHandler,
|
||||
private userSettings: UserSettings,
|
||||
) {
|
||||
this.theme = game.config().theme();
|
||||
) {}
|
||||
|
||||
profileName(): string {
|
||||
return "TerritoryLayer:renderLayer";
|
||||
}
|
||||
|
||||
shouldTransform(): boolean {
|
||||
@@ -70,355 +44,201 @@ export class TerritoryLayer implements Layer {
|
||||
}
|
||||
|
||||
init() {
|
||||
this.eventBus.on(AlternateViewEvent, (e) => {
|
||||
this.alternativeView = e.alternateView;
|
||||
this.territoryRenderer?.setAlternativeView(this.alternativeView);
|
||||
});
|
||||
this.eventBus.on(MouseOverEvent, (e) => {
|
||||
this.lastMousePosition = { x: e.x, y: e.y };
|
||||
});
|
||||
this.redraw();
|
||||
this.initialized = true;
|
||||
globalThis.addEventListener?.(
|
||||
`${USER_SETTINGS_CHANGED_EVENT}:${TERRITORY_RENDERER_KEY}`,
|
||||
this.settingsChanged,
|
||||
);
|
||||
|
||||
// Keep the map visible while accelerated renderers initialize.
|
||||
this.activateBackend(this.createBackend("classic"));
|
||||
void this.selectConfiguredBackend();
|
||||
}
|
||||
|
||||
tick() {
|
||||
const tickProfile = FrameProfiler.start();
|
||||
|
||||
const currentTheme = this.game.config().theme();
|
||||
if (currentTheme !== this.theme) {
|
||||
this.theme = currentTheme;
|
||||
this.territoryRenderer?.refreshTerrain();
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
this.refreshPaletteIfNeeded();
|
||||
this.refreshDefensePostsIfNeeded();
|
||||
this.applyTerrainShaderSettings();
|
||||
this.applyTerritoryShaderSettings();
|
||||
this.applyTerritorySmoothingSettings();
|
||||
|
||||
const updatedTiles = this.game.recentlyUpdatedTiles();
|
||||
for (let i = 0; i < updatedTiles.length; i++) {
|
||||
this.markTile(updatedTiles[i]);
|
||||
}
|
||||
|
||||
// After collecting pending updates and handling palette/theme changes,
|
||||
// invoke the renderer's tick() to process compute passes. This ensures
|
||||
// compute shaders run at the simulation rate rather than every frame.
|
||||
if (this.territoryRenderer) {
|
||||
const start = performance.now();
|
||||
this.territoryRenderer.tick();
|
||||
const computeMs = performance.now() - start;
|
||||
this.eventBus.emit(new WebGPUComputeMetricsEvent(computeMs));
|
||||
}
|
||||
|
||||
FrameProfiler.end("TerritoryLayer:tick", tickProfile);
|
||||
this.runActive("tick", (backend) => backend.tick?.());
|
||||
}
|
||||
|
||||
redraw() {
|
||||
this.configureRenderer();
|
||||
}
|
||||
|
||||
private configureRenderer() {
|
||||
const { renderer, reason } = TerritoryRenderer.create(
|
||||
this.game,
|
||||
this.theme,
|
||||
);
|
||||
if (!renderer) {
|
||||
throw new Error(reason ?? "WebGPU is required for territory rendering.");
|
||||
if (!this.initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.territoryRenderer = renderer;
|
||||
this.territoryRenderer.setAlternativeView(this.alternativeView);
|
||||
this.territoryRenderer.setHighlightedOwnerId(this.hoveredOwnerSmallId);
|
||||
this.applyTerrainShaderSettings(true);
|
||||
this.applyTerritoryShaderSettings(true);
|
||||
this.applyTerritorySmoothingSettings(true);
|
||||
this.territoryRenderer.markAllDirty();
|
||||
this.territoryRenderer.refreshPalette();
|
||||
this.lastPaletteSignature = this.computePaletteSignature();
|
||||
|
||||
this.lastDefensePostsSignature = this.computeDefensePostsSignature();
|
||||
// Ensure defense posts buffer is uploaded on first tick.
|
||||
this.territoryRenderer.markDefensePostsDirty();
|
||||
|
||||
// Run an initial tick to upload state and build the colour texture. Without
|
||||
// this, the first render call may occur before the initial compute pass
|
||||
// has been executed, resulting in undefined colours.
|
||||
this.territoryRenderer.tick();
|
||||
this.runActive("redraw", (backend) => backend.redraw?.());
|
||||
void this.selectConfiguredBackend();
|
||||
}
|
||||
|
||||
renderLayer(context: CanvasRenderingContext2D) {
|
||||
if (!this.territoryRenderer) {
|
||||
if (!this.activeBackend) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for theme changes in renderLayer too (for when game is paused)
|
||||
const currentTheme = this.game.config().theme();
|
||||
if (currentTheme !== this.theme) {
|
||||
this.theme = currentTheme;
|
||||
this.territoryRenderer.refreshTerrain();
|
||||
this.redraw();
|
||||
if (this.activeBackend.id !== "webgpu") {
|
||||
this.fillBackground(context);
|
||||
}
|
||||
|
||||
// Apply user settings even while the game is paused (settings modal).
|
||||
this.applyTerritoryShaderSettings();
|
||||
this.applyTerritorySmoothingSettings();
|
||||
this.runActive("renderLayer", (backend) => backend.renderLayer?.(context));
|
||||
}
|
||||
|
||||
this.ensureTerritoryCanvasAttached(context.canvas);
|
||||
this.updateHoverHighlight();
|
||||
|
||||
const renderTerritoryStart = FrameProfiler.start();
|
||||
this.territoryRenderer.setViewSize(
|
||||
context.canvas.width,
|
||||
context.canvas.height,
|
||||
dispose() {
|
||||
globalThis.removeEventListener?.(
|
||||
`${USER_SETTINGS_CHANGED_EVENT}:${TERRITORY_RENDERER_KEY}`,
|
||||
this.settingsChanged,
|
||||
);
|
||||
const viewOffset = this.transformHandler.viewOffset();
|
||||
this.territoryRenderer.setViewTransform(
|
||||
this.transformHandler.scale,
|
||||
viewOffset.x,
|
||||
viewOffset.y,
|
||||
this.activeBackend?.dispose?.();
|
||||
this.activeBackend = null;
|
||||
}
|
||||
|
||||
private async selectConfiguredBackend() {
|
||||
const token = ++this.selectionToken;
|
||||
const preference = this.userSettings.territoryRenderer();
|
||||
const order = territoryRendererOrder(preference, this.failedBackends);
|
||||
if (
|
||||
this.activeBackend?.id === order[0] &&
|
||||
!this.activeBackend.getFailureReason?.()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selection = await selectTerritoryBackend(
|
||||
preference,
|
||||
this.failedBackends,
|
||||
(id) => this.createBackend(id),
|
||||
() => token === this.selectionToken,
|
||||
);
|
||||
this.territoryRenderer.render();
|
||||
FrameProfiler.end("TerritoryLayer:renderTerritory", renderTerritoryStart);
|
||||
}
|
||||
|
||||
private ensureTerritoryCanvasAttached(mainCanvas: HTMLCanvasElement) {
|
||||
if (!this.territoryRenderer) {
|
||||
if (selection.cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const canvas = this.territoryRenderer.canvas;
|
||||
|
||||
// If the renderer recreated its canvas, detach the old one.
|
||||
if (this.attachedTerritoryCanvas !== canvas) {
|
||||
this.attachedTerritoryCanvas?.remove();
|
||||
this.attachedTerritoryCanvas = canvas;
|
||||
|
||||
// Configure overlay canvas styles once. Avoid per-frame style reads/writes.
|
||||
canvas.style.pointerEvents = "none";
|
||||
canvas.style.position = "absolute";
|
||||
canvas.style.inset = "0";
|
||||
canvas.style.width = "100%";
|
||||
canvas.style.height = "100%";
|
||||
canvas.style.display = "block";
|
||||
}
|
||||
|
||||
const parent = mainCanvas.parentElement;
|
||||
if (!parent) {
|
||||
// Fallback: if the canvas isn't in the DOM yet, append to body.
|
||||
if (!canvas.isConnected) {
|
||||
document.body.appendChild(canvas);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure the main canvas is wrapped in a positioned container so the
|
||||
// territory canvas can overlay it without mirroring computed styles.
|
||||
let wrapper: HTMLElement;
|
||||
const currentParent = mainCanvas.parentElement;
|
||||
if (currentParent && currentParent.dataset.territoryOverlay === "1") {
|
||||
wrapper = currentParent;
|
||||
} else {
|
||||
wrapper = document.createElement("div");
|
||||
wrapper.dataset.territoryOverlay = "1";
|
||||
wrapper.style.position = "relative";
|
||||
wrapper.style.display = "inline-block";
|
||||
wrapper.style.lineHeight = "0";
|
||||
|
||||
// Replace mainCanvas with wrapper, then re-insert mainCanvas inside wrapper.
|
||||
parent.replaceChild(wrapper, mainCanvas);
|
||||
wrapper.appendChild(mainCanvas);
|
||||
}
|
||||
|
||||
if (this.overlayWrapper !== wrapper) {
|
||||
this.overlayWrapper = wrapper;
|
||||
this.overlayResizeObserver?.disconnect();
|
||||
this.overlayResizeObserver = new ResizeObserver(() => {
|
||||
this.syncOverlayWrapperSize(mainCanvas, wrapper);
|
||||
});
|
||||
this.overlayResizeObserver.observe(mainCanvas);
|
||||
// Kick an initial size update; further updates are handled by ResizeObserver.
|
||||
this.syncOverlayWrapperSize(mainCanvas, wrapper);
|
||||
}
|
||||
|
||||
// Ensure territory canvas is the first child so it's the lowest layer.
|
||||
if (canvas.parentElement !== wrapper) {
|
||||
canvas.remove();
|
||||
wrapper.insertBefore(canvas, mainCanvas);
|
||||
} else if (canvas !== wrapper.firstElementChild) {
|
||||
wrapper.insertBefore(canvas, mainCanvas);
|
||||
}
|
||||
}
|
||||
|
||||
private syncOverlayWrapperSize(
|
||||
mainCanvas: HTMLCanvasElement,
|
||||
wrapper: HTMLElement,
|
||||
) {
|
||||
// Ensure the wrapper has real layout size so the absolutely-positioned
|
||||
// territory canvas (100% width/height) is non-zero even if the main canvas
|
||||
// is positioned absolutely.
|
||||
const rect = mainCanvas.getBoundingClientRect();
|
||||
const w = rect.width > 0 ? rect.width : mainCanvas.clientWidth;
|
||||
const h = rect.height > 0 ? rect.height : mainCanvas.clientHeight;
|
||||
if (w > 0) wrapper.style.width = `${w}px`;
|
||||
if (h > 0) wrapper.style.height = `${h}px`;
|
||||
}
|
||||
|
||||
private markTile(tile: TileRef) {
|
||||
this.territoryRenderer?.markTile(tile);
|
||||
}
|
||||
|
||||
private updateHoverHighlight() {
|
||||
if (!this.territoryRenderer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = performance.now();
|
||||
if (now - this.lastHoverUpdateMs < 100) {
|
||||
return;
|
||||
}
|
||||
this.lastHoverUpdateMs = now;
|
||||
|
||||
let nextOwnerSmallId: number | null = null;
|
||||
if (this.lastMousePosition) {
|
||||
const cell = this.transformHandler.screenToWorldCoordinates(
|
||||
this.lastMousePosition.x,
|
||||
this.lastMousePosition.y,
|
||||
for (const failure of selection.failures) {
|
||||
console.warn(
|
||||
`[TerritoryLayer] ${failure.id} renderer unavailable: ${failure.reason}`,
|
||||
failure.error ?? "",
|
||||
);
|
||||
if (this.game.isValidCoord(cell.x, cell.y)) {
|
||||
const tile = this.game.ref(cell.x, cell.y);
|
||||
const owner = this.game.owner(tile);
|
||||
if (owner && owner.isPlayer()) {
|
||||
nextOwnerSmallId = owner.smallID();
|
||||
if (failure.id !== "classic") {
|
||||
this.failedBackends.add(failure.id);
|
||||
}
|
||||
}
|
||||
|
||||
if (selection.backend !== null) {
|
||||
this.activateBackend(selection.backend);
|
||||
}
|
||||
}
|
||||
|
||||
private async initializeCandidate(
|
||||
backend: TerritoryBackend,
|
||||
token: number,
|
||||
): Promise<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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (nextOwnerSmallId === this.hoveredOwnerSmallId) {
|
||||
return;
|
||||
}
|
||||
this.hoveredOwnerSmallId = nextOwnerSmallId;
|
||||
this.territoryRenderer.setHighlightedOwnerId(nextOwnerSmallId);
|
||||
}
|
||||
|
||||
private computePaletteSignature(): string {
|
||||
let maxSmallId = 0;
|
||||
for (const player of this.game.playerViews()) {
|
||||
maxSmallId = Math.max(maxSmallId, player.smallID());
|
||||
}
|
||||
const patternsEnabled = this.userSettings.territoryPatterns();
|
||||
return `${this.game.playerViews().length}:${maxSmallId}:${patternsEnabled ? 1 : 0}`;
|
||||
}
|
||||
|
||||
private refreshPaletteIfNeeded() {
|
||||
if (!this.territoryRenderer) {
|
||||
return;
|
||||
}
|
||||
const signature = this.computePaletteSignature();
|
||||
if (signature !== this.lastPaletteSignature) {
|
||||
this.lastPaletteSignature = signature;
|
||||
this.territoryRenderer.refreshPalette();
|
||||
}
|
||||
}
|
||||
|
||||
private applyTerritoryShaderSettings(force: boolean = false) {
|
||||
if (!this.territoryRenderer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shaderId = readTerritoryShaderId(this.userSettings);
|
||||
const { shaderPath, params0, params1 } = buildTerritoryShaderParams(
|
||||
this.userSettings,
|
||||
shaderId,
|
||||
);
|
||||
|
||||
const signature = `${shaderPath}:${Array.from(params0).join(",")}:${Array.from(params1).join(",")}`;
|
||||
if (!force && signature === this.lastTerritoryShaderSignature) {
|
||||
return;
|
||||
}
|
||||
this.lastTerritoryShaderSignature = signature;
|
||||
|
||||
this.territoryRenderer.setTerritoryShader(shaderPath);
|
||||
this.territoryRenderer.setTerritoryShaderParams(params0, params1);
|
||||
}
|
||||
|
||||
private applyTerrainShaderSettings(force: boolean = false) {
|
||||
if (!this.territoryRenderer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const terrainId = readTerrainShaderId(this.userSettings);
|
||||
const { shaderPath, params0, params1 } = buildTerrainShaderParams(
|
||||
this.userSettings,
|
||||
terrainId,
|
||||
);
|
||||
const signature = `${shaderPath}:${Array.from(params0).join(",")}:${Array.from(params1).join(",")}`;
|
||||
if (!force && signature === this.lastTerrainShaderSignature) {
|
||||
return;
|
||||
}
|
||||
this.lastTerrainShaderSignature = signature;
|
||||
this.territoryRenderer.setTerrainShader(shaderPath);
|
||||
this.territoryRenderer.setTerrainShaderParams(params0, params1);
|
||||
}
|
||||
|
||||
private applyTerritorySmoothingSettings(force: boolean = false) {
|
||||
if (!this.territoryRenderer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const preId = readTerritoryPreSmoothingId(this.userSettings);
|
||||
const preParams = buildTerritoryPreSmoothingParams(
|
||||
this.userSettings,
|
||||
preId,
|
||||
);
|
||||
const preSignature = `${preId}:${Array.from(preParams.params0).join(",")}`;
|
||||
if (force || preSignature !== this.lastPreSmoothingSignature) {
|
||||
this.lastPreSmoothingSignature = preSignature;
|
||||
this.territoryRenderer.setPreSmoothing(
|
||||
preParams.enabled,
|
||||
preParams.shaderPath,
|
||||
preParams.params0,
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[TerritoryLayer] ${backend.id} renderer failed init`,
|
||||
error,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private activateBackend(backend: TerritoryBackend) {
|
||||
if (this.activeBackend === backend) {
|
||||
return;
|
||||
}
|
||||
const previous = this.activeBackend;
|
||||
this.activeBackend = backend;
|
||||
previous?.dispose?.();
|
||||
console.info(`[TerritoryLayer] active renderer: ${backend.id}`);
|
||||
}
|
||||
|
||||
private runActive(
|
||||
operation: "tick" | "redraw" | "renderLayer",
|
||||
run: (backend: TerritoryBackend) => void,
|
||||
) {
|
||||
const backend = this.activeBackend;
|
||||
if (!backend) {
|
||||
return;
|
||||
}
|
||||
|
||||
const postId = readTerritoryPostSmoothingId(this.userSettings);
|
||||
const postParams = buildTerritoryPostSmoothingParams(
|
||||
this.userSettings,
|
||||
postId,
|
||||
);
|
||||
const postSignature = `${postId}:${Array.from(postParams.params0).join(",")}`;
|
||||
if (force || postSignature !== this.lastPostSmoothingSignature) {
|
||||
this.lastPostSmoothingSignature = postSignature;
|
||||
this.territoryRenderer.setPostSmoothing(
|
||||
postParams.enabled,
|
||||
postParams.shaderPath,
|
||||
postParams.params0,
|
||||
try {
|
||||
run(backend);
|
||||
const reason = backend.getFailureReason?.();
|
||||
if (reason) {
|
||||
this.handleBackendFailure(backend, `${operation}: ${reason}`);
|
||||
}
|
||||
} catch (error) {
|
||||
this.handleBackendFailure(backend, `${operation}: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
private handleBackendFailure(backend: TerritoryBackend, reason: string) {
|
||||
console.warn(`[TerritoryLayer] ${backend.id} renderer failed: ${reason}`);
|
||||
if (backend.id !== "classic") {
|
||||
this.failedBackends.add(backend.id);
|
||||
}
|
||||
if (this.activeBackend === backend) {
|
||||
this.activeBackend = null;
|
||||
backend.dispose?.();
|
||||
const classic = this.createBackend("classic");
|
||||
void this.initializeCandidate(classic, ++this.selectionToken).then(
|
||||
(ready) => {
|
||||
if (ready) {
|
||||
this.activateBackend(classic);
|
||||
void this.selectConfiguredBackend();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private computeDefensePostsSignature(): string {
|
||||
// Active + completed posts only.
|
||||
const parts: string[] = [];
|
||||
for (const u of this.game.units(UnitType.DefensePost)) {
|
||||
if (!u.isActive() || u.isUnderConstruction()) continue;
|
||||
const tile = u.tile();
|
||||
parts.push(
|
||||
`${u.owner().smallID()},${this.game.x(tile)},${this.game.y(tile)}`,
|
||||
private createBackend(id: TerritoryRendererId): TerritoryBackend {
|
||||
if (id === "webgpu") {
|
||||
return new WebGPUTerritoryBackend(
|
||||
this.game,
|
||||
this.eventBus,
|
||||
this.transformHandler,
|
||||
this.userSettings,
|
||||
);
|
||||
}
|
||||
parts.sort();
|
||||
return parts.join("|");
|
||||
if (id === "webgl") {
|
||||
return new WebGLTerritoryBackend(
|
||||
this.game,
|
||||
this.eventBus,
|
||||
this.transformHandler,
|
||||
);
|
||||
}
|
||||
return new ClassicTerritoryBackend(
|
||||
this.game,
|
||||
this.eventBus,
|
||||
this.transformHandler,
|
||||
);
|
||||
}
|
||||
|
||||
private refreshDefensePostsIfNeeded() {
|
||||
if (!this.territoryRenderer) {
|
||||
return;
|
||||
}
|
||||
const signature = this.computeDefensePostsSignature();
|
||||
if (signature !== this.lastDefensePostsSignature) {
|
||||
this.lastDefensePostsSignature = signature;
|
||||
this.territoryRenderer.markDefensePostsDirty();
|
||||
}
|
||||
private fillBackground(context: CanvasRenderingContext2D) {
|
||||
context.save();
|
||||
context.setTransform(1, 0, 0, 1, 0, 0);
|
||||
context.fillStyle = this.game.config().theme().backgroundColor().toHex();
|
||||
context.fillRect(0, 0, context.canvas.width, context.canvas.height);
|
||||
context.restore();
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ export class TerritoryRenderer {
|
||||
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);
|
||||
@@ -99,15 +100,25 @@ export class TerritoryRenderer {
|
||||
|
||||
private startInit(): void {
|
||||
if (this.initPromise) return;
|
||||
this.initPromise = this.init();
|
||||
this.initPromise = this.init().catch((error) => {
|
||||
this.ready = false;
|
||||
this.failureReason =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
console.warn("[TerritoryRenderer] WebGPU init failed", error);
|
||||
});
|
||||
}
|
||||
|
||||
private async init(): Promise<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(
|
||||
@@ -182,6 +193,25 @@ export class TerritoryRenderer {
|
||||
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.
|
||||
|
||||
@@ -205,6 +205,7 @@ export interface Theme {
|
||||
allyColor(): Colord;
|
||||
neutralColor(): Colord;
|
||||
enemyColor(): Colord;
|
||||
playerHighlightColor(): Colord;
|
||||
spawnHighlightColor(): Colord;
|
||||
spawnHighlightSelfColor(): Colord;
|
||||
spawnHighlightTeamColor(): Colord;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -692,6 +692,7 @@ export class GameImpl implements Game {
|
||||
owner._lastTileChange = this._ticks;
|
||||
this.updateBorders(tile);
|
||||
this._map.setFallout(tile, false);
|
||||
this.updateDefendedStateForTileChange(tile, owner);
|
||||
this.recordTileUpdate(tile);
|
||||
}
|
||||
|
||||
@@ -710,6 +711,9 @@ export class GameImpl implements Game {
|
||||
|
||||
this._map.setOwnerID(tile, 0);
|
||||
this.updateBorders(tile);
|
||||
if (this._map.isDefended(tile)) {
|
||||
this._map.setDefended(tile, false);
|
||||
}
|
||||
this.recordTileUpdate(tile);
|
||||
}
|
||||
|
||||
@@ -971,9 +975,18 @@ export class GameImpl implements Game {
|
||||
}
|
||||
}
|
||||
updateUnitTile(u: Unit) {
|
||||
if (u.type() === UnitType.DefensePost) {
|
||||
this.updateDefendedStateForDefensePost(u.tile(), u.owner() as PlayerImpl);
|
||||
}
|
||||
this.unitGrid.updateUnitCell(u);
|
||||
}
|
||||
|
||||
refreshDefensePostDefendedState(u: Unit) {
|
||||
if (u.type() === UnitType.DefensePost) {
|
||||
this.updateDefendedStateForDefensePost(u.tile(), u.owner() as PlayerImpl);
|
||||
}
|
||||
}
|
||||
|
||||
hasUnitNearby(
|
||||
tile: TileRef,
|
||||
searchRange: number,
|
||||
@@ -1254,6 +1267,49 @@ export class GameImpl implements Game {
|
||||
gold: goldCaptured,
|
||||
});
|
||||
}
|
||||
|
||||
private updateDefendedStateForDefensePost(
|
||||
center: TileRef,
|
||||
owner: PlayerImpl,
|
||||
) {
|
||||
const range = this.config().defensePostRange();
|
||||
const rangeSq = range * range;
|
||||
|
||||
for (const tile of owner._borderTiles) {
|
||||
if (this._map.euclideanDistSquared(center, tile) <= rangeSq) {
|
||||
const wasDefended = this._map.isDefended(tile);
|
||||
const isDefended = this.unitGrid.hasUnitNearby(
|
||||
tile,
|
||||
range,
|
||||
UnitType.DefensePost,
|
||||
owner.id(),
|
||||
);
|
||||
if (wasDefended !== isDefended) {
|
||||
this._map.setDefended(tile, isDefended);
|
||||
this.recordTileUpdate(tile);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private updateDefendedStateForTileChange(tile: TileRef, owner: PlayerImpl) {
|
||||
const wasDefended = this._map.isDefended(tile);
|
||||
const isDefended = this.unitGrid.hasUnitNearby(
|
||||
tile,
|
||||
this.config().defensePostRange(),
|
||||
UnitType.DefensePost,
|
||||
owner.id(),
|
||||
);
|
||||
if (wasDefended !== isDefended) {
|
||||
this._map.setDefended(tile, isDefended);
|
||||
}
|
||||
|
||||
if (
|
||||
this.unitGrid.hasUnitNearby(tile, 0, UnitType.DefensePost, owner.id())
|
||||
) {
|
||||
this.updateDefendedStateForDefensePost(tile, owner);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Or a more dynamic approach that will catch new enum values:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,12 @@ export const COLOR_KEY = "settings.territoryColor";
|
||||
export const DARK_MODE_KEY = "settings.darkMode";
|
||||
export const PERFORMANCE_OVERLAY_KEY = "settings.performanceOverlay";
|
||||
export const KEYBINDS_KEY = "settings.keybinds";
|
||||
export const TERRITORY_RENDERER_KEY = "settings.territoryRenderer";
|
||||
export type TerritoryRendererPreference =
|
||||
| "auto"
|
||||
| "classic"
|
||||
| "webgl"
|
||||
| "webgpu";
|
||||
|
||||
export class UserSettings {
|
||||
private static cache = new Map<string, string | null>();
|
||||
@@ -154,7 +160,7 @@ export class UserSettings {
|
||||
}
|
||||
|
||||
webgpuDebug(): boolean {
|
||||
return this.get("settings.webgpuDebug", true);
|
||||
return this.get("settings.webgpuDebug", false);
|
||||
}
|
||||
|
||||
alertFrame() {
|
||||
@@ -197,6 +203,27 @@ export class UserSettings {
|
||||
return this.getInt("settings.territoryBorderMode", 1);
|
||||
}
|
||||
|
||||
territoryRenderer(): TerritoryRendererPreference {
|
||||
const value = this.getString(TERRITORY_RENDERER_KEY, "auto");
|
||||
if (
|
||||
value === "auto" ||
|
||||
value === "classic" ||
|
||||
value === "webgl" ||
|
||||
value === "webgpu"
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
return "auto";
|
||||
}
|
||||
|
||||
setTerritoryRenderer(value: string): void {
|
||||
const renderer =
|
||||
value === "classic" || value === "webgl" || value === "webgpu"
|
||||
? value
|
||||
: "auto";
|
||||
this.setString(TERRITORY_RENDERER_KEY, renderer);
|
||||
}
|
||||
|
||||
toggleAttackingTroopsOverlay() {
|
||||
this.setBool(
|
||||
"settings.attackingTroopsOverlay",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user