mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 15:20:43 +00:00
this isnt getting good soon
This commit is contained in:
@@ -538,11 +538,19 @@ export class ClientGameRunner {
|
||||
const tile = this.gameView.ref(cell.x, cell.y);
|
||||
if (
|
||||
this.gameView.isLand(tile) &&
|
||||
!this.gameView.hasOwner(tile) &&
|
||||
this.gameView.inSpawnPhase() &&
|
||||
!this.gameView.config().isRandomSpawn()
|
||||
) {
|
||||
this.eventBus.emit(new SendSpawnIntentEvent(tile));
|
||||
// Main thread no longer maintains authoritative tile ownership. Query the
|
||||
// worker for spawn validation.
|
||||
this.worker
|
||||
.tileContext(tile)
|
||||
.then((ctx) => {
|
||||
if (!ctx.hasOwner) {
|
||||
this.eventBus.emit(new SendSpawnIntentEvent(tile));
|
||||
}
|
||||
})
|
||||
.catch((err) => console.warn("tileContext spawn lookup failed:", err));
|
||||
return;
|
||||
}
|
||||
if (this.gameView.inSpawnPhase()) {
|
||||
@@ -556,12 +564,22 @@ export class ClientGameRunner {
|
||||
this.myPlayer.actions(tile).then((actions) => {
|
||||
if (this.myPlayer === null) return;
|
||||
if (actions.canAttack) {
|
||||
this.eventBus.emit(
|
||||
new SendAttackIntentEvent(
|
||||
this.gameView.owner(tile).id(),
|
||||
this.myPlayer.troops() * this.renderer.uiState.attackRatio,
|
||||
),
|
||||
);
|
||||
this.worker
|
||||
.tileContext(tile)
|
||||
.then((ctx) => {
|
||||
if (!this.myPlayer) {
|
||||
return;
|
||||
}
|
||||
this.eventBus.emit(
|
||||
new SendAttackIntentEvent(
|
||||
ctx.ownerId ?? null,
|
||||
this.myPlayer.troops() * this.renderer.uiState.attackRatio,
|
||||
),
|
||||
);
|
||||
})
|
||||
.catch((err) =>
|
||||
console.warn("tileContext attack lookup failed:", err),
|
||||
);
|
||||
} else if (this.canAutoBoat(actions, tile)) {
|
||||
this.sendBoatAttackIntent(tile);
|
||||
}
|
||||
@@ -672,12 +690,22 @@ export class ClientGameRunner {
|
||||
this.myPlayer.actions(tile).then((actions) => {
|
||||
if (this.myPlayer === null) return;
|
||||
if (actions.canAttack) {
|
||||
this.eventBus.emit(
|
||||
new SendAttackIntentEvent(
|
||||
this.gameView.owner(tile).id(),
|
||||
this.myPlayer.troops() * this.renderer.uiState.attackRatio,
|
||||
),
|
||||
);
|
||||
this.worker
|
||||
.tileContext(tile)
|
||||
.then((ctx) => {
|
||||
if (!this.myPlayer) {
|
||||
return;
|
||||
}
|
||||
this.eventBus.emit(
|
||||
new SendAttackIntentEvent(
|
||||
ctx.ownerId ?? null,
|
||||
this.myPlayer.troops() * this.renderer.uiState.attackRatio,
|
||||
),
|
||||
);
|
||||
})
|
||||
.catch((err) =>
|
||||
console.warn("tileContext attack lookup failed:", err),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -140,12 +140,9 @@ export function getPlayerIcons(
|
||||
return isSendingNuke && notMyPlayer && unit.isActive();
|
||||
});
|
||||
|
||||
const isMyPlayerTarget = nukesSentByOtherPlayer.some((unit) => {
|
||||
const detonationDst = unit.targetTile();
|
||||
if (!detonationDst || !myPlayer) return false;
|
||||
const targetId = game.owner(detonationDst).id();
|
||||
return targetId === myPlayer.id();
|
||||
});
|
||||
// Main thread does not maintain authoritative tile ownership; treat this icon
|
||||
// as informational only (no "targeted at me" specialization here).
|
||||
const isMyPlayerTarget = false;
|
||||
|
||||
if (nukesSentByOtherPlayer.length > 0) {
|
||||
const icon = isMyPlayerTarget ? nukeRedIcon : nukeWhiteIcon;
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
import { createCanvas } from "src/client/Utils";
|
||||
import { Theme } from "../../../core/configuration/Config";
|
||||
import { TileRef } from "../../../core/game/GameMap";
|
||||
import { GameView } from "../../../core/game/GameView";
|
||||
import { WorkerClient } from "../../../core/worker/WorkerClient";
|
||||
import {
|
||||
InitRendererMessage,
|
||||
MarkAllDirtyMessage,
|
||||
MarkTileMessage,
|
||||
RefreshPaletteMessage,
|
||||
RefreshTerrainMessage,
|
||||
RenderFrameMessage,
|
||||
SetAlternativeViewMessage,
|
||||
SetHighlightedOwnerMessage,
|
||||
SetPaletteMessage,
|
||||
SetPatternsEnabledMessage,
|
||||
SetShaderSettingsMessage,
|
||||
SetViewSizeMessage,
|
||||
SetViewTransformMessage,
|
||||
TickRendererMessage,
|
||||
} from "../../../core/worker/WorkerMessages";
|
||||
|
||||
export interface Canvas2DCreateResult {
|
||||
renderer: Canvas2DRendererProxy | null;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export class Canvas2DRendererProxy {
|
||||
public readonly canvas: HTMLCanvasElement;
|
||||
private offscreenCanvas: OffscreenCanvas | null = null;
|
||||
private worker: WorkerClient | null = null;
|
||||
private ready = false;
|
||||
private failed = false;
|
||||
private initPromise: Promise<void> | null = null;
|
||||
private pendingMessages: Array<{ message: any; transferables?: any[] }> = [];
|
||||
|
||||
private constructor(
|
||||
private readonly game: GameView,
|
||||
private readonly theme: Theme,
|
||||
) {
|
||||
this.canvas = createCanvas();
|
||||
this.canvas.style.pointerEvents = "none";
|
||||
this.canvas.width = 1;
|
||||
this.canvas.height = 1;
|
||||
}
|
||||
|
||||
static create(
|
||||
game: GameView,
|
||||
theme: Theme,
|
||||
worker: WorkerClient,
|
||||
): Canvas2DCreateResult {
|
||||
if (typeof OffscreenCanvas === "undefined") {
|
||||
return {
|
||||
renderer: null,
|
||||
reason:
|
||||
"OffscreenCanvas not supported; Canvas2D worker renderer disabled.",
|
||||
};
|
||||
}
|
||||
if (
|
||||
typeof HTMLCanvasElement.prototype.transferControlToOffscreen !==
|
||||
"function"
|
||||
) {
|
||||
return {
|
||||
renderer: null,
|
||||
reason:
|
||||
"transferControlToOffscreen not supported; Canvas2D worker renderer disabled.",
|
||||
};
|
||||
}
|
||||
|
||||
const renderer = new Canvas2DRendererProxy(game, theme);
|
||||
renderer.worker = worker;
|
||||
renderer.startInit();
|
||||
return { renderer };
|
||||
}
|
||||
|
||||
private startInit(): void {
|
||||
if (this.initPromise) return;
|
||||
this.initPromise = this.init().catch((err) => {
|
||||
this.failed = true;
|
||||
this.pendingMessages = [];
|
||||
console.error("Worker canvas2d renderer init failed:", err);
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
|
||||
private async init(): Promise<void> {
|
||||
if (!this.worker) {
|
||||
throw new Error("Worker not set");
|
||||
}
|
||||
|
||||
this.offscreenCanvas = this.canvas.transferControlToOffscreen();
|
||||
|
||||
const themeAny = this.theme as any;
|
||||
const darkMode = themeAny.darkShore !== undefined;
|
||||
|
||||
const messageId = `init_renderer_canvas2d_${Date.now()}`;
|
||||
const initMessage: InitRendererMessage = {
|
||||
type: "init_renderer",
|
||||
id: messageId,
|
||||
offscreenCanvas: this.offscreenCanvas,
|
||||
darkMode: darkMode,
|
||||
backend: "canvas2d",
|
||||
};
|
||||
|
||||
this.worker.postMessage(initMessage, [this.offscreenCanvas]);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
this.worker?.removeMessageHandler(messageId);
|
||||
reject(new Error("Renderer initialization timeout"));
|
||||
}, 10000);
|
||||
|
||||
const handler = (message: any) => {
|
||||
if (message.type === "renderer_ready" && message.id === messageId) {
|
||||
clearTimeout(timeout);
|
||||
this.worker?.removeMessageHandler(messageId);
|
||||
if (message.ok === false) {
|
||||
reject(
|
||||
new Error(message.error ?? "Renderer initialization failed"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.ready = true;
|
||||
for (const pending of this.pendingMessages) {
|
||||
if (pending.transferables) {
|
||||
this.worker?.postMessage(pending.message, pending.transferables);
|
||||
} else {
|
||||
this.sendToWorker(pending.message);
|
||||
}
|
||||
}
|
||||
this.pendingMessages = [];
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
|
||||
this.worker?.addMessageHandler(messageId, handler);
|
||||
});
|
||||
}
|
||||
|
||||
private sendToWorker(message: any): void {
|
||||
if (!this.worker) return;
|
||||
if (this.failed) return;
|
||||
if (!this.ready) {
|
||||
this.pendingMessages.push({ message });
|
||||
return;
|
||||
}
|
||||
this.worker.postMessage(message);
|
||||
}
|
||||
|
||||
private sendToWorkerWithTransfer(message: any, transferables: any[]): void {
|
||||
if (!this.worker) return;
|
||||
if (this.failed) return;
|
||||
if (!this.ready) {
|
||||
this.pendingMessages.push({ message, transferables });
|
||||
return;
|
||||
}
|
||||
this.worker.postMessage(message, transferables);
|
||||
}
|
||||
|
||||
setViewSize(width: number, height: number): void {
|
||||
const message: SetViewSizeMessage = {
|
||||
type: "set_view_size",
|
||||
width,
|
||||
height,
|
||||
};
|
||||
this.sendToWorker(message);
|
||||
}
|
||||
|
||||
setViewTransform(scale: number, offsetX: number, offsetY: number): void {
|
||||
const message: SetViewTransformMessage = {
|
||||
type: "set_view_transform",
|
||||
scale,
|
||||
offsetX,
|
||||
offsetY,
|
||||
};
|
||||
this.sendToWorker(message);
|
||||
}
|
||||
|
||||
setAlternativeView(enabled: boolean): void {
|
||||
const message: SetAlternativeViewMessage = {
|
||||
type: "set_alternative_view",
|
||||
enabled,
|
||||
};
|
||||
this.sendToWorker(message);
|
||||
}
|
||||
|
||||
setPatternsEnabled(enabled: boolean): void {
|
||||
const message: SetPatternsEnabledMessage = {
|
||||
type: "set_patterns_enabled",
|
||||
enabled,
|
||||
};
|
||||
this.sendToWorker(message);
|
||||
}
|
||||
|
||||
setHighlightedOwnerId(ownerSmallId: number | null): void {
|
||||
const message: SetHighlightedOwnerMessage = {
|
||||
type: "set_highlighted_owner",
|
||||
ownerSmallId,
|
||||
};
|
||||
this.sendToWorker(message);
|
||||
}
|
||||
|
||||
// Shader controls are ignored by the Canvas2D backend but kept for API parity.
|
||||
setTerritoryShader(_shaderPath: string): void {}
|
||||
setTerrainShader(_shaderPath: string): void {}
|
||||
setTerritoryShaderParams(
|
||||
_params0: Float32Array | number[],
|
||||
_params1: Float32Array | number[],
|
||||
): void {}
|
||||
setTerrainShaderParams(
|
||||
_params0: Float32Array | number[],
|
||||
_params1: Float32Array | number[],
|
||||
): void {}
|
||||
setPreSmoothing(
|
||||
_enabled: boolean,
|
||||
_shaderPath: string,
|
||||
_params0: Float32Array | number[],
|
||||
): void {}
|
||||
setPostSmoothing(
|
||||
_enabled: boolean,
|
||||
_shaderPath: string,
|
||||
_params0: Float32Array | number[],
|
||||
): void {}
|
||||
setShaderSettings(_settings: SetShaderSettingsMessage): void {}
|
||||
|
||||
markTile(tile: TileRef): void {
|
||||
const message: MarkTileMessage = { type: "mark_tile", tile };
|
||||
this.sendToWorker(message);
|
||||
}
|
||||
|
||||
markAllDirty(): void {
|
||||
const message: MarkAllDirtyMessage = { type: "mark_all_dirty" };
|
||||
this.sendToWorker(message);
|
||||
}
|
||||
|
||||
markDefensePostsDirty(): void {
|
||||
this.markAllDirty();
|
||||
}
|
||||
|
||||
refreshPalette(): void {
|
||||
if (!this.worker) return;
|
||||
|
||||
let maxSmallId = 0;
|
||||
for (const player of this.game.playerViews()) {
|
||||
maxSmallId = Math.max(maxSmallId, player.smallID());
|
||||
}
|
||||
|
||||
const RESERVED = 10;
|
||||
const paletteWidth = RESERVED + Math.max(1, maxSmallId + 1);
|
||||
const rowStride = paletteWidth * 4;
|
||||
|
||||
const row0 = new Uint8Array(rowStride);
|
||||
const row1 = new Uint8Array(rowStride);
|
||||
|
||||
// Fallout slot (index 0)
|
||||
row0[0] = 120;
|
||||
row0[1] = 255;
|
||||
row0[2] = 71;
|
||||
row0[3] = 255;
|
||||
|
||||
const toByte = (value: number): number =>
|
||||
Math.max(0, Math.min(255, Math.round(value)));
|
||||
|
||||
for (const player of this.game.playerViews()) {
|
||||
const id = player.smallID();
|
||||
if (id <= 0) continue;
|
||||
const idx = (RESERVED + id) * 4;
|
||||
|
||||
const tc = player.territoryColor().toRgb();
|
||||
row0[idx] = toByte(tc.r);
|
||||
row0[idx + 1] = toByte(tc.g);
|
||||
row0[idx + 2] = toByte(tc.b);
|
||||
row0[idx + 3] = 255;
|
||||
|
||||
const bc = player.borderColor().toRgb();
|
||||
row1[idx] = toByte(bc.r);
|
||||
row1[idx + 1] = toByte(bc.g);
|
||||
row1[idx + 2] = toByte(bc.b);
|
||||
row1[idx + 3] = 255;
|
||||
}
|
||||
|
||||
const message: SetPaletteMessage = {
|
||||
type: "set_palette",
|
||||
paletteWidth,
|
||||
maxSmallId,
|
||||
row0,
|
||||
row1,
|
||||
};
|
||||
this.sendToWorkerWithTransfer(message, [row0.buffer, row1.buffer]);
|
||||
|
||||
const fallback: RefreshPaletteMessage = { type: "refresh_palette" };
|
||||
this.sendToWorker(fallback);
|
||||
}
|
||||
|
||||
refreshTerrain(): void {
|
||||
const message: RefreshTerrainMessage = { type: "refresh_terrain" };
|
||||
this.sendToWorker(message);
|
||||
}
|
||||
|
||||
tick(): void {
|
||||
const message: TickRendererMessage = { type: "tick_renderer" };
|
||||
this.sendToWorker(message);
|
||||
}
|
||||
|
||||
render(): void {
|
||||
const message: RenderFrameMessage = { type: "render_frame" };
|
||||
this.sendToWorker(message);
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import { customElement, state } from "lit/decorators.js";
|
||||
import { EventBus } from "../../../core/EventBus";
|
||||
import { AllPlayers } from "../../../core/game/Game";
|
||||
import { GameView, PlayerView } from "../../../core/game/GameView";
|
||||
import { TerraNulliusImpl } from "../../../core/game/TerraNulliusImpl";
|
||||
import { Emoji, flattenedEmojiTable } from "../../../core/Util";
|
||||
import { CloseViewEvent, ShowEmojiMenuEvent } from "../../InputHandler";
|
||||
import { SendEmojiIntentEvent } from "../../Transport";
|
||||
@@ -24,28 +23,36 @@ export class EmojiTable extends LitElement {
|
||||
}
|
||||
|
||||
const tile = this.game.ref(cell.x, cell.y);
|
||||
if (!this.game.hasOwner(tile)) {
|
||||
return;
|
||||
}
|
||||
this.game.worker.tileContext(tile).then((ctx) => {
|
||||
if (!ctx.ownerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetPlayer = this.game.owner(tile);
|
||||
// maybe redundant due to owner check but better safe than sorry
|
||||
if (targetPlayer instanceof TerraNulliusImpl) {
|
||||
return;
|
||||
}
|
||||
let targetPlayer: PlayerView | null = null;
|
||||
try {
|
||||
const maybe = this.game.player(ctx.ownerId);
|
||||
targetPlayer =
|
||||
maybe && maybe.isPlayer() ? (maybe as PlayerView) : null;
|
||||
} catch {
|
||||
targetPlayer = null;
|
||||
}
|
||||
if (!targetPlayer) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.showTable((emoji) => {
|
||||
const recipient =
|
||||
targetPlayer === this.game.myPlayer()
|
||||
? AllPlayers
|
||||
: (targetPlayer as PlayerView);
|
||||
eventBus.emit(
|
||||
new SendEmojiIntentEvent(
|
||||
recipient,
|
||||
flattenedEmojiTable.indexOf(emoji as Emoji),
|
||||
),
|
||||
);
|
||||
this.hideTable();
|
||||
this.showTable((emoji) => {
|
||||
const recipient =
|
||||
targetPlayer === this.game.myPlayer()
|
||||
? AllPlayers
|
||||
: (targetPlayer as PlayerView);
|
||||
eventBus.emit(
|
||||
new SendEmojiIntentEvent(
|
||||
recipient,
|
||||
flattenedEmojiTable.indexOf(emoji as Emoji),
|
||||
),
|
||||
);
|
||||
this.hideTable();
|
||||
});
|
||||
});
|
||||
});
|
||||
eventBus.on(CloseViewEvent, (e) => {
|
||||
|
||||
@@ -110,8 +110,17 @@ export class MainRadialMenu extends LitElement implements Layer {
|
||||
) {
|
||||
this.buildMenu.playerActions = actions;
|
||||
|
||||
const tileOwner = this.game.owner(tile);
|
||||
const recipient = tileOwner.isPlayer() ? (tileOwner as PlayerView) : null;
|
||||
let recipient: PlayerView | null = null;
|
||||
try {
|
||||
const ctx = await this.game.worker.tileContext(tile);
|
||||
if (ctx.ownerId) {
|
||||
const maybe = this.game.player(ctx.ownerId);
|
||||
recipient = maybe && maybe.isPlayer() ? (maybe as PlayerView) : null;
|
||||
}
|
||||
} catch {
|
||||
// Best-effort; the menu can still operate from PlayerActions alone.
|
||||
recipient = null;
|
||||
}
|
||||
|
||||
if (myPlayer && recipient) {
|
||||
this.chatIntegration.setupChatModal(myPlayer, recipient);
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
renderTroops,
|
||||
translateText,
|
||||
} from "../../Utils";
|
||||
import { getHoverInfo } from "../HoverInfo";
|
||||
import { getFirstPlacePlayer, getPlayerIcons } from "../PlayerIcons";
|
||||
import { TransformHandler } from "../TransformHandler";
|
||||
import { Layer } from "./Layer";
|
||||
@@ -66,6 +65,7 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
|
||||
private lastMouseUpdate = 0;
|
||||
|
||||
private showDetails = true;
|
||||
private hoverSeq = 0;
|
||||
|
||||
init() {
|
||||
this.eventBus.on(MouseMoveEvent, (e: MouseMoveEvent) =>
|
||||
@@ -98,20 +98,69 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
|
||||
public maybeShow(x: number, y: number) {
|
||||
this.hide();
|
||||
const worldCoord = this.transform.screenToWorldCoordinates(x, y);
|
||||
const info = getHoverInfo(this.game, worldCoord);
|
||||
if (!this.game.isValidCoord(worldCoord.x, worldCoord.y)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (info.player) {
|
||||
this.player = info.player;
|
||||
this.player.profile().then((p) => {
|
||||
this.playerProfile = p;
|
||||
const tile = this.game.ref(worldCoord.x, worldCoord.y);
|
||||
|
||||
// Land hover info requires tile ownership/fallout, which is authoritative in
|
||||
// the worker (main thread does not maintain a full tile mirror).
|
||||
if (this.game.isLand(tile)) {
|
||||
const seq = ++this.hoverSeq;
|
||||
this.game.worker
|
||||
.tileContext(tile)
|
||||
.then((ctx) => {
|
||||
if (!this._isActive || seq !== this.hoverSeq) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (ctx.ownerId) {
|
||||
try {
|
||||
const owner = this.game.player(ctx.ownerId);
|
||||
if (owner && owner.isPlayer()) {
|
||||
this.player = owner;
|
||||
this.player.profile().then((p) => {
|
||||
if (this._isActive && seq === this.hoverSeq) {
|
||||
this.playerProfile = p;
|
||||
}
|
||||
});
|
||||
this.setVisible(true);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.isIrradiatedWilderness = ctx.hasFallout;
|
||||
this.isWilderness = !ctx.hasFallout;
|
||||
this.setVisible(true);
|
||||
})
|
||||
.catch(() => {
|
||||
// ignore hover failures
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Water hover info can be derived from unit view data (already on main).
|
||||
const units = this.game
|
||||
.units(UnitType.Warship, UnitType.TradeShip, UnitType.TransportShip)
|
||||
.filter((u) => {
|
||||
const dx = worldCoord.x - this.game.x(u.tile());
|
||||
const dy = worldCoord.y - this.game.y(u.tile());
|
||||
return Math.sqrt(dx * dx + dy * dy) < 50;
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const dxA = worldCoord.x - this.game.x(a.tile());
|
||||
const dyA = worldCoord.y - this.game.y(a.tile());
|
||||
const dxB = worldCoord.x - this.game.x(b.tile());
|
||||
const dyB = worldCoord.y - this.game.y(b.tile());
|
||||
return dxA * dxA + dyA * dyA - (dxB * dxB + dyB * dyB);
|
||||
});
|
||||
this.setVisible(true);
|
||||
} else if (info.isWilderness || info.isIrradiatedWilderness) {
|
||||
this.isWilderness = info.isWilderness;
|
||||
this.isIrradiatedWilderness = info.isIrradiatedWilderness;
|
||||
this.setVisible(true);
|
||||
} else if (info.unit) {
|
||||
this.unit = info.unit;
|
||||
|
||||
if (units.length > 0) {
|
||||
this.unit = units[0];
|
||||
this.setVisible(true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ export class PlayerPanel extends LitElement implements Layer {
|
||||
private actions: PlayerActions | null = null;
|
||||
private tile: TileRef | null = null;
|
||||
private _profileForPlayerId: number | null = null;
|
||||
@state() private otherPlayer: PlayerView | null = null;
|
||||
|
||||
@state() private sendTarget: PlayerView | null = null;
|
||||
@state() private sendMode: "troops" | "gold" | "none" = "none";
|
||||
@@ -103,15 +104,29 @@ export class PlayerPanel extends LitElement implements Layer {
|
||||
|
||||
async tick() {
|
||||
if (this.isVisible && this.tile) {
|
||||
const owner = this.g.owner(this.tile);
|
||||
if (owner && owner.isPlayer()) {
|
||||
const pv = owner as PlayerView;
|
||||
const id = pv.id();
|
||||
const tile = this.tile;
|
||||
try {
|
||||
const ctx = await this.g.worker.tileContext(tile);
|
||||
if (!ctx.ownerId) {
|
||||
this.hide();
|
||||
return;
|
||||
}
|
||||
const owner = this.g.player(ctx.ownerId);
|
||||
if (!owner || !owner.isPlayer()) {
|
||||
this.hide();
|
||||
return;
|
||||
}
|
||||
|
||||
this.otherPlayer = owner as PlayerView;
|
||||
|
||||
const id = this.otherPlayer.id();
|
||||
// fetch only if we don't have it or the player changed
|
||||
if (this._profileForPlayerId !== Number(id)) {
|
||||
this.otherProfile = await pv.profile();
|
||||
this.otherProfile = await this.otherPlayer.profile();
|
||||
this._profileForPlayerId = Number(id);
|
||||
}
|
||||
} catch {
|
||||
// If tile context fails (rare), keep the panel as-is.
|
||||
}
|
||||
|
||||
// Refresh actions & alliance expiry
|
||||
@@ -142,6 +157,9 @@ export class PlayerPanel extends LitElement implements Layer {
|
||||
public show(actions: PlayerActions, tile: TileRef) {
|
||||
this.actions = actions;
|
||||
this.tile = tile;
|
||||
this.otherPlayer = null;
|
||||
this.otherProfile = null;
|
||||
this._profileForPlayerId = null;
|
||||
this.isVisible = true;
|
||||
this.requestUpdate();
|
||||
}
|
||||
@@ -154,6 +172,7 @@ export class PlayerPanel extends LitElement implements Layer {
|
||||
this.suppressNextHide = true;
|
||||
this.actions = actions;
|
||||
this.tile = tile;
|
||||
this.otherPlayer = target;
|
||||
this.sendTarget = target;
|
||||
this.sendMode = "gold";
|
||||
this.isVisible = true;
|
||||
@@ -164,6 +183,11 @@ export class PlayerPanel extends LitElement implements Layer {
|
||||
this.isVisible = false;
|
||||
this.sendMode = "none";
|
||||
this.sendTarget = null;
|
||||
this.tile = null;
|
||||
this.actions = null;
|
||||
this.otherPlayer = null;
|
||||
this.otherProfile = null;
|
||||
this._profileForPlayerId = null;
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
@@ -815,13 +839,21 @@ export class PlayerPanel extends LitElement implements Layer {
|
||||
if (!my) return html``;
|
||||
if (!this.tile) return html``;
|
||||
|
||||
const owner = this.g.owner(this.tile);
|
||||
if (!owner || !owner.isPlayer()) {
|
||||
this.hide();
|
||||
console.warn("Tile is not owned by a player");
|
||||
return html``;
|
||||
const other = this.otherPlayer;
|
||||
if (!other) {
|
||||
return html`
|
||||
<div
|
||||
class="fixed inset-0 z-10001 flex items-center justify-center overflow-auto
|
||||
bg-black/15 backdrop-brightness-110 pointer-events-auto"
|
||||
>
|
||||
<div
|
||||
class="bg-zinc-900/95 text-white rounded-[10px] p-3 ring-1 ring-white/5"
|
||||
>
|
||||
${translateText("loading")}…
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
const other = owner as PlayerView;
|
||||
const myGoldNum = my.gold();
|
||||
const myTroopsNum = Number(my.troops());
|
||||
|
||||
|
||||
@@ -456,10 +456,9 @@ export const deleteUnitElement: MenuElement = {
|
||||
name: "delete",
|
||||
cooldown: (params: MenuElementParams) => params.myPlayer.deleteUnitCooldown(),
|
||||
disabled: (params: MenuElementParams) => {
|
||||
const tileOwner = params.game.owner(params.tile);
|
||||
const isLand = params.game.isLand(params.tile);
|
||||
|
||||
if (!tileOwner.isPlayer() || tileOwner.id() !== params.myPlayer.id()) {
|
||||
if (!params.selected || params.selected.id() !== params.myPlayer.id()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -556,7 +555,6 @@ export const boatMenuElement: MenuElement = {
|
||||
|
||||
export const centerButtonElement: CenterButtonElement = {
|
||||
disabled: (params: MenuElementParams): boolean => {
|
||||
const tileOwner = params.game.owner(params.tile);
|
||||
const isLand = params.game.isLand(params.tile);
|
||||
if (!isLand) {
|
||||
return true;
|
||||
@@ -565,7 +563,7 @@ export const centerButtonElement: CenterButtonElement = {
|
||||
if (params.game.config().isRandomSpawn()) {
|
||||
return true;
|
||||
}
|
||||
if (tileOwner.isPlayer()) {
|
||||
if (params.selected) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@@ -614,10 +612,8 @@ export const rootMenuElement: MenuElement = {
|
||||
ally = allyBreakElement;
|
||||
}
|
||||
|
||||
const tileOwner = params.game.owner(params.tile);
|
||||
const isOwnTerritory =
|
||||
tileOwner.isPlayer() &&
|
||||
(tileOwner as PlayerView).id() === params.myPlayer.id();
|
||||
params.selected !== null && params.selected.id() === params.myPlayer.id();
|
||||
|
||||
const menuItems: (MenuElement | null)[] = [
|
||||
infoMenuElement,
|
||||
|
||||
@@ -4,11 +4,8 @@ import { UnitType } from "../../../core/game/Game";
|
||||
import { TileRef } from "../../../core/game/GameMap";
|
||||
import { GameView } from "../../../core/game/GameView";
|
||||
import { UserSettings } from "../../../core/game/UserSettings";
|
||||
import {
|
||||
AlternateViewEvent,
|
||||
MouseOverEvent,
|
||||
WebGPUComputeMetricsEvent,
|
||||
} from "../../InputHandler";
|
||||
import { AlternateViewEvent, MouseOverEvent } from "../../InputHandler";
|
||||
import { Canvas2DRendererProxy } from "../canvas2d/Canvas2DRendererProxy";
|
||||
import { FrameProfiler } from "../FrameProfiler";
|
||||
import { TransformHandler } from "../TransformHandler";
|
||||
import {
|
||||
@@ -43,11 +40,15 @@ export class TerritoryLayer implements Layer {
|
||||
|
||||
private theme: Theme;
|
||||
|
||||
private territoryRenderer: TerritoryRenderer | TerritoryRendererProxy | null =
|
||||
null;
|
||||
private territoryRenderer:
|
||||
| TerritoryRenderer
|
||||
| TerritoryRendererProxy
|
||||
| Canvas2DRendererProxy
|
||||
| null = null;
|
||||
private alternativeView = false;
|
||||
|
||||
private lastPaletteSignature: string | null = null;
|
||||
private lastPatternsEnabled: boolean | null = null;
|
||||
private lastDefensePostsSignature: string | null = null;
|
||||
private lastTerrainShaderSignature: string | null = null;
|
||||
private lastTerritoryShaderSignature: string | null = null;
|
||||
@@ -57,6 +58,7 @@ export class TerritoryLayer implements Layer {
|
||||
private lastMousePosition: { x: number; y: number } | null = null;
|
||||
private hoveredOwnerSmallId: number | null = null;
|
||||
private lastHoverUpdateMs = 0;
|
||||
private hoverRequestSeq = 0;
|
||||
|
||||
constructor(
|
||||
private game: GameView,
|
||||
@@ -98,20 +100,9 @@ export class TerritoryLayer implements Layer {
|
||||
this.applyTerritoryShaderSettings();
|
||||
this.applyTerritorySmoothingSettings();
|
||||
|
||||
const updatedTiles = this.game.recentlyUpdatedTiles();
|
||||
for (let i = 0; i < updatedTiles.length; i++) {
|
||||
this.markTile(updatedTiles[i]);
|
||||
}
|
||||
|
||||
// After collecting pending updates and handling palette/theme changes,
|
||||
// invoke the renderer's tick() to process compute passes. This ensures
|
||||
// compute shaders run at the simulation rate rather than every frame.
|
||||
if (this.territoryRenderer) {
|
||||
const start = performance.now();
|
||||
this.territoryRenderer.tick();
|
||||
const computeMs = performance.now() - start;
|
||||
this.eventBus.emit(new WebGPUComputeMetricsEvent(computeMs));
|
||||
}
|
||||
// Renderer tick and dirty-tile marking are driven in the worker from
|
||||
// simulation-derived tile updates (tileUpdateSink). The main thread only
|
||||
// drives render frames + view transforms.
|
||||
|
||||
FrameProfiler.end("TerritoryLayer:tick", tickProfile);
|
||||
}
|
||||
@@ -121,17 +112,33 @@ export class TerritoryLayer implements Layer {
|
||||
}
|
||||
|
||||
private configureRenderer() {
|
||||
// Use proxy to render in worker thread
|
||||
const { renderer, reason } = TerritoryRendererProxy.create(
|
||||
this.game,
|
||||
this.theme,
|
||||
this.game.worker,
|
||||
);
|
||||
const backend = this.userSettings.backgroundRenderer();
|
||||
const create = (b: "webgpu" | "canvas2d") =>
|
||||
b === "canvas2d"
|
||||
? Canvas2DRendererProxy.create(this.game, this.theme, this.game.worker)
|
||||
: TerritoryRendererProxy.create(
|
||||
this.game,
|
||||
this.theme,
|
||||
this.game.worker,
|
||||
);
|
||||
|
||||
let { renderer, reason } = create(backend);
|
||||
if (!renderer && backend === "webgpu") {
|
||||
// Graceful fallback: allow the game to run even without WebGPU.
|
||||
console.warn(
|
||||
`WebGPU renderer unavailable (${reason ?? "unknown"}); falling back to Canvas2D worker renderer.`,
|
||||
);
|
||||
({ renderer, reason } = create("canvas2d"));
|
||||
}
|
||||
|
||||
if (!renderer) {
|
||||
throw new Error(reason ?? "WebGPU is required for territory rendering.");
|
||||
throw new Error(reason ?? "No supported background renderer available.");
|
||||
}
|
||||
|
||||
this.territoryRenderer = renderer;
|
||||
const patternsEnabled = this.userSettings.territoryPatterns();
|
||||
this.lastPatternsEnabled = patternsEnabled;
|
||||
this.territoryRenderer.setPatternsEnabled(patternsEnabled);
|
||||
this.territoryRenderer.setAlternativeView(this.alternativeView);
|
||||
this.territoryRenderer.setHighlightedOwnerId(this.hoveredOwnerSmallId);
|
||||
this.applyTerrainShaderSettings(true);
|
||||
@@ -148,7 +155,7 @@ export class TerritoryLayer implements Layer {
|
||||
// Run an initial tick to upload state and build the colour texture. Without
|
||||
// this, the first render call may occur before the initial compute pass
|
||||
// has been executed, resulting in undefined colours.
|
||||
this.territoryRenderer.tick();
|
||||
// Note: compute passes are ticked in the worker at simulation cadence.
|
||||
}
|
||||
|
||||
renderLayer(context: CanvasRenderingContext2D) {
|
||||
@@ -165,6 +172,9 @@ export class TerritoryLayer implements Layer {
|
||||
}
|
||||
|
||||
// Apply user settings even while the game is paused (settings modal).
|
||||
this.refreshPaletteIfNeeded();
|
||||
this.refreshDefensePostsIfNeeded();
|
||||
this.applyTerrainShaderSettings();
|
||||
this.applyTerritoryShaderSettings();
|
||||
this.applyTerritorySmoothingSettings();
|
||||
|
||||
@@ -288,41 +298,96 @@ export class TerritoryLayer implements Layer {
|
||||
}
|
||||
this.lastHoverUpdateMs = now;
|
||||
|
||||
let nextOwnerSmallId: number | null = null;
|
||||
if (this.lastMousePosition) {
|
||||
const cell = this.transformHandler.screenToWorldCoordinates(
|
||||
this.lastMousePosition.x,
|
||||
this.lastMousePosition.y,
|
||||
);
|
||||
if (this.game.isValidCoord(cell.x, cell.y)) {
|
||||
const tile = this.game.ref(cell.x, cell.y);
|
||||
const owner = this.game.owner(tile);
|
||||
if (owner && owner.isPlayer()) {
|
||||
nextOwnerSmallId = owner.smallID();
|
||||
}
|
||||
if (!this.lastMousePosition) {
|
||||
if (this.hoveredOwnerSmallId !== null) {
|
||||
this.hoveredOwnerSmallId = null;
|
||||
this.territoryRenderer.setHighlightedOwnerId(null);
|
||||
}
|
||||
}
|
||||
|
||||
if (nextOwnerSmallId === this.hoveredOwnerSmallId) {
|
||||
return;
|
||||
}
|
||||
this.hoveredOwnerSmallId = nextOwnerSmallId;
|
||||
this.territoryRenderer.setHighlightedOwnerId(nextOwnerSmallId);
|
||||
|
||||
const cell = this.transformHandler.screenToWorldCoordinates(
|
||||
this.lastMousePosition.x,
|
||||
this.lastMousePosition.y,
|
||||
);
|
||||
if (!this.game.isValidCoord(cell.x, cell.y)) {
|
||||
if (this.hoveredOwnerSmallId !== null) {
|
||||
this.hoveredOwnerSmallId = null;
|
||||
this.territoryRenderer.setHighlightedOwnerId(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const tile = this.game.ref(cell.x, cell.y);
|
||||
const seq = ++this.hoverRequestSeq;
|
||||
this.game.worker
|
||||
.tileContext(tile)
|
||||
.then((ctx) => {
|
||||
if (seq !== this.hoverRequestSeq) {
|
||||
return;
|
||||
}
|
||||
const nextOwnerSmallId = ctx.ownerSmallId;
|
||||
if (nextOwnerSmallId === this.hoveredOwnerSmallId) {
|
||||
return;
|
||||
}
|
||||
this.hoveredOwnerSmallId = nextOwnerSmallId;
|
||||
this.territoryRenderer?.setHighlightedOwnerId(nextOwnerSmallId);
|
||||
})
|
||||
.catch((err) => {
|
||||
// Don't spam; hover is best-effort.
|
||||
console.warn("tileContext hover lookup failed:", err);
|
||||
});
|
||||
}
|
||||
|
||||
private computePaletteSignature(): string {
|
||||
let maxSmallId = 0;
|
||||
for (const player of this.game.playerViews()) {
|
||||
maxSmallId = Math.max(maxSmallId, player.smallID());
|
||||
}
|
||||
const patternsEnabled = this.userSettings.territoryPatterns();
|
||||
return `${this.game.playerViews().length}:${maxSmallId}:${patternsEnabled ? 1 : 0}`;
|
||||
const players = this.game.playerViews();
|
||||
|
||||
const fnvByte = (hash: number, byte: number): number =>
|
||||
Math.imul(hash ^ (byte & 0xff), 16777619) >>> 0;
|
||||
|
||||
const fnv32 = (hash: number, value: number): number => {
|
||||
hash = fnvByte(hash, value);
|
||||
hash = fnvByte(hash, value >>> 8);
|
||||
hash = fnvByte(hash, value >>> 16);
|
||||
hash = fnvByte(hash, value >>> 24);
|
||||
return hash;
|
||||
};
|
||||
|
||||
let hash = patternsEnabled ? 2166136261 : 2166136262;
|
||||
hash = fnv32(hash, players.length);
|
||||
|
||||
let maxSmallId = 0;
|
||||
for (const player of players) {
|
||||
const id = player.smallID();
|
||||
maxSmallId = Math.max(maxSmallId, id);
|
||||
|
||||
hash = fnv32(hash, id);
|
||||
|
||||
const tc = player.territoryColor().rgba;
|
||||
hash = fnvByte(hash, tc.r);
|
||||
hash = fnvByte(hash, tc.g);
|
||||
hash = fnvByte(hash, tc.b);
|
||||
|
||||
const bc = player.borderColor().rgba;
|
||||
hash = fnvByte(hash, bc.r);
|
||||
hash = fnvByte(hash, bc.g);
|
||||
hash = fnvByte(hash, bc.b);
|
||||
}
|
||||
hash = fnv32(hash, maxSmallId);
|
||||
|
||||
return `${hash}`;
|
||||
}
|
||||
|
||||
private refreshPaletteIfNeeded() {
|
||||
if (!this.territoryRenderer) {
|
||||
return;
|
||||
}
|
||||
const patternsEnabled = this.userSettings.territoryPatterns();
|
||||
if (patternsEnabled !== this.lastPatternsEnabled) {
|
||||
this.lastPatternsEnabled = patternsEnabled;
|
||||
this.territoryRenderer.setPatternsEnabled(patternsEnabled);
|
||||
}
|
||||
const signature = this.computePaletteSignature();
|
||||
if (signature !== this.lastPaletteSignature) {
|
||||
this.lastPaletteSignature = signature;
|
||||
|
||||
@@ -3,7 +3,10 @@ import { customElement, property, state } from "lit/decorators.js";
|
||||
import { live } from "lit/directives/live.js";
|
||||
import { EventBus } from "../../../core/EventBus";
|
||||
import { UserSettings } from "../../../core/game/UserSettings";
|
||||
import { WebGPUComputeMetricsEvent } from "../../InputHandler";
|
||||
import {
|
||||
RefreshGraphicsEvent,
|
||||
WebGPUComputeMetricsEvent,
|
||||
} from "../../InputHandler";
|
||||
import {
|
||||
TERRAIN_SHADER_KEY,
|
||||
TERRAIN_SHADERS,
|
||||
@@ -306,6 +309,8 @@ export class WebGPUDebugOverlay extends LitElement implements Layer {
|
||||
return null;
|
||||
}
|
||||
|
||||
const backgroundRenderer = this.userSettings.backgroundRenderer();
|
||||
|
||||
const shaderId = this.selectedShaderId();
|
||||
const shader =
|
||||
TERRITORY_SHADERS.find((s) => s.id === shaderId) ?? TERRITORY_SHADERS[0];
|
||||
@@ -328,6 +333,31 @@ export class WebGPUDebugOverlay extends LitElement implements Layer {
|
||||
<div>WebGPU Debug</div>
|
||||
</div>
|
||||
|
||||
<div class="sectionTitle">Renderer</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="label">Background</div>
|
||||
<select
|
||||
.value=${live(backgroundRenderer)}
|
||||
@change=${(e: Event) => {
|
||||
const raw = (e.target as HTMLSelectElement).value;
|
||||
const next =
|
||||
raw === "canvas2d"
|
||||
? ("canvas2d" as const)
|
||||
: ("webgpu" as const);
|
||||
if (next === this.userSettings.backgroundRenderer()) {
|
||||
return;
|
||||
}
|
||||
this.userSettings.setBackgroundRenderer(next);
|
||||
this.eventBus.emit(new RefreshGraphicsEvent());
|
||||
this.requestUpdate();
|
||||
}}
|
||||
>
|
||||
<option value="webgpu">WebGPU (worker)</option>
|
||||
<option value="canvas2d">Canvas2D (worker)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="metrics">
|
||||
<div class="metric">
|
||||
<div class="label">tick ms compute</div>
|
||||
|
||||
@@ -271,6 +271,12 @@ export class TerritoryRenderer {
|
||||
this.resources.setAlternativeView(enabled);
|
||||
}
|
||||
|
||||
// Worker renderer needs this; on main thread this is currently a no-op beyond
|
||||
// forcing a palette refresh (PlayerView colors are not recomputed dynamically).
|
||||
setPatternsEnabled(_enabled: boolean): void {
|
||||
this.refreshPalette();
|
||||
}
|
||||
|
||||
setHighlightedOwnerId(ownerSmallId: number | null): void {
|
||||
if (!this.resources) {
|
||||
return;
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
RenderFrameMessage,
|
||||
SetAlternativeViewMessage,
|
||||
SetHighlightedOwnerMessage,
|
||||
SetPaletteMessage,
|
||||
SetPatternsEnabledMessage,
|
||||
SetShaderSettingsMessage,
|
||||
SetViewSizeMessage,
|
||||
SetViewTransformMessage,
|
||||
@@ -34,7 +36,7 @@ export class TerritoryRendererProxy {
|
||||
private ready = false;
|
||||
private failed = false;
|
||||
private initPromise: Promise<void> | null = null;
|
||||
private pendingMessages: any[] = [];
|
||||
private pendingMessages: Array<{ message: any; transferables?: any[] }> = [];
|
||||
|
||||
private constructor(
|
||||
private readonly game: GameView,
|
||||
@@ -110,6 +112,7 @@ export class TerritoryRendererProxy {
|
||||
id: messageId,
|
||||
offscreenCanvas: this.offscreenCanvas,
|
||||
darkMode: darkMode,
|
||||
backend: "webgpu",
|
||||
};
|
||||
|
||||
// Transfer the offscreen canvas
|
||||
@@ -135,8 +138,12 @@ export class TerritoryRendererProxy {
|
||||
|
||||
this.ready = true;
|
||||
// Send any pending messages
|
||||
for (const msg of this.pendingMessages) {
|
||||
this.sendToWorker(msg);
|
||||
for (const pending of this.pendingMessages) {
|
||||
if (pending.transferables) {
|
||||
this.worker?.postMessage(pending.message, pending.transferables);
|
||||
} else {
|
||||
this.sendToWorker(pending.message);
|
||||
}
|
||||
}
|
||||
this.pendingMessages = [];
|
||||
resolve();
|
||||
@@ -155,12 +162,26 @@ export class TerritoryRendererProxy {
|
||||
return;
|
||||
}
|
||||
if (!this.ready) {
|
||||
this.pendingMessages.push(message);
|
||||
this.pendingMessages.push({ message });
|
||||
return;
|
||||
}
|
||||
this.worker.postMessage(message);
|
||||
}
|
||||
|
||||
private sendToWorkerWithTransfer(message: any, transferables: any[]): void {
|
||||
if (!this.worker) {
|
||||
return;
|
||||
}
|
||||
if (this.failed) {
|
||||
return;
|
||||
}
|
||||
if (!this.ready) {
|
||||
this.pendingMessages.push({ message, transferables });
|
||||
return;
|
||||
}
|
||||
this.worker.postMessage(message, transferables);
|
||||
}
|
||||
|
||||
setViewSize(width: number, height: number): void {
|
||||
const message: SetViewSizeMessage = {
|
||||
type: "set_view_size",
|
||||
@@ -188,6 +209,14 @@ export class TerritoryRendererProxy {
|
||||
this.sendToWorker(message);
|
||||
}
|
||||
|
||||
setPatternsEnabled(enabled: boolean): void {
|
||||
const message: SetPatternsEnabledMessage = {
|
||||
type: "set_patterns_enabled",
|
||||
enabled,
|
||||
};
|
||||
this.sendToWorker(message);
|
||||
}
|
||||
|
||||
setHighlightedOwnerId(ownerSmallId: number | null): void {
|
||||
const message: SetHighlightedOwnerMessage = {
|
||||
type: "set_highlighted_owner",
|
||||
@@ -284,10 +313,65 @@ export class TerritoryRendererProxy {
|
||||
}
|
||||
|
||||
refreshPalette(): void {
|
||||
const message: RefreshPaletteMessage = {
|
||||
type: "refresh_palette",
|
||||
if (!this.worker) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Build palette on the main thread to avoid order-dependent color allocator
|
||||
// divergence between main and worker.
|
||||
let maxSmallId = 0;
|
||||
for (const player of this.game.playerViews()) {
|
||||
maxSmallId = Math.max(maxSmallId, player.smallID());
|
||||
}
|
||||
|
||||
const RESERVED = 10;
|
||||
const paletteWidth = RESERVED + Math.max(1, maxSmallId + 1);
|
||||
const rowStride = paletteWidth * 4;
|
||||
|
||||
const row0 = new Uint8Array(rowStride);
|
||||
const row1 = new Uint8Array(rowStride);
|
||||
|
||||
// Fallout slot (index 0)
|
||||
row0[0] = 120;
|
||||
row0[1] = 255;
|
||||
row0[2] = 71;
|
||||
row0[3] = 255;
|
||||
|
||||
const toByte = (value: number): number =>
|
||||
Math.max(0, Math.min(255, Math.round(value)));
|
||||
|
||||
for (const player of this.game.playerViews()) {
|
||||
const id = player.smallID();
|
||||
if (id <= 0) continue;
|
||||
const idx = (RESERVED + id) * 4;
|
||||
|
||||
const tc = player.territoryColor().toRgb();
|
||||
row0[idx] = toByte(tc.r);
|
||||
row0[idx + 1] = toByte(tc.g);
|
||||
row0[idx + 2] = toByte(tc.b);
|
||||
row0[idx + 3] = 255;
|
||||
|
||||
const bc = player.borderColor().toRgb();
|
||||
row1[idx] = toByte(bc.r);
|
||||
row1[idx + 1] = toByte(bc.g);
|
||||
row1[idx + 2] = toByte(bc.b);
|
||||
row1[idx + 3] = 255;
|
||||
}
|
||||
|
||||
const message: SetPaletteMessage = {
|
||||
type: "set_palette",
|
||||
paletteWidth,
|
||||
maxSmallId,
|
||||
row0,
|
||||
row1,
|
||||
};
|
||||
this.sendToWorker(message);
|
||||
|
||||
// Transfer buffers to avoid copies; arrays are rebuilt when needed.
|
||||
this.sendToWorkerWithTransfer(message, [row0.buffer, row1.buffer]);
|
||||
|
||||
// Back-compat: also mark palette dirty in worker for older code paths.
|
||||
const fallback: RefreshPaletteMessage = { type: "refresh_palette" };
|
||||
this.sendToWorker(fallback);
|
||||
}
|
||||
|
||||
markDefensePostsDirty(): void {
|
||||
|
||||
@@ -75,6 +75,12 @@ export class GroundTruthData {
|
||||
private tickCount = 0;
|
||||
private readonly tickEmaAlpha = 0.2;
|
||||
private paletteWidth = 1;
|
||||
private paletteOverride: {
|
||||
paletteWidth: number;
|
||||
maxSmallId: number;
|
||||
row0: Uint8Array;
|
||||
row1: Uint8Array;
|
||||
} | null = null;
|
||||
private needsDefensePostsUpload = true;
|
||||
private defensePostsTotalCount = 0;
|
||||
private defendedDirtyTilesCount = 0;
|
||||
@@ -715,12 +721,65 @@ export class GroundTruthData {
|
||||
this.needsPaletteUpload = false;
|
||||
|
||||
let maxSmallId = 0;
|
||||
for (const player of this.game.playerViews()) {
|
||||
maxSmallId = Math.max(maxSmallId, player.smallID());
|
||||
let nextPaletteWidth = 0;
|
||||
let row0: Uint8Array | null = null;
|
||||
let row1: Uint8Array | null = null;
|
||||
|
||||
if (this.paletteOverride) {
|
||||
maxSmallId = this.paletteOverride.maxSmallId;
|
||||
nextPaletteWidth = this.paletteOverride.paletteWidth;
|
||||
|
||||
const expectedRowStride = nextPaletteWidth * 4;
|
||||
if (
|
||||
this.paletteOverride.row0.length === expectedRowStride &&
|
||||
this.paletteOverride.row1.length === expectedRowStride
|
||||
) {
|
||||
row0 = this.paletteOverride.row0;
|
||||
row1 = this.paletteOverride.row1;
|
||||
} else {
|
||||
// Malformed; fall back to local generation.
|
||||
this.paletteOverride = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!row0 || !row1) {
|
||||
for (const player of this.game.playerViews()) {
|
||||
maxSmallId = Math.max(maxSmallId, player.smallID());
|
||||
}
|
||||
nextPaletteWidth =
|
||||
GroundTruthData.PALETTE_RESERVED_SLOTS + Math.max(1, maxSmallId + 1);
|
||||
|
||||
const rowStride = nextPaletteWidth * 4;
|
||||
row0 = new Uint8Array(rowStride);
|
||||
row1 = new Uint8Array(rowStride);
|
||||
|
||||
// Store special colors in reserved slots (0-9)
|
||||
const falloutIdx = GroundTruthData.PALETTE_FALLOUT_INDEX * 4;
|
||||
row0[falloutIdx] = 120;
|
||||
row0[falloutIdx + 1] = 255;
|
||||
row0[falloutIdx + 2] = 71;
|
||||
row0[falloutIdx + 3] = 255;
|
||||
|
||||
// Store player colors starting at index 10
|
||||
for (const player of this.game.playerViews()) {
|
||||
const id = player.smallID();
|
||||
if (id <= 0) continue;
|
||||
const rgba = player.territoryColor().rgba;
|
||||
const idx = (GroundTruthData.PALETTE_RESERVED_SLOTS + id) * 4;
|
||||
row0[idx] = rgba.r;
|
||||
row0[idx + 1] = rgba.g;
|
||||
row0[idx + 2] = rgba.b;
|
||||
row0[idx + 3] = 255;
|
||||
|
||||
const borderRgba = player.borderColor().rgba;
|
||||
row1[idx] = borderRgba.r;
|
||||
row1[idx + 1] = borderRgba.g;
|
||||
row1[idx + 2] = borderRgba.b;
|
||||
row1[idx + 3] = 255;
|
||||
}
|
||||
}
|
||||
|
||||
this.paletteMaxSmallId = maxSmallId;
|
||||
const nextPaletteWidth =
|
||||
GroundTruthData.PALETTE_RESERVED_SLOTS + Math.max(1, maxSmallId + 1);
|
||||
|
||||
let textureRecreated = false;
|
||||
if (nextPaletteWidth !== this.paletteWidth) {
|
||||
@@ -738,32 +797,10 @@ export class GroundTruthData {
|
||||
}
|
||||
|
||||
const rowStride = this.paletteWidth * 4;
|
||||
const row0 = new Uint8Array(rowStride);
|
||||
const row1 = new Uint8Array(rowStride);
|
||||
|
||||
// Store special colors in reserved slots (0-9)
|
||||
const falloutIdx = GroundTruthData.PALETTE_FALLOUT_INDEX * 4;
|
||||
row0[falloutIdx] = 120;
|
||||
row0[falloutIdx + 1] = 255;
|
||||
row0[falloutIdx + 2] = 71;
|
||||
row0[falloutIdx + 3] = 255;
|
||||
|
||||
// Store player colors starting at index 10
|
||||
for (const player of this.game.playerViews()) {
|
||||
const id = player.smallID();
|
||||
if (id <= 0) continue;
|
||||
const rgba = player.territoryColor().rgba;
|
||||
const idx = (GroundTruthData.PALETTE_RESERVED_SLOTS + id) * 4;
|
||||
row0[idx] = rgba.r;
|
||||
row0[idx + 1] = rgba.g;
|
||||
row0[idx + 2] = rgba.b;
|
||||
row0[idx + 3] = 255;
|
||||
|
||||
const borderRgba = player.borderColor().rgba;
|
||||
row1[idx] = borderRgba.r;
|
||||
row1[idx + 1] = borderRgba.g;
|
||||
row1[idx + 2] = borderRgba.b;
|
||||
row1[idx + 3] = 255;
|
||||
if (row0.length !== rowStride || row1.length !== rowStride) {
|
||||
throw new Error(
|
||||
`Palette row size mismatch: expected ${rowStride} bytes, got ${row0.length}/${row1.length}`,
|
||||
);
|
||||
}
|
||||
|
||||
const bytesPerRow = align(rowStride, 256);
|
||||
@@ -1250,10 +1287,29 @@ export class GroundTruthData {
|
||||
this.needsPaletteUpload = true;
|
||||
}
|
||||
|
||||
setPaletteOverride(
|
||||
paletteWidth: number,
|
||||
maxSmallId: number,
|
||||
row0: Uint8Array,
|
||||
row1: Uint8Array,
|
||||
): void {
|
||||
this.paletteOverride = { paletteWidth, maxSmallId, row0, row1 };
|
||||
this.needsPaletteUpload = true;
|
||||
}
|
||||
|
||||
markDefensePostsDirty(): void {
|
||||
this.needsDefensePostsUpload = true;
|
||||
}
|
||||
|
||||
markStateDirty(): void {
|
||||
this.needsStateUpload = true;
|
||||
}
|
||||
|
||||
markDefendedFullRecompute(): void {
|
||||
this.needsFullDefendedStrengthRecompute = true;
|
||||
this.defendedDirtyTilesCount = 0;
|
||||
}
|
||||
|
||||
getState(): Uint16Array {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
+14
-2
@@ -86,6 +86,7 @@ export class GameRunner {
|
||||
private isExecuting = false;
|
||||
|
||||
private playerViewData: Record<PlayerID, NameViewData> = {};
|
||||
public tileUpdateSink?: (tile: TileRef) => void;
|
||||
|
||||
constructor(
|
||||
public game: Game,
|
||||
@@ -166,12 +167,23 @@ export class GameRunner {
|
||||
}
|
||||
|
||||
// Many tiles are updated to pack it into an array
|
||||
const packedTileUpdates = updates[GameUpdateType.Tile].map((u) => u.update);
|
||||
const tileUpdates = updates[GameUpdateType.Tile];
|
||||
let packedTileUpdates: BigUint64Array;
|
||||
if (this.tileUpdateSink) {
|
||||
for (const u of tileUpdates) {
|
||||
// packed tile updates encode [tileRef << 16 | state] as bigint.
|
||||
const tileRef = Number(u.update >> 16n) as TileRef;
|
||||
this.tileUpdateSink(tileRef);
|
||||
}
|
||||
packedTileUpdates = new BigUint64Array(0);
|
||||
} else {
|
||||
packedTileUpdates = new BigUint64Array(tileUpdates.map((u) => u.update));
|
||||
}
|
||||
updates[GameUpdateType.Tile] = [];
|
||||
|
||||
this.callBack({
|
||||
tick: this.game.ticks(),
|
||||
packedTileUpdates: new BigUint64Array(packedTileUpdates),
|
||||
packedTileUpdates,
|
||||
updates: updates,
|
||||
playerNameViewData: this.playerViewData,
|
||||
tickExecutionDuration: tickExecutionDuration,
|
||||
|
||||
@@ -4,6 +4,16 @@ import { PlayerPattern } from "../Schemas";
|
||||
const PATTERN_KEY = "territoryPattern";
|
||||
|
||||
export class UserSettings {
|
||||
getString(key: string, defaultValue: string): string {
|
||||
const value = localStorage.getItem(key);
|
||||
if (value === null) return defaultValue;
|
||||
return value;
|
||||
}
|
||||
|
||||
setString(key: string, value: string): void {
|
||||
localStorage.setItem(key, value);
|
||||
}
|
||||
|
||||
get(key: string, defaultValue: boolean): boolean {
|
||||
const value = localStorage.getItem(key);
|
||||
if (!value) return defaultValue;
|
||||
@@ -59,6 +69,15 @@ export class UserSettings {
|
||||
return this.get("settings.webgpuDebug", true);
|
||||
}
|
||||
|
||||
backgroundRenderer(): "webgpu" | "canvas2d" {
|
||||
const raw = this.getString("settings.backgroundRenderer", "webgpu");
|
||||
return raw === "canvas2d" ? "canvas2d" : "webgpu";
|
||||
}
|
||||
|
||||
setBackgroundRenderer(renderer: "webgpu" | "canvas2d"): void {
|
||||
this.setString("settings.backgroundRenderer", renderer);
|
||||
}
|
||||
|
||||
alertFrame() {
|
||||
return this.get("settings.alertFrame", true);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { TileRef } from "../game/GameMap";
|
||||
|
||||
/**
|
||||
* Worker-local deduping dirty-tile queue.
|
||||
*
|
||||
* Mirrors the SAB branch "dirtyFlags + ring buffer" idea, but without Atomics
|
||||
* (single-threaded within the worker).
|
||||
*/
|
||||
export class DirtyTileQueue {
|
||||
private readonly dirtyFlags: Uint8Array;
|
||||
private readonly queue: Uint32Array;
|
||||
private head = 0;
|
||||
private tail = 0;
|
||||
private size = 0;
|
||||
|
||||
constructor(
|
||||
numTiles: number,
|
||||
private readonly capacity: number,
|
||||
) {
|
||||
this.dirtyFlags = new Uint8Array(numTiles);
|
||||
this.queue = new Uint32Array(capacity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a tile dirty (idempotent until drained).
|
||||
*
|
||||
* Returns `false` if the queue overflows.
|
||||
*/
|
||||
mark(tile: TileRef): boolean {
|
||||
const idx = tile as unknown as number;
|
||||
if (idx < 0 || idx >= this.dirtyFlags.length) {
|
||||
return true;
|
||||
}
|
||||
if (this.dirtyFlags[idx] === 1) {
|
||||
return true;
|
||||
}
|
||||
if (this.size >= this.capacity) {
|
||||
return false;
|
||||
}
|
||||
this.dirtyFlags[idx] = 1;
|
||||
this.queue[this.tail] = idx >>> 0;
|
||||
this.tail = (this.tail + 1) % this.capacity;
|
||||
this.size++;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drain up to `maxCount` dirty tiles.
|
||||
*
|
||||
* Clears the dirty flag for each returned tile.
|
||||
*/
|
||||
drain(maxCount: number): TileRef[] {
|
||||
const count = Math.min(maxCount, this.size);
|
||||
if (count === 0) {
|
||||
return [];
|
||||
}
|
||||
const out: TileRef[] = new Array(count);
|
||||
for (let i = 0; i < count; i++) {
|
||||
const idx = this.queue[this.head];
|
||||
this.head = (this.head + 1) % this.capacity;
|
||||
this.size--;
|
||||
this.dirtyFlags[idx] = 0;
|
||||
out[i] = idx as unknown as TileRef;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
if (this.size > 0) {
|
||||
this.dirtyFlags.fill(0);
|
||||
}
|
||||
this.head = 0;
|
||||
this.tail = 0;
|
||||
this.size = 0;
|
||||
}
|
||||
|
||||
pendingCount(): number {
|
||||
return this.size;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
import { Colord, colord } from "colord";
|
||||
import { Theme } from "../configuration/Config";
|
||||
import { Game, UnitType } from "../game/Game";
|
||||
import { TileRef } from "../game/GameMap";
|
||||
import { GameUpdateViewData } from "../game/GameUpdates";
|
||||
import { GameView } from "../game/GameView";
|
||||
import { TerrainMapData } from "../game/TerrainMapLoader";
|
||||
import { ClientID, PlayerCosmetics } from "../Schemas";
|
||||
|
||||
/**
|
||||
* Adapter that makes Game work as GameView for rendering purposes.
|
||||
@@ -12,13 +14,20 @@ import { TerrainMapData } from "../game/TerrainMapLoader";
|
||||
*/
|
||||
export class GameViewAdapter implements Partial<GameView> {
|
||||
private lastUpdate: GameUpdateViewData | null = null;
|
||||
private patternsEnabled = false;
|
||||
|
||||
constructor(
|
||||
private game: Game,
|
||||
private mapData: TerrainMapData,
|
||||
private theme: Theme,
|
||||
private readonly myClientId: ClientID | null,
|
||||
private readonly cosmeticsByClientID: Map<ClientID, PlayerCosmetics>,
|
||||
) {}
|
||||
|
||||
setPatternsEnabled(enabled: boolean): void {
|
||||
this.patternsEnabled = enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update adapter with latest game update data.
|
||||
* Invalidates caches so they're recomputed on next access.
|
||||
@@ -70,15 +79,53 @@ export class GameViewAdapter implements Partial<GameView> {
|
||||
|
||||
/**
|
||||
* Convert Game players to PlayerView-like objects for rendering.
|
||||
* Computes colors from theme directly (no PlayerView needed).
|
||||
*
|
||||
* Important: this must match the *main-thread* PlayerView color selection,
|
||||
* otherwise the worker-rendered territory will disagree with UI.
|
||||
*/
|
||||
playerViews(): any[] {
|
||||
const theme = this.theme;
|
||||
return this.game.players().map((player) => {
|
||||
const clientId = player.clientID();
|
||||
const cosmetics =
|
||||
clientId && this.cosmeticsByClientID.has(clientId)
|
||||
? this.cosmeticsByClientID.get(clientId)!
|
||||
: ({} as PlayerCosmetics);
|
||||
|
||||
const defaultTerritoryColor = theme.territoryColor(player as any);
|
||||
const defaultBorderColor = theme.borderColor(defaultTerritoryColor);
|
||||
const territoryRgb = defaultTerritoryColor.toRgb();
|
||||
const borderRgb = defaultBorderColor.toRgb();
|
||||
|
||||
const pattern = this.patternsEnabled ? cosmetics.pattern : undefined;
|
||||
if (pattern) {
|
||||
pattern.colorPalette ??= {
|
||||
name: "",
|
||||
primaryColor: defaultTerritoryColor.toHex(),
|
||||
secondaryColor: defaultBorderColor.toHex(),
|
||||
};
|
||||
}
|
||||
|
||||
const territoryColor: Colord =
|
||||
player.team() === null
|
||||
? colord(
|
||||
cosmetics.color?.color ??
|
||||
pattern?.colorPalette?.primaryColor ??
|
||||
defaultTerritoryColor.toHex(),
|
||||
)
|
||||
: defaultTerritoryColor;
|
||||
|
||||
const maybeFocusedBorderColor =
|
||||
this.myClientId !== null && clientId === this.myClientId
|
||||
? theme.focusedBorderColor()
|
||||
: defaultBorderColor;
|
||||
|
||||
const borderColor: Colord = colord(
|
||||
pattern?.colorPalette?.secondaryColor ??
|
||||
cosmetics.color?.color ??
|
||||
maybeFocusedBorderColor.toHex(),
|
||||
);
|
||||
|
||||
const territoryRgb = territoryColor.toRgb();
|
||||
const borderRgb = borderColor.toRgb();
|
||||
|
||||
const view = {
|
||||
player,
|
||||
|
||||
@@ -3,10 +3,13 @@ import { Theme } from "../configuration/Config";
|
||||
import { PastelTheme } from "../configuration/PastelTheme";
|
||||
import { PastelThemeDark } from "../configuration/PastelThemeDark";
|
||||
import { FetchGameMapLoader } from "../game/FetchGameMapLoader";
|
||||
import { PlayerID } from "../game/Game";
|
||||
import { ErrorUpdate, GameUpdateViewData } from "../game/GameUpdates";
|
||||
import { loadTerrainMap, TerrainMapData } from "../game/TerrainMapLoader";
|
||||
import { createGameRunner, GameRunner } from "../GameRunner";
|
||||
import { GameStartInfo } from "../Schemas";
|
||||
import { ClientID, GameStartInfo, PlayerCosmetics } from "../Schemas";
|
||||
import { DirtyTileQueue } from "./DirtyTileQueue";
|
||||
import { WorkerCanvas2DRenderer } from "./WorkerCanvas2DRenderer";
|
||||
import {
|
||||
AttackAveragePositionResultMessage,
|
||||
InitializedMessage,
|
||||
@@ -15,6 +18,7 @@ import {
|
||||
PlayerBorderTilesResultMessage,
|
||||
PlayerProfileResultMessage,
|
||||
RendererReadyMessage,
|
||||
TileContextResultMessage,
|
||||
TransportShipSpawnResultMessage,
|
||||
WorkerMessage,
|
||||
} from "./WorkerMessages";
|
||||
@@ -23,19 +27,37 @@ import { WorkerTerritoryRenderer } from "./WorkerTerritoryRenderer";
|
||||
const ctx: Worker = self as any;
|
||||
let gameRunner: Promise<GameRunner> | null = null;
|
||||
let gameStartInfo: GameStartInfo | null = null;
|
||||
let myClientID: ClientID | null = null;
|
||||
const mapLoader = new FetchGameMapLoader(`/maps`, version);
|
||||
let renderer: WorkerTerritoryRenderer | null = null;
|
||||
let renderer: WorkerTerritoryRenderer | WorkerCanvas2DRenderer | null = null;
|
||||
let mapData: TerrainMapData | null = null;
|
||||
let dirtyTiles: DirtyTileQueue | null = null;
|
||||
let dirtyTilesOverflow = false;
|
||||
|
||||
function gameUpdate(gu: GameUpdateViewData | ErrorUpdate) {
|
||||
// skip if ErrorUpdate
|
||||
if (!("updates" in gu)) {
|
||||
return;
|
||||
}
|
||||
// Update renderer with game update
|
||||
if (renderer) {
|
||||
renderer.updateGameView(gu);
|
||||
|
||||
// Flush simulation-derived dirty tiles into the renderer before running
|
||||
// compute passes for this tick.
|
||||
if (renderer && dirtyTiles) {
|
||||
if (dirtyTilesOverflow) {
|
||||
dirtyTilesOverflow = false;
|
||||
dirtyTiles.clear();
|
||||
renderer.markAllDirty();
|
||||
} else {
|
||||
const tiles = dirtyTiles.drain(dirtyTiles.pendingCount());
|
||||
for (const tile of tiles) {
|
||||
renderer.markTile(tile);
|
||||
}
|
||||
}
|
||||
|
||||
// Run compute passes at simulation tick cadence (not at render FPS).
|
||||
renderer.tick();
|
||||
}
|
||||
|
||||
sendMessage({
|
||||
type: "game_update",
|
||||
gameUpdate: gu,
|
||||
@@ -56,12 +78,31 @@ ctx.addEventListener("message", async (e: MessageEvent<MainThreadMessage>) => {
|
||||
case "init":
|
||||
try {
|
||||
gameStartInfo = message.gameStartInfo;
|
||||
myClientID = message.clientID;
|
||||
gameRunner = createGameRunner(
|
||||
message.gameStartInfo,
|
||||
message.clientID,
|
||||
mapLoader,
|
||||
gameUpdate,
|
||||
).then((gr) => {
|
||||
const numTiles = gr.game.width() * gr.game.height();
|
||||
// Capacity is bounded; on overflow we fall back to markAllDirty().
|
||||
dirtyTiles = new DirtyTileQueue(numTiles, Math.max(4096, numTiles));
|
||||
dirtyTilesOverflow = false;
|
||||
|
||||
gr.tileUpdateSink = (tile) => {
|
||||
if (!dirtyTiles) {
|
||||
return;
|
||||
}
|
||||
const mark = (t: any) => {
|
||||
if (!dirtyTiles!.mark(t)) {
|
||||
dirtyTilesOverflow = true;
|
||||
}
|
||||
};
|
||||
mark(tile);
|
||||
gr.game.forEachNeighbor(tile, (n) => mark(n));
|
||||
};
|
||||
|
||||
sendMessage({
|
||||
type: "initialized",
|
||||
id: message.id,
|
||||
@@ -88,6 +129,37 @@ ctx.addEventListener("message", async (e: MessageEvent<MainThreadMessage>) => {
|
||||
}
|
||||
break;
|
||||
|
||||
case "tile_context":
|
||||
if (!gameRunner) {
|
||||
throw new Error("Game runner not initialized");
|
||||
}
|
||||
try {
|
||||
const gr = await gameRunner;
|
||||
const tile = message.tile;
|
||||
const hasOwner = gr.game.hasOwner(tile);
|
||||
const ownerSmallId = hasOwner ? gr.game.ownerID(tile) : null;
|
||||
let ownerId: PlayerID | null = null;
|
||||
if (hasOwner) {
|
||||
const owner = gr.game.owner(tile);
|
||||
ownerId = owner && owner.isPlayer() ? owner.id() : null;
|
||||
}
|
||||
sendMessage({
|
||||
type: "tile_context_result",
|
||||
id: message.id,
|
||||
result: {
|
||||
hasOwner,
|
||||
ownerSmallId,
|
||||
ownerId,
|
||||
hasFallout: gr.game.hasFallout(tile),
|
||||
isDefended: gr.game.isDefended(tile),
|
||||
},
|
||||
} as TileContextResultMessage);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch tile context:", error);
|
||||
throw error;
|
||||
}
|
||||
break;
|
||||
|
||||
case "player_actions":
|
||||
if (!gameRunner) {
|
||||
throw new Error("Game runner not initialized");
|
||||
@@ -193,6 +265,9 @@ ctx.addEventListener("message", async (e: MessageEvent<MainThreadMessage>) => {
|
||||
}
|
||||
const gr = await gameRunner;
|
||||
|
||||
(renderer as any)?.dispose?.();
|
||||
renderer = null;
|
||||
|
||||
// Load map data if not already loaded
|
||||
// Use gameStartInfo.config which has the original game map info
|
||||
mapData ??= await loadTerrainMap(
|
||||
@@ -207,9 +282,28 @@ ctx.addEventListener("message", async (e: MessageEvent<MainThreadMessage>) => {
|
||||
? new PastelThemeDark()
|
||||
: new PastelTheme();
|
||||
|
||||
renderer = new WorkerTerritoryRenderer();
|
||||
const cosmeticsByClientID = new Map<ClientID, PlayerCosmetics>();
|
||||
for (const p of gameStartInfo.players) {
|
||||
cosmeticsByClientID.set(
|
||||
p.clientID,
|
||||
(p.cosmetics ?? {}) as PlayerCosmetics,
|
||||
);
|
||||
}
|
||||
|
||||
await renderer.init(message.offscreenCanvas, gr, mapData, theme);
|
||||
const backend = message.backend ?? "webgpu";
|
||||
renderer =
|
||||
backend === "canvas2d"
|
||||
? new WorkerCanvas2DRenderer()
|
||||
: new WorkerTerritoryRenderer();
|
||||
|
||||
await renderer.init(
|
||||
message.offscreenCanvas,
|
||||
gr,
|
||||
mapData,
|
||||
theme,
|
||||
myClientID,
|
||||
cosmeticsByClientID,
|
||||
);
|
||||
|
||||
sendMessage({
|
||||
type: "renderer_ready",
|
||||
@@ -228,6 +322,25 @@ ctx.addEventListener("message", async (e: MessageEvent<MainThreadMessage>) => {
|
||||
}
|
||||
break;
|
||||
|
||||
case "set_patterns_enabled":
|
||||
if (renderer) {
|
||||
renderer.setPatternsEnabled(message.enabled);
|
||||
renderer.tick();
|
||||
}
|
||||
break;
|
||||
|
||||
case "set_palette":
|
||||
if (renderer) {
|
||||
renderer.setPaletteFromBytes(
|
||||
message.paletteWidth,
|
||||
message.maxSmallId,
|
||||
message.row0,
|
||||
message.row1,
|
||||
);
|
||||
renderer.tick();
|
||||
}
|
||||
break;
|
||||
|
||||
case "set_view_size":
|
||||
if (renderer) {
|
||||
renderer.setViewSize(message.width, message.height);
|
||||
@@ -258,33 +371,34 @@ ctx.addEventListener("message", async (e: MessageEvent<MainThreadMessage>) => {
|
||||
|
||||
case "set_shader_settings":
|
||||
if (renderer) {
|
||||
const r: any = renderer as any;
|
||||
if (message.territoryShader) {
|
||||
renderer.setTerritoryShader(message.territoryShader);
|
||||
r.setTerritoryShader?.(message.territoryShader);
|
||||
}
|
||||
if (message.territoryShaderParams0 && message.territoryShaderParams1) {
|
||||
renderer.setTerritoryShaderParams(
|
||||
r.setTerritoryShaderParams?.(
|
||||
message.territoryShaderParams0,
|
||||
message.territoryShaderParams1,
|
||||
);
|
||||
}
|
||||
if (message.terrainShader) {
|
||||
renderer.setTerrainShader(message.terrainShader);
|
||||
r.setTerrainShader?.(message.terrainShader);
|
||||
}
|
||||
if (message.terrainShaderParams0 && message.terrainShaderParams1) {
|
||||
renderer.setTerrainShaderParams(
|
||||
r.setTerrainShaderParams?.(
|
||||
message.terrainShaderParams0,
|
||||
message.terrainShaderParams1,
|
||||
);
|
||||
}
|
||||
if (message.preSmoothing) {
|
||||
renderer.setPreSmoothing(
|
||||
r.setPreSmoothing?.(
|
||||
message.preSmoothing.enabled,
|
||||
message.preSmoothing.shaderPath,
|
||||
message.preSmoothing.params0,
|
||||
);
|
||||
}
|
||||
if (message.postSmoothing) {
|
||||
renderer.setPostSmoothing(
|
||||
r.setPostSmoothing?.(
|
||||
message.postSmoothing.enabled,
|
||||
message.postSmoothing.shaderPath,
|
||||
message.postSmoothing.params0,
|
||||
@@ -302,12 +416,14 @@ ctx.addEventListener("message", async (e: MessageEvent<MainThreadMessage>) => {
|
||||
case "mark_all_dirty":
|
||||
if (renderer) {
|
||||
renderer.markAllDirty();
|
||||
renderer.tick();
|
||||
}
|
||||
break;
|
||||
|
||||
case "refresh_palette":
|
||||
if (renderer) {
|
||||
renderer.refreshPalette();
|
||||
renderer.tick();
|
||||
}
|
||||
break;
|
||||
|
||||
|
||||
@@ -0,0 +1,381 @@
|
||||
import { Theme } from "../configuration/Config";
|
||||
import { TileRef } from "../game/GameMap";
|
||||
import { TerrainMapData } from "../game/TerrainMapLoader";
|
||||
import { GameRunner } from "../GameRunner";
|
||||
import { ClientID, PlayerCosmetics } from "../Schemas";
|
||||
import { GameViewAdapter } from "./GameViewAdapter";
|
||||
|
||||
type Offscreen2D = OffscreenCanvasRenderingContext2D;
|
||||
|
||||
export class WorkerCanvas2DRenderer {
|
||||
private canvas: OffscreenCanvas | null = null;
|
||||
private ctx: Offscreen2D | null = null;
|
||||
|
||||
private rasterCanvas: OffscreenCanvas | null = null;
|
||||
private rasterCtx: Offscreen2D | null = null;
|
||||
private rasterImage: ImageData | null = null;
|
||||
|
||||
private gameViewAdapter: GameViewAdapter | null = null;
|
||||
private gameRunner: GameRunner | null = null;
|
||||
private theme: Theme | null = null;
|
||||
|
||||
private ready = false;
|
||||
|
||||
private viewScale = 1;
|
||||
private viewOffsetX = 0;
|
||||
private viewOffsetY = 0;
|
||||
|
||||
private readonly chunkSize = 64;
|
||||
private chunksX = 1;
|
||||
private chunksY = 1;
|
||||
|
||||
private dirtyChunkFlags: Uint8Array = new Uint8Array(0);
|
||||
private dirtyChunkQueue: Uint32Array = new Uint32Array(0);
|
||||
private dirtyHead = 0;
|
||||
private dirtyTail = 0;
|
||||
private dirtyCapacity = 0;
|
||||
|
||||
private paletteWidth = 1;
|
||||
private paletteMaxSmallId = 0;
|
||||
private paletteRow0: Uint8Array = new Uint8Array(4);
|
||||
private paletteRow1: Uint8Array = new Uint8Array(4);
|
||||
private hasExternalPalette = false;
|
||||
|
||||
async init(
|
||||
offscreenCanvas: OffscreenCanvas,
|
||||
gameRunner: GameRunner,
|
||||
mapData: TerrainMapData,
|
||||
theme: Theme,
|
||||
myClientID: ClientID | null,
|
||||
cosmeticsByClientID: Map<ClientID, PlayerCosmetics>,
|
||||
): Promise<void> {
|
||||
this.canvas = offscreenCanvas;
|
||||
this.ctx = offscreenCanvas.getContext("2d", { alpha: true }) as Offscreen2D;
|
||||
if (!this.ctx) {
|
||||
throw new Error("Failed to get 2D context for OffscreenCanvas");
|
||||
}
|
||||
|
||||
this.gameRunner = gameRunner;
|
||||
this.theme = theme;
|
||||
|
||||
this.gameViewAdapter = new GameViewAdapter(
|
||||
gameRunner.game,
|
||||
mapData,
|
||||
theme,
|
||||
myClientID,
|
||||
cosmeticsByClientID,
|
||||
);
|
||||
|
||||
const mapW = gameRunner.game.width();
|
||||
const mapH = gameRunner.game.height();
|
||||
this.rasterCanvas = new OffscreenCanvas(mapW, mapH);
|
||||
this.rasterCtx = this.rasterCanvas.getContext("2d", {
|
||||
alpha: true,
|
||||
willReadFrequently: true,
|
||||
}) as Offscreen2D;
|
||||
if (!this.rasterCtx) {
|
||||
throw new Error("Failed to get 2D context for raster canvas");
|
||||
}
|
||||
|
||||
this.rasterImage = new ImageData(mapW, mapH);
|
||||
|
||||
this.chunksX = Math.ceil(mapW / this.chunkSize);
|
||||
this.chunksY = Math.ceil(mapH / this.chunkSize);
|
||||
const numChunks = this.chunksX * this.chunksY;
|
||||
|
||||
this.dirtyChunkFlags = new Uint8Array(numChunks);
|
||||
// Chunk queue sized so markAllDirty() can enqueue every chunk.
|
||||
this.dirtyCapacity = Math.max(1024, numChunks + 1);
|
||||
this.dirtyChunkQueue = new Uint32Array(this.dirtyCapacity);
|
||||
this.dirtyHead = 0;
|
||||
this.dirtyTail = 0;
|
||||
|
||||
this.ready = true;
|
||||
|
||||
// First paint.
|
||||
this.rebuildPaletteFromGame();
|
||||
this.markAllDirty();
|
||||
this.tick();
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.ready = false;
|
||||
this.canvas = null;
|
||||
this.ctx = null;
|
||||
this.rasterCanvas = null;
|
||||
this.rasterCtx = null;
|
||||
this.rasterImage = null;
|
||||
this.gameViewAdapter = null;
|
||||
this.gameRunner = null;
|
||||
this.theme = null;
|
||||
this.dirtyChunkFlags = new Uint8Array(0);
|
||||
this.dirtyChunkQueue = new Uint32Array(0);
|
||||
this.dirtyHead = 0;
|
||||
this.dirtyTail = 0;
|
||||
this.dirtyCapacity = 0;
|
||||
}
|
||||
|
||||
setViewSize(width: number, height: number): void {
|
||||
if (!this.canvas) return;
|
||||
this.canvas.width = Math.max(1, Math.floor(width));
|
||||
this.canvas.height = Math.max(1, Math.floor(height));
|
||||
}
|
||||
|
||||
setViewTransform(scale: number, offsetX: number, offsetY: number): void {
|
||||
this.viewScale = scale;
|
||||
this.viewOffsetX = offsetX;
|
||||
this.viewOffsetY = offsetY;
|
||||
}
|
||||
|
||||
setAlternativeView(_enabled: boolean): void {}
|
||||
setHighlightedOwnerId(_ownerSmallId: number | null): void {}
|
||||
setPatternsEnabled(enabled: boolean): void {
|
||||
this.gameViewAdapter?.setPatternsEnabled(enabled);
|
||||
// Patterns affect colours; simplest is a full repaint.
|
||||
if (!this.hasExternalPalette) {
|
||||
this.rebuildPaletteFromGame();
|
||||
}
|
||||
this.markAllDirty();
|
||||
}
|
||||
|
||||
setPaletteFromBytes(
|
||||
paletteWidth: number,
|
||||
maxSmallId: number,
|
||||
row0: Uint8Array,
|
||||
row1: Uint8Array,
|
||||
): void {
|
||||
this.paletteWidth = paletteWidth;
|
||||
this.paletteMaxSmallId = maxSmallId;
|
||||
this.paletteRow0 = row0;
|
||||
this.paletteRow1 = row1;
|
||||
this.hasExternalPalette = true;
|
||||
this.markAllDirty();
|
||||
}
|
||||
|
||||
refreshPalette(): void {
|
||||
if (!this.hasExternalPalette) {
|
||||
this.rebuildPaletteFromGame();
|
||||
}
|
||||
this.markAllDirty();
|
||||
}
|
||||
|
||||
refreshTerrain(): void {
|
||||
this.markAllDirty();
|
||||
}
|
||||
|
||||
markTile(tile: TileRef): void {
|
||||
if (!this.ready || !this.gameRunner) return;
|
||||
const x = this.gameRunner.game.x(tile);
|
||||
const y = this.gameRunner.game.y(tile);
|
||||
this.markChunkAt(x, y);
|
||||
}
|
||||
|
||||
markAllDirty(): void {
|
||||
if (!this.ready) return;
|
||||
this.dirtyChunkFlags.fill(0);
|
||||
this.dirtyHead = 0;
|
||||
this.dirtyTail = 0;
|
||||
const numChunks = this.dirtyChunkFlags.length;
|
||||
for (let i = 0; i < numChunks; i++) {
|
||||
this.enqueueChunk(i);
|
||||
}
|
||||
}
|
||||
|
||||
tick(): void {
|
||||
if (
|
||||
!this.ready ||
|
||||
!this.gameRunner ||
|
||||
!this.theme ||
|
||||
!this.gameViewAdapter ||
|
||||
!this.rasterCtx ||
|
||||
!this.rasterImage
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mapW = this.gameRunner.game.width();
|
||||
const mapH = this.gameRunner.game.height();
|
||||
const data = this.rasterImage.data;
|
||||
|
||||
const budgetMs = 6;
|
||||
const start = performance.now();
|
||||
|
||||
while (this.dirtyHead !== this.dirtyTail) {
|
||||
if (performance.now() - start > budgetMs) {
|
||||
break;
|
||||
}
|
||||
|
||||
const chunkId = this.dirtyChunkQueue[this.dirtyHead];
|
||||
this.dirtyHead = (this.dirtyHead + 1) % this.dirtyCapacity;
|
||||
this.dirtyChunkFlags[chunkId] = 0;
|
||||
|
||||
const cx = chunkId % this.chunksX;
|
||||
const cy = Math.floor(chunkId / this.chunksX);
|
||||
const sx = cx * this.chunkSize;
|
||||
const sy = cy * this.chunkSize;
|
||||
const ex = Math.min(mapW, sx + this.chunkSize);
|
||||
const ey = Math.min(mapH, sy + this.chunkSize);
|
||||
|
||||
for (let y = sy; y < ey; y++) {
|
||||
const row = y * mapW;
|
||||
for (let x = sx; x < ex; x++) {
|
||||
const tile = this.gameRunner.game.ref(x, y);
|
||||
|
||||
let r = 0,
|
||||
g = 0,
|
||||
b = 0,
|
||||
a = 255;
|
||||
|
||||
if (this.gameRunner.game.hasFallout(tile)) {
|
||||
const idx = 0;
|
||||
r = this.paletteRow0[idx] ?? 120;
|
||||
g = this.paletteRow0[idx + 1] ?? 255;
|
||||
b = this.paletteRow0[idx + 2] ?? 71;
|
||||
} else if (this.gameRunner.game.hasOwner(tile)) {
|
||||
const ownerSmallId = this.gameRunner.game.ownerID(tile);
|
||||
const slot = 10 + Math.max(0, ownerSmallId);
|
||||
const idx = slot * 4;
|
||||
if (idx + 2 < this.paletteRow0.length) {
|
||||
r = this.paletteRow0[idx];
|
||||
g = this.paletteRow0[idx + 1];
|
||||
b = this.paletteRow0[idx + 2];
|
||||
} else {
|
||||
const rgba = this.theme.terrainColor(
|
||||
this.gameRunner.game,
|
||||
tile,
|
||||
).rgba;
|
||||
r = rgba.r;
|
||||
g = rgba.g;
|
||||
b = rgba.b;
|
||||
a = rgba.a ?? 255;
|
||||
}
|
||||
} else {
|
||||
const rgba = this.theme.terrainColor(
|
||||
this.gameRunner.game,
|
||||
tile,
|
||||
).rgba;
|
||||
r = rgba.r;
|
||||
g = rgba.g;
|
||||
b = rgba.b;
|
||||
a = rgba.a ?? 255;
|
||||
}
|
||||
|
||||
const p = (row + x) * 4;
|
||||
data[p] = r;
|
||||
data[p + 1] = g;
|
||||
data[p + 2] = b;
|
||||
data[p + 3] = a;
|
||||
}
|
||||
}
|
||||
|
||||
this.rasterCtx.putImageData(
|
||||
this.rasterImage,
|
||||
0,
|
||||
0,
|
||||
sx,
|
||||
sy,
|
||||
ex - sx,
|
||||
ey - sy,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
render(): void {
|
||||
if (!this.ready || !this.ctx || !this.gameRunner || !this.rasterCanvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
const w = (this.canvas?.width ?? 1) as number;
|
||||
const h = (this.canvas?.height ?? 1) as number;
|
||||
|
||||
this.ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||
this.ctx.clearRect(0, 0, w, h);
|
||||
this.ctx.imageSmoothingEnabled = false;
|
||||
|
||||
const scale = this.viewScale;
|
||||
this.ctx.setTransform(
|
||||
scale,
|
||||
0,
|
||||
0,
|
||||
scale,
|
||||
this.gameRunner.game.width() / 2 - this.viewOffsetX * scale,
|
||||
this.gameRunner.game.height() / 2 - this.viewOffsetY * scale,
|
||||
);
|
||||
|
||||
this.ctx.drawImage(
|
||||
this.rasterCanvas,
|
||||
-this.gameRunner.game.width() / 2,
|
||||
-this.gameRunner.game.height() / 2,
|
||||
);
|
||||
}
|
||||
|
||||
private markChunkAt(x: number, y: number): void {
|
||||
const cx = Math.floor(x / this.chunkSize);
|
||||
const cy = Math.floor(y / this.chunkSize);
|
||||
if (cx < 0 || cy < 0 || cx >= this.chunksX || cy >= this.chunksY) {
|
||||
return;
|
||||
}
|
||||
const chunkId = cx + cy * this.chunksX;
|
||||
this.enqueueChunk(chunkId);
|
||||
}
|
||||
|
||||
private enqueueChunk(chunkId: number): void {
|
||||
if (this.dirtyChunkFlags[chunkId] === 1) {
|
||||
return;
|
||||
}
|
||||
this.dirtyChunkFlags[chunkId] = 1;
|
||||
this.dirtyChunkQueue[this.dirtyTail] = chunkId;
|
||||
this.dirtyTail = (this.dirtyTail + 1) % this.dirtyCapacity;
|
||||
if (this.dirtyTail === this.dirtyHead) {
|
||||
// Overflow: fall back to repaint everything next tick.
|
||||
this.markAllDirty();
|
||||
}
|
||||
}
|
||||
|
||||
private rebuildPaletteFromGame(): void {
|
||||
if (!this.gameViewAdapter) {
|
||||
return;
|
||||
}
|
||||
|
||||
let maxSmallId = 0;
|
||||
const players = this.gameViewAdapter.playerViews();
|
||||
for (const p of players) {
|
||||
maxSmallId = Math.max(maxSmallId, p.smallID());
|
||||
}
|
||||
|
||||
const RESERVED = 10;
|
||||
this.paletteMaxSmallId = maxSmallId;
|
||||
this.paletteWidth = RESERVED + Math.max(1, maxSmallId + 1);
|
||||
const rowStride = this.paletteWidth * 4;
|
||||
|
||||
const row0 = new Uint8Array(rowStride);
|
||||
const row1 = new Uint8Array(rowStride);
|
||||
|
||||
row0[0] = 120;
|
||||
row0[1] = 255;
|
||||
row0[2] = 71;
|
||||
row0[3] = 255;
|
||||
|
||||
for (const p of players) {
|
||||
const id = p.smallID();
|
||||
if (id <= 0) continue;
|
||||
const idx = (RESERVED + id) * 4;
|
||||
|
||||
const tr = p.territoryColor().rgba;
|
||||
row0[idx] = tr.r;
|
||||
row0[idx + 1] = tr.g;
|
||||
row0[idx + 2] = tr.b;
|
||||
row0[idx + 3] = 255;
|
||||
|
||||
const br = p.borderColor().rgba;
|
||||
row1[idx] = br.r;
|
||||
row1[idx + 1] = br.g;
|
||||
row1[idx + 2] = br.b;
|
||||
row1[idx + 3] = 255;
|
||||
}
|
||||
|
||||
this.paletteRow0 = row0;
|
||||
this.paletteRow1 = row1;
|
||||
this.hasExternalPalette = false;
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import { TileRef } from "../game/GameMap";
|
||||
import { ErrorUpdate, GameUpdateViewData } from "../game/GameUpdates";
|
||||
import { ClientID, GameStartInfo, Turn } from "../Schemas";
|
||||
import { generateID } from "../Util";
|
||||
import { WorkerMessage } from "./WorkerMessages";
|
||||
import { TileContext, WorkerMessage } from "./WorkerMessages";
|
||||
|
||||
export class WorkerClient {
|
||||
private worker: Worker;
|
||||
@@ -286,6 +286,29 @@ export class WorkerClient {
|
||||
});
|
||||
}
|
||||
|
||||
tileContext(tile: TileRef): Promise<TileContext> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.isInitialized) {
|
||||
reject(new Error("Worker not initialized"));
|
||||
return;
|
||||
}
|
||||
|
||||
const messageId = generateID();
|
||||
|
||||
this.messageHandlers.set(messageId, (message) => {
|
||||
if (message.type === "tile_context_result" && message.result) {
|
||||
resolve(message.result);
|
||||
}
|
||||
});
|
||||
|
||||
this.worker.postMessage({
|
||||
type: "tile_context",
|
||||
id: messageId,
|
||||
tile,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
this.worker.terminate();
|
||||
this.messageHandlers.clear();
|
||||
|
||||
@@ -14,6 +14,8 @@ export type WorkerMessageType =
|
||||
| "initialized"
|
||||
| "turn"
|
||||
| "game_update"
|
||||
| "tile_context"
|
||||
| "tile_context_result"
|
||||
| "player_actions"
|
||||
| "player_actions_result"
|
||||
| "player_profile"
|
||||
@@ -26,6 +28,8 @@ export type WorkerMessageType =
|
||||
| "transport_ship_spawn_result"
|
||||
| "init_renderer"
|
||||
| "renderer_ready"
|
||||
| "set_patterns_enabled"
|
||||
| "set_palette"
|
||||
| "set_view_size"
|
||||
| "set_view_transform"
|
||||
| "set_alternative_view"
|
||||
@@ -71,6 +75,24 @@ export interface GameUpdateMessage extends BaseWorkerMessage {
|
||||
gameUpdate: GameUpdateViewData;
|
||||
}
|
||||
|
||||
export interface TileContext {
|
||||
hasOwner: boolean;
|
||||
ownerSmallId: number | null;
|
||||
ownerId: PlayerID | null;
|
||||
hasFallout: boolean;
|
||||
isDefended: boolean;
|
||||
}
|
||||
|
||||
export interface TileContextMessage extends BaseWorkerMessage {
|
||||
type: "tile_context";
|
||||
tile: TileRef;
|
||||
}
|
||||
|
||||
export interface TileContextResultMessage extends BaseWorkerMessage {
|
||||
type: "tile_context_result";
|
||||
result: TileContext;
|
||||
}
|
||||
|
||||
export interface PlayerActionsMessage extends BaseWorkerMessage {
|
||||
type: "player_actions";
|
||||
playerID: PlayerID;
|
||||
@@ -131,6 +153,20 @@ export interface InitRendererMessage extends BaseWorkerMessage {
|
||||
type: "init_renderer";
|
||||
offscreenCanvas: OffscreenCanvas;
|
||||
darkMode: boolean; // Whether to use dark theme
|
||||
backend?: "webgpu" | "canvas2d";
|
||||
}
|
||||
|
||||
export interface SetPatternsEnabledMessage extends BaseWorkerMessage {
|
||||
type: "set_patterns_enabled";
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface SetPaletteMessage extends BaseWorkerMessage {
|
||||
type: "set_palette";
|
||||
paletteWidth: number;
|
||||
maxSmallId: number;
|
||||
row0: Uint8Array;
|
||||
row1: Uint8Array;
|
||||
}
|
||||
|
||||
export interface SetViewSizeMessage extends BaseWorkerMessage {
|
||||
@@ -218,12 +254,15 @@ export type MainThreadMessage =
|
||||
| HeartbeatMessage
|
||||
| InitMessage
|
||||
| TurnMessage
|
||||
| TileContextMessage
|
||||
| PlayerActionsMessage
|
||||
| PlayerProfileMessage
|
||||
| PlayerBorderTilesMessage
|
||||
| AttackAveragePositionMessage
|
||||
| TransportShipSpawnMessage
|
||||
| InitRendererMessage
|
||||
| SetPatternsEnabledMessage
|
||||
| SetPaletteMessage
|
||||
| SetViewSizeMessage
|
||||
| SetViewTransformMessage
|
||||
| SetAlternativeViewMessage
|
||||
@@ -240,6 +279,7 @@ export type MainThreadMessage =
|
||||
export type WorkerMessage =
|
||||
| InitializedMessage
|
||||
| GameUpdateMessage
|
||||
| TileContextResultMessage
|
||||
| PlayerActionsResultMessage
|
||||
| PlayerProfileResultMessage
|
||||
| PlayerBorderTilesResultMessage
|
||||
|
||||
@@ -3,6 +3,7 @@ import { TileRef } from "../game/GameMap";
|
||||
import { GameUpdateViewData } from "../game/GameUpdates";
|
||||
import { TerrainMapData } from "../game/TerrainMapLoader";
|
||||
import { GameRunner } from "../GameRunner";
|
||||
import { ClientID, PlayerCosmetics } from "../Schemas";
|
||||
import { GameViewAdapter } from "./GameViewAdapter";
|
||||
|
||||
// Import rendering components from client (they should work with adapter)
|
||||
@@ -61,6 +62,7 @@ export class WorkerTerritoryRenderer {
|
||||
private preSmoothingEnabled = false;
|
||||
private postSmoothingEnabled = false;
|
||||
private defensePostRange: number;
|
||||
private patternsEnabled = false;
|
||||
|
||||
/**
|
||||
* Initialize renderer with offscreen canvas and game data.
|
||||
@@ -70,13 +72,22 @@ export class WorkerTerritoryRenderer {
|
||||
gameRunner: GameRunner,
|
||||
mapData: TerrainMapData,
|
||||
theme: Theme,
|
||||
myClientID: ClientID | null,
|
||||
cosmeticsByClientID: Map<ClientID, PlayerCosmetics>,
|
||||
): Promise<void> {
|
||||
this.canvas = offscreenCanvas;
|
||||
const game = gameRunner.game;
|
||||
this.defensePostRange = game.config().defensePostRange();
|
||||
|
||||
// Create adapter
|
||||
this.gameViewAdapter = new GameViewAdapter(game, mapData, theme);
|
||||
this.gameViewAdapter = new GameViewAdapter(
|
||||
game,
|
||||
mapData,
|
||||
theme,
|
||||
myClientID,
|
||||
cosmeticsByClientID,
|
||||
);
|
||||
this.gameViewAdapter.setPatternsEnabled(this.patternsEnabled);
|
||||
|
||||
// Initialize WebGPU device with offscreen canvas
|
||||
const webgpuDevice = await WebGPUDevice.create(offscreenCanvas);
|
||||
@@ -265,6 +276,13 @@ export class WorkerTerritoryRenderer {
|
||||
this.resources.setHighlightedOwnerId(ownerSmallId);
|
||||
}
|
||||
|
||||
setPatternsEnabled(enabled: boolean): void {
|
||||
this.patternsEnabled = enabled;
|
||||
this.gameViewAdapter?.setPatternsEnabled(enabled);
|
||||
this.resources?.markPaletteDirty();
|
||||
this.resources?.invalidateHistory();
|
||||
}
|
||||
|
||||
setTerritoryShader(shaderPath: string): void {
|
||||
this.territoryShaderPath = shaderPath;
|
||||
if (this.territoryRenderPass) {
|
||||
@@ -402,7 +420,18 @@ export class WorkerTerritoryRenderer {
|
||||
}
|
||||
|
||||
markAllDirty(): void {
|
||||
this.resources?.markDefensePostsDirty();
|
||||
if (!this.resources) {
|
||||
return;
|
||||
}
|
||||
// Full sync points used when the dirty-tile pipeline overflows or when
|
||||
// global settings require a complete rebuild.
|
||||
this.resources.markStateDirty();
|
||||
this.resources.markDefensePostsDirty();
|
||||
this.resources.markDefendedFullRecompute();
|
||||
this.resources.markPaletteDirty();
|
||||
this.resources.invalidateHistory();
|
||||
|
||||
this.terrainComputePass?.markDirty();
|
||||
}
|
||||
|
||||
refreshPalette(): void {
|
||||
@@ -412,6 +441,19 @@ export class WorkerTerritoryRenderer {
|
||||
this.resources.markPaletteDirty();
|
||||
}
|
||||
|
||||
setPaletteFromBytes(
|
||||
paletteWidth: number,
|
||||
maxSmallId: number,
|
||||
row0: Uint8Array,
|
||||
row1: Uint8Array,
|
||||
): void {
|
||||
if (!this.resources) {
|
||||
return;
|
||||
}
|
||||
this.resources.setPaletteOverride(paletteWidth, maxSmallId, row0, row1);
|
||||
this.resources.invalidateHistory();
|
||||
}
|
||||
|
||||
markDefensePostsDirty(): void {
|
||||
if (!this.resources) {
|
||||
return;
|
||||
@@ -430,6 +472,26 @@ export class WorkerTerritoryRenderer {
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.ready = false;
|
||||
this.computePasses = [];
|
||||
this.computePassOrder = [];
|
||||
this.frameComputePasses = [];
|
||||
this.renderPasses = [];
|
||||
this.renderPassOrder = [];
|
||||
this.terrainComputePass = null;
|
||||
this.stateUpdatePass = null;
|
||||
this.defendedStrengthFullPass = null;
|
||||
this.defendedStrengthPass = null;
|
||||
this.visualStateSmoothingPass = null;
|
||||
this.territoryRenderPass = null;
|
||||
this.temporalResolvePass = null;
|
||||
this.resources = null;
|
||||
this.gameViewAdapter = null;
|
||||
this.device = null;
|
||||
this.canvas = null;
|
||||
}
|
||||
|
||||
private computeTerrainImmediate(): void {
|
||||
if (
|
||||
!this.ready ||
|
||||
|
||||
Reference in New Issue
Block a user