Files
OpenFrontIO/src/client/ClientGameRunner.ts
T

1074 lines
31 KiB
TypeScript

import { translateText } from "../client/Utils";
import { EventBus } from "../core/EventBus";
import {
ClientID,
GameID,
GameRecord,
GameStartInfo,
PlayerCosmeticRefs,
PlayerRecord,
ServerMessage,
} from "../core/Schemas";
import { createPartialGameRecord, replacer } from "../core/Util";
import { ServerConfig } from "../core/configuration/Config";
import { getConfig } from "../core/configuration/ConfigLoader";
import { GameUpdates, PlayerActions, UnitType } from "../core/game/Game";
import { TileRef } from "../core/game/GameMap";
import { GameMapLoader } from "../core/game/GameMapLoader";
import {
ErrorUpdate,
GameUpdateType,
GameUpdateViewData,
HashUpdate,
WinUpdate,
} from "../core/game/GameUpdates";
import { GameView, PlayerView } from "../core/game/GameView";
import { loadTerrainMap, TerrainMapData } from "../core/game/TerrainMapLoader";
import { UserSettings } from "../core/game/UserSettings";
import {
createSharedTileRingBuffers,
createSharedTileRingViews,
drainTileUpdates,
SharedTileRingBuffers,
SharedTileRingViews,
TILE_RING_HEADER_OVERFLOW,
} from "../core/worker/SharedTileRing";
import { WorkerClient } from "../core/worker/WorkerClient";
import {
AutoUpgradeEvent,
BacklogStatusEvent,
DoBoatAttackEvent,
DoGroundAttackEvent,
InputHandler,
MouseMoveEvent,
MouseUpEvent,
TickMetricsEvent,
} from "./InputHandler";
import { endGame, startGame, startTime } from "./LocalPersistantStats";
import { getPersistentID } from "./Main";
import { terrainMapFileLoader } from "./TerrainMapFileLoader";
import {
SendAttackIntentEvent,
SendBoatAttackIntentEvent,
SendHashEvent,
SendSpawnIntentEvent,
SendUpgradeStructureIntentEvent,
Transport,
} from "./Transport";
import { createCanvas } from "./Utils";
import { createRenderer, GameRenderer } from "./graphics/GameRenderer";
import { GoToPlayerEvent } from "./graphics/layers/Leaderboard";
import SoundManager from "./sound/SoundManager";
export interface LobbyConfig {
serverConfig: ServerConfig;
cosmetics: PlayerCosmeticRefs;
playerName: string;
clientID: ClientID;
gameID: GameID;
token: string;
turnstileToken: string | null;
// GameStartInfo only exists when playing a singleplayer game.
gameStartInfo?: GameStartInfo;
// GameRecord exists when replaying an archived game.
gameRecord?: GameRecord;
}
export function joinLobby(
eventBus: EventBus,
lobbyConfig: LobbyConfig,
onPrestart: () => void,
onJoin: () => void,
): () => void {
console.log(
`joining lobby: gameID: ${lobbyConfig.gameID}, clientID: ${lobbyConfig.clientID}`,
);
const userSettings: UserSettings = new UserSettings();
startGame(lobbyConfig.gameID, lobbyConfig.gameStartInfo?.config ?? {});
const transport = new Transport(lobbyConfig, eventBus);
let hasJoined = false;
const onconnect = () => {
if (hasJoined) {
console.log("rejoining game");
transport.rejoinGame(0);
} else {
hasJoined = true;
console.log(`Joining game lobby ${lobbyConfig.gameID}`);
transport.joinGame();
}
};
let terrainLoad: Promise<TerrainMapData> | null = null;
const onmessage = (message: ServerMessage) => {
if (message.type === "prestart") {
console.log(
`lobby: game prestarting: ${JSON.stringify(message, replacer)}`,
);
terrainLoad = loadTerrainMap(
message.gameMap,
message.gameMapSize,
terrainMapFileLoader,
);
onPrestart();
}
if (message.type === "start") {
// Trigger prestart for singleplayer games
onPrestart();
console.log(
`lobby: game started: ${JSON.stringify(message, replacer, 2)}`,
);
onJoin();
// For multiplayer games, GameStartInfo is not known until game starts.
lobbyConfig.gameStartInfo = message.gameStartInfo;
createClientGame(
lobbyConfig,
eventBus,
transport,
userSettings,
terrainLoad,
terrainMapFileLoader,
).then((r) => r.start());
}
if (message.type === "error") {
if (message.error === "full-lobby") {
document.dispatchEvent(
new CustomEvent("leave-lobby", {
detail: { lobby: lobbyConfig.gameID },
bubbles: true,
composed: true,
}),
);
} else {
showErrorModal(
message.error,
message.message,
lobbyConfig.gameID,
lobbyConfig.clientID,
true,
false,
"error_modal.connection_error",
);
}
}
};
transport.connect(onconnect, onmessage);
return () => {
console.log("leaving game");
transport.leaveGame();
};
}
async function createClientGame(
lobbyConfig: LobbyConfig,
eventBus: EventBus,
transport: Transport,
userSettings: UserSettings,
terrainLoad: Promise<TerrainMapData> | null,
mapLoader: GameMapLoader,
): Promise<ClientGameRunner> {
if (lobbyConfig.gameStartInfo === undefined) {
throw new Error("missing gameStartInfo");
}
const config = await getConfig(
lobbyConfig.gameStartInfo.config,
userSettings,
lobbyConfig.gameRecord !== undefined,
);
let gameMap: TerrainMapData | null = null;
if (terrainLoad) {
gameMap = await terrainLoad;
} else {
gameMap = await loadTerrainMap(
lobbyConfig.gameStartInfo.config.gameMap,
lobbyConfig.gameStartInfo.config.gameMapSize,
mapLoader,
);
}
let sharedTileRingBuffers: SharedTileRingBuffers | undefined;
let sharedTileRingViews: SharedTileRingViews | null = null;
let sharedDirtyBuffer: SharedArrayBuffer | undefined;
let sharedDirtyFlags: Uint8Array | null = null;
const isIsolated =
typeof (globalThis as any).crossOriginIsolated === "boolean"
? (globalThis as any).crossOriginIsolated === true
: false;
const canUseSharedBuffers =
typeof SharedArrayBuffer !== "undefined" &&
typeof Atomics !== "undefined" &&
isIsolated;
const sharedStateBuffer =
canUseSharedBuffers && gameMap.sharedStateBuffer
? gameMap.sharedStateBuffer
: undefined;
const usesSharedTileState = !!sharedStateBuffer;
if (canUseSharedBuffers) {
const numTiles = gameMap.gameMap.width() * gameMap.gameMap.height();
// Ring capacity scales with world size: at most one entry per tile.
const TILE_RING_CAPACITY = numTiles;
sharedTileRingBuffers = createSharedTileRingBuffers(
TILE_RING_CAPACITY,
numTiles,
);
sharedTileRingViews = createSharedTileRingViews(sharedTileRingBuffers);
sharedDirtyBuffer = sharedTileRingBuffers.dirty;
sharedDirtyFlags = sharedTileRingViews.dirtyFlags;
}
const worker = new WorkerClient(
lobbyConfig.gameStartInfo,
lobbyConfig.clientID,
sharedTileRingBuffers,
sharedStateBuffer,
sharedDirtyBuffer,
);
await worker.initialize();
const gameView = new GameView(
worker,
config,
gameMap,
lobbyConfig.clientID,
lobbyConfig.gameStartInfo.gameID,
lobbyConfig.gameStartInfo.players,
usesSharedTileState,
);
const canvas = createCanvas();
const gameRenderer = createRenderer(canvas, gameView, eventBus);
console.log(
`creating private game got difficulty: ${lobbyConfig.gameStartInfo.config.difficulty}`,
);
return new ClientGameRunner(
lobbyConfig,
eventBus,
gameRenderer,
new InputHandler(gameRenderer.uiState, canvas, eventBus),
transport,
worker,
gameView,
sharedTileRingViews,
sharedDirtyFlags,
);
}
export class ClientGameRunner {
private myPlayer: PlayerView | null = null;
private isActive = false;
private turnsSeen = 0;
private lastMousePosition: { x: number; y: number } | null = null;
private lastMessageTime: number = 0;
private connectionCheckInterval: NodeJS.Timeout | null = null;
private goToPlayerTimeout: NodeJS.Timeout | null = null;
private lastTickReceiveTime: number = 0;
private currentTickDelay: number | undefined = undefined;
// Track how far behind the client simulation is compared to the server.
private serverTurnHighWater: number = 0;
private lastProcessedTick: number = 0;
private backlogTurns: number = 0;
private backlogGrowing: boolean = false;
private lastRenderedTick: number = 0;
private workerTicksSinceSample: number = 0;
private renderTicksSinceSample: number = 0;
private metricsSampleStart: number = 0;
private pendingUpdates: GameUpdateViewData[] = [];
private pendingStart = 0;
private isProcessingUpdates = false;
private tileRingViews: SharedTileRingViews | null;
private dirtyFlags: Uint8Array | null;
constructor(
private lobby: LobbyConfig,
private eventBus: EventBus,
private renderer: GameRenderer,
private input: InputHandler,
private transport: Transport,
private worker: WorkerClient,
private gameView: GameView,
tileRingViews: SharedTileRingViews | null,
dirtyFlags: Uint8Array | null,
) {
this.lastMessageTime = Date.now();
this.tileRingViews = tileRingViews;
this.dirtyFlags = dirtyFlags;
}
private saveGame(update: WinUpdate) {
if (this.myPlayer === null) {
return;
}
const players: PlayerRecord[] = [
{
persistentID: getPersistentID(),
username: this.lobby.playerName,
clientID: this.lobby.clientID,
stats: update.allPlayersStats[this.lobby.clientID],
},
];
if (this.lobby.gameStartInfo === undefined) {
throw new Error("missing gameStartInfo");
}
const record = createPartialGameRecord(
this.lobby.gameStartInfo.gameID,
this.lobby.gameStartInfo.config,
players,
// Not saving turns locally
[],
startTime(),
Date.now(),
update.winner,
this.lobby.gameStartInfo.lobbyCreatedAt,
);
endGame(record);
}
public start() {
SoundManager.playBackgroundMusic();
console.log("starting client game");
this.isActive = true;
this.lastMessageTime = Date.now();
setTimeout(() => {
this.connectionCheckInterval = setInterval(
() => this.onConnectionCheck(),
1000,
);
}, 20000);
this.eventBus.on(MouseUpEvent, this.inputEvent.bind(this));
this.eventBus.on(MouseMoveEvent, this.onMouseMove.bind(this));
this.eventBus.on(AutoUpgradeEvent, this.autoUpgradeEvent.bind(this));
this.eventBus.on(
DoBoatAttackEvent,
this.doBoatAttackUnderCursor.bind(this),
);
this.eventBus.on(
DoGroundAttackEvent,
this.doGroundAttackUnderCursor.bind(this),
);
this.renderer.initialize();
this.input.initialize();
this.worker.start((gu: GameUpdateViewData | ErrorUpdate) => {
if (this.lobby.gameStartInfo === undefined) {
throw new Error("missing gameStartInfo");
}
if ("errMsg" in gu) {
showErrorModal(
gu.errMsg,
gu.stack ?? "missing",
this.lobby.gameStartInfo.gameID,
this.lobby.clientID,
);
console.error(gu.stack);
this.stop();
return;
}
this.pendingUpdates.push(gu);
this.processPendingUpdates();
});
const onconnect = () => {
console.log("Connected to game server!");
this.transport.rejoinGame(this.turnsSeen);
};
const onmessage = (message: ServerMessage) => {
this.lastMessageTime = Date.now();
if (message.type === "start") {
console.log("starting game! in client game runner");
if (this.gameView.config().isRandomSpawn()) {
const goToPlayer = () => {
const myPlayer = this.gameView.myPlayer();
if (this.gameView.inSpawnPhase() && !myPlayer?.hasSpawned()) {
this.goToPlayerTimeout = setTimeout(goToPlayer, 1000);
return;
}
if (!myPlayer) {
return;
}
if (!this.gameView.inSpawnPhase() && !myPlayer.hasSpawned()) {
showErrorModal(
"spawn_failed",
translateText("error_modal.spawn_failed.description"),
this.lobby.gameID,
this.lobby.clientID,
true,
false,
translateText("error_modal.spawn_failed.title"),
);
return;
}
this.eventBus.emit(new GoToPlayerEvent(myPlayer));
};
goToPlayer();
}
for (const turn of message.turns) {
this.serverTurnHighWater = Math.max(
this.serverTurnHighWater,
turn.turnNumber,
);
if (turn.turnNumber < this.turnsSeen) {
continue;
}
while (turn.turnNumber - 1 > this.turnsSeen) {
this.worker.sendTurn({
turnNumber: this.turnsSeen,
intents: [],
});
this.turnsSeen++;
}
this.worker.sendTurn(turn);
this.turnsSeen++;
}
}
if (message.type === "desync") {
if (this.lobby.gameStartInfo === undefined) {
throw new Error("missing gameStartInfo");
}
showErrorModal(
`desync from server: ${JSON.stringify(message)}`,
"",
this.lobby.gameStartInfo.gameID,
this.lobby.clientID,
true,
false,
"error_modal.desync_notice",
);
}
if (message.type === "error") {
showErrorModal(
message.error,
message.message,
this.lobby.gameID,
this.lobby.clientID,
true,
false,
"error_modal.connection_error",
);
}
if (message.type === "turn") {
// Track when we receive the turn to calculate delay
const now = Date.now();
if (this.lastTickReceiveTime > 0) {
// Calculate delay between receiving turn messages
this.currentTickDelay = now - this.lastTickReceiveTime;
}
this.lastTickReceiveTime = now;
this.serverTurnHighWater = Math.max(
this.serverTurnHighWater,
message.turn.turnNumber,
);
if (this.turnsSeen !== message.turn.turnNumber) {
console.error(
`got wrong turn have turns ${this.turnsSeen}, received turn ${message.turn.turnNumber}`,
);
} else {
this.worker.sendTurn(message.turn);
this.turnsSeen++;
}
}
};
this.transport.updateCallback(onconnect, onmessage);
console.log("sending join game");
// Rejoin game from the start so we don't miss any turns.
this.transport.rejoinGame(0);
}
public stop() {
SoundManager.stopBackgroundMusic();
if (!this.isActive) return;
this.isActive = false;
this.worker.cleanup();
this.transport.leaveGame();
if (this.connectionCheckInterval) {
clearInterval(this.connectionCheckInterval);
this.connectionCheckInterval = null;
}
if (this.goToPlayerTimeout) {
clearTimeout(this.goToPlayerTimeout);
this.goToPlayerTimeout = null;
}
}
private processPendingUpdates() {
const pendingCount = this.pendingUpdates.length - this.pendingStart;
if (this.isProcessingUpdates || pendingCount <= 0) {
return;
}
this.isProcessingUpdates = true;
const processFrame = () => {
const BASE_SLICE_BUDGET_MS = 8; // keep UI responsive while catching up
const MAX_SLICE_BUDGET_MS = 1000; // allow longer slices when backlog is large
const BACKLOG_FREE_TURNS = 10; // scaling starts at this many turns
const BACKLOG_MAX_TURNS = 500; // MAX_SLICE_BUDGET_MS is reached at this many turns
const MAX_TICKS_PER_SLICE = 1000;
const backlogOverhead = Math.max(
0,
this.backlogTurns - BACKLOG_FREE_TURNS,
);
const backlogScale = Math.min(
1,
backlogOverhead / (BACKLOG_MAX_TURNS - BACKLOG_FREE_TURNS),
);
const sliceBudgetMs =
BASE_SLICE_BUDGET_MS +
backlogScale * (MAX_SLICE_BUDGET_MS - BASE_SLICE_BUDGET_MS);
const frameStart = performance.now();
const batch: GameUpdateViewData[] = [];
let lastTickDuration: number | undefined;
let lastTick: number | undefined;
let processedCount = 0;
// Consume updates until we hit the time budget or per-slice cap.
while (this.pendingStart < this.pendingUpdates.length) {
const gu = this.pendingUpdates[this.pendingStart++];
processedCount++;
this.workerTicksSinceSample++;
batch.push(gu);
this.transport.turnComplete();
gu.updates[GameUpdateType.Hash].forEach((hu: HashUpdate) => {
this.eventBus.emit(new SendHashEvent(hu.tick, hu.hash));
});
this.updateBacklogMetrics(gu.tick);
if (gu.updates[GameUpdateType.Win].length > 0) {
this.saveGame(gu.updates[GameUpdateType.Win][0]);
}
if (gu.tickExecutionDuration !== undefined) {
lastTickDuration = gu.tickExecutionDuration;
}
lastTick = gu.tick;
const elapsed = performance.now() - frameStart;
if (processedCount >= MAX_TICKS_PER_SLICE || elapsed >= sliceBudgetMs) {
break;
}
}
// Compact the queue if we've advanced far into it.
if (
this.pendingStart > 0 &&
(this.pendingStart > 1024 ||
this.pendingStart >= this.pendingUpdates.length / 2)
) {
this.pendingUpdates = this.pendingUpdates.slice(this.pendingStart);
this.pendingStart = 0;
}
// Only update view and render when ALL processing is complete
if (
this.pendingStart >= this.pendingUpdates.length &&
batch.length > 0 &&
lastTick !== undefined
) {
const { gameUpdate: combinedGu, tileMetrics } =
this.mergeGameUpdates(batch);
if (combinedGu) {
this.gameView.update(combinedGu);
}
const ticksPerRender =
this.lastRenderedTick === 0
? lastTick
: lastTick - this.lastRenderedTick;
this.lastRenderedTick = lastTick;
this.renderTicksSinceSample++;
let workerTicksPerSecond: number | undefined;
let renderTicksPerSecond: number | undefined;
const now = performance.now();
if (this.metricsSampleStart === 0) {
this.metricsSampleStart = now;
} else {
const elapsedSeconds = (now - this.metricsSampleStart) / 1000;
if (elapsedSeconds > 0) {
workerTicksPerSecond = this.workerTicksSinceSample / elapsedSeconds;
renderTicksPerSecond = this.renderTicksSinceSample / elapsedSeconds;
}
this.metricsSampleStart = now;
this.workerTicksSinceSample = 0;
this.renderTicksSinceSample = 0;
}
this.renderer.tick();
this.eventBus.emit(
new TickMetricsEvent(
lastTickDuration,
this.currentTickDelay,
this.backlogTurns,
ticksPerRender,
workerTicksPerSecond,
renderTicksPerSecond,
tileMetrics.count,
tileMetrics.utilization,
tileMetrics.overflow,
tileMetrics.drainTime,
),
);
// Reset tick delay for next measurement
this.currentTickDelay = undefined;
}
if (this.pendingStart < this.pendingUpdates.length) {
requestAnimationFrame(processFrame);
} else {
this.isProcessingUpdates = false;
}
};
requestAnimationFrame(processFrame);
}
private mergeGameUpdates(batch: GameUpdateViewData[]): {
gameUpdate: GameUpdateViewData | null;
tileMetrics: {
count: number;
utilization: number;
overflow: number;
drainTime: number;
};
} {
if (batch.length === 0) {
return {
gameUpdate: null,
tileMetrics: {
count: 0,
utilization: 0,
overflow: 0,
drainTime: 0,
},
};
}
const last = batch[batch.length - 1];
const combinedUpdates: GameUpdates = {} as GameUpdates;
// Initialize combinedUpdates with empty arrays for each existing key
for (const key in last.updates) {
const type = Number(key) as GameUpdateType;
combinedUpdates[type] = [];
}
const combinedPackedTileUpdates: bigint[] = [];
for (const gu of batch) {
for (const key in gu.updates) {
const type = Number(key) as GameUpdateType;
// We don't care about the specific update subtype here; just treat it
// as an array we can concatenate.
const updatesForType = gu.updates[type] as unknown as any[];
(combinedUpdates[type] as unknown as any[]).push(...updatesForType);
}
}
let tileMetrics = {
count: 0,
utilization: 0,
overflow: 0,
drainTime: 0,
};
if (this.tileRingViews) {
const MAX_TILE_UPDATES_PER_RENDER = 100000;
const tileRefs: TileRef[] = [];
const drainStart = performance.now();
drainTileUpdates(
this.tileRingViews,
MAX_TILE_UPDATES_PER_RENDER,
tileRefs,
);
const drainTime = performance.now() - drainStart;
// Deduplicate tile refs for this render slice
const uniqueTiles = new Set<TileRef>();
for (const ref of tileRefs) {
uniqueTiles.add(ref);
}
// Calculate ring buffer utilization and overflow using dynamic capacity
const TILE_RING_CAPACITY = this.tileRingViews.capacity;
const utilization = (uniqueTiles.size / TILE_RING_CAPACITY) * 100;
const overflow = Atomics.load(
this.tileRingViews.header,
TILE_RING_HEADER_OVERFLOW,
);
tileMetrics = {
count: uniqueTiles.size,
utilization,
overflow,
drainTime,
};
for (const ref of uniqueTiles) {
if (this.dirtyFlags) {
Atomics.store(this.dirtyFlags, ref, 0);
}
combinedPackedTileUpdates.push(BigInt(ref));
}
} else {
// Non-SAB mode: merge packed tile updates from batch
let totalTileUpdates = 0;
for (const gu of batch) {
totalTileUpdates += gu.packedTileUpdates.length;
for (const tu of gu.packedTileUpdates) {
combinedPackedTileUpdates.push(tu);
}
}
tileMetrics.count = totalTileUpdates;
}
return {
gameUpdate: {
tick: last.tick,
updates: combinedUpdates,
packedTileUpdates: new BigUint64Array(combinedPackedTileUpdates),
playerNameViewData: last.playerNameViewData,
tickExecutionDuration: last.tickExecutionDuration,
},
tileMetrics,
};
}
private updateBacklogMetrics(processedTick: number) {
this.lastProcessedTick = processedTick;
const previousBacklog = this.backlogTurns;
this.backlogTurns = Math.max(
0,
this.serverTurnHighWater - this.lastProcessedTick,
);
this.backlogGrowing = this.backlogTurns > previousBacklog;
this.eventBus.emit(
new BacklogStatusEvent(this.backlogTurns, this.backlogGrowing),
);
}
private inputEvent(event: MouseUpEvent) {
if (!this.isActive || this.renderer.uiState.ghostStructure !== null) {
return;
}
const cell = this.renderer.transformHandler.screenToWorldCoordinates(
event.x,
event.y,
);
if (!this.gameView.isValidCoord(cell.x, cell.y)) {
return;
}
console.log(`clicked cell ${cell}`);
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));
return;
}
if (this.gameView.inSpawnPhase()) {
return;
}
if (this.myPlayer === null) {
const myPlayer = this.gameView.playerByClientID(this.lobby.clientID);
if (myPlayer === null) return;
this.myPlayer = myPlayer;
}
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,
),
);
} else if (this.canAutoBoat(actions, tile)) {
this.sendBoatAttackIntent(tile);
}
});
}
private autoUpgradeEvent(event: AutoUpgradeEvent) {
if (!this.isActive) {
return;
}
const cell = this.renderer.transformHandler.screenToWorldCoordinates(
event.x,
event.y,
);
if (!this.gameView.isValidCoord(cell.x, cell.y)) {
return;
}
const tile = this.gameView.ref(cell.x, cell.y);
if (this.myPlayer === null) {
const myPlayer = this.gameView.playerByClientID(this.lobby.clientID);
if (myPlayer === null) return;
this.myPlayer = myPlayer;
}
if (this.gameView.inSpawnPhase()) {
return;
}
this.findAndUpgradeNearestBuilding(tile);
}
private findAndUpgradeNearestBuilding(clickedTile: TileRef) {
this.myPlayer!.actions(clickedTile).then((actions) => {
const upgradeUnits: {
unitId: number;
unitType: UnitType;
distance: number;
}[] = [];
for (const bu of actions.buildableUnits) {
if (bu.canUpgrade !== false) {
const existingUnit = this.gameView
.units()
.find((unit) => unit.id() === bu.canUpgrade);
if (existingUnit) {
const distance = this.gameView.manhattanDist(
clickedTile,
existingUnit.tile(),
);
upgradeUnits.push({
unitId: bu.canUpgrade,
unitType: bu.type,
distance: distance,
});
}
}
}
if (upgradeUnits.length > 0) {
upgradeUnits.sort((a, b) => a.distance - b.distance);
const bestUpgrade = upgradeUnits[0];
this.eventBus.emit(
new SendUpgradeStructureIntentEvent(
bestUpgrade.unitId,
bestUpgrade.unitType,
),
);
}
});
}
private doBoatAttackUnderCursor(): void {
const tile = this.getTileUnderCursor();
if (tile === null) {
return;
}
if (this.myPlayer === null) {
const myPlayer = this.gameView.playerByClientID(this.lobby.clientID);
if (myPlayer === null) return;
this.myPlayer = myPlayer;
}
this.myPlayer.actions(tile).then((actions) => {
if (this.canBoatAttack(actions) !== false) {
this.sendBoatAttackIntent(tile);
}
});
}
private doGroundAttackUnderCursor(): void {
const tile = this.getTileUnderCursor();
if (tile === null) {
return;
}
if (this.myPlayer === null) {
const myPlayer = this.gameView.playerByClientID(this.lobby.clientID);
if (myPlayer === null) return;
this.myPlayer = myPlayer;
}
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,
),
);
}
});
}
private getTileUnderCursor(): TileRef | null {
if (!this.isActive || !this.lastMousePosition) {
return null;
}
if (this.gameView.inSpawnPhase()) {
return null;
}
const cell = this.renderer.transformHandler.screenToWorldCoordinates(
this.lastMousePosition.x,
this.lastMousePosition.y,
);
if (!this.gameView.isValidCoord(cell.x, cell.y)) {
return null;
}
return this.gameView.ref(cell.x, cell.y);
}
private canBoatAttack(actions: PlayerActions): false | TileRef {
const bu = actions.buildableUnits.find(
(bu) => bu.type === UnitType.TransportShip,
);
if (bu === undefined) {
console.warn(`no transport ship buildable units`);
return false;
}
return bu.canBuild;
}
private sendBoatAttackIntent(tile: TileRef) {
if (!this.myPlayer) return;
this.myPlayer.bestTransportShipSpawn(tile).then((spawn: number | false) => {
if (this.myPlayer === null) throw new Error("not initialized");
this.eventBus.emit(
new SendBoatAttackIntentEvent(
this.gameView.owner(tile).id(),
tile,
this.myPlayer.troops() * this.renderer.uiState.attackRatio,
spawn === false ? null : spawn,
),
);
});
}
private canAutoBoat(actions: PlayerActions, tile: TileRef): boolean {
if (!this.gameView.isLand(tile)) return false;
const canBuild = this.canBoatAttack(actions);
if (canBuild === false) return false;
// TODO: Global enable flag
// TODO: Global limit autoboat to nearby shore flag
// if (!enableAutoBoat) return false;
// if (!limitAutoBoatNear) return true;
const distanceSquared = this.gameView.euclideanDistSquared(tile, canBuild);
const limit = 100;
const limitSquared = limit * limit;
return distanceSquared < limitSquared;
}
private onMouseMove(event: MouseMoveEvent) {
this.lastMousePosition = { x: event.x, y: event.y };
}
private onConnectionCheck() {
if (this.transport.isLocal) {
return;
}
const now = Date.now();
const timeSinceLastMessage = now - this.lastMessageTime;
if (timeSinceLastMessage > 5000) {
console.log(
`No message from server for ${timeSinceLastMessage} ms, reconnecting`,
);
this.lastMessageTime = now;
this.transport.reconnect();
}
}
}
function showErrorModal(
error: string,
message: string | undefined,
gameID: GameID,
clientID: ClientID,
closable = false,
showDiscord = true,
heading = "error_modal.crashed",
) {
if (document.querySelector("#error-modal")) {
return;
}
const modal = document.createElement("div");
modal.id = "error-modal";
const content = [
showDiscord ? translateText("error_modal.paste_discord") : null,
translateText(heading),
`game id: ${gameID}`,
`client id: ${clientID}`,
`Error: ${error}`,
message ? `Message: ${message}` : null,
]
.filter(Boolean)
.join("\n");
// Create elements
const pre = document.createElement("pre");
pre.textContent = content;
const button = document.createElement("button");
button.textContent = translateText("error_modal.copy_clipboard");
button.className = "copy-btn";
button.addEventListener("click", async () => {
try {
await navigator.clipboard.writeText(content);
button.textContent = translateText("error_modal.copied");
} catch {
button.textContent = translateText("error_modal.failed_copy");
}
});
// Add to modal
modal.appendChild(pre);
modal.appendChild(button);
if (closable) {
const closeButton = document.createElement("button");
closeButton.textContent = "X";
closeButton.className = "close-btn";
closeButton.addEventListener("click", () => {
modal.remove();
});
modal.appendChild(closeButton);
}
document.body.appendChild(modal);
}