quick push

This commit is contained in:
scamiv
2025-11-28 19:54:00 +01:00
parent 6ca81211ea
commit eb1421bf55
24 changed files with 2972 additions and 396 deletions
+1
View File
@@ -6,6 +6,7 @@ coverage/
TODO.txt
resources/images/.DS_Store
resources/.DS_Store
resources/certs/
.env*
.DS_Store
.clinic/
+1
View File
@@ -7,6 +7,7 @@
"start:server": "node --loader ts-node/esm --experimental-specifier-resolution=node src/server/Server.ts",
"start:server-dev": "cross-env GAME_ENV=dev node --loader ts-node/esm --experimental-specifier-resolution=node src/server/Server.ts",
"dev": "cross-env GAME_ENV=dev concurrently \"npm run start:client\" \"npm run start:server-dev\"",
"dev:secure": "cross-env GAME_ENV=dev DEV_HTTPS=1 DEV_CERT=resources/certs/dev.crt DEV_KEY=resources/certs/dev.key concurrently \"npm run start:client\" \"npm run start:server-dev\"",
"dev:staging": "cross-env GAME_ENV=dev API_DOMAIN=api.openfront.dev concurrently \"npm run start:client\" \"npm run start:server-dev\"",
"dev:prod": "cross-env GAME_ENV=dev API_DOMAIN=api.openfront.io concurrently \"npm run start:client\" \"npm run start:server-dev\"",
"tunnel": "npm run build-prod && npm run start:server",
+337 -27
View File
@@ -12,7 +12,7 @@ import {
import { createPartialGameRecord, replacer } from "../core/Util";
import { ServerConfig } from "../core/configuration/Config";
import { getConfig } from "../core/configuration/ConfigLoader";
import { PlayerActions, UnitType } from "../core/game/Game";
import { GameUpdates, PlayerActions, UnitType } from "../core/game/Game";
import { TileRef } from "../core/game/GameMap";
import { GameMapLoader } from "../core/game/GameMapLoader";
import {
@@ -25,9 +25,18 @@ import {
import { GameView, PlayerView } from "../core/game/GameView";
import { loadTerrainMap, TerrainMapData } from "../core/game/TerrainMapLoader";
import { UserSettings } from "../core/game/UserSettings";
import {
createSharedTileRingBuffers,
createSharedTileRingViews,
drainTileUpdates,
SharedTileRingBuffers,
SharedTileRingViews,
TILE_RING_HEADER_OVERFLOW,
} from "../core/worker/SharedTileRing";
import { WorkerClient } from "../core/worker/WorkerClient";
import {
AutoUpgradeEvent,
BacklogStatusEvent,
DoBoatAttackEvent,
DoGroundAttackEvent,
InputHandler,
@@ -171,9 +180,44 @@ async function createClientGame(
mapLoader,
);
}
let sharedTileRingBuffers: SharedTileRingBuffers | undefined;
let sharedTileRingViews: SharedTileRingViews | null = null;
let sharedDirtyBuffer: SharedArrayBuffer | undefined;
let sharedDirtyFlags: Uint8Array | null = null;
const isIsolated =
typeof (globalThis as any).crossOriginIsolated === "boolean"
? (globalThis as any).crossOriginIsolated === true
: false;
const canUseSharedBuffers =
typeof SharedArrayBuffer !== "undefined" &&
typeof Atomics !== "undefined" &&
isIsolated;
const sharedStateBuffer =
canUseSharedBuffers && gameMap.sharedStateBuffer
? gameMap.sharedStateBuffer
: undefined;
const usesSharedTileState = !!sharedStateBuffer;
if (canUseSharedBuffers) {
const numTiles = gameMap.gameMap.width() * gameMap.gameMap.height();
// Ring capacity scales with world size: at most one entry per tile.
const TILE_RING_CAPACITY = numTiles;
sharedTileRingBuffers = createSharedTileRingBuffers(
TILE_RING_CAPACITY,
numTiles,
);
sharedTileRingViews = createSharedTileRingViews(sharedTileRingBuffers);
sharedDirtyBuffer = sharedTileRingBuffers.dirty;
sharedDirtyFlags = sharedTileRingViews.dirtyFlags;
}
const worker = new WorkerClient(
lobbyConfig.gameStartInfo,
lobbyConfig.clientID,
sharedTileRingBuffers,
sharedStateBuffer,
sharedDirtyBuffer,
);
await worker.initialize();
const gameView = new GameView(
@@ -183,6 +227,7 @@ async function createClientGame(
lobbyConfig.clientID,
lobbyConfig.gameStartInfo.gameID,
lobbyConfig.gameStartInfo.players,
usesSharedTileState,
);
const canvas = createCanvas();
@@ -200,6 +245,8 @@ async function createClientGame(
transport,
worker,
gameView,
sharedTileRingViews,
sharedDirtyFlags,
);
}
@@ -218,6 +265,22 @@ export class ClientGameRunner {
private lastTickReceiveTime: number = 0;
private currentTickDelay: number | undefined = undefined;
// Track how far behind the client simulation is compared to the server.
private serverTurnHighWater: number = 0;
private lastProcessedTick: number = 0;
private backlogTurns: number = 0;
private backlogGrowing: boolean = false;
private lastRenderedTick: number = 0;
private workerTicksSinceSample: number = 0;
private renderTicksSinceSample: number = 0;
private metricsSampleStart: number = 0;
private pendingUpdates: GameUpdateViewData[] = [];
private pendingStart = 0;
private isProcessingUpdates = false;
private tileRingViews: SharedTileRingViews | null;
private dirtyFlags: Uint8Array | null;
constructor(
private lobby: LobbyConfig,
private eventBus: EventBus,
@@ -226,8 +289,12 @@ export class ClientGameRunner {
private transport: Transport,
private worker: WorkerClient,
private gameView: GameView,
tileRingViews: SharedTileRingViews | null,
dirtyFlags: Uint8Array | null,
) {
this.lastMessageTime = Date.now();
this.tileRingViews = tileRingViews;
this.dirtyFlags = dirtyFlags;
}
private saveGame(update: WinUpdate) {
@@ -302,33 +369,9 @@ export class ClientGameRunner {
this.stop();
return;
}
this.transport.turnComplete();
gu.updates[GameUpdateType.Hash].forEach((hu: HashUpdate) => {
this.eventBus.emit(new SendHashEvent(hu.tick, hu.hash));
});
this.gameView.update(gu);
this.renderer.tick();
// Emit tick metrics event for performance overlay
this.eventBus.emit(
new TickMetricsEvent(gu.tickExecutionDuration, this.currentTickDelay),
);
// Reset tick delay for next measurement
this.currentTickDelay = undefined;
if (gu.updates[GameUpdateType.Win].length > 0) {
this.saveGame(gu.updates[GameUpdateType.Win][0]);
}
this.pendingUpdates.push(gu);
this.processPendingUpdates();
});
const worker = this.worker;
const keepWorkerAlive = () => {
if (this.isActive) {
worker.sendHeartbeat();
requestAnimationFrame(keepWorkerAlive);
}
};
requestAnimationFrame(keepWorkerAlive);
const onconnect = () => {
console.log("Connected to game server!");
@@ -373,6 +416,10 @@ export class ClientGameRunner {
}
for (const turn of message.turns) {
this.serverTurnHighWater = Math.max(
this.serverTurnHighWater,
turn.turnNumber,
);
if (turn.turnNumber < this.turnsSeen) {
continue;
}
@@ -425,6 +472,11 @@ export class ClientGameRunner {
}
this.lastTickReceiveTime = now;
this.serverTurnHighWater = Math.max(
this.serverTurnHighWater,
message.turn.turnNumber,
);
if (this.turnsSeen !== message.turn.turnNumber) {
console.error(
`got wrong turn have turns ${this.turnsSeen}, received turn ${message.turn.turnNumber}`,
@@ -455,6 +507,264 @@ export class ClientGameRunner {
}
}
private processPendingUpdates() {
const pendingCount = this.pendingUpdates.length - this.pendingStart;
if (this.isProcessingUpdates || pendingCount <= 0) {
return;
}
this.isProcessingUpdates = true;
const processFrame = () => {
const BASE_SLICE_BUDGET_MS = 8; // keep UI responsive while catching up
const MAX_SLICE_BUDGET_MS = 1000; // allow longer slices when backlog is large
const BACKLOG_FREE_TURNS = 10; // scaling starts at this many turns
const BACKLOG_MAX_TURNS = 500; // MAX_SLICE_BUDGET_MS is reached at this many turns
const MAX_TICKS_PER_SLICE = 1000;
const backlogOverhead = Math.max(
0,
this.backlogTurns - BACKLOG_FREE_TURNS,
);
const backlogScale = Math.min(
1,
backlogOverhead / (BACKLOG_MAX_TURNS - BACKLOG_FREE_TURNS),
);
const sliceBudgetMs =
BASE_SLICE_BUDGET_MS +
backlogScale * (MAX_SLICE_BUDGET_MS - BASE_SLICE_BUDGET_MS);
const frameStart = performance.now();
const batch: GameUpdateViewData[] = [];
let lastTickDuration: number | undefined;
let lastTick: number | undefined;
let processedCount = 0;
// Consume updates until we hit the time budget or per-slice cap.
while (this.pendingStart < this.pendingUpdates.length) {
const gu = this.pendingUpdates[this.pendingStart++];
processedCount++;
this.workerTicksSinceSample++;
batch.push(gu);
this.transport.turnComplete();
gu.updates[GameUpdateType.Hash].forEach((hu: HashUpdate) => {
this.eventBus.emit(new SendHashEvent(hu.tick, hu.hash));
});
this.updateBacklogMetrics(gu.tick);
if (gu.updates[GameUpdateType.Win].length > 0) {
this.saveGame(gu.updates[GameUpdateType.Win][0]);
}
if (gu.tickExecutionDuration !== undefined) {
lastTickDuration = gu.tickExecutionDuration;
}
lastTick = gu.tick;
const elapsed = performance.now() - frameStart;
if (processedCount >= MAX_TICKS_PER_SLICE || elapsed >= sliceBudgetMs) {
break;
}
}
// Compact the queue if we've advanced far into it.
if (
this.pendingStart > 0 &&
(this.pendingStart > 1024 ||
this.pendingStart >= this.pendingUpdates.length / 2)
) {
this.pendingUpdates = this.pendingUpdates.slice(this.pendingStart);
this.pendingStart = 0;
}
// Only update view and render when ALL processing is complete
if (
this.pendingStart >= this.pendingUpdates.length &&
batch.length > 0 &&
lastTick !== undefined
) {
const { gameUpdate: combinedGu, tileMetrics } =
this.mergeGameUpdates(batch);
if (combinedGu) {
this.gameView.update(combinedGu);
}
const ticksPerRender =
this.lastRenderedTick === 0
? lastTick
: lastTick - this.lastRenderedTick;
this.lastRenderedTick = lastTick;
this.renderTicksSinceSample++;
let workerTicksPerSecond: number | undefined;
let renderTicksPerSecond: number | undefined;
const now = performance.now();
if (this.metricsSampleStart === 0) {
this.metricsSampleStart = now;
} else {
const elapsedSeconds = (now - this.metricsSampleStart) / 1000;
if (elapsedSeconds > 0) {
workerTicksPerSecond = this.workerTicksSinceSample / elapsedSeconds;
renderTicksPerSecond = this.renderTicksSinceSample / elapsedSeconds;
}
this.metricsSampleStart = now;
this.workerTicksSinceSample = 0;
this.renderTicksSinceSample = 0;
}
this.renderer.tick();
this.eventBus.emit(
new TickMetricsEvent(
lastTickDuration,
this.currentTickDelay,
this.backlogTurns,
ticksPerRender,
workerTicksPerSecond,
renderTicksPerSecond,
tileMetrics.count,
tileMetrics.utilization,
tileMetrics.overflow,
tileMetrics.drainTime,
),
);
// Reset tick delay for next measurement
this.currentTickDelay = undefined;
}
if (this.pendingStart < this.pendingUpdates.length) {
requestAnimationFrame(processFrame);
} else {
this.isProcessingUpdates = false;
}
};
requestAnimationFrame(processFrame);
}
private mergeGameUpdates(batch: GameUpdateViewData[]): {
gameUpdate: GameUpdateViewData | null;
tileMetrics: {
count: number;
utilization: number;
overflow: number;
drainTime: number;
};
} {
if (batch.length === 0) {
return {
gameUpdate: null,
tileMetrics: {
count: 0,
utilization: 0,
overflow: 0,
drainTime: 0,
},
};
}
const last = batch[batch.length - 1];
const combinedUpdates: GameUpdates = {} as GameUpdates;
// Initialize combinedUpdates with empty arrays for each existing key
for (const key in last.updates) {
const type = Number(key) as GameUpdateType;
combinedUpdates[type] = [];
}
const combinedPackedTileUpdates: bigint[] = [];
for (const gu of batch) {
for (const key in gu.updates) {
const type = Number(key) as GameUpdateType;
// We don't care about the specific update subtype here; just treat it
// as an array we can concatenate.
const updatesForType = gu.updates[type] as unknown as any[];
(combinedUpdates[type] as unknown as any[]).push(...updatesForType);
}
}
let tileMetrics = {
count: 0,
utilization: 0,
overflow: 0,
drainTime: 0,
};
if (this.tileRingViews) {
const MAX_TILE_UPDATES_PER_RENDER = 100000;
const tileRefs: TileRef[] = [];
const drainStart = performance.now();
drainTileUpdates(
this.tileRingViews,
MAX_TILE_UPDATES_PER_RENDER,
tileRefs,
);
const drainTime = performance.now() - drainStart;
// Deduplicate tile refs for this render slice
const uniqueTiles = new Set<TileRef>();
for (const ref of tileRefs) {
uniqueTiles.add(ref);
}
// Calculate ring buffer utilization and overflow using dynamic capacity
const TILE_RING_CAPACITY = this.tileRingViews.capacity;
const utilization = (uniqueTiles.size / TILE_RING_CAPACITY) * 100;
const overflow = Atomics.load(
this.tileRingViews.header,
TILE_RING_HEADER_OVERFLOW,
);
tileMetrics = {
count: uniqueTiles.size,
utilization,
overflow,
drainTime,
};
for (const ref of uniqueTiles) {
if (this.dirtyFlags) {
Atomics.store(this.dirtyFlags, ref, 0);
}
combinedPackedTileUpdates.push(BigInt(ref));
}
} else {
// Non-SAB mode: count tile updates from batch
let totalTileUpdates = 0;
for (const gu of batch) {
totalTileUpdates += gu.packedTileUpdates.length;
}
tileMetrics.count = totalTileUpdates;
}
return {
gameUpdate: {
tick: last.tick,
updates: combinedUpdates,
packedTileUpdates: new BigUint64Array(combinedPackedTileUpdates),
playerNameViewData: last.playerNameViewData,
tickExecutionDuration: last.tickExecutionDuration,
},
tileMetrics,
};
}
private updateBacklogMetrics(processedTick: number) {
this.lastProcessedTick = processedTick;
const previousBacklog = this.backlogTurns;
this.backlogTurns = Math.max(
0,
this.serverTurnHighWater - this.lastProcessedTick,
);
this.backlogGrowing = this.backlogTurns > previousBacklog;
this.eventBus.emit(
new BacklogStatusEvent(this.backlogTurns, this.backlogGrowing),
);
}
private inputEvent(event: MouseUpEvent) {
if (!this.isActive || this.renderer.uiState.ghostStructure !== null) {
return;
+31
View File
@@ -81,6 +81,17 @@ export class RefreshGraphicsEvent implements GameEvent {}
export class TogglePerformanceOverlayEvent implements GameEvent {}
export class ToggleTerritoryWebGLEvent implements GameEvent {}
export class TerritoryWebGLStatusEvent implements GameEvent {
constructor(
public readonly enabled: boolean,
public readonly active: boolean,
public readonly supported: boolean,
public readonly message?: string,
) {}
}
export class ToggleStructureEvent implements GameEvent {
constructor(public readonly structureTypes: UnitType[] | null) {}
}
@@ -129,6 +140,26 @@ export class TickMetricsEvent implements GameEvent {
constructor(
public readonly tickExecutionDuration?: number,
public readonly tickDelay?: number,
// Number of turns the client is behind the server (if known)
public readonly backlogTurns?: number,
// Number of simulation ticks applied since last render
public readonly ticksPerRender?: number,
// Approximate worker simulation ticks per second
public readonly workerTicksPerSecond?: number,
// Approximate render tick() calls per second
public readonly renderTicksPerSecond?: number,
// Tile update metrics
public readonly tileUpdatesCount?: number,
public readonly ringBufferUtilization?: number,
public readonly ringBufferOverflows?: number,
public readonly ringDrainTime?: number,
) {}
}
export class BacklogStatusEvent implements GameEvent {
constructor(
public readonly backlogTurns: number,
public readonly backlogGrowing: boolean,
) {}
}
+52 -2
View File
@@ -2,7 +2,10 @@ import { EventBus } from "../../core/EventBus";
import { GameView } from "../../core/game/GameView";
import { UserSettings } from "../../core/game/UserSettings";
import { GameStartingModal } from "../GameStartingModal";
import { RefreshGraphicsEvent as RedrawGraphicsEvent } from "../InputHandler";
import {
BacklogStatusEvent,
RefreshGraphicsEvent as RedrawGraphicsEvent,
} from "../InputHandler";
import { FrameProfiler } from "./FrameProfiler";
import { TransformHandler } from "./TransformHandler";
import { UIState } from "./UIState";
@@ -37,6 +40,7 @@ import { StructureLayer } from "./layers/StructureLayer";
import { TeamStats } from "./layers/TeamStats";
import { TerrainLayer } from "./layers/TerrainLayer";
import { TerritoryLayer } from "./layers/TerritoryLayer";
import { TerritoryWebGLStatus } from "./layers/TerritoryWebGLStatus";
import { UILayer } from "./layers/UILayer";
import { UnitDisplay } from "./layers/UnitDisplay";
import { UnitLayer } from "./layers/UnitLayer";
@@ -220,6 +224,18 @@ export function createRenderer(
performanceOverlay.eventBus = eventBus;
performanceOverlay.userSettings = userSettings;
let territoryWebGLStatus = document.querySelector(
"territory-webgl-status",
) as TerritoryWebGLStatus;
if (!(territoryWebGLStatus instanceof TerritoryWebGLStatus)) {
territoryWebGLStatus = document.createElement(
"territory-webgl-status",
) as TerritoryWebGLStatus;
document.body.appendChild(territoryWebGLStatus);
}
territoryWebGLStatus.eventBus = eventBus;
territoryWebGLStatus.userSettings = userSettings;
const alertFrame = document.querySelector("alert-frame") as AlertFrame;
if (!(alertFrame instanceof AlertFrame)) {
console.error("alert frame not found");
@@ -237,6 +253,7 @@ 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[] = [
territoryWebGLStatus,
new TerrainLayer(game, transformHandler),
new TerritoryLayer(game, eventBus, transformHandler, userSettings),
new RailroadLayer(game, transformHandler),
@@ -292,6 +309,9 @@ export function createRenderer(
export class GameRenderer {
private context: CanvasRenderingContext2D;
private backlogTurns: number = 0;
private backlogGrowing: boolean = false;
private lastRenderTime: number = 0;
constructor(
private game: GameView,
@@ -309,6 +329,10 @@ export class GameRenderer {
initialize() {
this.eventBus.on(RedrawGraphicsEvent, () => this.redraw());
this.eventBus.on(BacklogStatusEvent, (event: BacklogStatusEvent) => {
this.backlogTurns = event.backlogTurns;
this.backlogGrowing = event.backlogGrowing;
});
this.layers.forEach((l) => l.init?.());
// only append the canvas if it's not already in the document to avoid reparenting side-effects
@@ -348,6 +372,28 @@ export class GameRenderer {
}
renderGame() {
const now = performance.now();
if (this.backlogTurns > 0) {
const BASE_FPS = 60;
const MIN_FPS = 30;
const BACKLOG_MAX_TURNS = 50;
const scale = Math.min(1, this.backlogTurns / BACKLOG_MAX_TURNS);
const targetFps = BASE_FPS - scale * (BASE_FPS - MIN_FPS);
const minFrameInterval = 1000 / targetFps;
if (this.lastRenderTime !== 0) {
const sinceLast = now - this.lastRenderTime;
if (sinceLast < minFrameInterval) {
requestAnimationFrame(() => this.renderGame());
return;
}
}
}
this.lastRenderTime = now;
FrameProfiler.clear();
const start = performance.now();
// Set background
@@ -384,7 +430,11 @@ export class GameRenderer {
const layerStart = FrameProfiler.start();
layer.renderLayer?.(this.context);
FrameProfiler.end(layer.constructor?.name ?? "UnknownLayer", layerStart);
const profileName =
(layer as any).profileName?.() ??
layer.constructor?.name ??
"UnknownLayer";
FrameProfiler.end(profileName, layerStart);
}
handleTransformState(false, isTransformActive); // Ensure context is clean after rendering
this.transformHandler.resetChanged();
+63
View File
@@ -0,0 +1,63 @@
import { UnitType } from "../../core/game/Game";
import { TileRef } from "../../core/game/GameMap";
import { GameView, PlayerView, UnitView } from "../../core/game/GameView";
export interface HoverTargetResolution {
player: PlayerView | null;
unit: UnitView | null;
}
const HOVER_UNIT_TYPES: UnitType[] = [
UnitType.Warship,
UnitType.TradeShip,
UnitType.TransportShip,
];
const HOVER_DISTANCE_PX = 5;
function distSquared(
game: GameView,
tile: TileRef,
coord: { x: number; y: number },
): number {
const dx = game.x(tile) - coord.x;
const dy = game.y(tile) - coord.y;
return dx * dx + dy * dy;
}
export function resolveHoverTarget(
game: GameView,
worldCoord: { x: number; y: number },
): HoverTargetResolution {
if (!game.isValidCoord(worldCoord.x, worldCoord.y)) {
return { player: null, unit: null };
}
const tile = game.ref(worldCoord.x, worldCoord.y);
const owner = game.owner(tile);
if ((owner as any).isPlayer?.()) {
return { player: owner as PlayerView, unit: null };
}
if (game.isLand(tile)) {
return { player: null, unit: null };
}
const units = game
.units(...HOVER_UNIT_TYPES)
.filter(
(u) =>
distSquared(game, u.tile(), worldCoord) <
HOVER_DISTANCE_PX * HOVER_DISTANCE_PX,
)
.sort(
(a, b) =>
distSquared(game, a.tile(), worldCoord) -
distSquared(game, b.tile(), worldCoord),
);
if (units.length > 0) {
return { player: units[0].owner(), unit: units[0] };
}
return { player: null, unit: null };
}
+1
View File
@@ -4,4 +4,5 @@ export interface Layer {
renderLayer?: (context: CanvasRenderingContext2D) => void;
shouldTransform?: () => boolean;
redraw?: () => void;
profileName?: () => string;
}
@@ -229,7 +229,18 @@ export class PerformanceOverlay extends LitElement implements Layer {
this.setVisible(this.userSettings.performanceOverlay());
});
this.eventBus.on(TickMetricsEvent, (event: TickMetricsEvent) => {
this.updateTickMetrics(event.tickExecutionDuration, event.tickDelay);
this.updateTickMetrics(
event.tickExecutionDuration,
event.tickDelay,
event.backlogTurns,
event.ticksPerRender,
event.workerTicksPerSecond,
event.renderTicksPerSecond,
event.tileUpdatesCount,
event.ringBufferUtilization,
event.ringBufferOverflows,
event.ringDrainTime,
);
});
}
@@ -312,6 +323,14 @@ export class PerformanceOverlay extends LitElement implements Layer {
this.layerStats.clear();
this.layerBreakdown = [];
// reset tile metrics
this.tileUpdatesPerRender = 0;
this.tileUpdatesPeak = 0;
this.ringBufferUtilization = 0;
this.ringBufferOverflows = 0;
this.ringDrainTime = 0;
this.totalTilesUpdated = 0;
this.requestUpdate();
};
@@ -418,7 +437,48 @@ export class PerformanceOverlay extends LitElement implements Layer {
this.layerBreakdown = breakdown;
}
updateTickMetrics(tickExecutionDuration?: number, tickDelay?: number) {
@state()
private backlogTurns: number = 0;
@state()
private ticksPerRender: number = 0;
@state()
private workerTicksPerSecond: number = 0;
@state()
private renderTicksPerSecond: number = 0;
@state()
private tileUpdatesPerRender: number = 0;
@state()
private tileUpdatesPeak: number = 0;
@state()
private ringBufferUtilization: number = 0;
@state()
private ringBufferOverflows: number = 0;
@state()
private ringDrainTime: number = 0;
@state()
private totalTilesUpdated: number = 0;
updateTickMetrics(
tickExecutionDuration?: number,
tickDelay?: number,
backlogTurns?: number,
ticksPerRender?: number,
workerTicksPerSecond?: number,
renderTicksPerSecond?: number,
tileUpdatesCount?: number,
ringBufferUtilization?: number,
ringBufferOverflows?: number,
ringDrainTime?: number,
) {
if (!this.isVisible || !this.userSettings.performanceOverlay()) return;
// Update tick execution duration stats
@@ -455,6 +515,42 @@ export class PerformanceOverlay extends LitElement implements Layer {
}
}
if (backlogTurns !== undefined) {
this.backlogTurns = backlogTurns;
}
if (ticksPerRender !== undefined) {
this.ticksPerRender = ticksPerRender;
}
if (workerTicksPerSecond !== undefined) {
this.workerTicksPerSecond = workerTicksPerSecond;
}
if (renderTicksPerSecond !== undefined) {
this.renderTicksPerSecond = renderTicksPerSecond;
}
if (tileUpdatesCount !== undefined) {
this.tileUpdatesPerRender = tileUpdatesCount;
this.tileUpdatesPeak = Math.max(this.tileUpdatesPeak, tileUpdatesCount);
this.totalTilesUpdated += tileUpdatesCount;
}
if (ringBufferUtilization !== undefined) {
this.ringBufferUtilization =
Math.round(ringBufferUtilization * 100) / 100;
}
if (ringBufferOverflows !== undefined && ringBufferOverflows !== 0) {
// Remember that an overflow has occurred at least once this run.
this.ringBufferOverflows = 1;
}
if (ringDrainTime !== undefined) {
this.ringDrainTime = Math.round(ringDrainTime * 100) / 100;
}
this.requestUpdate();
}
@@ -485,6 +581,14 @@ export class PerformanceOverlay extends LitElement implements Layer {
executionSamples: [...this.tickExecutionTimes],
delaySamples: [...this.tickDelayTimes],
},
tiles: {
updatesPerRender: this.tileUpdatesPerRender,
peakUpdates: this.tileUpdatesPeak,
ringBufferUtilization: this.ringBufferUtilization,
ringBufferOverflows: this.ringBufferOverflows,
ringDrainTimeMs: this.ringDrainTime,
totalTilesUpdated: this.totalTilesUpdated,
},
layers: this.layerBreakdown.map((layer) => ({ ...layer })),
};
}
@@ -600,6 +704,37 @@ export class PerformanceOverlay extends LitElement implements Layer {
<span>${this.tickDelayAvg.toFixed(2)}ms</span>
(max: <span>${this.tickDelayMax}ms</span>)
</div>
<div class="performance-line">
Worker ticks/s:
<span>${this.workerTicksPerSecond.toFixed(1)}</span>
</div>
<div class="performance-line">
Render ticks/s:
<span>${this.renderTicksPerSecond.toFixed(1)}</span>
</div>
<div class="performance-line">
Ticks per render:
<span>${this.ticksPerRender}</span>
</div>
<div class="performance-line">
Backlog turns:
<span>${this.backlogTurns}</span>
</div>
<div class="performance-line">
Tile updates/render:
<span>${this.tileUpdatesPerRender}</span>
(peak: <span>${this.tileUpdatesPeak}</span>)
</div>
<div class="performance-line">
Ring buffer:
<span>${this.ringBufferUtilization}%</span>
(${this.totalTilesUpdated} total, ${this.ringBufferOverflows}
overflows)
</div>
<div class="performance-line">
Ring drain time:
<span>${this.ringDrainTime.toFixed(2)}ms</span>
</div>
${this.layerBreakdown.length
? html`<div class="layers-section">
<div class="performance-line">
+14 -39
View File
@@ -15,10 +15,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 { ContextMenuEvent, MouseMoveEvent } from "../../InputHandler";
@@ -28,31 +26,12 @@ import {
renderTroops,
translateText,
} from "../../Utils";
import { resolveHoverTarget } from "../HoverTarget";
import { getFirstPlacePlayer, getPlayerIcons } from "../PlayerIcons";
import { TransformHandler } from "../TransformHandler";
import { Layer } from "./Layer";
import { CloseRadialMenuEvent } from "./RadialMenu";
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 })
@@ -115,27 +94,16 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
return;
}
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;
const target = this.resolveHoverTarget(worldCoord);
if (target.player) {
this.player = target.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 (target.unit) {
this.unit = target.unit;
this.setVisible(true);
}
}
@@ -186,6 +154,13 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
}
}
private resolveHoverTarget(worldCoord: { x: number; y: number }): {
player: PlayerView | null;
unit: UnitView | null;
} {
return resolveHoverTarget(this.game, worldCoord);
}
private displayUnitCount(
player: PlayerView,
type: UnitType,
+214 -196
View File
@@ -3,7 +3,6 @@ import { Colord } from "colord";
import { Theme } from "../../../core/configuration/Config";
import { EventBus } from "../../../core/EventBus";
import {
Cell,
ColoredTeams,
PlayerType,
Team,
@@ -16,19 +15,29 @@ import { UserSettings } from "../../../core/game/UserSettings";
import { PseudoRandom } from "../../../core/PseudoRandom";
import {
AlternateViewEvent,
ContextMenuEvent,
DragEvent,
MouseOverEvent,
TerritoryWebGLStatusEvent,
ToggleTerritoryWebGLEvent,
} from "../../InputHandler";
import { FrameProfiler } from "../FrameProfiler";
import { resolveHoverTarget } from "../HoverTarget";
import { TransformHandler } from "../TransformHandler";
import { Layer } from "./Layer";
import {
CanvasTerritoryRenderer,
TerritoryRendererStrategy,
WebglTerritoryRenderer,
} from "./TerritoryRenderers";
import { TerritoryWebGLRenderer } from "./TerritoryWebGLRenderer";
export class TerritoryLayer implements Layer {
profileName(): string {
return "TerritoryLayer:renderLayer";
}
private userSettings: UserSettings;
private canvas: HTMLCanvasElement;
private context: CanvasRenderingContext2D;
private imageData: ImageData;
private alternativeImageData: ImageData;
private borderAnimTime = 0;
private cachedTerritoryPatternsEnabled: boolean | undefined;
@@ -47,6 +56,7 @@ export class TerritoryLayer implements Layer {
private highlightContext: CanvasRenderingContext2D;
private highlightedTerritory: PlayerView | null = null;
private territoryRenderer: TerritoryRendererStrategy | null = null;
private alternativeView = false;
private lastDragTime = 0;
@@ -57,6 +67,9 @@ export class TerritoryLayer implements Layer {
private lastRefresh = 0;
private lastFocusedPlayer: PlayerView | null = null;
private lastMyPlayerSmallId: number | null = null;
private useWebGL: boolean;
private webglSupported = true;
constructor(
private game: GameView,
@@ -67,6 +80,8 @@ export class TerritoryLayer implements Layer {
this.userSettings = userSettings;
this.theme = game.config().theme();
this.cachedTerritoryPatternsEnabled = undefined;
this.lastMyPlayerSmallId = game.myPlayer()?.smallID() ?? null;
this.useWebGL = this.userSettings.territoryWebGL();
}
shouldTransform(): boolean {
@@ -81,6 +96,7 @@ export class TerritoryLayer implements Layer {
}
tick() {
const tickProfile = FrameProfiler.start();
if (this.game.inSpawnPhase()) {
this.spawnHighlight();
}
@@ -88,6 +104,11 @@ export class TerritoryLayer implements Layer {
this.game.recentlyUpdatedTiles().forEach((t) => this.enqueueTile(t));
const updates = this.game.updatesSinceLastTick();
const unitUpdates = updates !== null ? updates[GameUpdateType.Unit] : [];
const playerUpdates =
updates !== null ? updates[GameUpdateType.Player] : [];
if (playerUpdates.length > 0) {
this.territoryRenderer?.refreshPalette();
}
unitUpdates.forEach((update) => {
if (update.unitType === UnitType.DefensePost) {
// Only update borders if the defense post is not under construction
@@ -153,14 +174,26 @@ export class TerritoryLayer implements Layer {
const focusedPlayer = this.game.focusedPlayer();
if (focusedPlayer !== this.lastFocusedPlayer) {
if (this.lastFocusedPlayer) {
this.paintPlayerBorder(this.lastFocusedPlayer);
}
if (focusedPlayer) {
this.paintPlayerBorder(focusedPlayer);
if (this.territoryRenderer) {
// Force a full repaint so the GPU textures match the new focus context
// (e.g., when jumping to another location during spawn).
this.redraw();
} else {
if (this.lastFocusedPlayer) {
this.paintPlayerBorder(this.lastFocusedPlayer);
}
if (focusedPlayer) {
this.paintPlayerBorder(focusedPlayer);
}
}
this.lastFocusedPlayer = focusedPlayer;
}
const currentMyPlayer = this.game.myPlayer()?.smallID() ?? null;
if (currentMyPlayer !== this.lastMyPlayerSmallId) {
this.redraw();
}
FrameProfiler.end("TerritoryLayer:tick", tickProfile);
}
private spawnHighlight() {
@@ -267,8 +300,19 @@ export class TerritoryLayer implements Layer {
init() {
this.eventBus.on(MouseOverEvent, (e) => this.onMouseOver(e));
this.eventBus.on(ContextMenuEvent, (e) => this.onMouseOver(e));
this.eventBus.on(AlternateViewEvent, (e) => {
this.alternativeView = e.alternateView;
this.territoryRenderer?.setAlternativeView(this.alternativeView);
this.territoryRenderer?.markAllDirty();
this.territoryRenderer?.setHoverHighlightOptions(
this.hoverHighlightOptions(),
);
});
this.eventBus.on(ToggleTerritoryWebGLEvent, () => {
this.userSettings.toggleTerritoryWebGL();
this.useWebGL = this.userSettings.territoryWebGL();
this.redraw();
});
this.eventBus.on(DragEvent, (e) => {
// TODO: consider re-enabling this on mobile or low end devices for smoother dragging.
@@ -283,7 +327,9 @@ export class TerritoryLayer implements Layer {
}
private updateHighlightedTerritory() {
if (!this.alternativeView) {
const supportsHover =
this.alternativeView || this.territoryRenderer !== null;
if (!supportsHover) {
return;
}
@@ -295,12 +341,8 @@ export class TerritoryLayer implements Layer {
this.lastMousePosition.x,
this.lastMousePosition.y,
);
if (!this.game.isValidCoord(cell.x, cell.y)) {
return;
}
const previousTerritory = this.highlightedTerritory;
const territory = this.getTerritoryAtCell(cell);
const territory = resolveHoverTarget(this.game, cell).player;
if (territory) {
this.highlightedTerritory = territory;
@@ -309,58 +351,28 @@ export class TerritoryLayer implements Layer {
}
if (previousTerritory?.id() !== this.highlightedTerritory?.id()) {
const territories: PlayerView[] = [];
if (previousTerritory) {
territories.push(previousTerritory);
if (this.territoryRenderer?.isWebGL()) {
this.territoryRenderer.setHover(
this.highlightedTerritory?.smallID() ?? null,
);
} else {
const territories: PlayerView[] = [];
if (previousTerritory) {
territories.push(previousTerritory);
}
if (this.highlightedTerritory) {
territories.push(this.highlightedTerritory);
}
this.redrawBorder(...territories);
}
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,
);
this.lastMyPlayerSmallId = this.game.myPlayer()?.smallID() ?? null;
this.configureRenderers();
this.territoryRenderer?.redraw();
// Add a second canvas for highlights
this.highlightCanvas = document.createElement("canvas");
@@ -377,6 +389,95 @@ export class TerritoryLayer implements Layer {
});
}
private configureRenderers() {
this.territoryRenderer = null;
if (!this.useWebGL) {
this.territoryRenderer = new CanvasTerritoryRenderer(
this.game,
this.theme,
);
this.territoryRenderer.setAlternativeView(this.alternativeView);
this.territoryRenderer.setHoverHighlightOptions(
this.hoverHighlightOptions(),
);
this.webglSupported = true;
this.emitWebGLStatus(
false,
false,
this.webglSupported,
"WebGL territory layer hidden.",
);
return;
}
const { renderer, reason } = TerritoryWebGLRenderer.create(
this.game,
this.theme,
);
if (renderer) {
const strategy = new WebglTerritoryRenderer(renderer, this.game);
strategy.setAlternativeView(this.alternativeView);
strategy.markAllDirty();
strategy.refreshPalette();
strategy.setHoverHighlightOptions(this.hoverHighlightOptions());
strategy.setHover(this.highlightedTerritory?.smallID() ?? null);
this.territoryRenderer = strategy;
this.webglSupported = true;
this.emitWebGLStatus(true, true, true, undefined);
return;
}
const fallbackReason =
reason ??
"WebGL not available. Using canvas fallback for borders and fill.";
this.territoryRenderer = new CanvasTerritoryRenderer(this.game, this.theme);
this.territoryRenderer.setAlternativeView(this.alternativeView);
this.territoryRenderer.setHoverHighlightOptions(
this.hoverHighlightOptions(),
);
this.webglSupported = false;
this.emitWebGLStatus(true, false, false, fallbackReason);
}
/**
* Central configuration for WebGL border hover styling.
* Keeps main view and alternate view behavior explicit and tweakable.
*/
private hoverHighlightOptions() {
const baseColor = this.theme.spawnHighlightSelfColor();
const rgba = baseColor.rgba;
if (this.alternativeView) {
// Alternate view: borders are the primary visual, so make hover stronger
return {
color: { r: rgba.r, g: rgba.g, b: rgba.b },
strength: 0.8,
pulseStrength: 0.45,
pulseSpeed: Math.PI * 2,
};
}
// Main view: keep highlight noticeable but a bit subtler
return {
color: { r: rgba.r, g: rgba.g, b: rgba.b },
strength: 0.6,
pulseStrength: 0.35,
pulseSpeed: Math.PI * 2,
};
}
private emitWebGLStatus(
enabled: boolean,
active: boolean,
supported: boolean,
message?: string,
) {
this.eventBus.emit(
new TerritoryWebGLStatusEvent(enabled, active, supported, message),
);
}
redrawBorder(...players: PlayerView[]) {
return Promise.all(
players.map(async (player) => {
@@ -388,60 +489,39 @@ export class TerritoryLayer implements Layer {
);
}
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 (
const canRefresh =
now > this.lastDragTime + this.nodrawDragDuration &&
now > this.lastRefresh + this.refreshRate
) {
now > this.lastRefresh + this.refreshRate;
if (canRefresh) {
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);
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 (this.territoryRenderer) {
this.territoryRenderer.render(
context,
{
x: vx0,
y: vy0,
width: w,
height: h,
},
canRefresh,
);
}
if (this.game.inSpawnPhase()) {
const highlightDrawStart = FrameProfiler.start();
context.drawImage(
@@ -459,11 +539,21 @@ export class TerritoryLayer implements Layer {
}
renderTerritory() {
if (!this.territoryRenderer) {
return;
}
let numToRender = Math.floor(this.tileToRenderQueue.size() / 10);
if (numToRender === 0 || this.game.inSpawnPhase()) {
if (
numToRender === 0 ||
this.game.inSpawnPhase() ||
this.territoryRenderer.isWebGL()
) {
numToRender = this.tileToRenderQueue.size();
}
const useNeighborPaint = !(this.territoryRenderer?.isWebGL() ?? false);
const neighborsToPaint: TileRef[] = [];
const mainSpan = FrameProfiler.start();
while (numToRender > 0) {
numToRender--;
@@ -474,105 +564,33 @@ export class TerritoryLayer implements Layer {
const tile = entry.tile;
this.paintTerritory(tile);
for (const neighbor of this.game.neighbors(tile)) {
this.paintTerritory(neighbor, true);
if (useNeighborPaint) {
for (const neighbor of this.game.neighbors(tile)) {
neighborsToPaint.push(neighbor);
}
}
}
}
FrameProfiler.end("TerritoryLayer:renderTerritory.mainPaint", mainSpan);
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;
if (useNeighborPaint && neighborsToPaint.length > 0) {
const neighborSpan = FrameProfiler.start();
for (const neighbor of neighborsToPaint) {
this.paintTerritory(neighbor, true); //this is a misuse of the _Border parameter, making it a maybe stale border
}
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(),
FrameProfiler.end(
"TerritoryLayer:renderTerritory.neighborPaint",
neighborSpan,
);
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;
paintTerritory(tile: TileRef, _maybeStaleBorder: boolean = false) {
this.territoryRenderer?.paintTile(tile);
}
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)
this.territoryRenderer?.clearTile(tile);
}
enqueueTile(tile: TileRef) {
@@ -0,0 +1,342 @@
import { Colord } from "colord";
import { Theme } from "../../../core/configuration/Config";
import { UnitType } from "../../../core/game/Game";
import { TileRef } from "../../../core/game/GameMap";
import { GameView, PlayerView } from "../../../core/game/GameView";
import { FrameProfiler } from "../FrameProfiler";
import {
HoverHighlightOptions,
TerritoryWebGLRenderer,
} from "./TerritoryWebGLRenderer";
export interface TerritoryRendererStrategy {
isWebGL(): boolean;
redraw(): void;
markAllDirty(): void;
paintTile(tile: TileRef): void;
render(
context: CanvasRenderingContext2D,
viewport: { x: number; y: number; width: number; height: number },
shouldBlit: boolean,
): void;
setAlternativeView(enabled: boolean): void;
setHover(playerSmallId: number | null): void;
setHoverHighlightOptions(options: HoverHighlightOptions): void;
refreshPalette(): void;
clearTile(tile: TileRef): void;
}
export class CanvasTerritoryRenderer implements TerritoryRendererStrategy {
private canvas: HTMLCanvasElement;
private context: CanvasRenderingContext2D;
private imageData: ImageData;
private alternativeImageData: ImageData;
private alternativeView = false;
constructor(
private readonly game: GameView,
private readonly theme: Theme,
) {
this.canvas = document.createElement("canvas");
const context = this.canvas.getContext("2d");
if (!context) throw new Error("2d context not supported");
this.context = context;
this.imageData = context.createImageData(1, 1);
this.alternativeImageData = context.createImageData(1, 1);
}
isWebGL(): boolean {
return false;
}
redraw() {
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();
}
markAllDirty(): void {
// No special handling needed for canvas path.
}
paintTile(tile: TileRef) {
const cpuStart = FrameProfiler.start();
const hasOwner = this.game.hasOwner(tile);
const rawOwner = hasOwner ? this.game.owner(tile) : null;
const owner =
rawOwner &&
typeof (rawOwner as any).isPlayer === "function" &&
(rawOwner as any).isPlayer()
? (rawOwner as PlayerView)
: null;
const isBorderTile = this.game.isBorder(tile);
const hasFallout = this.game.hasFallout(tile);
let isDefended = false;
if (owner && isBorderTile) {
isDefended = this.game.hasUnitNearby(
tile,
this.game.config().defensePostRange(),
UnitType.DefensePost,
owner.id(),
);
}
if (!owner) {
if (hasFallout) {
this.paintTileColor(
this.imageData,
tile,
this.theme.falloutColor(),
150,
);
this.paintTileColor(
this.alternativeImageData,
tile,
this.theme.falloutColor(),
150,
);
} else {
this.clearTile(tile);
}
FrameProfiler.end("CanvasTerritoryRenderer:paintTile", cpuStart);
return;
}
const myPlayer = this.game.myPlayer();
if (isBorderTile) {
if (myPlayer) {
const alternativeColor = this.alternateViewColor(owner);
this.paintTileColor(
this.alternativeImageData,
tile,
alternativeColor,
255,
);
}
this.paintTileColor(
this.imageData,
tile,
owner.borderColor(tile, isDefended),
255,
);
} else {
// Alternative view only shows borders.
this.clearAlternativeTile(tile);
this.paintTileColor(
this.imageData,
tile,
owner.territoryColor(tile),
150,
);
}
FrameProfiler.end("CanvasTerritoryRenderer:paintTile", cpuStart);
}
render(
context: CanvasRenderingContext2D,
viewport: { x: number; y: number; width: number; height: number },
shouldBlit: boolean,
) {
const { x, y, width, height } = viewport;
if (width <= 0 || height <= 0) {
return;
}
if (shouldBlit) {
const putImageStart = FrameProfiler.start();
this.context.putImageData(
this.alternativeView ? this.alternativeImageData : this.imageData,
0,
0,
x,
y,
width,
height,
);
FrameProfiler.end("CanvasTerritoryRenderer: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("CanvasTerritoryRenderer:drawCanvas", drawCanvasStart);
}
setAlternativeView(enabled: boolean): void {
this.alternativeView = enabled;
}
setHover(): void {
// Canvas path relies on CPU highlight redraw in TerritoryLayer.
}
setHoverHighlightOptions(): void {
// Not used in canvas mode.
}
refreshPalette(): void {
// Nothing to refresh for canvas path.
}
clearTile(tile: TileRef) {
const offset = tile * 4;
this.imageData.data[offset + 3] = 0;
this.alternativeImageData.data[offset + 3] = 0;
}
private 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();
}
private paintTileColor(
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;
}
private clearAlternativeTile(tile: TileRef) {
const offset = tile * 4;
this.alternativeImageData.data[offset + 3] = 0;
}
private initImageData() {
this.game.forEachTile((tile) => {
const offset = tile * 4;
this.imageData.data[offset + 3] = 0;
this.alternativeImageData.data[offset + 3] = 0;
});
}
}
export class WebglTerritoryRenderer implements TerritoryRendererStrategy {
constructor(
private readonly renderer: TerritoryWebGLRenderer,
private readonly game: GameView,
) {}
isWebGL(): boolean {
return true;
}
redraw(): void {
this.markAllDirty();
}
markAllDirty(): void {
this.renderer.markAllDirty();
}
paintTile(tile: TileRef): void {
const hasOwner = this.game.hasOwner(tile);
const rawOwner = hasOwner ? this.game.owner(tile) : null;
const owner =
rawOwner &&
typeof (rawOwner as any).isPlayer === "function" &&
(rawOwner as any).isPlayer()
? (rawOwner as PlayerView)
: null;
const isBorderTile = this.game.isBorder(tile);
// Update defended and relation state in the shared buffer
if (owner && isBorderTile) {
const isDefended = this.game.hasUnitNearby(
tile,
this.game.config().defensePostRange(),
UnitType.DefensePost,
owner.id(),
);
const { hasEmbargo, hasFriendly } = owner.borderRelationFlags(tile);
let relation = 0; // neutral
if (hasFriendly) {
relation = 1; // friendly
} else if (hasEmbargo) {
relation = 2; // embargo
}
this.game.setDefended(tile, isDefended);
this.game.setRelation(tile, relation);
} else {
// Clear defended/relation state for non-border tiles
this.game.setDefended(tile, false);
this.game.setRelation(tile, 0);
}
this.renderer.markTile(tile);
}
render(
context: CanvasRenderingContext2D,
_viewport: { x: number; y: number; width: number; height: number },
_shouldBlit: boolean,
): void {
const webglRenderStart = FrameProfiler.start();
this.renderer.render();
FrameProfiler.end("WebglTerritoryRenderer:render", webglRenderStart);
const drawCanvasStart = FrameProfiler.start();
context.drawImage(
this.renderer.canvas,
-this.game.width() / 2,
-this.game.height() / 2,
this.game.width(),
this.game.height(),
);
FrameProfiler.end("WebglTerritoryRenderer:drawImage", drawCanvasStart);
}
setAlternativeView(enabled: boolean): void {
this.renderer.setAlternativeView(enabled);
}
setHover(playerSmallId: number | null): void {
this.renderer.setHoveredPlayerId(playerSmallId ?? null);
}
setHoverHighlightOptions(options: HoverHighlightOptions): void {
this.renderer.setHoverHighlightOptions(options);
}
refreshPalette(): void {
this.renderer.refreshPalette();
}
clearTile(): void {
// No-op for WebGL; canvas alpha clearing is not used.
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,176 @@
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { EventBus } from "../../../core/EventBus";
import { UserSettings } from "../../../core/game/UserSettings";
import {
TerritoryWebGLStatusEvent,
ToggleTerritoryWebGLEvent,
} from "../../InputHandler";
import { Layer } from "./Layer";
@customElement("territory-webgl-status")
export class TerritoryWebGLStatus extends LitElement implements Layer {
@property({ attribute: false })
public eventBus!: EventBus;
@property({ attribute: false })
public userSettings!: UserSettings;
@state()
private enabled = true;
@state()
private active = false;
@state()
private supported = true;
@state()
private lastMessage: string | null = null;
static styles = css`
:host {
position: fixed;
bottom: 16px;
right: 16px;
z-index: 9998;
pointer-events: none;
}
.panel {
background: rgba(15, 23, 42, 0.85);
color: white;
border-radius: 8px;
padding: 10px 14px;
min-width: 220px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
font-family:
"Inter",
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
sans-serif;
font-size: 12px;
pointer-events: auto;
display: flex;
flex-direction: column;
gap: 8px;
}
.status-line {
display: flex;
flex-direction: column;
gap: 2px;
}
.label {
text-transform: uppercase;
font-size: 10px;
letter-spacing: 0.08em;
opacity: 0.7;
}
.value {
font-weight: 600;
}
.status-active {
color: #4ade80;
}
.status-fallback {
color: #fbbf24;
}
.status-disabled {
color: #f87171;
}
.message {
font-size: 11px;
line-height: 1.3;
opacity: 0.85;
}
.actions {
display: flex;
justify-content: flex-end;
}
button {
background: #1e293b;
border: 1px solid rgba(255, 255, 255, 0.1);
color: white;
font-size: 11px;
border-radius: 4px;
padding: 4px 10px;
cursor: pointer;
}
button:hover {
background: #334155;
}
`;
init() {
this.enabled = this.userSettings?.territoryWebGL() ?? true;
if (this.eventBus) {
this.eventBus.on(TerritoryWebGLStatusEvent, (event) => {
this.enabled = event.enabled;
this.active = event.active;
this.supported = event.supported;
this.lastMessage = event.message ?? null;
this.requestUpdate();
});
}
}
shouldTransform(): boolean {
return false;
}
private handleToggle() {
if (!this.eventBus) return;
this.eventBus.emit(new ToggleTerritoryWebGLEvent());
}
private statusClass(): string {
if (!this.enabled) return "status-disabled";
if (this.enabled && this.active) return "status-active";
if (!this.supported) return "status-disabled";
return "status-fallback";
}
private statusText(): string {
if (!this.enabled) {
return "WebGL borders hidden";
}
if (!this.supported) {
return "WebGL unsupported (fallback)";
}
if (this.active) {
return "WebGL borders active";
}
return "WebGL enabled (fallback)";
}
render() {
return html`
<div class="panel">
<div class="status-line">
<span class="label">Territory Renderer</span>
<span class="value ${this.statusClass()}">${this.statusText()}</span>
</div>
${this.lastMessage
? html`<div class="message">${this.lastMessage}</div>`
: html``}
<div class="actions">
<button type="button" @click=${this.handleToggle}>
${this.enabled ? "Hide WebGL layer" : "Show WebGL layer"}
</button>
</div>
</div>
`;
}
}
+24 -3
View File
@@ -37,12 +37,15 @@ export async function createGameRunner(
clientID: ClientID,
mapLoader: GameMapLoader,
callBack: (gu: GameUpdateViewData | ErrorUpdate) => void,
tileUpdateSink?: (tile: TileRef) => void,
sharedStateBuffer?: SharedArrayBuffer,
): Promise<GameRunner> {
const config = await getConfig(gameStart.config, null);
const gameMap = await loadGameMap(
gameStart.config.gameMap,
gameStart.config.gameMapSize,
mapLoader,
sharedStateBuffer,
);
const random = new PseudoRandom(simpleHash(gameStart.gameID));
@@ -85,6 +88,7 @@ export async function createGameRunner(
game,
new Executor(game, gameStart.gameID, clientID),
callBack,
tileUpdateSink,
);
gr.init();
return gr;
@@ -101,6 +105,7 @@ export class GameRunner {
public game: Game,
private execManager: Executor,
private callBack: (gu: GameUpdateViewData | ErrorUpdate) => void,
private tileUpdateSink?: (tile: TileRef) => void,
) {}
init() {
@@ -175,13 +180,25 @@ export class GameRunner {
});
}
// Many tiles are updated to pack it into an array
const packedTileUpdates = updates[GameUpdateType.Tile].map((u) => u.update);
// Many tiles are updated; either publish them via a shared sink or pack
// them into the view data.
let packedTileUpdates: BigUint64Array;
const tileUpdates = updates[GameUpdateType.Tile];
if (this.tileUpdateSink !== undefined) {
for (const u of tileUpdates) {
const tileRef = Number(u.update >> 16n) as TileRef;
this.tileUpdateSink(tileRef);
}
packedTileUpdates = new BigUint64Array();
} else {
const raw = tileUpdates.map((u) => u.update);
packedTileUpdates = new BigUint64Array(raw);
}
updates[GameUpdateType.Tile] = [];
this.callBack({
tick: this.game.ticks(),
packedTileUpdates: new BigUint64Array(packedTileUpdates),
packedTileUpdates,
updates: updates,
playerNameViewData: this.playerViewData,
tickExecutionDuration: tickExecutionDuration,
@@ -272,4 +289,8 @@ export class GameRunner {
}
return player.bestTransportShipSpawn(targetTile);
}
public hasPendingTurns(): boolean {
return this.currTurn < this.turns.length;
}
}
+16
View File
@@ -852,6 +852,22 @@ 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 {
return this._map.setDefended(ref, value);
}
getRelation(ref: TileRef): number {
return this._map.getRelation(ref);
}
setRelation(ref: TileRef, relation: number): void {
return this._map.setRelation(ref, relation);
}
isBorder(ref: TileRef): boolean {
return this._map.isBorder(ref);
}
+50 -15
View File
@@ -27,6 +27,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;
getRelation(ref: TileRef): number;
setRelation(ref: TileRef, relation: number): void;
isOnEdgeOfMap(ref: TileRef): boolean;
isBorder(ref: TileRef): boolean;
neighbors(ref: TileRef): TileRef[];
@@ -72,14 +76,20 @@ export class GameMapImpl implements GameMap {
// State bits (Uint16Array)
private static readonly PLAYER_ID_MASK = 0xfff;
private static readonly FALLOUT_BIT = 13;
private static readonly DEFENSE_BONUS_BIT = 14;
// Bit 15 still reserved
private static readonly DEFENDED_BIT = 12;
private static readonly RELATION_MASK = 0xc000; // bits 14-15
private static readonly RELATION_SHIFT = 14;
// Relation values (stored in bits 14-15)
private static readonly RELATION_NEUTRAL = 0;
private static readonly RELATION_FRIENDLY = 1;
private static readonly RELATION_EMBARGO = 2;
constructor(
width: number,
height: number,
terrainData: Uint8Array,
private numLandTiles_: number,
stateBuffer?: ArrayBufferLike,
) {
if (terrainData.length !== width * height) {
throw new Error(
@@ -89,7 +99,17 @@ export class GameMapImpl implements GameMap {
this.width_ = width;
this.height_ = height;
this.terrain = terrainData;
this.state = new Uint16Array(width * height);
if (stateBuffer !== undefined) {
const state = new Uint16Array(stateBuffer);
if (state.length !== width * height) {
throw new Error(
`State buffer length ${state.length} doesn't match dimensions ${width}x${height}`,
);
}
this.state = state;
} else {
this.state = new Uint16Array(width * height);
}
// Precompute the LUTs
let ref = 0;
this.refToX = new Array(width * height);
@@ -206,6 +226,33 @@ 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);
}
}
getRelation(ref: TileRef): number {
return (
(this.state[ref] & GameMapImpl.RELATION_MASK) >>
GameMapImpl.RELATION_SHIFT
);
}
setRelation(ref: TileRef, relation: number): void {
// Clear existing relation bits
this.state[ref] &= ~GameMapImpl.RELATION_MASK;
// Set new relation bits
this.state[ref] |=
(relation << GameMapImpl.RELATION_SHIFT) & GameMapImpl.RELATION_MASK;
}
isOnEdgeOfMap(ref: TileRef): boolean {
const x = this.x(ref);
const y = this.y(ref);
@@ -220,18 +267,6 @@ export class GameMapImpl implements GameMap {
);
}
hasDefenseBonus(ref: TileRef): boolean {
return Boolean(this.state[ref] & (1 << GameMapImpl.DEFENSE_BONUS_BIT));
}
setDefenseBonus(ref: TileRef, value: boolean): void {
if (value) {
this.state[ref] |= 1 << GameMapImpl.DEFENSE_BONUS_BIT;
} else {
this.state[ref] &= ~(1 << GameMapImpl.DEFENSE_BONUS_BIT);
}
}
// Helper methods
isWater(ref: TileRef): boolean {
return !this.isLand(ref);
+166 -14
View File
@@ -41,6 +41,10 @@ import { UserSettings } from "./UserSettings";
const userSettings: UserSettings = new UserSettings();
const FRIENDLY_TINT_TARGET = { r: 0, g: 255, b: 0, a: 1 };
const EMBARGO_TINT_TARGET = { r: 255, g: 0, b: 0, a: 1 };
const BORDER_TINT_RATIO = 0.35;
export class UnitView {
public _wasUpdated = true;
public lastPos: TileRef[] = [];
@@ -184,9 +188,17 @@ export class PlayerView {
private _territoryColor: Colord;
private _borderColor: Colord;
// Update here to include structure light and dark colors
private _structureColors: { light: Colord; dark: Colord };
private _defendedBorderColors: { light: Colord; dark: Colord };
// Pre-computed border color variants
private _borderColorNeutral: Colord;
private _borderColorFriendly: Colord;
private _borderColorEmbargo: Colord;
private _borderColorDefendedNeutral: { light: Colord; dark: Colord };
private _borderColorDefendedFriendly: { light: Colord; dark: Colord };
private _borderColorDefendedEmbargo: { light: Colord; dark: Colord };
constructor(
private game: GameView,
@@ -248,11 +260,56 @@ export class PlayerView {
this.cosmetics.color?.color ??
maybeFocusedBorderColor.toHex(),
);
const theme = this.game.config().theme();
const baseRgb = this._borderColor.toRgb();
this._defendedBorderColors = this.game
.config()
.theme()
.defendedBorderColors(this._borderColor);
// Neutral is just the base color
this._borderColorNeutral = this._borderColor;
// Compute friendly tint
this._borderColorFriendly = colord({
r: Math.round(
baseRgb.r * (1 - BORDER_TINT_RATIO) +
FRIENDLY_TINT_TARGET.r * BORDER_TINT_RATIO,
),
g: Math.round(
baseRgb.g * (1 - BORDER_TINT_RATIO) +
FRIENDLY_TINT_TARGET.g * BORDER_TINT_RATIO,
),
b: Math.round(
baseRgb.b * (1 - BORDER_TINT_RATIO) +
FRIENDLY_TINT_TARGET.b * BORDER_TINT_RATIO,
),
a: baseRgb.a,
});
// Compute embargo tint
this._borderColorEmbargo = colord({
r: Math.round(
baseRgb.r * (1 - BORDER_TINT_RATIO) +
EMBARGO_TINT_TARGET.r * BORDER_TINT_RATIO,
),
g: Math.round(
baseRgb.g * (1 - BORDER_TINT_RATIO) +
EMBARGO_TINT_TARGET.g * BORDER_TINT_RATIO,
),
b: Math.round(
baseRgb.b * (1 - BORDER_TINT_RATIO) +
EMBARGO_TINT_TARGET.b * BORDER_TINT_RATIO,
),
a: baseRgb.a,
});
// Pre-compute defended variants
this._borderColorDefendedNeutral = theme.defendedBorderColors(
this._borderColorNeutral,
);
this._borderColorDefendedFriendly = theme.defendedBorderColors(
this._borderColorFriendly,
);
this._borderColorDefendedEmbargo = theme.defendedBorderColors(
this._borderColorEmbargo,
);
this.decoder =
pattern === undefined
@@ -275,18 +332,74 @@ export class PlayerView {
return this._structureColors;
}
/**
* Border color for a tile:
* - Tints by neighbor relations (embargo → red, friendly → green, else neutral).
* - If defended, applies theme checkerboard to the tinted color.
*/
borderColor(tile?: TileRef, isDefended: boolean = false): Colord {
if (tile === undefined || !isDefended) {
if (tile === undefined) {
return this._borderColor;
}
const { hasEmbargo, hasFriendly } = this.borderRelationFlags(tile);
let baseColor: Colord;
let defendedColors: { light: Colord; dark: Colord };
if (hasEmbargo) {
baseColor = this._borderColorEmbargo;
defendedColors = this._borderColorDefendedEmbargo;
} else if (hasFriendly) {
baseColor = this._borderColorFriendly;
defendedColors = this._borderColorDefendedFriendly;
} else {
baseColor = this._borderColorNeutral;
defendedColors = this._borderColorDefendedNeutral;
}
if (!isDefended) {
return baseColor;
}
const x = this.game.x(tile);
const y = this.game.y(tile);
const lightTile =
(x % 2 === 0 && y % 2 === 0) || (y % 2 === 1 && x % 2 === 1);
return lightTile
? this._defendedBorderColors.light
: this._defendedBorderColors.dark;
return lightTile ? defendedColors.light : defendedColors.dark;
}
/**
* Border relation flags for a tile, used by both CPU and WebGL renderers.
*/
borderRelationFlags(tile: TileRef): {
hasEmbargo: boolean;
hasFriendly: boolean;
} {
const mySmallID = this.smallID();
let hasEmbargo = false;
let hasFriendly = false;
for (const n of this.game.neighbors(tile)) {
if (!this.game.hasOwner(n)) {
continue;
}
const otherOwner = this.game.owner(n);
if (!otherOwner.isPlayer() || otherOwner.smallID() === mySmallID) {
continue;
}
if (this.hasEmbargo(otherOwner)) {
hasEmbargo = true;
break;
}
if (this.isFriendly(otherOwner) || otherOwner.isFriendly(this)) {
hasFriendly = true;
}
}
return { hasEmbargo, hasFriendly };
}
async actions(tile?: TileRef): Promise<PlayerActions> {
@@ -474,6 +587,8 @@ export class GameView implements GameMap {
private _cosmetics: Map<string, PlayerCosmetics> = new Map();
private _map: GameMap;
private readonly usesSharedTileState: boolean;
private readonly terraNullius = new TerraNulliusImpl();
constructor(
public worker: WorkerClient,
@@ -482,8 +597,10 @@ export class GameView implements GameMap {
private _myClientID: ClientID,
private _gameID: GameID,
private humans: Player[],
usesSharedTileState: boolean = false,
) {
this._map = this._mapData.gameMap;
this.usesSharedTileState = usesSharedTileState;
this.lastUpdate = null;
this.unitGrid = new UnitGrid(this._map);
this._cosmetics = new Map(
@@ -512,9 +629,16 @@ export class GameView implements GameMap {
this.lastUpdate = gu;
this.updatedTiles = [];
this.lastUpdate.packedTileUpdates.forEach((tu) => {
this.updatedTiles.push(this.updateTile(tu));
});
if (this.usesSharedTileState) {
this.lastUpdate.packedTileUpdates.forEach((tu) => {
const tileRef = Number(tu);
this.updatedTiles.push(tileRef);
});
} else {
this.lastUpdate.packedTileUpdates.forEach((tu) => {
this.updatedTiles.push(this.updateTile(tu));
});
}
if (gu.updates === null) {
throw new Error("lastUpdate.updates not initialized");
@@ -620,11 +744,11 @@ export class GameView implements GameMap {
playerBySmallID(id: number): PlayerView | TerraNullius {
if (id === 0) {
return new TerraNulliusImpl();
return this.terraNullius;
}
const playerId = this.smallIDToID.get(id);
if (playerId === undefined) {
throw new Error(`small id ${id} not found`);
return this.terraNullius;
}
return this.player(playerId);
}
@@ -732,6 +856,22 @@ 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);
}
getRelation(ref: TileRef): number {
return this._map.getRelation(ref);
}
setRelation(ref: TileRef, relation: number): void {
return this._map.setRelation(ref, relation);
}
isBorder(ref: TileRef): boolean {
return this._map.isBorder(ref);
}
@@ -781,6 +921,18 @@ export class GameView implements GameMap {
return this._gameID;
}
hasSharedTileState(): boolean {
return this.usesSharedTileState;
}
sharedStateBuffer(): SharedArrayBuffer | undefined {
if (!this.usesSharedTileState) {
return undefined;
}
const buffer = this._mapData.sharedStateBuffer;
return buffer instanceof SharedArrayBuffer ? buffer : undefined;
}
focusedPlayer(): PlayerView | null {
return this.myPlayer();
}
+45 -5
View File
@@ -6,6 +6,8 @@ export type TerrainMapData = {
nations: Nation[];
gameMap: GameMap;
miniGameMap: GameMap;
sharedStateBuffer?: SharedArrayBuffer;
sharedDirtyBuffer?: SharedArrayBuffer;
};
const loadedMaps = new Map<GameMapType, TerrainMapData>();
@@ -35,15 +37,42 @@ export async function loadTerrainMap(
map: GameMapType,
mapSize: GameMapSize,
terrainMapFileLoader: GameMapLoader,
sharedStateBuffer?: SharedArrayBuffer,
): Promise<TerrainMapData> {
const cached = loadedMaps.get(map);
if (cached !== undefined) return cached;
const useCache = sharedStateBuffer === undefined;
const canUseSharedBuffers =
typeof SharedArrayBuffer !== "undefined" &&
typeof Atomics !== "undefined" &&
typeof (globalThis as any).crossOriginIsolated === "boolean" &&
(globalThis as any).crossOriginIsolated === true;
// Don't use cache if we can create SharedArrayBuffer but none was provided
const shouldUseCache = useCache && !canUseSharedBuffers;
if (shouldUseCache) {
const cached = loadedMaps.get(map);
if (cached !== undefined) return cached;
}
const mapFiles = terrainMapFileLoader.getMapData(map);
const manifest = await mapFiles.manifest();
const stateBuffer =
sharedStateBuffer ??
(canUseSharedBuffers
? new SharedArrayBuffer(
manifest.map.width *
manifest.map.height *
Uint16Array.BYTES_PER_ELEMENT,
)
: undefined);
const gameMap =
mapSize === GameMapSize.Normal
? await genTerrainFromBin(manifest.map, await mapFiles.mapBin())
? await genTerrainFromBin(
manifest.map,
await mapFiles.mapBin(),
stateBuffer,
)
: await genTerrainFromBin(manifest.map4x, await mapFiles.map4xBin());
const miniMap =
@@ -63,18 +92,28 @@ export async function loadTerrainMap(
});
}
const result = {
const result: TerrainMapData = {
nations: manifest.nations,
gameMap: gameMap,
miniGameMap: miniMap,
sharedStateBuffer:
typeof SharedArrayBuffer !== "undefined" &&
stateBuffer instanceof SharedArrayBuffer
? stateBuffer
: undefined,
sharedDirtyBuffer: undefined, // populated by consumer when needed
};
loadedMaps.set(map, result);
// Only cache the result when caching is actually used (non-SAB path)
if (shouldUseCache) {
loadedMaps.set(map, result);
}
return result;
}
export async function genTerrainFromBin(
mapData: MapMetadata,
data: Uint8Array,
stateBuffer?: ArrayBufferLike,
): Promise<GameMap> {
if (data.length !== mapData.width * mapData.height) {
throw new Error(
@@ -87,5 +126,6 @@ export async function genTerrainFromBin(
mapData.height,
data,
mapData.num_land_tiles,
stateBuffer,
);
}
+8
View File
@@ -61,6 +61,10 @@ export class UserSettings {
return this.get("settings.structureSprites", true);
}
territoryWebGL() {
return this.get("settings.territoryWebGL", true);
}
darkMode() {
return this.get("settings.darkMode", false);
}
@@ -115,6 +119,10 @@ export class UserSettings {
this.set("settings.structureSprites", !this.structureSprites());
}
toggleTerritoryWebGL() {
this.set("settings.territoryWebGL", !this.territoryWebGL());
}
toggleTerritoryPatterns() {
this.set("settings.territoryPatterns", !this.territoryPatterns());
}
+85
View File
@@ -0,0 +1,85 @@
import { TileRef } from "../game/GameMap";
export interface SharedTileRingBuffers {
header: SharedArrayBuffer;
data: SharedArrayBuffer;
dirty: SharedArrayBuffer;
}
export interface SharedTileRingViews {
header: Int32Array;
buffer: Uint32Array;
dirtyFlags: Uint8Array;
capacity: number;
}
// Header indices
export const TILE_RING_HEADER_WRITE_INDEX = 0;
export const TILE_RING_HEADER_READ_INDEX = 1;
export const TILE_RING_HEADER_OVERFLOW = 2;
export function createSharedTileRingBuffers(
capacity: number,
numTiles: number,
): SharedTileRingBuffers {
const header = new SharedArrayBuffer(3 * Int32Array.BYTES_PER_ELEMENT);
const data = new SharedArrayBuffer(capacity * Uint32Array.BYTES_PER_ELEMENT);
const dirty = new SharedArrayBuffer(numTiles * Uint8Array.BYTES_PER_ELEMENT);
return { header, data, dirty };
}
export function createSharedTileRingViews(
buffers: SharedTileRingBuffers,
): SharedTileRingViews {
const header = new Int32Array(buffers.header);
const buffer = new Uint32Array(buffers.data);
const dirtyFlags = new Uint8Array(buffers.dirty);
return {
header,
buffer,
dirtyFlags,
capacity: buffer.length,
};
}
export function pushTileUpdate(
views: SharedTileRingViews,
value: TileRef,
): void {
const { header, buffer, capacity } = views;
const write = Atomics.load(header, TILE_RING_HEADER_WRITE_INDEX);
const read = Atomics.load(header, TILE_RING_HEADER_READ_INDEX);
const nextWrite = (write + 1) % capacity;
// If the buffer is full, advance read (drop oldest) and mark overflow.
if (nextWrite === read) {
Atomics.store(header, TILE_RING_HEADER_OVERFLOW, 1);
const nextRead = (read + 1) % capacity;
Atomics.store(header, TILE_RING_HEADER_READ_INDEX, nextRead);
}
buffer[write] = value;
Atomics.store(header, TILE_RING_HEADER_WRITE_INDEX, nextWrite);
}
export function drainTileUpdates(
views: SharedTileRingViews,
maxItems: number,
out: TileRef[],
): void {
const { header, buffer, capacity } = views;
let read = Atomics.load(header, TILE_RING_HEADER_READ_INDEX);
const write = Atomics.load(header, TILE_RING_HEADER_WRITE_INDEX);
let count = 0;
while (read !== write && count < maxItems) {
out.push(buffer[read]);
read = (read + 1) % capacity;
count++;
}
Atomics.store(header, TILE_RING_HEADER_READ_INDEX, read);
}
+56 -3
View File
@@ -1,7 +1,13 @@
import version from "../../../resources/version.txt";
import { createGameRunner, GameRunner } from "../GameRunner";
import { FetchGameMapLoader } from "../game/FetchGameMapLoader";
import { TileRef } from "../game/GameMap";
import { ErrorUpdate, GameUpdateViewData } from "../game/GameUpdates";
import {
createSharedTileRingViews,
pushTileUpdate,
SharedTileRingViews,
} from "./SharedTileRing";
import {
AttackAveragePositionResultMessage,
InitializedMessage,
@@ -16,6 +22,9 @@ import {
const ctx: Worker = self as any;
let gameRunner: Promise<GameRunner> | null = null;
const mapLoader = new FetchGameMapLoader(`/maps`, version);
let isProcessingTurns = false;
let sharedTileRing: SharedTileRingViews | null = null;
let dirtyFlags: Uint8Array | null = null;
function gameUpdate(gu: GameUpdateViewData | ErrorUpdate) {
// skip if ErrorUpdate
@@ -32,25 +41,68 @@ function sendMessage(message: WorkerMessage) {
ctx.postMessage(message);
}
async function processPendingTurns() {
if (isProcessingTurns) {
return;
}
if (!gameRunner) {
return;
}
const gr = await gameRunner;
if (!gr || !gr.hasPendingTurns()) {
return;
}
isProcessingTurns = true;
try {
while (gr.hasPendingTurns()) {
gr.executeNextTick();
}
} finally {
isProcessingTurns = false;
}
}
ctx.addEventListener("message", async (e: MessageEvent<MainThreadMessage>) => {
const message = e.data;
switch (message.type) {
case "heartbeat":
(await gameRunner)?.executeNextTick();
break;
case "init":
try {
if (message.sharedTileRingHeader && message.sharedTileRingData) {
sharedTileRing = createSharedTileRingViews({
header: message.sharedTileRingHeader,
data: message.sharedTileRingData,
dirty: message.sharedDirtyBuffer!,
});
dirtyFlags = sharedTileRing.dirtyFlags;
} else {
sharedTileRing = null;
dirtyFlags = null;
}
gameRunner = createGameRunner(
message.gameStartInfo,
message.clientID,
mapLoader,
gameUpdate,
sharedTileRing && dirtyFlags
? (tile: TileRef) => {
if (Atomics.compareExchange(dirtyFlags!, tile, 0, 1) === 0) {
pushTileUpdate(sharedTileRing!, tile);
}
}
: sharedTileRing
? (tile: TileRef) => pushTileUpdate(sharedTileRing!, tile)
: undefined,
message.sharedStateBuffer,
).then((gr) => {
sendMessage({
type: "initialized",
id: message.id,
} as InitializedMessage);
processPendingTurns();
return gr;
});
} catch (error) {
@@ -67,6 +119,7 @@ ctx.addEventListener("message", async (e: MessageEvent<MainThreadMessage>) => {
try {
const gr = await gameRunner;
await gr.addTurn(message.turn);
processPendingTurns();
} catch (error) {
console.error("Failed to process turn:", error);
throw error;
+8 -6
View File
@@ -9,6 +9,7 @@ import { TileRef } from "../game/GameMap";
import { ErrorUpdate, GameUpdateViewData } from "../game/GameUpdates";
import { ClientID, GameStartInfo, Turn } from "../Schemas";
import { generateID } from "../Util";
import { SharedTileRingBuffers } from "./SharedTileRing";
import { WorkerMessage } from "./WorkerMessages";
export class WorkerClient {
@@ -22,6 +23,9 @@ export class WorkerClient {
constructor(
private gameStartInfo: GameStartInfo,
private clientID: ClientID,
private sharedTileRingBuffers?: SharedTileRingBuffers,
private sharedStateBuffer?: SharedArrayBuffer,
private sharedDirtyBuffer?: SharedArrayBuffer,
) {
this.worker = new Worker(new URL("./Worker.worker.ts", import.meta.url));
this.messageHandlers = new Map();
@@ -70,6 +74,10 @@ export class WorkerClient {
id: messageId,
gameStartInfo: this.gameStartInfo,
clientID: this.clientID,
sharedTileRingHeader: this.sharedTileRingBuffers?.header,
sharedTileRingData: this.sharedTileRingBuffers?.data,
sharedStateBuffer: this.sharedStateBuffer,
sharedDirtyBuffer: this.sharedDirtyBuffer,
});
// Add timeout for initialization
@@ -100,12 +108,6 @@ export class WorkerClient {
});
}
sendHeartbeat() {
this.worker.postMessage({
type: "heartbeat",
});
}
playerProfile(playerID: number): Promise<PlayerProfile> {
return new Promise((resolve, reject) => {
if (!this.isInitialized) {
+4 -6
View File
@@ -9,7 +9,6 @@ import { GameUpdateViewData } from "../game/GameUpdates";
import { ClientID, GameStartInfo, Turn } from "../Schemas";
export type WorkerMessageType =
| "heartbeat"
| "init"
| "initialized"
| "turn"
@@ -31,15 +30,15 @@ interface BaseWorkerMessage {
id?: string;
}
export interface HeartbeatMessage extends BaseWorkerMessage {
type: "heartbeat";
}
// Messages from main thread to worker
export interface InitMessage extends BaseWorkerMessage {
type: "init";
gameStartInfo: GameStartInfo;
clientID: ClientID;
sharedTileRingHeader?: SharedArrayBuffer;
sharedTileRingData?: SharedArrayBuffer;
sharedStateBuffer?: SharedArrayBuffer;
sharedDirtyBuffer?: SharedArrayBuffer;
}
export interface TurnMessage extends BaseWorkerMessage {
@@ -114,7 +113,6 @@ export interface TransportShipSpawnResultMessage extends BaseWorkerMessage {
// Union types for type safety
export type MainThreadMessage =
| HeartbeatMessage
| InitMessage
| TurnMessage
| PlayerActionsMessage
+131 -78
View File
@@ -1,6 +1,7 @@
import { execSync } from "child_process";
import CopyPlugin from "copy-webpack-plugin";
import ESLintPlugin from "eslint-webpack-plugin";
import fs from "fs";
import HtmlWebpackPlugin from "html-webpack-plugin";
import path from "path";
import { fileURLToPath } from "url";
@@ -9,11 +10,138 @@ import webpack from "webpack";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const crossOriginHeaders = {
"Cross-Origin-Opener-Policy": "same-origin",
"Cross-Origin-Embedder-Policy": "require-corp",
"Cross-Origin-Resource-Policy": "same-origin",
"Origin-Agent-Cluster": "?1",
};
const devHttpsEnabled =
process.env.DEV_HTTPS === "1" ||
(process.env.DEV_HTTPS ?? "").toLowerCase() === "true";
const devKeyPath =
process.env.DEV_KEY ?? path.resolve(__dirname, "resources/certs/dev.key");
const devCertPath =
process.env.DEV_CERT ?? path.resolve(__dirname, "resources/certs/dev.crt");
const addProxyHeaders = (proxyRes) => {
Object.entries(crossOriginHeaders).forEach(([key, value]) => {
proxyRes.headers[key] = value;
});
};
const buildDevProxyConfig = () =>
[
// WebSocket proxies
{
context: ["/socket"],
target: "ws://localhost:3000",
ws: true,
changeOrigin: true,
logLevel: "debug",
},
// Worker WebSocket proxies - using direct paths without /socket suffix
{
context: ["/w0"],
target: "ws://localhost:3001",
ws: true,
secure: false,
changeOrigin: true,
logLevel: "debug",
},
{
context: ["/w1"],
target: "ws://localhost:3002",
ws: true,
secure: false,
changeOrigin: true,
logLevel: "debug",
},
{
context: ["/w2"],
target: "ws://localhost:3003",
ws: true,
secure: false,
changeOrigin: true,
logLevel: "debug",
},
// Worker proxies for HTTP requests
{
context: ["/w0"],
target: "http://localhost:3001",
pathRewrite: { "^/w0": "" },
secure: false,
changeOrigin: true,
logLevel: "debug",
},
{
context: ["/w1"],
target: "http://localhost:3002",
pathRewrite: { "^/w1": "" },
secure: false,
changeOrigin: true,
logLevel: "debug",
},
{
context: ["/w2"],
target: "http://localhost:3003",
pathRewrite: { "^/w2": "" },
secure: false,
changeOrigin: true,
logLevel: "debug",
},
// Original API endpoints
{
context: [
"/api/env",
"/api/game",
"/api/public_lobbies",
"/api/join_game",
"/api/start_game",
"/api/create_game",
"/api/archive_singleplayer_game",
"/api/auth/callback",
"/api/auth/discord",
"/api/kick_player",
],
target: "http://localhost:3000",
secure: false,
changeOrigin: true,
},
].map((proxyEntry) => ({
onProxyRes: addProxyHeaders,
...proxyEntry,
}));
const getHttpsServerConfig = () => {
if (!devHttpsEnabled) return undefined;
try {
return {
type: "https",
options: {
key: fs.readFileSync(devKeyPath),
cert: fs.readFileSync(devCertPath),
},
};
} catch (error) {
console.error(
`DEV_HTTPS enabled but could not read cert/key at ${devCertPath} / ${devKeyPath}`,
error,
);
throw error;
}
};
const gitCommit =
process.env.GIT_COMMIT ?? execSync("git rev-parse HEAD").toString().trim();
export default async (env, argv) => {
const isProduction = argv.mode === "production";
const serverConfig = isProduction ? undefined : getHttpsServerConfig();
const proxyConfig = isProduction ? [] : buildDevProxyConfig();
return {
entry: "./src/client/Main.ts",
@@ -173,6 +301,8 @@ export default async (env, argv) => {
devServer: isProduction
? {}
: {
server: serverConfig,
headers: crossOriginHeaders,
devMiddleware: { writeToDisk: true },
static: {
directory: path.join(__dirname, "static"),
@@ -180,84 +310,7 @@ export default async (env, argv) => {
historyApiFallback: true,
compress: true,
port: 9000,
proxy: [
// WebSocket proxies
{
context: ["/socket"],
target: "ws://localhost:3000",
ws: true,
changeOrigin: true,
logLevel: "debug",
},
// Worker WebSocket proxies - using direct paths without /socket suffix
{
context: ["/w0"],
target: "ws://localhost:3001",
ws: true,
secure: false,
changeOrigin: true,
logLevel: "debug",
},
{
context: ["/w1"],
target: "ws://localhost:3002",
ws: true,
secure: false,
changeOrigin: true,
logLevel: "debug",
},
{
context: ["/w2"],
target: "ws://localhost:3003",
ws: true,
secure: false,
changeOrigin: true,
logLevel: "debug",
},
// Worker proxies for HTTP requests
{
context: ["/w0"],
target: "http://localhost:3001",
pathRewrite: { "^/w0": "" },
secure: false,
changeOrigin: true,
logLevel: "debug",
},
{
context: ["/w1"],
target: "http://localhost:3002",
pathRewrite: { "^/w1": "" },
secure: false,
changeOrigin: true,
logLevel: "debug",
},
{
context: ["/w2"],
target: "http://localhost:3003",
pathRewrite: { "^/w2": "" },
secure: false,
changeOrigin: true,
logLevel: "debug",
},
// Original API endpoints
{
context: [
"/api/env",
"/api/game",
"/api/public_lobbies",
"/api/join_game",
"/api/start_game",
"/api/create_game",
"/api/archive_singleplayer_game",
"/api/auth/callback",
"/api/auth/discord",
"/api/kick_player",
],
target: "http://localhost:3000",
secure: false,
changeOrigin: true,
},
],
proxy: proxyConfig,
},
};
};