mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-22 10:58:10 +00:00
quick push
This commit is contained in:
@@ -6,6 +6,7 @@ coverage/
|
||||
TODO.txt
|
||||
resources/images/.DS_Store
|
||||
resources/.DS_Store
|
||||
resources/certs/
|
||||
.env*
|
||||
.DS_Store
|
||||
.clinic/
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
) {}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user