mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-30 09:09:40 +00:00
Worker renderers: decouple from Game/TerrainMap, coalesce view and sim
- GameViewAdapter: build from tileState/terrainData buffers and game updates (players, defense posts, embargo/alliance) instead of Game + TerrainMapData; add DefensePostUnit/PlayerLiteView and drop config(). - Worker: keep local renderTileState; tileUpdateSink receives packed bigint and updates buffer + dirty queue; no terrain map load in worker. - Proxies: send view size/transform only when changed, inline in render_frame (optional viewSize/viewTransform); remove separate set_view_size/set_view_transform messages. - Simulation: remove main-thread RAF heartbeat loop; worker uses scheduleSimPump() on heartbeat/addTurn to coalesce ticks. - GroundTruthData: take defensePostRange at construction; Territory renderer passes it through; remove runtime defensePostRange change check. - GameRunner: tileUpdateSink(packedTileUpdate: bigint); add hasPendingTurns().
This commit is contained in:
@@ -381,15 +381,6 @@ export class ClientGameRunner {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const worker = this.worker;
|
|
||||||
const keepWorkerAlive = () => {
|
|
||||||
if (this.isActive) {
|
|
||||||
worker.sendHeartbeat();
|
|
||||||
requestAnimationFrame(keepWorkerAlive);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
requestAnimationFrame(keepWorkerAlive);
|
|
||||||
|
|
||||||
const onconnect = () => {
|
const onconnect = () => {
|
||||||
console.log("Connected to game server!");
|
console.log("Connected to game server!");
|
||||||
this.transport.rejoinGame(this.turnsSeen);
|
this.transport.rejoinGame(this.turnsSeen);
|
||||||
|
|||||||
@@ -15,9 +15,9 @@ import {
|
|||||||
SetPaletteMessage,
|
SetPaletteMessage,
|
||||||
SetPatternsEnabledMessage,
|
SetPatternsEnabledMessage,
|
||||||
SetShaderSettingsMessage,
|
SetShaderSettingsMessage,
|
||||||
SetViewSizeMessage,
|
|
||||||
SetViewTransformMessage,
|
|
||||||
TickRendererMessage,
|
TickRendererMessage,
|
||||||
|
ViewSize,
|
||||||
|
ViewTransform,
|
||||||
} from "../../../core/worker/WorkerMessages";
|
} from "../../../core/worker/WorkerMessages";
|
||||||
|
|
||||||
export interface Canvas2DCreateResult {
|
export interface Canvas2DCreateResult {
|
||||||
@@ -34,6 +34,11 @@ export class Canvas2DRendererProxy {
|
|||||||
private initPromise: Promise<void> | null = null;
|
private initPromise: Promise<void> | null = null;
|
||||||
private pendingMessages: Array<{ message: any; transferables?: any[] }> = [];
|
private pendingMessages: Array<{ message: any; transferables?: any[] }> = [];
|
||||||
|
|
||||||
|
private viewSize: ViewSize = { width: 1, height: 1 };
|
||||||
|
private viewTransform: ViewTransform = { scale: 1, offsetX: 0, offsetY: 0 };
|
||||||
|
private lastSentViewSize: ViewSize | null = null;
|
||||||
|
private lastSentViewTransform: ViewTransform | null = null;
|
||||||
|
|
||||||
private constructor(
|
private constructor(
|
||||||
private readonly game: GameView,
|
private readonly game: GameView,
|
||||||
private readonly theme: Theme,
|
private readonly theme: Theme,
|
||||||
@@ -159,22 +164,14 @@ export class Canvas2DRendererProxy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setViewSize(width: number, height: number): void {
|
setViewSize(width: number, height: number): void {
|
||||||
const message: SetViewSizeMessage = {
|
this.viewSize = {
|
||||||
type: "set_view_size",
|
width: Math.max(1, Math.floor(width)),
|
||||||
width,
|
height: Math.max(1, Math.floor(height)),
|
||||||
height,
|
|
||||||
};
|
};
|
||||||
this.sendToWorker(message);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setViewTransform(scale: number, offsetX: number, offsetY: number): void {
|
setViewTransform(scale: number, offsetX: number, offsetY: number): void {
|
||||||
const message: SetViewTransformMessage = {
|
this.viewTransform = { scale, offsetX, offsetY };
|
||||||
type: "set_view_transform",
|
|
||||||
scale,
|
|
||||||
offsetX,
|
|
||||||
offsetY,
|
|
||||||
};
|
|
||||||
this.sendToWorker(message);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setAlternativeView(enabled: boolean): void {
|
setAlternativeView(enabled: boolean): void {
|
||||||
@@ -305,6 +302,26 @@ export class Canvas2DRendererProxy {
|
|||||||
|
|
||||||
render(): void {
|
render(): void {
|
||||||
const message: RenderFrameMessage = { type: "render_frame" };
|
const message: RenderFrameMessage = { type: "render_frame" };
|
||||||
|
|
||||||
|
if (
|
||||||
|
!this.lastSentViewSize ||
|
||||||
|
this.lastSentViewSize.width !== this.viewSize.width ||
|
||||||
|
this.lastSentViewSize.height !== this.viewSize.height
|
||||||
|
) {
|
||||||
|
message.viewSize = this.viewSize;
|
||||||
|
this.lastSentViewSize = this.viewSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!this.lastSentViewTransform ||
|
||||||
|
this.lastSentViewTransform.scale !== this.viewTransform.scale ||
|
||||||
|
this.lastSentViewTransform.offsetX !== this.viewTransform.offsetX ||
|
||||||
|
this.lastSentViewTransform.offsetY !== this.viewTransform.offsetY
|
||||||
|
) {
|
||||||
|
message.viewTransform = this.viewTransform;
|
||||||
|
this.lastSentViewTransform = this.viewTransform;
|
||||||
|
}
|
||||||
|
|
||||||
this.sendToWorker(message);
|
this.sendToWorker(message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ export class TerritoryRenderer {
|
|||||||
webgpuDevice.device,
|
webgpuDevice.device,
|
||||||
this.game,
|
this.game,
|
||||||
this.theme,
|
this.theme,
|
||||||
|
this.defensePostRange,
|
||||||
state,
|
state,
|
||||||
);
|
);
|
||||||
this.resources.setTerritoryShaderParams(
|
this.resources.setTerritoryShaderParams(
|
||||||
|
|||||||
@@ -15,9 +15,9 @@ import {
|
|||||||
SetPaletteMessage,
|
SetPaletteMessage,
|
||||||
SetPatternsEnabledMessage,
|
SetPatternsEnabledMessage,
|
||||||
SetShaderSettingsMessage,
|
SetShaderSettingsMessage,
|
||||||
SetViewSizeMessage,
|
|
||||||
SetViewTransformMessage,
|
|
||||||
TickRendererMessage,
|
TickRendererMessage,
|
||||||
|
ViewSize,
|
||||||
|
ViewTransform,
|
||||||
} from "../../../core/worker/WorkerMessages";
|
} from "../../../core/worker/WorkerMessages";
|
||||||
|
|
||||||
export interface TerritoryWebGLCreateResult {
|
export interface TerritoryWebGLCreateResult {
|
||||||
@@ -38,6 +38,11 @@ export class TerritoryRendererProxy {
|
|||||||
private initPromise: Promise<void> | null = null;
|
private initPromise: Promise<void> | null = null;
|
||||||
private pendingMessages: Array<{ message: any; transferables?: any[] }> = [];
|
private pendingMessages: Array<{ message: any; transferables?: any[] }> = [];
|
||||||
|
|
||||||
|
private viewSize: ViewSize = { width: 1, height: 1 };
|
||||||
|
private viewTransform: ViewTransform = { scale: 1, offsetX: 0, offsetY: 0 };
|
||||||
|
private lastSentViewSize: ViewSize | null = null;
|
||||||
|
private lastSentViewTransform: ViewTransform | null = null;
|
||||||
|
|
||||||
private constructor(
|
private constructor(
|
||||||
private readonly game: GameView,
|
private readonly game: GameView,
|
||||||
private readonly theme: Theme,
|
private readonly theme: Theme,
|
||||||
@@ -183,22 +188,14 @@ export class TerritoryRendererProxy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setViewSize(width: number, height: number): void {
|
setViewSize(width: number, height: number): void {
|
||||||
const message: SetViewSizeMessage = {
|
this.viewSize = {
|
||||||
type: "set_view_size",
|
width: Math.max(1, Math.floor(width)),
|
||||||
width,
|
height: Math.max(1, Math.floor(height)),
|
||||||
height,
|
|
||||||
};
|
};
|
||||||
this.sendToWorker(message);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setViewTransform(scale: number, offsetX: number, offsetY: number): void {
|
setViewTransform(scale: number, offsetX: number, offsetY: number): void {
|
||||||
const message: SetViewTransformMessage = {
|
this.viewTransform = { scale, offsetX, offsetY };
|
||||||
type: "set_view_transform",
|
|
||||||
scale,
|
|
||||||
offsetX,
|
|
||||||
offsetY,
|
|
||||||
};
|
|
||||||
this.sendToWorker(message);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setAlternativeView(enabled: boolean): void {
|
setAlternativeView(enabled: boolean): void {
|
||||||
@@ -393,9 +390,27 @@ export class TerritoryRendererProxy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render(): void {
|
render(): void {
|
||||||
const message: RenderFrameMessage = {
|
const message: RenderFrameMessage = { type: "render_frame" };
|
||||||
type: "render_frame",
|
|
||||||
};
|
if (
|
||||||
|
!this.lastSentViewSize ||
|
||||||
|
this.lastSentViewSize.width !== this.viewSize.width ||
|
||||||
|
this.lastSentViewSize.height !== this.viewSize.height
|
||||||
|
) {
|
||||||
|
message.viewSize = this.viewSize;
|
||||||
|
this.lastSentViewSize = this.viewSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!this.lastSentViewTransform ||
|
||||||
|
this.lastSentViewTransform.scale !== this.viewTransform.scale ||
|
||||||
|
this.lastSentViewTransform.offsetX !== this.viewTransform.offsetX ||
|
||||||
|
this.lastSentViewTransform.offsetY !== this.viewTransform.offsetY
|
||||||
|
) {
|
||||||
|
message.viewTransform = this.viewTransform;
|
||||||
|
this.lastSentViewTransform = this.viewTransform;
|
||||||
|
}
|
||||||
|
|
||||||
this.sendToWorker(message);
|
this.sendToWorker(message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ export class GroundTruthData {
|
|||||||
private defendedDirtyTilesCount = 0;
|
private defendedDirtyTilesCount = 0;
|
||||||
private needsFullDefendedStrengthRecompute = false;
|
private needsFullDefendedStrengthRecompute = false;
|
||||||
private lastDefensePostKeys = new Set<string>();
|
private lastDefensePostKeys = new Set<string>();
|
||||||
|
private defensePostRange = 0;
|
||||||
private defenseCircleRange = -1;
|
private defenseCircleRange = -1;
|
||||||
private defenseCircleOffsets: Int16Array = new Int16Array(0); // [dx0, dy0, dx1, dy1, ...]
|
private defenseCircleOffsets: Int16Array = new Int16Array(0); // [dx0, dy0, dx1, dy1, ...]
|
||||||
|
|
||||||
@@ -122,6 +123,7 @@ export class GroundTruthData {
|
|||||||
private readonly device: GPUDevice,
|
private readonly device: GPUDevice,
|
||||||
private readonly game: GameView,
|
private readonly game: GameView,
|
||||||
private readonly theme: Theme,
|
private readonly theme: Theme,
|
||||||
|
defensePostRange: number,
|
||||||
state: Uint16Array,
|
state: Uint16Array,
|
||||||
terrainData: Uint8Array,
|
terrainData: Uint8Array,
|
||||||
mapWidth: number,
|
mapWidth: number,
|
||||||
@@ -131,6 +133,7 @@ export class GroundTruthData {
|
|||||||
this.terrainData = terrainData;
|
this.terrainData = terrainData;
|
||||||
this.mapWidth = mapWidth;
|
this.mapWidth = mapWidth;
|
||||||
this.mapHeight = mapHeight;
|
this.mapHeight = mapHeight;
|
||||||
|
this.defensePostRange = Math.max(0, defensePostRange | 0);
|
||||||
|
|
||||||
const GPUBufferUsage = (globalThis as any).GPUBufferUsage;
|
const GPUBufferUsage = (globalThis as any).GPUBufferUsage;
|
||||||
const GPUTextureUsage = (globalThis as any).GPUTextureUsage;
|
const GPUTextureUsage = (globalThis as any).GPUTextureUsage;
|
||||||
@@ -247,12 +250,14 @@ export class GroundTruthData {
|
|||||||
device: GPUDevice,
|
device: GPUDevice,
|
||||||
game: GameView,
|
game: GameView,
|
||||||
theme: Theme,
|
theme: Theme,
|
||||||
|
defensePostRange: number,
|
||||||
state: Uint16Array,
|
state: Uint16Array,
|
||||||
): GroundTruthData {
|
): GroundTruthData {
|
||||||
return new GroundTruthData(
|
return new GroundTruthData(
|
||||||
device,
|
device,
|
||||||
game,
|
game,
|
||||||
theme,
|
theme,
|
||||||
|
defensePostRange,
|
||||||
state,
|
state,
|
||||||
game.terrainDataView(),
|
game.terrainDataView(),
|
||||||
game.width(),
|
game.width(),
|
||||||
@@ -1006,7 +1011,7 @@ export class GroundTruthData {
|
|||||||
}
|
}
|
||||||
this.needsDefensePostsUpload = false;
|
this.needsDefensePostsUpload = false;
|
||||||
|
|
||||||
const range = this.game.config().defensePostRange();
|
const range = this.defensePostRange;
|
||||||
const posts = this.collectDefensePosts();
|
const posts = this.collectDefensePosts();
|
||||||
this.defensePostsTotalCount = posts.length;
|
this.defensePostsTotalCount = posts.length;
|
||||||
|
|
||||||
@@ -1062,7 +1067,7 @@ export class GroundTruthData {
|
|||||||
|
|
||||||
writeStateUpdateParamsBuffer(updateCount: number): void {
|
writeStateUpdateParamsBuffer(updateCount: number): void {
|
||||||
this.stateUpdateParamsData[0] = updateCount >>> 0;
|
this.stateUpdateParamsData[0] = updateCount >>> 0;
|
||||||
this.stateUpdateParamsData[1] = this.game.config().defensePostRange() >>> 0;
|
this.stateUpdateParamsData[1] = this.defensePostRange >>> 0;
|
||||||
this.stateUpdateParamsData[2] = 0;
|
this.stateUpdateParamsData[2] = 0;
|
||||||
this.stateUpdateParamsData[3] = 0;
|
this.stateUpdateParamsData[3] = 0;
|
||||||
this.device.queue.writeBuffer(
|
this.device.queue.writeBuffer(
|
||||||
@@ -1074,8 +1079,7 @@ export class GroundTruthData {
|
|||||||
|
|
||||||
writeDefendedStrengthParamsBuffer(dirtyCount: number): void {
|
writeDefendedStrengthParamsBuffer(dirtyCount: number): void {
|
||||||
this.defendedStrengthParamsData[0] = dirtyCount >>> 0;
|
this.defendedStrengthParamsData[0] = dirtyCount >>> 0;
|
||||||
this.defendedStrengthParamsData[1] =
|
this.defendedStrengthParamsData[1] = this.defensePostRange >>> 0;
|
||||||
this.game.config().defensePostRange() >>> 0;
|
|
||||||
this.defendedStrengthParamsData[2] = 0;
|
this.defendedStrengthParamsData[2] = 0;
|
||||||
this.defendedStrengthParamsData[3] = 0;
|
this.defendedStrengthParamsData[3] = 0;
|
||||||
this.device.queue.writeBuffer(
|
this.device.queue.writeBuffer(
|
||||||
|
|||||||
+13
-4
@@ -86,7 +86,14 @@ export class GameRunner {
|
|||||||
private isExecuting = false;
|
private isExecuting = false;
|
||||||
|
|
||||||
private playerViewData: Record<PlayerID, NameViewData> = {};
|
private playerViewData: Record<PlayerID, NameViewData> = {};
|
||||||
public tileUpdateSink?: (tile: TileRef) => void;
|
/**
|
||||||
|
* Optional sink for tile state updates. When set, the runner avoids sending
|
||||||
|
* packed tile updates to the callback (to reduce transfer overhead) and
|
||||||
|
* instead forwards packed updates to the sink.
|
||||||
|
*
|
||||||
|
* Packed encoding: [tileRef << 16 | state] as bigint.
|
||||||
|
*/
|
||||||
|
public tileUpdateSink?: (packedTileUpdate: bigint) => void;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
public game: Game,
|
public game: Game,
|
||||||
@@ -113,6 +120,10 @@ export class GameRunner {
|
|||||||
this.turns.push(turn);
|
this.turns.push(turn);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public hasPendingTurns(): boolean {
|
||||||
|
return this.currTurn < this.turns.length;
|
||||||
|
}
|
||||||
|
|
||||||
public executeNextTick() {
|
public executeNextTick() {
|
||||||
if (this.isExecuting) {
|
if (this.isExecuting) {
|
||||||
return;
|
return;
|
||||||
@@ -171,9 +182,7 @@ export class GameRunner {
|
|||||||
let packedTileUpdates: BigUint64Array;
|
let packedTileUpdates: BigUint64Array;
|
||||||
if (this.tileUpdateSink) {
|
if (this.tileUpdateSink) {
|
||||||
for (const u of tileUpdates) {
|
for (const u of tileUpdates) {
|
||||||
// packed tile updates encode [tileRef << 16 | state] as bigint.
|
this.tileUpdateSink(u.update);
|
||||||
const tileRef = Number(u.update >> 16n) as TileRef;
|
|
||||||
this.tileUpdateSink(tileRef);
|
|
||||||
}
|
}
|
||||||
packedTileUpdates = new BigUint64Array(0);
|
packedTileUpdates = new BigUint64Array(0);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,12 +1,173 @@
|
|||||||
import { Colord, colord } from "colord";
|
import { Colord, colord } from "colord";
|
||||||
import { Theme } from "../configuration/Config";
|
import { Theme } from "../configuration/Config";
|
||||||
import { Game, UnitType } from "../game/Game";
|
import { UnitType } from "../game/Game";
|
||||||
import { TileRef } from "../game/GameMap";
|
import { TileRef } from "../game/GameMap";
|
||||||
import { GameUpdateViewData } from "../game/GameUpdates";
|
import {
|
||||||
|
AllianceExpiredUpdate,
|
||||||
|
AllianceRequestReplyUpdate,
|
||||||
|
BrokeAllianceUpdate,
|
||||||
|
EmbargoUpdate,
|
||||||
|
GameUpdateType,
|
||||||
|
GameUpdateViewData,
|
||||||
|
PlayerUpdate,
|
||||||
|
UnitUpdate,
|
||||||
|
} from "../game/GameUpdates";
|
||||||
import { GameView } from "../game/GameView";
|
import { GameView } from "../game/GameView";
|
||||||
import { TerrainMapData } from "../game/TerrainMapLoader";
|
|
||||||
import { ClientID, PlayerCosmetics } from "../Schemas";
|
import { ClientID, PlayerCosmetics } from "../Schemas";
|
||||||
|
|
||||||
|
class DefensePostUnit {
|
||||||
|
public index = -1;
|
||||||
|
private readonly ownerView = { smallID: () => this.ownerSmallId };
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public readonly id: number,
|
||||||
|
private tileRef: TileRef,
|
||||||
|
private ownerSmallId: number,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
isActive(): boolean {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
isUnderConstruction(): boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
tile(): TileRef {
|
||||||
|
return this.tileRef;
|
||||||
|
}
|
||||||
|
|
||||||
|
owner(): { smallID: () => number } {
|
||||||
|
return this.ownerView;
|
||||||
|
}
|
||||||
|
|
||||||
|
set(tileRef: TileRef, ownerSmallId: number): void {
|
||||||
|
this.tileRef = tileRef;
|
||||||
|
this.ownerSmallId = ownerSmallId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class PlayerLiteView {
|
||||||
|
private readonly territoryRgba = { r: 0, g: 0, b: 0, a: 255 };
|
||||||
|
private readonly borderRgba = { r: 0, g: 0, b: 0, a: 255 };
|
||||||
|
private readonly territoryObj = { rgba: this.territoryRgba };
|
||||||
|
private readonly borderObj = { rgba: this.borderRgba };
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly adapter: GameViewAdapter,
|
||||||
|
public data: PlayerUpdate,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
id(): string {
|
||||||
|
return this.data.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
smallID(): number {
|
||||||
|
return this.data.smallID;
|
||||||
|
}
|
||||||
|
|
||||||
|
clientID(): ClientID | null {
|
||||||
|
return this.data.clientID;
|
||||||
|
}
|
||||||
|
|
||||||
|
team(): any | null {
|
||||||
|
return this.data.team ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type(): any {
|
||||||
|
return this.data.playerType;
|
||||||
|
}
|
||||||
|
|
||||||
|
isPlayer(): boolean {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
territoryColor(): { rgba: { r: number; g: number; b: number; a: number } } {
|
||||||
|
this.ensureColors();
|
||||||
|
return this.territoryObj;
|
||||||
|
}
|
||||||
|
|
||||||
|
borderColor(): { rgba: { r: number; g: number; b: number; a: number } } {
|
||||||
|
this.ensureColors();
|
||||||
|
return this.borderObj;
|
||||||
|
}
|
||||||
|
|
||||||
|
hasEmbargoAgainst(other: PlayerLiteView): boolean {
|
||||||
|
return this.adapter.hasEmbargoPair(this.smallID(), other.smallID());
|
||||||
|
}
|
||||||
|
|
||||||
|
hasEmbargo(other: PlayerLiteView): boolean {
|
||||||
|
return this.hasEmbargoAgainst(other) || other.hasEmbargoAgainst(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
isFriendly(other: PlayerLiteView): boolean {
|
||||||
|
const team = this.team();
|
||||||
|
return (
|
||||||
|
(team !== null && team === other.team()) ||
|
||||||
|
this.adapter.hasFriendlyPair(this.smallID(), other.smallID())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
markColorsDirty(): void {
|
||||||
|
this.adapter.markPlayerColorsDirty(this.smallID());
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureColors(): void {
|
||||||
|
if (!this.adapter.consumePlayerColorsDirty(this.smallID())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const theme = this.adapter.getTheme();
|
||||||
|
const defaultTerritoryColor = theme.territoryColor(this as any);
|
||||||
|
const defaultBorderColor = theme.borderColor(defaultTerritoryColor);
|
||||||
|
|
||||||
|
const cosmetics = this.adapter.getCosmetics(this.clientID());
|
||||||
|
const pattern = this.adapter.getPatternsEnabled()
|
||||||
|
? cosmetics.pattern
|
||||||
|
: undefined;
|
||||||
|
if (pattern) {
|
||||||
|
(pattern as any).colorPalette ??= {
|
||||||
|
name: "",
|
||||||
|
primaryColor: defaultTerritoryColor.toHex(),
|
||||||
|
secondaryColor: defaultBorderColor.toHex(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const territoryColor: Colord =
|
||||||
|
this.team() === null
|
||||||
|
? colord(
|
||||||
|
cosmetics.color?.color ??
|
||||||
|
(pattern as any)?.colorPalette?.primaryColor ??
|
||||||
|
defaultTerritoryColor.toHex(),
|
||||||
|
)
|
||||||
|
: defaultTerritoryColor;
|
||||||
|
|
||||||
|
const maybeFocusedBorderColor =
|
||||||
|
this.adapter.getMyClientId() !== null &&
|
||||||
|
this.clientID() === this.adapter.getMyClientId()
|
||||||
|
? theme.focusedBorderColor()
|
||||||
|
: defaultBorderColor;
|
||||||
|
|
||||||
|
const borderColor: Colord = colord(
|
||||||
|
(pattern as any)?.colorPalette?.secondaryColor ??
|
||||||
|
cosmetics.color?.color ??
|
||||||
|
maybeFocusedBorderColor.toHex(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const tc = territoryColor.toRgb();
|
||||||
|
this.territoryRgba.r = Math.round(tc.r);
|
||||||
|
this.territoryRgba.g = Math.round(tc.g);
|
||||||
|
this.territoryRgba.b = Math.round(tc.b);
|
||||||
|
this.territoryRgba.a = 255;
|
||||||
|
|
||||||
|
const bc = borderColor.toRgb();
|
||||||
|
this.borderRgba.r = Math.round(bc.r);
|
||||||
|
this.borderRgba.g = Math.round(bc.g);
|
||||||
|
this.borderRgba.b = Math.round(bc.b);
|
||||||
|
this.borderRgba.a = 255;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adapter that makes Game work as GameView for rendering purposes.
|
* Adapter that makes Game work as GameView for rendering purposes.
|
||||||
* Provides the interface that GroundTruthData and rendering passes need,
|
* Provides the interface that GroundTruthData and rendering passes need,
|
||||||
@@ -16,16 +177,139 @@ export class GameViewAdapter implements Partial<GameView> {
|
|||||||
private lastUpdate: GameUpdateViewData | null = null;
|
private lastUpdate: GameUpdateViewData | null = null;
|
||||||
private patternsEnabled = false;
|
private patternsEnabled = false;
|
||||||
|
|
||||||
|
private defensePostsDirty = true;
|
||||||
|
private readonly defensePostsById = new Map<number, DefensePostUnit>();
|
||||||
|
private readonly defensePosts: DefensePostUnit[] = [];
|
||||||
|
|
||||||
|
private playersDirty = true;
|
||||||
|
private readonly playersBySmallId = new Map<number, PlayerLiteView>();
|
||||||
|
private playerViewsCache: PlayerLiteView[] = [];
|
||||||
|
private playersEpoch = 1;
|
||||||
|
private playerViewsCacheEpoch = 0;
|
||||||
|
private playerColorsEpoch = 1;
|
||||||
|
private readonly playerColorsDirtyEpochBySmallId = new Map<number, number>();
|
||||||
|
private readonly embargoPairs = new Set<bigint>();
|
||||||
|
private readonly friendlyPairs = new Set<bigint>();
|
||||||
|
private readonly emptyCosmetics = {} as PlayerCosmetics;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private game: Game,
|
private tileState: Uint16Array,
|
||||||
private mapData: TerrainMapData,
|
private terrainData: Uint8Array,
|
||||||
|
private readonly mapWidth: number,
|
||||||
|
private readonly mapHeight: number,
|
||||||
private theme: Theme,
|
private theme: Theme,
|
||||||
private readonly myClientId: ClientID | null,
|
private readonly myClientId: ClientID | null,
|
||||||
private readonly cosmeticsByClientID: Map<ClientID, PlayerCosmetics>,
|
private readonly cosmeticsByClientID: Map<ClientID, PlayerCosmetics>,
|
||||||
) {}
|
) {
|
||||||
|
void 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
getMyClientId(): ClientID | null {
|
||||||
|
return this.myClientId;
|
||||||
|
}
|
||||||
|
|
||||||
|
getTheme(): Theme {
|
||||||
|
return this.theme;
|
||||||
|
}
|
||||||
|
|
||||||
|
getPatternsEnabled(): boolean {
|
||||||
|
return this.patternsEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
getCosmetics(clientId: ClientID | null): PlayerCosmetics {
|
||||||
|
if (!clientId) {
|
||||||
|
return this.emptyCosmetics;
|
||||||
|
}
|
||||||
|
return this.cosmeticsByClientID.get(clientId) ?? this.emptyCosmetics;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static pairKey(a: number, b: number): bigint {
|
||||||
|
const lo = Math.min(a, b) >>> 0;
|
||||||
|
const hi = Math.max(a, b) >>> 0;
|
||||||
|
return (BigInt(lo) << 32n) | BigInt(hi);
|
||||||
|
}
|
||||||
|
|
||||||
|
hasEmbargoPair(aSmallId: number, bSmallId: number): boolean {
|
||||||
|
return this.embargoPairs.has(GameViewAdapter.pairKey(aSmallId, bSmallId));
|
||||||
|
}
|
||||||
|
|
||||||
|
hasFriendlyPair(aSmallId: number, bSmallId: number): boolean {
|
||||||
|
return this.friendlyPairs.has(GameViewAdapter.pairKey(aSmallId, bSmallId));
|
||||||
|
}
|
||||||
|
|
||||||
|
markPlayerColorsDirty(smallId: number): void {
|
||||||
|
this.playerColorsDirtyEpochBySmallId.delete(smallId);
|
||||||
|
}
|
||||||
|
|
||||||
|
consumePlayerColorsDirty(smallId: number): boolean {
|
||||||
|
const last = this.playerColorsDirtyEpochBySmallId.get(smallId) ?? 0;
|
||||||
|
if (last === this.playerColorsEpoch) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
this.playerColorsDirtyEpochBySmallId.set(smallId, this.playerColorsEpoch);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private upsertDefensePost(
|
||||||
|
id: number,
|
||||||
|
tile: TileRef,
|
||||||
|
ownerSmallId: number,
|
||||||
|
): void {
|
||||||
|
const existing = this.defensePostsById.get(id);
|
||||||
|
if (existing) {
|
||||||
|
if (
|
||||||
|
existing.tile() !== tile ||
|
||||||
|
existing.owner().smallID() !== ownerSmallId
|
||||||
|
) {
|
||||||
|
existing.set(tile, ownerSmallId);
|
||||||
|
this.defensePostsDirty = true;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const unit = new DefensePostUnit(id, tile, ownerSmallId);
|
||||||
|
unit.index = this.defensePosts.length;
|
||||||
|
this.defensePosts.push(unit);
|
||||||
|
this.defensePostsById.set(id, unit);
|
||||||
|
this.defensePostsDirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private removeDefensePost(id: number): void {
|
||||||
|
const existing = this.defensePostsById.get(id);
|
||||||
|
if (!existing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const idx = existing.index;
|
||||||
|
const last = this.defensePosts.pop();
|
||||||
|
if (last && last !== existing) {
|
||||||
|
this.defensePosts[idx] = last;
|
||||||
|
last.index = idx;
|
||||||
|
}
|
||||||
|
this.defensePostsById.delete(id);
|
||||||
|
this.defensePostsDirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
consumeDefensePostsDirty(): boolean {
|
||||||
|
const dirty = this.defensePostsDirty;
|
||||||
|
this.defensePostsDirty = false;
|
||||||
|
return dirty;
|
||||||
|
}
|
||||||
|
|
||||||
|
consumePlayersDirty(): boolean {
|
||||||
|
const dirty = this.playersDirty;
|
||||||
|
this.playersDirty = false;
|
||||||
|
return dirty;
|
||||||
|
}
|
||||||
|
|
||||||
setPatternsEnabled(enabled: boolean): void {
|
setPatternsEnabled(enabled: boolean): void {
|
||||||
|
if (this.patternsEnabled === enabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.patternsEnabled = enabled;
|
this.patternsEnabled = enabled;
|
||||||
|
this.playersDirty = true;
|
||||||
|
this.playersEpoch++;
|
||||||
|
this.playerColorsEpoch++;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -34,30 +318,140 @@ export class GameViewAdapter implements Partial<GameView> {
|
|||||||
*/
|
*/
|
||||||
update(gu: GameUpdateViewData): void {
|
update(gu: GameUpdateViewData): void {
|
||||||
this.lastUpdate = gu;
|
this.lastUpdate = gu;
|
||||||
}
|
|
||||||
|
|
||||||
config() {
|
const playerUpdates = (gu.updates?.[GameUpdateType.Player] ??
|
||||||
return this.game.config();
|
[]) as PlayerUpdate[];
|
||||||
|
let playersChanged = false;
|
||||||
|
for (const p of playerUpdates) {
|
||||||
|
const small = p.smallID;
|
||||||
|
if (small <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const existing = this.playersBySmallId.get(small);
|
||||||
|
if (existing) {
|
||||||
|
existing.data = p;
|
||||||
|
existing.markColorsDirty();
|
||||||
|
} else {
|
||||||
|
this.playersBySmallId.set(small, new PlayerLiteView(this, p));
|
||||||
|
}
|
||||||
|
playersChanged = true;
|
||||||
|
}
|
||||||
|
if (playersChanged) {
|
||||||
|
this.playersDirty = true;
|
||||||
|
this.playersEpoch++;
|
||||||
|
|
||||||
|
// Rebuild relations snapshot from authoritative PlayerUpdate state.
|
||||||
|
// This ensures correct initial relations without relying on event history.
|
||||||
|
this.embargoPairs.clear();
|
||||||
|
this.friendlyPairs.clear();
|
||||||
|
|
||||||
|
const idToSmall = new Map<string, number>();
|
||||||
|
for (const v of this.playersBySmallId.values()) {
|
||||||
|
idToSmall.set(v.data.id, v.data.smallID);
|
||||||
|
}
|
||||||
|
for (const v of this.playersBySmallId.values()) {
|
||||||
|
const a = v.data.smallID;
|
||||||
|
if (a <= 0) continue;
|
||||||
|
|
||||||
|
for (const b of v.data.allies ?? []) {
|
||||||
|
if (typeof b === "number" && b > 0) {
|
||||||
|
this.friendlyPairs.add(GameViewAdapter.pairKey(a, b));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const otherId of v.data.embargoes ?? []) {
|
||||||
|
if (typeof otherId !== "string") continue;
|
||||||
|
const b = idToSmall.get(otherId) ?? 0;
|
||||||
|
if (b > 0) {
|
||||||
|
this.embargoPairs.add(GameViewAdapter.pairKey(a, b));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const embargoUpdates = (gu.updates?.[GameUpdateType.EmbargoEvent] ??
|
||||||
|
[]) as EmbargoUpdate[];
|
||||||
|
for (const e of embargoUpdates) {
|
||||||
|
const key = GameViewAdapter.pairKey(e.playerID, e.embargoedID);
|
||||||
|
if (e.event === "start") {
|
||||||
|
this.embargoPairs.add(key);
|
||||||
|
} else {
|
||||||
|
this.embargoPairs.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const allianceReplies = (gu.updates?.[
|
||||||
|
GameUpdateType.AllianceRequestReply
|
||||||
|
] ?? []) as AllianceRequestReplyUpdate[];
|
||||||
|
for (const e of allianceReplies) {
|
||||||
|
if (!e.accepted) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
this.friendlyPairs.add(
|
||||||
|
GameViewAdapter.pairKey(e.request.requestorID, e.request.recipientID),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const brokeAllianceUpdates = (gu.updates?.[GameUpdateType.BrokeAlliance] ??
|
||||||
|
[]) as BrokeAllianceUpdate[];
|
||||||
|
for (const e of brokeAllianceUpdates) {
|
||||||
|
this.friendlyPairs.delete(
|
||||||
|
GameViewAdapter.pairKey(e.traitorID, e.betrayedID),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const expiredUpdates = (gu.updates?.[GameUpdateType.AllianceExpired] ??
|
||||||
|
[]) as AllianceExpiredUpdate[];
|
||||||
|
for (const e of expiredUpdates) {
|
||||||
|
this.friendlyPairs.delete(
|
||||||
|
GameViewAdapter.pairKey(e.player1ID, e.player2ID),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const unitUpdates = (gu.updates?.[GameUpdateType.Unit] ??
|
||||||
|
[]) as UnitUpdate[];
|
||||||
|
for (const u of unitUpdates) {
|
||||||
|
if (u.unitType !== UnitType.DefensePost) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const removed =
|
||||||
|
u.markedForDeletion !== false ||
|
||||||
|
!u.isActive ||
|
||||||
|
u.underConstruction === true;
|
||||||
|
if (removed) {
|
||||||
|
this.removeDefensePost(u.id);
|
||||||
|
} else {
|
||||||
|
this.upsertDefensePost(u.id, u.pos, u.ownerID);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
width(): number {
|
width(): number {
|
||||||
return this.game.width();
|
return this.mapWidth;
|
||||||
}
|
}
|
||||||
|
|
||||||
height(): number {
|
height(): number {
|
||||||
return this.game.height();
|
return this.mapHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
x(tile: TileRef): number {
|
x(tile: TileRef): number {
|
||||||
return this.game.x(tile);
|
return tile % this.mapWidth;
|
||||||
}
|
}
|
||||||
|
|
||||||
y(tile: TileRef): number {
|
y(tile: TileRef): number {
|
||||||
return this.game.y(tile);
|
return (tile / this.mapWidth) | 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
playerBySmallID(smallId: number): any | null {
|
||||||
|
return this.playersBySmallId.get(smallId) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
units(...types: UnitType[]): any[] {
|
units(...types: UnitType[]): any[] {
|
||||||
return this.game.units(...types);
|
if (types.length === 1 && types[0] === UnitType.DefensePost) {
|
||||||
|
return this.defensePosts;
|
||||||
|
}
|
||||||
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -67,14 +461,14 @@ export class GameViewAdapter implements Partial<GameView> {
|
|||||||
* read from it when individual tiles are marked dirty.
|
* read from it when individual tiles are marked dirty.
|
||||||
*/
|
*/
|
||||||
tileStateView(): Uint16Array {
|
tileStateView(): Uint16Array {
|
||||||
return this.game.tileStateView();
|
return this.tileState;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return the immutable terrain data view.
|
* Return the immutable terrain data view.
|
||||||
*/
|
*/
|
||||||
terrainDataView(): Uint8Array {
|
terrainDataView(): Uint8Array {
|
||||||
return this.game.terrainDataView();
|
return this.terrainData;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -84,85 +478,11 @@ export class GameViewAdapter implements Partial<GameView> {
|
|||||||
* otherwise the worker-rendered territory will disagree with UI.
|
* otherwise the worker-rendered territory will disagree with UI.
|
||||||
*/
|
*/
|
||||||
playerViews(): any[] {
|
playerViews(): any[] {
|
||||||
const theme = this.theme;
|
if (this.playerViewsCacheEpoch !== this.playersEpoch) {
|
||||||
return this.game.players().map((player) => {
|
this.playerViewsCache = [...this.playersBySmallId.values()];
|
||||||
const clientId = player.clientID();
|
this.playerViewsCacheEpoch = this.playersEpoch;
|
||||||
const cosmetics =
|
}
|
||||||
clientId && this.cosmeticsByClientID.has(clientId)
|
return this.playerViewsCache;
|
||||||
? this.cosmeticsByClientID.get(clientId)!
|
|
||||||
: ({} as PlayerCosmetics);
|
|
||||||
|
|
||||||
const defaultTerritoryColor = theme.territoryColor(player as any);
|
|
||||||
const defaultBorderColor = theme.borderColor(defaultTerritoryColor);
|
|
||||||
|
|
||||||
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,
|
|
||||||
smallID: () => player.smallID(),
|
|
||||||
territoryColor: () => ({
|
|
||||||
rgba: {
|
|
||||||
r: Math.round(territoryRgb.r),
|
|
||||||
g: Math.round(territoryRgb.g),
|
|
||||||
b: Math.round(territoryRgb.b),
|
|
||||||
a: Math.round((territoryRgb.a ?? 1) * 255),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
borderColor: () => ({
|
|
||||||
rgba: {
|
|
||||||
r: Math.round(borderRgb.r),
|
|
||||||
g: Math.round(borderRgb.g),
|
|
||||||
b: Math.round(borderRgb.b),
|
|
||||||
a: Math.round((borderRgb.a ?? 1) * 255),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
hasEmbargo: (other: any) => {
|
|
||||||
const otherPlayer = other?.player;
|
|
||||||
if (!otherPlayer) return false;
|
|
||||||
return (
|
|
||||||
player.hasEmbargoAgainst(otherPlayer) ||
|
|
||||||
otherPlayer.hasEmbargoAgainst(player)
|
|
||||||
);
|
|
||||||
},
|
|
||||||
isFriendly: (other: any) => {
|
|
||||||
const otherPlayer = other?.player;
|
|
||||||
if (!otherPlayer) return false;
|
|
||||||
return player.isFriendly(otherPlayer);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
return view;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { PastelTheme } from "../configuration/PastelTheme";
|
|||||||
import { PastelThemeDark } from "../configuration/PastelThemeDark";
|
import { PastelThemeDark } from "../configuration/PastelThemeDark";
|
||||||
import { FetchGameMapLoader } from "../game/FetchGameMapLoader";
|
import { FetchGameMapLoader } from "../game/FetchGameMapLoader";
|
||||||
import { PlayerID } from "../game/Game";
|
import { PlayerID } from "../game/Game";
|
||||||
|
import { TileRef } from "../game/GameMap";
|
||||||
import {
|
import {
|
||||||
AllianceExpiredUpdate,
|
AllianceExpiredUpdate,
|
||||||
AllianceRequestReplyUpdate,
|
AllianceRequestReplyUpdate,
|
||||||
@@ -13,7 +14,7 @@ import {
|
|||||||
GameUpdateType,
|
GameUpdateType,
|
||||||
GameUpdateViewData,
|
GameUpdateViewData,
|
||||||
} from "../game/GameUpdates";
|
} from "../game/GameUpdates";
|
||||||
import { loadTerrainMap, TerrainMapData } from "../game/TerrainMapLoader";
|
|
||||||
import { createGameRunner, GameRunner } from "../GameRunner";
|
import { createGameRunner, GameRunner } from "../GameRunner";
|
||||||
import { ClientID, GameStartInfo, PlayerCosmetics } from "../Schemas";
|
import { ClientID, GameStartInfo, PlayerCosmetics } from "../Schemas";
|
||||||
import { DirtyTileQueue } from "./DirtyTileQueue";
|
import { DirtyTileQueue } from "./DirtyTileQueue";
|
||||||
@@ -38,9 +39,29 @@ let gameStartInfo: GameStartInfo | null = null;
|
|||||||
let myClientID: ClientID | null = null;
|
let myClientID: ClientID | null = null;
|
||||||
const mapLoader = new FetchGameMapLoader(`/maps`, version);
|
const mapLoader = new FetchGameMapLoader(`/maps`, version);
|
||||||
let renderer: WorkerTerritoryRenderer | WorkerCanvas2DRenderer | null = null;
|
let renderer: WorkerTerritoryRenderer | WorkerCanvas2DRenderer | null = null;
|
||||||
let mapData: TerrainMapData | null = null;
|
|
||||||
let dirtyTiles: DirtyTileQueue | null = null;
|
let dirtyTiles: DirtyTileQueue | null = null;
|
||||||
let dirtyTilesOverflow = false;
|
let dirtyTilesOverflow = false;
|
||||||
|
let renderTileState: Uint16Array | null = null;
|
||||||
|
|
||||||
|
let simPumpScheduled = false;
|
||||||
|
function scheduleSimPump(): void {
|
||||||
|
if (simPumpScheduled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
simPumpScheduled = true;
|
||||||
|
|
||||||
|
setTimeout(async () => {
|
||||||
|
simPumpScheduled = false;
|
||||||
|
if (!gameRunner) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const gr = await gameRunner;
|
||||||
|
gr.executeNextTick();
|
||||||
|
if (gr.hasPendingTurns()) {
|
||||||
|
scheduleSimPump();
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
function gameUpdate(gu: GameUpdateViewData | ErrorUpdate) {
|
function gameUpdate(gu: GameUpdateViewData | ErrorUpdate) {
|
||||||
// skip if ErrorUpdate
|
// skip if ErrorUpdate
|
||||||
@@ -49,7 +70,7 @@ function gameUpdate(gu: GameUpdateViewData | ErrorUpdate) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Keep renderer-side adapter in sync (palette/relations/etc).
|
// Keep renderer-side adapter in sync (palette/relations/etc).
|
||||||
(renderer as any)?.updateGameView?.(gu);
|
const viewUpdateDidWork = (renderer as any)?.updateGameView?.(gu) === true;
|
||||||
|
|
||||||
// Uploading relations is expensive; only refresh when diplomacy changes,
|
// Uploading relations is expensive; only refresh when diplomacy changes,
|
||||||
// and only for the affected player pairs.
|
// and only for the affected player pairs.
|
||||||
@@ -94,6 +115,9 @@ function gameUpdate(gu: GameUpdateViewData | ErrorUpdate) {
|
|||||||
// compute passes for this tick.
|
// compute passes for this tick.
|
||||||
if (renderer && dirtyTiles) {
|
if (renderer && dirtyTiles) {
|
||||||
let didWork = false;
|
let didWork = false;
|
||||||
|
if (viewUpdateDidWork) {
|
||||||
|
didWork = true;
|
||||||
|
}
|
||||||
if (relationsChanged) {
|
if (relationsChanged) {
|
||||||
didWork = true;
|
didWork = true;
|
||||||
}
|
}
|
||||||
@@ -134,7 +158,9 @@ ctx.addEventListener("message", async (e: MessageEvent<MainThreadMessage>) => {
|
|||||||
|
|
||||||
switch (message.type) {
|
switch (message.type) {
|
||||||
case "heartbeat":
|
case "heartbeat":
|
||||||
(await gameRunner)?.executeNextTick();
|
// Heartbeat is a high-frequency "wake up" signal from the main thread.
|
||||||
|
// Coalesce it and run simulation work in small slices to avoid backlog.
|
||||||
|
scheduleSimPump();
|
||||||
break;
|
break;
|
||||||
case "init":
|
case "init":
|
||||||
try {
|
try {
|
||||||
@@ -150,11 +176,18 @@ ctx.addEventListener("message", async (e: MessageEvent<MainThreadMessage>) => {
|
|||||||
// Capacity is bounded; on overflow we fall back to markAllDirty().
|
// Capacity is bounded; on overflow we fall back to markAllDirty().
|
||||||
dirtyTiles = new DirtyTileQueue(numTiles, Math.max(4096, numTiles));
|
dirtyTiles = new DirtyTileQueue(numTiles, Math.max(4096, numTiles));
|
||||||
dirtyTilesOverflow = false;
|
dirtyTilesOverflow = false;
|
||||||
|
renderTileState = new Uint16Array(gr.game.tileStateView());
|
||||||
|
|
||||||
gr.tileUpdateSink = (tile) => {
|
gr.tileUpdateSink = (packedUpdate) => {
|
||||||
if (!dirtyTiles) {
|
if (!dirtyTiles) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tile = Number(packedUpdate >> 16n) as TileRef;
|
||||||
|
const state = Number(packedUpdate & 0xffffn);
|
||||||
|
if (renderTileState) {
|
||||||
|
renderTileState[tile] = state;
|
||||||
|
}
|
||||||
const mark = (t: any) => {
|
const mark = (t: any) => {
|
||||||
if (!dirtyTiles!.mark(t)) {
|
if (!dirtyTiles!.mark(t)) {
|
||||||
dirtyTilesOverflow = true;
|
dirtyTilesOverflow = true;
|
||||||
@@ -183,7 +216,8 @@ ctx.addEventListener("message", async (e: MessageEvent<MainThreadMessage>) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const gr = await gameRunner;
|
const gr = await gameRunner;
|
||||||
await gr.addTurn(message.turn);
|
gr.addTurn(message.turn);
|
||||||
|
scheduleSimPump();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to process turn:", error);
|
console.error("Failed to process turn:", error);
|
||||||
throw error;
|
throw error;
|
||||||
@@ -329,14 +363,6 @@ ctx.addEventListener("message", async (e: MessageEvent<MainThreadMessage>) => {
|
|||||||
(renderer as any)?.dispose?.();
|
(renderer as any)?.dispose?.();
|
||||||
renderer = null;
|
renderer = null;
|
||||||
|
|
||||||
// Load map data if not already loaded
|
|
||||||
// Use gameStartInfo.config which has the original game map info
|
|
||||||
mapData ??= await loadTerrainMap(
|
|
||||||
gameStartInfo.config.gameMap,
|
|
||||||
gameStartInfo.config.gameMapSize,
|
|
||||||
mapLoader,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Create theme based on darkMode flag from main thread
|
// Create theme based on darkMode flag from main thread
|
||||||
// (can't access userSettings in worker, so it's passed from main thread)
|
// (can't access userSettings in worker, so it's passed from main thread)
|
||||||
const theme: Theme = message.darkMode
|
const theme: Theme = message.darkMode
|
||||||
@@ -357,13 +383,14 @@ ctx.addEventListener("message", async (e: MessageEvent<MainThreadMessage>) => {
|
|||||||
? new WorkerCanvas2DRenderer()
|
? new WorkerCanvas2DRenderer()
|
||||||
: new WorkerTerritoryRenderer();
|
: new WorkerTerritoryRenderer();
|
||||||
|
|
||||||
|
renderTileState ??= new Uint16Array(gr.game.tileStateView());
|
||||||
await renderer.init(
|
await renderer.init(
|
||||||
message.offscreenCanvas,
|
message.offscreenCanvas,
|
||||||
gr,
|
gr,
|
||||||
mapData,
|
|
||||||
theme,
|
theme,
|
||||||
myClientID,
|
myClientID,
|
||||||
cosmeticsByClientID,
|
cosmeticsByClientID,
|
||||||
|
renderTileState,
|
||||||
);
|
);
|
||||||
|
|
||||||
sendMessage({
|
sendMessage({
|
||||||
@@ -508,6 +535,16 @@ ctx.addEventListener("message", async (e: MessageEvent<MainThreadMessage>) => {
|
|||||||
|
|
||||||
case "render_frame":
|
case "render_frame":
|
||||||
if (renderer) {
|
if (renderer) {
|
||||||
|
if ("viewSize" in message && message.viewSize) {
|
||||||
|
renderer.setViewSize(message.viewSize.width, message.viewSize.height);
|
||||||
|
}
|
||||||
|
if ("viewTransform" in message && message.viewTransform) {
|
||||||
|
renderer.setViewTransform(
|
||||||
|
message.viewTransform.scale,
|
||||||
|
message.viewTransform.offsetX,
|
||||||
|
message.viewTransform.offsetY,
|
||||||
|
);
|
||||||
|
}
|
||||||
renderer.render();
|
renderer.render();
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Theme } from "../configuration/Config";
|
|||||||
import { PastelTheme } from "../configuration/PastelTheme";
|
import { PastelTheme } from "../configuration/PastelTheme";
|
||||||
import { PastelThemeDark } from "../configuration/PastelThemeDark";
|
import { PastelThemeDark } from "../configuration/PastelThemeDark";
|
||||||
import { TileRef } from "../game/GameMap";
|
import { TileRef } from "../game/GameMap";
|
||||||
import { TerrainMapData } from "../game/TerrainMapLoader";
|
import { GameUpdateViewData } from "../game/GameUpdates";
|
||||||
import { GameRunner } from "../GameRunner";
|
import { GameRunner } from "../GameRunner";
|
||||||
import { ClientID, PlayerCosmetics } from "../Schemas";
|
import { ClientID, PlayerCosmetics } from "../Schemas";
|
||||||
import { GameViewAdapter } from "./GameViewAdapter";
|
import { GameViewAdapter } from "./GameViewAdapter";
|
||||||
@@ -17,6 +17,7 @@ export class WorkerCanvas2DRenderer {
|
|||||||
private rasterCtx: Offscreen2D | null = null;
|
private rasterCtx: Offscreen2D | null = null;
|
||||||
private rasterImage: ImageData | null = null;
|
private rasterImage: ImageData | null = null;
|
||||||
private terrainBaseRgba: Uint8Array | null = null;
|
private terrainBaseRgba: Uint8Array | null = null;
|
||||||
|
private tileState: Uint16Array | null = null;
|
||||||
|
|
||||||
private gameViewAdapter: GameViewAdapter | null = null;
|
private gameViewAdapter: GameViewAdapter | null = null;
|
||||||
private gameRunner: GameRunner | null = null;
|
private gameRunner: GameRunner | null = null;
|
||||||
@@ -49,10 +50,10 @@ export class WorkerCanvas2DRenderer {
|
|||||||
async init(
|
async init(
|
||||||
offscreenCanvas: OffscreenCanvas,
|
offscreenCanvas: OffscreenCanvas,
|
||||||
gameRunner: GameRunner,
|
gameRunner: GameRunner,
|
||||||
mapData: TerrainMapData,
|
|
||||||
theme: Theme,
|
theme: Theme,
|
||||||
myClientID: ClientID | null,
|
myClientID: ClientID | null,
|
||||||
cosmeticsByClientID: Map<ClientID, PlayerCosmetics>,
|
cosmeticsByClientID: Map<ClientID, PlayerCosmetics>,
|
||||||
|
tileState: Uint16Array,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
this.canvas = offscreenCanvas;
|
this.canvas = offscreenCanvas;
|
||||||
this.ctx = offscreenCanvas.getContext("2d", { alpha: true }) as Offscreen2D;
|
this.ctx = offscreenCanvas.getContext("2d", { alpha: true }) as Offscreen2D;
|
||||||
@@ -62,6 +63,7 @@ export class WorkerCanvas2DRenderer {
|
|||||||
|
|
||||||
this.gameRunner = gameRunner;
|
this.gameRunner = gameRunner;
|
||||||
this.theme = theme;
|
this.theme = theme;
|
||||||
|
this.tileState = tileState;
|
||||||
|
|
||||||
const mapW = gameRunner.game.width();
|
const mapW = gameRunner.game.width();
|
||||||
const mapH = gameRunner.game.height();
|
const mapH = gameRunner.game.height();
|
||||||
@@ -69,8 +71,10 @@ export class WorkerCanvas2DRenderer {
|
|||||||
this.mapHeight = mapH;
|
this.mapHeight = mapH;
|
||||||
|
|
||||||
this.gameViewAdapter = new GameViewAdapter(
|
this.gameViewAdapter = new GameViewAdapter(
|
||||||
gameRunner.game,
|
tileState,
|
||||||
mapData,
|
gameRunner.game.terrainDataView(),
|
||||||
|
gameRunner.game.width(),
|
||||||
|
gameRunner.game.height(),
|
||||||
theme,
|
theme,
|
||||||
myClientID,
|
myClientID,
|
||||||
cosmeticsByClientID,
|
cosmeticsByClientID,
|
||||||
@@ -107,6 +111,20 @@ export class WorkerCanvas2DRenderer {
|
|||||||
this.tick();
|
this.tick();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateGameView(gu: GameUpdateViewData): boolean {
|
||||||
|
if (!this.gameViewAdapter) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
this.gameViewAdapter.update(gu);
|
||||||
|
const playersDirty = this.gameViewAdapter.consumePlayersDirty();
|
||||||
|
if (playersDirty && !this.hasExternalPalette) {
|
||||||
|
this.rebuildPaletteFromGame();
|
||||||
|
this.markAllDirty();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
dispose(): void {
|
dispose(): void {
|
||||||
this.ready = false;
|
this.ready = false;
|
||||||
this.canvas = null;
|
this.canvas = null;
|
||||||
@@ -115,6 +133,7 @@ export class WorkerCanvas2DRenderer {
|
|||||||
this.rasterCtx = null;
|
this.rasterCtx = null;
|
||||||
this.rasterImage = null;
|
this.rasterImage = null;
|
||||||
this.terrainBaseRgba = null;
|
this.terrainBaseRgba = null;
|
||||||
|
this.tileState = null;
|
||||||
this.gameViewAdapter = null;
|
this.gameViewAdapter = null;
|
||||||
this.gameRunner = null;
|
this.gameRunner = null;
|
||||||
this.theme = null;
|
this.theme = null;
|
||||||
@@ -217,7 +236,10 @@ export class WorkerCanvas2DRenderer {
|
|||||||
const mapH = this.mapHeight;
|
const mapH = this.mapHeight;
|
||||||
const out = this.rasterImage.data;
|
const out = this.rasterImage.data;
|
||||||
const base = this.terrainBaseRgba;
|
const base = this.terrainBaseRgba;
|
||||||
const state = this.gameRunner.game.tileStateView();
|
const state = this.tileState;
|
||||||
|
if (!state) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const row0 = this.paletteRow0;
|
const row0 = this.paletteRow0;
|
||||||
const maxSmallId = this.paletteMaxSmallId;
|
const maxSmallId = this.paletteMaxSmallId;
|
||||||
|
|
||||||
|
|||||||
@@ -233,8 +233,25 @@ export interface TickRendererMessage extends BaseWorkerMessage {
|
|||||||
type: "tick_renderer";
|
type: "tick_renderer";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ViewSize {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ViewTransform {
|
||||||
|
scale: number;
|
||||||
|
offsetX: number;
|
||||||
|
offsetY: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface RenderFrameMessage extends BaseWorkerMessage {
|
export interface RenderFrameMessage extends BaseWorkerMessage {
|
||||||
type: "render_frame";
|
type: "render_frame";
|
||||||
|
/**
|
||||||
|
* Optional per-frame view state. This allows the main thread to coalesce
|
||||||
|
* high-frequency camera updates into the existing render message.
|
||||||
|
*/
|
||||||
|
viewSize?: ViewSize;
|
||||||
|
viewTransform?: ViewTransform;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Renderer messages from worker to main thread
|
// Renderer messages from worker to main thread
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Theme } from "../configuration/Config";
|
import { Theme } from "../configuration/Config";
|
||||||
import { TileRef } from "../game/GameMap";
|
import { TileRef } from "../game/GameMap";
|
||||||
import { GameUpdateViewData } from "../game/GameUpdates";
|
import { GameUpdateViewData } from "../game/GameUpdates";
|
||||||
import { TerrainMapData } from "../game/TerrainMapLoader";
|
|
||||||
import { GameRunner } from "../GameRunner";
|
import { GameRunner } from "../GameRunner";
|
||||||
import { ClientID, PlayerCosmetics } from "../Schemas";
|
import { ClientID, PlayerCosmetics } from "../Schemas";
|
||||||
import { GameViewAdapter } from "./GameViewAdapter";
|
import { GameViewAdapter } from "./GameViewAdapter";
|
||||||
@@ -70,10 +69,10 @@ export class WorkerTerritoryRenderer {
|
|||||||
async init(
|
async init(
|
||||||
offscreenCanvas: OffscreenCanvas,
|
offscreenCanvas: OffscreenCanvas,
|
||||||
gameRunner: GameRunner,
|
gameRunner: GameRunner,
|
||||||
mapData: TerrainMapData,
|
|
||||||
theme: Theme,
|
theme: Theme,
|
||||||
myClientID: ClientID | null,
|
myClientID: ClientID | null,
|
||||||
cosmeticsByClientID: Map<ClientID, PlayerCosmetics>,
|
cosmeticsByClientID: Map<ClientID, PlayerCosmetics>,
|
||||||
|
tileState: Uint16Array,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
this.canvas = offscreenCanvas;
|
this.canvas = offscreenCanvas;
|
||||||
const game = gameRunner.game;
|
const game = gameRunner.game;
|
||||||
@@ -81,8 +80,10 @@ export class WorkerTerritoryRenderer {
|
|||||||
|
|
||||||
// Create adapter
|
// Create adapter
|
||||||
this.gameViewAdapter = new GameViewAdapter(
|
this.gameViewAdapter = new GameViewAdapter(
|
||||||
game,
|
tileState,
|
||||||
mapData,
|
game.terrainDataView(),
|
||||||
|
game.width(),
|
||||||
|
game.height(),
|
||||||
theme,
|
theme,
|
||||||
myClientID,
|
myClientID,
|
||||||
cosmeticsByClientID,
|
cosmeticsByClientID,
|
||||||
@@ -97,11 +98,12 @@ export class WorkerTerritoryRenderer {
|
|||||||
this.device = webgpuDevice;
|
this.device = webgpuDevice;
|
||||||
|
|
||||||
// Create ground truth data using adapter
|
// Create ground truth data using adapter
|
||||||
const state = this.gameViewAdapter.tileStateView();
|
const state = tileState;
|
||||||
this.resources = GroundTruthData.create(
|
this.resources = GroundTruthData.create(
|
||||||
webgpuDevice.device,
|
webgpuDevice.device,
|
||||||
this.gameViewAdapter as any,
|
this.gameViewAdapter as any,
|
||||||
theme,
|
theme,
|
||||||
|
this.defensePostRange,
|
||||||
state,
|
state,
|
||||||
);
|
);
|
||||||
this.resources.setTerritoryShaderParams(
|
this.resources.setTerritoryShaderParams(
|
||||||
@@ -170,10 +172,23 @@ export class WorkerTerritoryRenderer {
|
|||||||
/**
|
/**
|
||||||
* Update game view adapter with latest game update.
|
* Update game view adapter with latest game update.
|
||||||
*/
|
*/
|
||||||
updateGameView(gu: GameUpdateViewData): void {
|
updateGameView(gu: GameUpdateViewData): boolean {
|
||||||
if (this.gameViewAdapter) {
|
if (!this.gameViewAdapter) {
|
||||||
this.gameViewAdapter.update(gu);
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.gameViewAdapter.update(gu);
|
||||||
|
const defensePostsDirty = this.gameViewAdapter.consumeDefensePostsDirty();
|
||||||
|
const playersDirty = this.gameViewAdapter.consumePlayersDirty();
|
||||||
|
if (defensePostsDirty) {
|
||||||
|
this.resources?.markDefensePostsDirty();
|
||||||
|
}
|
||||||
|
if (playersDirty) {
|
||||||
|
this.resources?.markPaletteDirty();
|
||||||
|
this.resources?.markRelationsDirty();
|
||||||
|
this.resources?.invalidateHistory();
|
||||||
|
}
|
||||||
|
return defensePostsDirty || playersDirty;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -536,13 +551,6 @@ export class WorkerTerritoryRenderer {
|
|||||||
|
|
||||||
this.resources.updateTickTiming(performance.now() / 1000);
|
this.resources.updateTickTiming(performance.now() / 1000);
|
||||||
|
|
||||||
if (
|
|
||||||
this.gameViewAdapter?.config().defensePostRange() !==
|
|
||||||
this.defensePostRange
|
|
||||||
) {
|
|
||||||
throw new Error("defensePostRange changed at runtime; unsupported.");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Upload palette if needed
|
// Upload palette if needed
|
||||||
this.resources.uploadPalette();
|
this.resources.uploadPalette();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user