import { Executor } from "../core/execution/ExecutionManager"; import { Cell, Game, PlayerID, GameMapType, Difficulty, GameType, } from "../core/game/Game"; import { EventBus } from "../core/EventBus"; import { createRenderer, GameRenderer } from "./graphics/GameRenderer"; import { InputHandler, MouseUpEvent, ZoomEvent, DragEvent, MouseDownEvent, } from "./InputHandler"; import { ClientID, ClientIntentMessageSchema, ClientJoinMessageSchema, ClientMessageSchema, GameConfig, GameID, Intent, ServerMessage, ServerMessageSchema, ServerSyncMessage, Turn, } from "../core/Schemas"; import { loadTerrainFromFile, loadTerrainMap, } from "../core/game/TerrainMapLoader"; import { SendAttackIntentEvent, SendSpawnIntentEvent, Transport, } from "./Transport"; import { createCanvas } from "./Utils"; import { MessageType } from "../core/game/Game"; import { DisplayMessageUpdate, ErrorUpdate } from "../core/game/GameUpdates"; import { WorkerClient } from "../core/worker/WorkerClient"; import { consolex, initRemoteSender } from "../core/Consolex"; import { getConfig, getServerConfig } from "../core/configuration/Config"; import { GameView, PlayerView } from "../core/game/GameView"; import { GameUpdateViewData } from "../core/game/GameUpdates"; export interface LobbyConfig { flag: () => string; playerName: () => string; clientID: ClientID; playerID: PlayerID; persistentID: string; gameType: GameType; gameID: GameID; map: GameMapType | null; difficulty: Difficulty | null; disableBots: boolean | null; disableNPCs: boolean | null; creativeMode: boolean | null; } export function joinLobby( lobbyConfig: LobbyConfig, onjoin: () => void ): () => void { const eventBus = new EventBus(); initRemoteSender(eventBus); consolex.log( `joinging lobby: gameID: ${lobbyConfig.gameID}, clientID: ${lobbyConfig.clientID}, persistentID: ${lobbyConfig.persistentID}` ); const serverConfig = getServerConfig(); let gameConfig: GameConfig = null; if (lobbyConfig.gameType == GameType.Singleplayer) { gameConfig = { gameType: GameType.Singleplayer, gameMap: lobbyConfig.map, difficulty: lobbyConfig.difficulty, disableBots: lobbyConfig.disableBots, disableNPCs: lobbyConfig.disableNPCs, creativeMode: lobbyConfig.creativeMode, }; } const transport = new Transport( lobbyConfig, gameConfig, eventBus, serverConfig ); const onconnect = () => { consolex.log(`Joined game lobby ${lobbyConfig.gameID}`); transport.joinGame(0); }; const onmessage = (message: ServerMessage) => { if (message.type == "start") { consolex.log("lobby: game started"); onjoin(); createClientGame(lobbyConfig, message.config, eventBus, transport).then( (r) => r.start() ); } }; transport.connect(onconnect, onmessage); return () => { consolex.log("leaving game"); transport.leaveGame(); }; } export async function createClientGame( lobbyConfig: LobbyConfig, gameConfig: GameConfig, eventBus: EventBus, transport: Transport ): Promise { const config = getConfig(gameConfig); const gameMap = await loadTerrainMap(gameConfig.gameMap); const worker = new WorkerClient( lobbyConfig.gameID, gameConfig, lobbyConfig.clientID ); await worker.initialize(); const gameView = new GameView( worker, config, gameMap.gameMap, lobbyConfig.clientID ); consolex.log("going to init path finder"); consolex.log("inited path finder"); const canvas = createCanvas(); let gameRenderer = createRenderer( canvas, gameView, eventBus, lobbyConfig.clientID ); consolex.log( `creating private game got difficulty: ${gameConfig.difficulty}` ); return new ClientGameRunner( lobbyConfig.clientID, eventBus, gameRenderer, new InputHandler(canvas, eventBus), transport, worker, gameView ); } export class ClientGameRunner { private myPlayer: PlayerView; private isActive = false; private turnsSeen = 0; private hasJoined = false; constructor( private clientID: ClientID, private eventBus: EventBus, private renderer: GameRenderer, private input: InputHandler, private transport: Transport, private worker: WorkerClient, private gameView: GameView ) {} public start() { consolex.log("starting client game"); this.isActive = true; this.eventBus.on(MouseUpEvent, (e) => this.inputEvent(e)); this.renderer.initialize(); this.input.initialize(); this.worker.start((gu: GameUpdateViewData | ErrorUpdate) => { if ("errMsg" in gu) { showErrorModal(gu.errMsg, gu.stack, this.clientID); return; } this.gameView.update(gu); this.renderer.tick(); }); const worker = this.worker; const keepWorkerAlive = () => { worker.sendHeartbeat(); requestAnimationFrame(keepWorkerAlive); }; requestAnimationFrame(keepWorkerAlive); const onconnect = () => { consolex.log("Connected to game server!"); this.transport.joinGame(this.turnsSeen); }; const onmessage = (message: ServerMessage) => { if (message.type == "start") { this.hasJoined = true; consolex.log("starting game!"); for (const turn of message.turns) { if (turn.turnNumber < this.turnsSeen) { continue; } this.worker.sendTurn(turn); this.turnsSeen++; } } if (message.type == "turn") { if (!this.hasJoined) { this.transport.joinGame(0); return; } if (this.turnsSeen != message.turn.turnNumber) { consolex.error( `got wrong turn have turns ${this.turnsSeen}, received turn ${message.turn.turnNumber}` ); } else { this.worker.sendTurn(message.turn); this.turnsSeen++; } } }; this.transport.connect(onconnect, onmessage); } public stop() { this.worker.cleanup(); this.isActive = false; this.transport.leaveGame(); } private inputEvent(event: MouseUpEvent) { if (!this.isActive) { return; } const cell = this.renderer.transformHandler.screenToWorldCoordinates( event.x, event.y ); if (!this.gameView.isValidCoord(cell.x, cell.y)) { return; } consolex.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.eventBus.emit(new SendSpawnIntentEvent(cell)); return; } if (this.gameView.inSpawnPhase()) { return; } if (this.myPlayer == null) { this.myPlayer = this.gameView.playerByClientID(this.clientID); if (this.myPlayer == null) { return; } } this.myPlayer.actions(tile).then((actions) => { console.log(`got actions: ${JSON.stringify(actions)}`); if (actions.canAttack) { this.eventBus.emit( new SendAttackIntentEvent( this.gameView.owner(tile).id(), this.myPlayer.troops() * this.renderer.uiState.attackRatio ) ); } }); } } function showErrorModal(errMsg: string, stack: string, clientID: ClientID) { const errorText = `Error: ${errMsg}\nStack: ${stack}`; consolex.error(errorText); const modal = document.createElement("div"); const content = `Game crashed! client id: ${clientID}\nPlease paste the following in your bug report in Discord:\n${errorText}`; // Create elements const pre = document.createElement("pre"); pre.textContent = content; const button = document.createElement("button"); button.textContent = "Copy to clipboard"; button.style.cssText = "padding: 8px 16px; margin-top: 10px; background: #4CAF50; color: white; border: none; border-radius: 4px; cursor: pointer;"; button.addEventListener("click", () => { navigator.clipboard .writeText(content) .then(() => (button.textContent = "Copied!")) .catch(() => (button.textContent = "Failed to copy")); }); // Add to modal modal.style.cssText = "position:fixed; padding:20px; background:white; border:1px solid black; top:50%; left:50%; transform:translate(-50%,-50%); z-index:9999;"; modal.appendChild(pre); modal.appendChild(button); document.body.appendChild(modal); }