Files
OpenFrontIO/src/client/ClientGameRunner.ts
T
2025-02-01 12:05:11 -08:00

240 lines
8.5 KiB
TypeScript

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 {
playerName: () => string
clientID: ClientID,
playerID: PlayerID,
persistentID: string,
gameType: GameType
gameID: GameID,
map: GameMapType | null
difficulty: Difficulty | 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,
}
}
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<ClientGameRunner> {
const config = getConfig(gameConfig)
const gameMap = await loadTerrainMap(gameConfig.gameMap);
const worker = new WorkerClient(lobbyConfig.gameID, gameConfig)
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);
}