mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-07 15:45:58 +00:00
quick push
This commit is contained in:
+24
-3
@@ -37,12 +37,15 @@ export async function createGameRunner(
|
||||
clientID: ClientID,
|
||||
mapLoader: GameMapLoader,
|
||||
callBack: (gu: GameUpdateViewData | ErrorUpdate) => void,
|
||||
tileUpdateSink?: (tile: TileRef) => void,
|
||||
sharedStateBuffer?: SharedArrayBuffer,
|
||||
): Promise<GameRunner> {
|
||||
const config = await getConfig(gameStart.config, null);
|
||||
const gameMap = await loadGameMap(
|
||||
gameStart.config.gameMap,
|
||||
gameStart.config.gameMapSize,
|
||||
mapLoader,
|
||||
sharedStateBuffer,
|
||||
);
|
||||
const random = new PseudoRandom(simpleHash(gameStart.gameID));
|
||||
|
||||
@@ -85,6 +88,7 @@ export async function createGameRunner(
|
||||
game,
|
||||
new Executor(game, gameStart.gameID, clientID),
|
||||
callBack,
|
||||
tileUpdateSink,
|
||||
);
|
||||
gr.init();
|
||||
return gr;
|
||||
@@ -101,6 +105,7 @@ export class GameRunner {
|
||||
public game: Game,
|
||||
private execManager: Executor,
|
||||
private callBack: (gu: GameUpdateViewData | ErrorUpdate) => void,
|
||||
private tileUpdateSink?: (tile: TileRef) => void,
|
||||
) {}
|
||||
|
||||
init() {
|
||||
@@ -175,13 +180,25 @@ export class GameRunner {
|
||||
});
|
||||
}
|
||||
|
||||
// Many tiles are updated to pack it into an array
|
||||
const packedTileUpdates = updates[GameUpdateType.Tile].map((u) => u.update);
|
||||
// Many tiles are updated; either publish them via a shared sink or pack
|
||||
// them into the view data.
|
||||
let packedTileUpdates: BigUint64Array;
|
||||
const tileUpdates = updates[GameUpdateType.Tile];
|
||||
if (this.tileUpdateSink !== undefined) {
|
||||
for (const u of tileUpdates) {
|
||||
const tileRef = Number(u.update >> 16n) as TileRef;
|
||||
this.tileUpdateSink(tileRef);
|
||||
}
|
||||
packedTileUpdates = new BigUint64Array();
|
||||
} else {
|
||||
const raw = tileUpdates.map((u) => u.update);
|
||||
packedTileUpdates = new BigUint64Array(raw);
|
||||
}
|
||||
updates[GameUpdateType.Tile] = [];
|
||||
|
||||
this.callBack({
|
||||
tick: this.game.ticks(),
|
||||
packedTileUpdates: new BigUint64Array(packedTileUpdates),
|
||||
packedTileUpdates,
|
||||
updates: updates,
|
||||
playerNameViewData: this.playerViewData,
|
||||
tickExecutionDuration: tickExecutionDuration,
|
||||
@@ -272,4 +289,8 @@ export class GameRunner {
|
||||
}
|
||||
return player.bestTransportShipSpawn(targetTile);
|
||||
}
|
||||
|
||||
public hasPendingTurns(): boolean {
|
||||
return this.currTurn < this.turns.length;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -852,6 +852,22 @@ export class GameImpl implements Game {
|
||||
hasFallout(ref: TileRef): boolean {
|
||||
return this._map.hasFallout(ref);
|
||||
}
|
||||
|
||||
isDefended(ref: TileRef): boolean {
|
||||
return this._map.isDefended(ref);
|
||||
}
|
||||
|
||||
setDefended(ref: TileRef, value: boolean): void {
|
||||
return this._map.setDefended(ref, value);
|
||||
}
|
||||
|
||||
getRelation(ref: TileRef): number {
|
||||
return this._map.getRelation(ref);
|
||||
}
|
||||
|
||||
setRelation(ref: TileRef, relation: number): void {
|
||||
return this._map.setRelation(ref, relation);
|
||||
}
|
||||
isBorder(ref: TileRef): boolean {
|
||||
return this._map.isBorder(ref);
|
||||
}
|
||||
|
||||
+50
-15
@@ -27,6 +27,10 @@ export interface GameMap {
|
||||
setOwnerID(ref: TileRef, playerId: number): void;
|
||||
hasFallout(ref: TileRef): boolean;
|
||||
setFallout(ref: TileRef, value: boolean): void;
|
||||
isDefended(ref: TileRef): boolean;
|
||||
setDefended(ref: TileRef, value: boolean): void;
|
||||
getRelation(ref: TileRef): number;
|
||||
setRelation(ref: TileRef, relation: number): void;
|
||||
isOnEdgeOfMap(ref: TileRef): boolean;
|
||||
isBorder(ref: TileRef): boolean;
|
||||
neighbors(ref: TileRef): TileRef[];
|
||||
@@ -72,14 +76,20 @@ export class GameMapImpl implements GameMap {
|
||||
// State bits (Uint16Array)
|
||||
private static readonly PLAYER_ID_MASK = 0xfff;
|
||||
private static readonly FALLOUT_BIT = 13;
|
||||
private static readonly DEFENSE_BONUS_BIT = 14;
|
||||
// Bit 15 still reserved
|
||||
private static readonly DEFENDED_BIT = 12;
|
||||
private static readonly RELATION_MASK = 0xc000; // bits 14-15
|
||||
private static readonly RELATION_SHIFT = 14;
|
||||
// Relation values (stored in bits 14-15)
|
||||
private static readonly RELATION_NEUTRAL = 0;
|
||||
private static readonly RELATION_FRIENDLY = 1;
|
||||
private static readonly RELATION_EMBARGO = 2;
|
||||
|
||||
constructor(
|
||||
width: number,
|
||||
height: number,
|
||||
terrainData: Uint8Array,
|
||||
private numLandTiles_: number,
|
||||
stateBuffer?: ArrayBufferLike,
|
||||
) {
|
||||
if (terrainData.length !== width * height) {
|
||||
throw new Error(
|
||||
@@ -89,7 +99,17 @@ export class GameMapImpl implements GameMap {
|
||||
this.width_ = width;
|
||||
this.height_ = height;
|
||||
this.terrain = terrainData;
|
||||
this.state = new Uint16Array(width * height);
|
||||
if (stateBuffer !== undefined) {
|
||||
const state = new Uint16Array(stateBuffer);
|
||||
if (state.length !== width * height) {
|
||||
throw new Error(
|
||||
`State buffer length ${state.length} doesn't match dimensions ${width}x${height}`,
|
||||
);
|
||||
}
|
||||
this.state = state;
|
||||
} else {
|
||||
this.state = new Uint16Array(width * height);
|
||||
}
|
||||
// Precompute the LUTs
|
||||
let ref = 0;
|
||||
this.refToX = new Array(width * height);
|
||||
@@ -206,6 +226,33 @@ export class GameMapImpl implements GameMap {
|
||||
}
|
||||
}
|
||||
|
||||
isDefended(ref: TileRef): boolean {
|
||||
return Boolean(this.state[ref] & (1 << GameMapImpl.DEFENDED_BIT));
|
||||
}
|
||||
|
||||
setDefended(ref: TileRef, value: boolean): void {
|
||||
if (value) {
|
||||
this.state[ref] |= 1 << GameMapImpl.DEFENDED_BIT;
|
||||
} else {
|
||||
this.state[ref] &= ~(1 << GameMapImpl.DEFENDED_BIT);
|
||||
}
|
||||
}
|
||||
|
||||
getRelation(ref: TileRef): number {
|
||||
return (
|
||||
(this.state[ref] & GameMapImpl.RELATION_MASK) >>
|
||||
GameMapImpl.RELATION_SHIFT
|
||||
);
|
||||
}
|
||||
|
||||
setRelation(ref: TileRef, relation: number): void {
|
||||
// Clear existing relation bits
|
||||
this.state[ref] &= ~GameMapImpl.RELATION_MASK;
|
||||
// Set new relation bits
|
||||
this.state[ref] |=
|
||||
(relation << GameMapImpl.RELATION_SHIFT) & GameMapImpl.RELATION_MASK;
|
||||
}
|
||||
|
||||
isOnEdgeOfMap(ref: TileRef): boolean {
|
||||
const x = this.x(ref);
|
||||
const y = this.y(ref);
|
||||
@@ -220,18 +267,6 @@ export class GameMapImpl implements GameMap {
|
||||
);
|
||||
}
|
||||
|
||||
hasDefenseBonus(ref: TileRef): boolean {
|
||||
return Boolean(this.state[ref] & (1 << GameMapImpl.DEFENSE_BONUS_BIT));
|
||||
}
|
||||
|
||||
setDefenseBonus(ref: TileRef, value: boolean): void {
|
||||
if (value) {
|
||||
this.state[ref] |= 1 << GameMapImpl.DEFENSE_BONUS_BIT;
|
||||
} else {
|
||||
this.state[ref] &= ~(1 << GameMapImpl.DEFENSE_BONUS_BIT);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
isWater(ref: TileRef): boolean {
|
||||
return !this.isLand(ref);
|
||||
|
||||
+166
-14
@@ -41,6 +41,10 @@ import { UserSettings } from "./UserSettings";
|
||||
|
||||
const userSettings: UserSettings = new UserSettings();
|
||||
|
||||
const FRIENDLY_TINT_TARGET = { r: 0, g: 255, b: 0, a: 1 };
|
||||
const EMBARGO_TINT_TARGET = { r: 255, g: 0, b: 0, a: 1 };
|
||||
const BORDER_TINT_RATIO = 0.35;
|
||||
|
||||
export class UnitView {
|
||||
public _wasUpdated = true;
|
||||
public lastPos: TileRef[] = [];
|
||||
@@ -184,9 +188,17 @@ export class PlayerView {
|
||||
|
||||
private _territoryColor: Colord;
|
||||
private _borderColor: Colord;
|
||||
|
||||
// Update here to include structure light and dark colors
|
||||
private _structureColors: { light: Colord; dark: Colord };
|
||||
private _defendedBorderColors: { light: Colord; dark: Colord };
|
||||
|
||||
// Pre-computed border color variants
|
||||
private _borderColorNeutral: Colord;
|
||||
private _borderColorFriendly: Colord;
|
||||
private _borderColorEmbargo: Colord;
|
||||
private _borderColorDefendedNeutral: { light: Colord; dark: Colord };
|
||||
private _borderColorDefendedFriendly: { light: Colord; dark: Colord };
|
||||
private _borderColorDefendedEmbargo: { light: Colord; dark: Colord };
|
||||
|
||||
constructor(
|
||||
private game: GameView,
|
||||
@@ -248,11 +260,56 @@ export class PlayerView {
|
||||
this.cosmetics.color?.color ??
|
||||
maybeFocusedBorderColor.toHex(),
|
||||
);
|
||||
const theme = this.game.config().theme();
|
||||
const baseRgb = this._borderColor.toRgb();
|
||||
|
||||
this._defendedBorderColors = this.game
|
||||
.config()
|
||||
.theme()
|
||||
.defendedBorderColors(this._borderColor);
|
||||
// Neutral is just the base color
|
||||
this._borderColorNeutral = this._borderColor;
|
||||
|
||||
// Compute friendly tint
|
||||
this._borderColorFriendly = colord({
|
||||
r: Math.round(
|
||||
baseRgb.r * (1 - BORDER_TINT_RATIO) +
|
||||
FRIENDLY_TINT_TARGET.r * BORDER_TINT_RATIO,
|
||||
),
|
||||
g: Math.round(
|
||||
baseRgb.g * (1 - BORDER_TINT_RATIO) +
|
||||
FRIENDLY_TINT_TARGET.g * BORDER_TINT_RATIO,
|
||||
),
|
||||
b: Math.round(
|
||||
baseRgb.b * (1 - BORDER_TINT_RATIO) +
|
||||
FRIENDLY_TINT_TARGET.b * BORDER_TINT_RATIO,
|
||||
),
|
||||
a: baseRgb.a,
|
||||
});
|
||||
|
||||
// Compute embargo tint
|
||||
this._borderColorEmbargo = colord({
|
||||
r: Math.round(
|
||||
baseRgb.r * (1 - BORDER_TINT_RATIO) +
|
||||
EMBARGO_TINT_TARGET.r * BORDER_TINT_RATIO,
|
||||
),
|
||||
g: Math.round(
|
||||
baseRgb.g * (1 - BORDER_TINT_RATIO) +
|
||||
EMBARGO_TINT_TARGET.g * BORDER_TINT_RATIO,
|
||||
),
|
||||
b: Math.round(
|
||||
baseRgb.b * (1 - BORDER_TINT_RATIO) +
|
||||
EMBARGO_TINT_TARGET.b * BORDER_TINT_RATIO,
|
||||
),
|
||||
a: baseRgb.a,
|
||||
});
|
||||
|
||||
// Pre-compute defended variants
|
||||
this._borderColorDefendedNeutral = theme.defendedBorderColors(
|
||||
this._borderColorNeutral,
|
||||
);
|
||||
this._borderColorDefendedFriendly = theme.defendedBorderColors(
|
||||
this._borderColorFriendly,
|
||||
);
|
||||
this._borderColorDefendedEmbargo = theme.defendedBorderColors(
|
||||
this._borderColorEmbargo,
|
||||
);
|
||||
|
||||
this.decoder =
|
||||
pattern === undefined
|
||||
@@ -275,18 +332,74 @@ export class PlayerView {
|
||||
return this._structureColors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Border color for a tile:
|
||||
* - Tints by neighbor relations (embargo → red, friendly → green, else neutral).
|
||||
* - If defended, applies theme checkerboard to the tinted color.
|
||||
*/
|
||||
borderColor(tile?: TileRef, isDefended: boolean = false): Colord {
|
||||
if (tile === undefined || !isDefended) {
|
||||
if (tile === undefined) {
|
||||
return this._borderColor;
|
||||
}
|
||||
|
||||
const { hasEmbargo, hasFriendly } = this.borderRelationFlags(tile);
|
||||
|
||||
let baseColor: Colord;
|
||||
let defendedColors: { light: Colord; dark: Colord };
|
||||
|
||||
if (hasEmbargo) {
|
||||
baseColor = this._borderColorEmbargo;
|
||||
defendedColors = this._borderColorDefendedEmbargo;
|
||||
} else if (hasFriendly) {
|
||||
baseColor = this._borderColorFriendly;
|
||||
defendedColors = this._borderColorDefendedFriendly;
|
||||
} else {
|
||||
baseColor = this._borderColorNeutral;
|
||||
defendedColors = this._borderColorDefendedNeutral;
|
||||
}
|
||||
|
||||
if (!isDefended) {
|
||||
return baseColor;
|
||||
}
|
||||
|
||||
const x = this.game.x(tile);
|
||||
const y = this.game.y(tile);
|
||||
const lightTile =
|
||||
(x % 2 === 0 && y % 2 === 0) || (y % 2 === 1 && x % 2 === 1);
|
||||
return lightTile
|
||||
? this._defendedBorderColors.light
|
||||
: this._defendedBorderColors.dark;
|
||||
return lightTile ? defendedColors.light : defendedColors.dark;
|
||||
}
|
||||
|
||||
/**
|
||||
* Border relation flags for a tile, used by both CPU and WebGL renderers.
|
||||
*/
|
||||
borderRelationFlags(tile: TileRef): {
|
||||
hasEmbargo: boolean;
|
||||
hasFriendly: boolean;
|
||||
} {
|
||||
const mySmallID = this.smallID();
|
||||
let hasEmbargo = false;
|
||||
let hasFriendly = false;
|
||||
|
||||
for (const n of this.game.neighbors(tile)) {
|
||||
if (!this.game.hasOwner(n)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const otherOwner = this.game.owner(n);
|
||||
if (!otherOwner.isPlayer() || otherOwner.smallID() === mySmallID) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (this.hasEmbargo(otherOwner)) {
|
||||
hasEmbargo = true;
|
||||
break;
|
||||
}
|
||||
|
||||
if (this.isFriendly(otherOwner) || otherOwner.isFriendly(this)) {
|
||||
hasFriendly = true;
|
||||
}
|
||||
}
|
||||
return { hasEmbargo, hasFriendly };
|
||||
}
|
||||
|
||||
async actions(tile?: TileRef): Promise<PlayerActions> {
|
||||
@@ -474,6 +587,8 @@ export class GameView implements GameMap {
|
||||
private _cosmetics: Map<string, PlayerCosmetics> = new Map();
|
||||
|
||||
private _map: GameMap;
|
||||
private readonly usesSharedTileState: boolean;
|
||||
private readonly terraNullius = new TerraNulliusImpl();
|
||||
|
||||
constructor(
|
||||
public worker: WorkerClient,
|
||||
@@ -482,8 +597,10 @@ export class GameView implements GameMap {
|
||||
private _myClientID: ClientID,
|
||||
private _gameID: GameID,
|
||||
private humans: Player[],
|
||||
usesSharedTileState: boolean = false,
|
||||
) {
|
||||
this._map = this._mapData.gameMap;
|
||||
this.usesSharedTileState = usesSharedTileState;
|
||||
this.lastUpdate = null;
|
||||
this.unitGrid = new UnitGrid(this._map);
|
||||
this._cosmetics = new Map(
|
||||
@@ -512,9 +629,16 @@ export class GameView implements GameMap {
|
||||
this.lastUpdate = gu;
|
||||
|
||||
this.updatedTiles = [];
|
||||
this.lastUpdate.packedTileUpdates.forEach((tu) => {
|
||||
this.updatedTiles.push(this.updateTile(tu));
|
||||
});
|
||||
if (this.usesSharedTileState) {
|
||||
this.lastUpdate.packedTileUpdates.forEach((tu) => {
|
||||
const tileRef = Number(tu);
|
||||
this.updatedTiles.push(tileRef);
|
||||
});
|
||||
} else {
|
||||
this.lastUpdate.packedTileUpdates.forEach((tu) => {
|
||||
this.updatedTiles.push(this.updateTile(tu));
|
||||
});
|
||||
}
|
||||
|
||||
if (gu.updates === null) {
|
||||
throw new Error("lastUpdate.updates not initialized");
|
||||
@@ -620,11 +744,11 @@ export class GameView implements GameMap {
|
||||
|
||||
playerBySmallID(id: number): PlayerView | TerraNullius {
|
||||
if (id === 0) {
|
||||
return new TerraNulliusImpl();
|
||||
return this.terraNullius;
|
||||
}
|
||||
const playerId = this.smallIDToID.get(id);
|
||||
if (playerId === undefined) {
|
||||
throw new Error(`small id ${id} not found`);
|
||||
return this.terraNullius;
|
||||
}
|
||||
return this.player(playerId);
|
||||
}
|
||||
@@ -732,6 +856,22 @@ export class GameView implements GameMap {
|
||||
setFallout(ref: TileRef, value: boolean): void {
|
||||
return this._map.setFallout(ref, value);
|
||||
}
|
||||
|
||||
isDefended(ref: TileRef): boolean {
|
||||
return this._map.isDefended(ref);
|
||||
}
|
||||
|
||||
setDefended(ref: TileRef, value: boolean): void {
|
||||
return this._map.setDefended(ref, value);
|
||||
}
|
||||
|
||||
getRelation(ref: TileRef): number {
|
||||
return this._map.getRelation(ref);
|
||||
}
|
||||
|
||||
setRelation(ref: TileRef, relation: number): void {
|
||||
return this._map.setRelation(ref, relation);
|
||||
}
|
||||
isBorder(ref: TileRef): boolean {
|
||||
return this._map.isBorder(ref);
|
||||
}
|
||||
@@ -781,6 +921,18 @@ export class GameView implements GameMap {
|
||||
return this._gameID;
|
||||
}
|
||||
|
||||
hasSharedTileState(): boolean {
|
||||
return this.usesSharedTileState;
|
||||
}
|
||||
|
||||
sharedStateBuffer(): SharedArrayBuffer | undefined {
|
||||
if (!this.usesSharedTileState) {
|
||||
return undefined;
|
||||
}
|
||||
const buffer = this._mapData.sharedStateBuffer;
|
||||
return buffer instanceof SharedArrayBuffer ? buffer : undefined;
|
||||
}
|
||||
|
||||
focusedPlayer(): PlayerView | null {
|
||||
return this.myPlayer();
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ export type TerrainMapData = {
|
||||
nations: Nation[];
|
||||
gameMap: GameMap;
|
||||
miniGameMap: GameMap;
|
||||
sharedStateBuffer?: SharedArrayBuffer;
|
||||
sharedDirtyBuffer?: SharedArrayBuffer;
|
||||
};
|
||||
|
||||
const loadedMaps = new Map<GameMapType, TerrainMapData>();
|
||||
@@ -35,15 +37,42 @@ export async function loadTerrainMap(
|
||||
map: GameMapType,
|
||||
mapSize: GameMapSize,
|
||||
terrainMapFileLoader: GameMapLoader,
|
||||
sharedStateBuffer?: SharedArrayBuffer,
|
||||
): Promise<TerrainMapData> {
|
||||
const cached = loadedMaps.get(map);
|
||||
if (cached !== undefined) return cached;
|
||||
const useCache = sharedStateBuffer === undefined;
|
||||
const canUseSharedBuffers =
|
||||
typeof SharedArrayBuffer !== "undefined" &&
|
||||
typeof Atomics !== "undefined" &&
|
||||
typeof (globalThis as any).crossOriginIsolated === "boolean" &&
|
||||
(globalThis as any).crossOriginIsolated === true;
|
||||
|
||||
// Don't use cache if we can create SharedArrayBuffer but none was provided
|
||||
const shouldUseCache = useCache && !canUseSharedBuffers;
|
||||
|
||||
if (shouldUseCache) {
|
||||
const cached = loadedMaps.get(map);
|
||||
if (cached !== undefined) return cached;
|
||||
}
|
||||
const mapFiles = terrainMapFileLoader.getMapData(map);
|
||||
const manifest = await mapFiles.manifest();
|
||||
|
||||
const stateBuffer =
|
||||
sharedStateBuffer ??
|
||||
(canUseSharedBuffers
|
||||
? new SharedArrayBuffer(
|
||||
manifest.map.width *
|
||||
manifest.map.height *
|
||||
Uint16Array.BYTES_PER_ELEMENT,
|
||||
)
|
||||
: undefined);
|
||||
|
||||
const gameMap =
|
||||
mapSize === GameMapSize.Normal
|
||||
? await genTerrainFromBin(manifest.map, await mapFiles.mapBin())
|
||||
? await genTerrainFromBin(
|
||||
manifest.map,
|
||||
await mapFiles.mapBin(),
|
||||
stateBuffer,
|
||||
)
|
||||
: await genTerrainFromBin(manifest.map4x, await mapFiles.map4xBin());
|
||||
|
||||
const miniMap =
|
||||
@@ -63,18 +92,28 @@ export async function loadTerrainMap(
|
||||
});
|
||||
}
|
||||
|
||||
const result = {
|
||||
const result: TerrainMapData = {
|
||||
nations: manifest.nations,
|
||||
gameMap: gameMap,
|
||||
miniGameMap: miniMap,
|
||||
sharedStateBuffer:
|
||||
typeof SharedArrayBuffer !== "undefined" &&
|
||||
stateBuffer instanceof SharedArrayBuffer
|
||||
? stateBuffer
|
||||
: undefined,
|
||||
sharedDirtyBuffer: undefined, // populated by consumer when needed
|
||||
};
|
||||
loadedMaps.set(map, result);
|
||||
// Only cache the result when caching is actually used (non-SAB path)
|
||||
if (shouldUseCache) {
|
||||
loadedMaps.set(map, result);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function genTerrainFromBin(
|
||||
mapData: MapMetadata,
|
||||
data: Uint8Array,
|
||||
stateBuffer?: ArrayBufferLike,
|
||||
): Promise<GameMap> {
|
||||
if (data.length !== mapData.width * mapData.height) {
|
||||
throw new Error(
|
||||
@@ -87,5 +126,6 @@ export async function genTerrainFromBin(
|
||||
mapData.height,
|
||||
data,
|
||||
mapData.num_land_tiles,
|
||||
stateBuffer,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -61,6 +61,10 @@ export class UserSettings {
|
||||
return this.get("settings.structureSprites", true);
|
||||
}
|
||||
|
||||
territoryWebGL() {
|
||||
return this.get("settings.territoryWebGL", true);
|
||||
}
|
||||
|
||||
darkMode() {
|
||||
return this.get("settings.darkMode", false);
|
||||
}
|
||||
@@ -115,6 +119,10 @@ export class UserSettings {
|
||||
this.set("settings.structureSprites", !this.structureSprites());
|
||||
}
|
||||
|
||||
toggleTerritoryWebGL() {
|
||||
this.set("settings.territoryWebGL", !this.territoryWebGL());
|
||||
}
|
||||
|
||||
toggleTerritoryPatterns() {
|
||||
this.set("settings.territoryPatterns", !this.territoryPatterns());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { TileRef } from "../game/GameMap";
|
||||
|
||||
export interface SharedTileRingBuffers {
|
||||
header: SharedArrayBuffer;
|
||||
data: SharedArrayBuffer;
|
||||
dirty: SharedArrayBuffer;
|
||||
}
|
||||
|
||||
export interface SharedTileRingViews {
|
||||
header: Int32Array;
|
||||
buffer: Uint32Array;
|
||||
dirtyFlags: Uint8Array;
|
||||
capacity: number;
|
||||
}
|
||||
|
||||
// Header indices
|
||||
export const TILE_RING_HEADER_WRITE_INDEX = 0;
|
||||
export const TILE_RING_HEADER_READ_INDEX = 1;
|
||||
export const TILE_RING_HEADER_OVERFLOW = 2;
|
||||
|
||||
export function createSharedTileRingBuffers(
|
||||
capacity: number,
|
||||
numTiles: number,
|
||||
): SharedTileRingBuffers {
|
||||
const header = new SharedArrayBuffer(3 * Int32Array.BYTES_PER_ELEMENT);
|
||||
const data = new SharedArrayBuffer(capacity * Uint32Array.BYTES_PER_ELEMENT);
|
||||
const dirty = new SharedArrayBuffer(numTiles * Uint8Array.BYTES_PER_ELEMENT);
|
||||
return { header, data, dirty };
|
||||
}
|
||||
|
||||
export function createSharedTileRingViews(
|
||||
buffers: SharedTileRingBuffers,
|
||||
): SharedTileRingViews {
|
||||
const header = new Int32Array(buffers.header);
|
||||
const buffer = new Uint32Array(buffers.data);
|
||||
const dirtyFlags = new Uint8Array(buffers.dirty);
|
||||
return {
|
||||
header,
|
||||
buffer,
|
||||
dirtyFlags,
|
||||
capacity: buffer.length,
|
||||
};
|
||||
}
|
||||
|
||||
export function pushTileUpdate(
|
||||
views: SharedTileRingViews,
|
||||
value: TileRef,
|
||||
): void {
|
||||
const { header, buffer, capacity } = views;
|
||||
|
||||
const write = Atomics.load(header, TILE_RING_HEADER_WRITE_INDEX);
|
||||
const read = Atomics.load(header, TILE_RING_HEADER_READ_INDEX);
|
||||
const nextWrite = (write + 1) % capacity;
|
||||
|
||||
// If the buffer is full, advance read (drop oldest) and mark overflow.
|
||||
if (nextWrite === read) {
|
||||
Atomics.store(header, TILE_RING_HEADER_OVERFLOW, 1);
|
||||
const nextRead = (read + 1) % capacity;
|
||||
Atomics.store(header, TILE_RING_HEADER_READ_INDEX, nextRead);
|
||||
}
|
||||
|
||||
buffer[write] = value;
|
||||
Atomics.store(header, TILE_RING_HEADER_WRITE_INDEX, nextWrite);
|
||||
}
|
||||
|
||||
export function drainTileUpdates(
|
||||
views: SharedTileRingViews,
|
||||
maxItems: number,
|
||||
out: TileRef[],
|
||||
): void {
|
||||
const { header, buffer, capacity } = views;
|
||||
|
||||
let read = Atomics.load(header, TILE_RING_HEADER_READ_INDEX);
|
||||
const write = Atomics.load(header, TILE_RING_HEADER_WRITE_INDEX);
|
||||
|
||||
let count = 0;
|
||||
|
||||
while (read !== write && count < maxItems) {
|
||||
out.push(buffer[read]);
|
||||
read = (read + 1) % capacity;
|
||||
count++;
|
||||
}
|
||||
|
||||
Atomics.store(header, TILE_RING_HEADER_READ_INDEX, read);
|
||||
}
|
||||
@@ -1,7 +1,13 @@
|
||||
import version from "../../../resources/version.txt";
|
||||
import { createGameRunner, GameRunner } from "../GameRunner";
|
||||
import { FetchGameMapLoader } from "../game/FetchGameMapLoader";
|
||||
import { TileRef } from "../game/GameMap";
|
||||
import { ErrorUpdate, GameUpdateViewData } from "../game/GameUpdates";
|
||||
import {
|
||||
createSharedTileRingViews,
|
||||
pushTileUpdate,
|
||||
SharedTileRingViews,
|
||||
} from "./SharedTileRing";
|
||||
import {
|
||||
AttackAveragePositionResultMessage,
|
||||
InitializedMessage,
|
||||
@@ -16,6 +22,9 @@ import {
|
||||
const ctx: Worker = self as any;
|
||||
let gameRunner: Promise<GameRunner> | null = null;
|
||||
const mapLoader = new FetchGameMapLoader(`/maps`, version);
|
||||
let isProcessingTurns = false;
|
||||
let sharedTileRing: SharedTileRingViews | null = null;
|
||||
let dirtyFlags: Uint8Array | null = null;
|
||||
|
||||
function gameUpdate(gu: GameUpdateViewData | ErrorUpdate) {
|
||||
// skip if ErrorUpdate
|
||||
@@ -32,25 +41,68 @@ function sendMessage(message: WorkerMessage) {
|
||||
ctx.postMessage(message);
|
||||
}
|
||||
|
||||
async function processPendingTurns() {
|
||||
if (isProcessingTurns) {
|
||||
return;
|
||||
}
|
||||
if (!gameRunner) {
|
||||
return;
|
||||
}
|
||||
|
||||
const gr = await gameRunner;
|
||||
if (!gr || !gr.hasPendingTurns()) {
|
||||
return;
|
||||
}
|
||||
|
||||
isProcessingTurns = true;
|
||||
try {
|
||||
while (gr.hasPendingTurns()) {
|
||||
gr.executeNextTick();
|
||||
}
|
||||
} finally {
|
||||
isProcessingTurns = false;
|
||||
}
|
||||
}
|
||||
|
||||
ctx.addEventListener("message", async (e: MessageEvent<MainThreadMessage>) => {
|
||||
const message = e.data;
|
||||
|
||||
switch (message.type) {
|
||||
case "heartbeat":
|
||||
(await gameRunner)?.executeNextTick();
|
||||
break;
|
||||
case "init":
|
||||
try {
|
||||
if (message.sharedTileRingHeader && message.sharedTileRingData) {
|
||||
sharedTileRing = createSharedTileRingViews({
|
||||
header: message.sharedTileRingHeader,
|
||||
data: message.sharedTileRingData,
|
||||
dirty: message.sharedDirtyBuffer!,
|
||||
});
|
||||
dirtyFlags = sharedTileRing.dirtyFlags;
|
||||
} else {
|
||||
sharedTileRing = null;
|
||||
dirtyFlags = null;
|
||||
}
|
||||
|
||||
gameRunner = createGameRunner(
|
||||
message.gameStartInfo,
|
||||
message.clientID,
|
||||
mapLoader,
|
||||
gameUpdate,
|
||||
sharedTileRing && dirtyFlags
|
||||
? (tile: TileRef) => {
|
||||
if (Atomics.compareExchange(dirtyFlags!, tile, 0, 1) === 0) {
|
||||
pushTileUpdate(sharedTileRing!, tile);
|
||||
}
|
||||
}
|
||||
: sharedTileRing
|
||||
? (tile: TileRef) => pushTileUpdate(sharedTileRing!, tile)
|
||||
: undefined,
|
||||
message.sharedStateBuffer,
|
||||
).then((gr) => {
|
||||
sendMessage({
|
||||
type: "initialized",
|
||||
id: message.id,
|
||||
} as InitializedMessage);
|
||||
processPendingTurns();
|
||||
return gr;
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -67,6 +119,7 @@ ctx.addEventListener("message", async (e: MessageEvent<MainThreadMessage>) => {
|
||||
try {
|
||||
const gr = await gameRunner;
|
||||
await gr.addTurn(message.turn);
|
||||
processPendingTurns();
|
||||
} catch (error) {
|
||||
console.error("Failed to process turn:", error);
|
||||
throw error;
|
||||
|
||||
@@ -9,6 +9,7 @@ import { TileRef } from "../game/GameMap";
|
||||
import { ErrorUpdate, GameUpdateViewData } from "../game/GameUpdates";
|
||||
import { ClientID, GameStartInfo, Turn } from "../Schemas";
|
||||
import { generateID } from "../Util";
|
||||
import { SharedTileRingBuffers } from "./SharedTileRing";
|
||||
import { WorkerMessage } from "./WorkerMessages";
|
||||
|
||||
export class WorkerClient {
|
||||
@@ -22,6 +23,9 @@ export class WorkerClient {
|
||||
constructor(
|
||||
private gameStartInfo: GameStartInfo,
|
||||
private clientID: ClientID,
|
||||
private sharedTileRingBuffers?: SharedTileRingBuffers,
|
||||
private sharedStateBuffer?: SharedArrayBuffer,
|
||||
private sharedDirtyBuffer?: SharedArrayBuffer,
|
||||
) {
|
||||
this.worker = new Worker(new URL("./Worker.worker.ts", import.meta.url));
|
||||
this.messageHandlers = new Map();
|
||||
@@ -70,6 +74,10 @@ export class WorkerClient {
|
||||
id: messageId,
|
||||
gameStartInfo: this.gameStartInfo,
|
||||
clientID: this.clientID,
|
||||
sharedTileRingHeader: this.sharedTileRingBuffers?.header,
|
||||
sharedTileRingData: this.sharedTileRingBuffers?.data,
|
||||
sharedStateBuffer: this.sharedStateBuffer,
|
||||
sharedDirtyBuffer: this.sharedDirtyBuffer,
|
||||
});
|
||||
|
||||
// Add timeout for initialization
|
||||
@@ -100,12 +108,6 @@ export class WorkerClient {
|
||||
});
|
||||
}
|
||||
|
||||
sendHeartbeat() {
|
||||
this.worker.postMessage({
|
||||
type: "heartbeat",
|
||||
});
|
||||
}
|
||||
|
||||
playerProfile(playerID: number): Promise<PlayerProfile> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.isInitialized) {
|
||||
|
||||
@@ -9,7 +9,6 @@ import { GameUpdateViewData } from "../game/GameUpdates";
|
||||
import { ClientID, GameStartInfo, Turn } from "../Schemas";
|
||||
|
||||
export type WorkerMessageType =
|
||||
| "heartbeat"
|
||||
| "init"
|
||||
| "initialized"
|
||||
| "turn"
|
||||
@@ -31,15 +30,15 @@ interface BaseWorkerMessage {
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export interface HeartbeatMessage extends BaseWorkerMessage {
|
||||
type: "heartbeat";
|
||||
}
|
||||
|
||||
// Messages from main thread to worker
|
||||
export interface InitMessage extends BaseWorkerMessage {
|
||||
type: "init";
|
||||
gameStartInfo: GameStartInfo;
|
||||
clientID: ClientID;
|
||||
sharedTileRingHeader?: SharedArrayBuffer;
|
||||
sharedTileRingData?: SharedArrayBuffer;
|
||||
sharedStateBuffer?: SharedArrayBuffer;
|
||||
sharedDirtyBuffer?: SharedArrayBuffer;
|
||||
}
|
||||
|
||||
export interface TurnMessage extends BaseWorkerMessage {
|
||||
@@ -114,7 +113,6 @@ export interface TransportShipSpawnResultMessage extends BaseWorkerMessage {
|
||||
|
||||
// Union types for type safety
|
||||
export type MainThreadMessage =
|
||||
| HeartbeatMessage
|
||||
| InitMessage
|
||||
| TurnMessage
|
||||
| PlayerActionsMessage
|
||||
|
||||
Reference in New Issue
Block a user