mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 11:10:42 +00:00
35e6ee0d39
- Added support for rewriting game history through new TimelineRewriteHistoryEvent. - Enhanced ClientGameRunner to handle timeline jump and history rewrite events. - Introduced methods in LocalServer and Transport to manage rewrite state and truncate turns. - Updated TimelineController to facilitate history truncation and checkpoint management. - Improved TimelineArchive for efficient deletion of tick records and checkpoints after a specified tick. This commit enhances the timeline feature, allowing players to discard future events and rewrite history, improving gameplay flexibility.
899 lines
25 KiB
TypeScript
899 lines
25 KiB
TypeScript
import { translateText } from "../client/Utils";
|
|
import { EventBus } from "../core/EventBus";
|
|
import {
|
|
ClientID,
|
|
GameID,
|
|
GameRecord,
|
|
GameStartInfo,
|
|
LobbyInfoEvent,
|
|
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 { 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 { WorkerClient } from "../core/worker/WorkerClient";
|
|
import { getPersistentID } from "./Auth";
|
|
import {
|
|
AutoUpgradeEvent,
|
|
DoBoatAttackEvent,
|
|
DoGroundAttackEvent,
|
|
InputHandler,
|
|
MouseMoveEvent,
|
|
MouseUpEvent,
|
|
TickMetricsEvent,
|
|
} from "./InputHandler";
|
|
import { endGame, startGame, startTime } from "./LocalPersistantStats";
|
|
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";
|
|
import { TimelineController } from "./timeline/TimelineController";
|
|
import {
|
|
TimelineJumpEvent,
|
|
TimelineRewriteHistoryEvent,
|
|
} from "./timeline/TimelineEvents";
|
|
|
|
export interface LobbyConfig {
|
|
serverConfig: ServerConfig;
|
|
cosmetics: PlayerCosmeticRefs;
|
|
playerName: string;
|
|
gameID: GameID;
|
|
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,
|
|
): (force?: boolean) => boolean {
|
|
// Mutable clientID state — assigned by server (multiplayer) or derived from gameStartInfo (singleplayer)
|
|
let clientID: ClientID | undefined;
|
|
|
|
console.log(`joining lobby: gameID: ${lobbyConfig.gameID}`);
|
|
|
|
const userSettings: UserSettings = new UserSettings();
|
|
startGame(lobbyConfig.gameID, lobbyConfig.gameStartInfo?.config ?? {});
|
|
|
|
const transport = new Transport(lobbyConfig, eventBus);
|
|
|
|
let currentGameRunner: ClientGameRunner | null = null;
|
|
|
|
const onconnect = () => {
|
|
// Always send join - server will detect reconnection via persistentID
|
|
console.log(`Joining game lobby ${lobbyConfig.gameID}`);
|
|
transport.joinGame();
|
|
};
|
|
let terrainLoad: Promise<TerrainMapData> | null = null;
|
|
|
|
const onmessage = (message: ServerMessage) => {
|
|
if (message.type === "lobby_info") {
|
|
// Server tells us our assigned clientID
|
|
clientID = message.myClientID;
|
|
eventBus.emit(new LobbyInfoEvent(message.lobby, message.myClientID));
|
|
return;
|
|
}
|
|
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)}`,
|
|
);
|
|
// Server tells us our assigned clientID (also sent on start for late joins)
|
|
clientID = message.myClientID;
|
|
onJoin();
|
|
// For multiplayer games, GameStartInfo is not known until game starts.
|
|
lobbyConfig.gameStartInfo = message.gameStartInfo;
|
|
createClientGame(
|
|
lobbyConfig,
|
|
clientID,
|
|
eventBus,
|
|
transport,
|
|
userSettings,
|
|
terrainLoad,
|
|
terrainMapFileLoader,
|
|
)
|
|
.then((r) => {
|
|
currentGameRunner = r;
|
|
r.start();
|
|
})
|
|
.catch((e) => {
|
|
console.error("error creating client game", e);
|
|
|
|
currentGameRunner = null;
|
|
|
|
const startingModal = document.querySelector(
|
|
"game-starting-modal",
|
|
) as HTMLElement;
|
|
if (startingModal) {
|
|
startingModal.classList.add("hidden");
|
|
}
|
|
showErrorModal(
|
|
e.message,
|
|
e.stack,
|
|
lobbyConfig.gameID,
|
|
clientID,
|
|
true,
|
|
false,
|
|
"error_modal.connection_error",
|
|
);
|
|
});
|
|
}
|
|
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,
|
|
clientID,
|
|
true,
|
|
false,
|
|
"error_modal.connection_error",
|
|
);
|
|
}
|
|
}
|
|
};
|
|
transport.connect(onconnect, onmessage);
|
|
return (force: boolean = false) => {
|
|
if (!force && currentGameRunner?.shouldPreventWindowClose()) {
|
|
console.log("Player is active, prevent leaving game");
|
|
|
|
return false;
|
|
}
|
|
|
|
console.log("leaving game");
|
|
|
|
currentGameRunner = null;
|
|
transport.leaveGame();
|
|
|
|
return true;
|
|
};
|
|
}
|
|
|
|
async function createClientGame(
|
|
lobbyConfig: LobbyConfig,
|
|
clientID: ClientID,
|
|
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,
|
|
);
|
|
}
|
|
const worker = new WorkerClient(lobbyConfig.gameStartInfo, clientID);
|
|
await worker.initialize();
|
|
const gameView = new GameView(
|
|
worker,
|
|
config,
|
|
gameMap,
|
|
clientID,
|
|
lobbyConfig.playerName,
|
|
lobbyConfig.gameStartInfo.gameID,
|
|
lobbyConfig.gameStartInfo.players,
|
|
);
|
|
|
|
const canvas = createCanvas();
|
|
const gameRenderer = createRenderer(canvas, gameView, eventBus);
|
|
const timelineController = new TimelineController(
|
|
worker,
|
|
gameView,
|
|
gameRenderer,
|
|
eventBus,
|
|
);
|
|
await timelineController.initialize();
|
|
|
|
console.log(
|
|
`creating private game got difficulty: ${lobbyConfig.gameStartInfo.config.difficulty}`,
|
|
);
|
|
|
|
return new ClientGameRunner(
|
|
lobbyConfig,
|
|
clientID,
|
|
eventBus,
|
|
gameRenderer,
|
|
new InputHandler(gameRenderer.uiState, canvas, eventBus),
|
|
transport,
|
|
worker,
|
|
gameView,
|
|
timelineController,
|
|
);
|
|
}
|
|
|
|
export class ClientGameRunner {
|
|
private myPlayer: PlayerView | null = null;
|
|
private isActive = false;
|
|
private rewriteInProgress = false;
|
|
private suppressWorkerUpdates = 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;
|
|
|
|
constructor(
|
|
private lobby: LobbyConfig,
|
|
private clientID: ClientID,
|
|
private eventBus: EventBus,
|
|
private renderer: GameRenderer,
|
|
private input: InputHandler,
|
|
private transport: Transport,
|
|
private worker: WorkerClient,
|
|
private gameView: GameView,
|
|
private timeline: TimelineController,
|
|
) {
|
|
this.lastMessageTime = Date.now();
|
|
|
|
this.eventBus.on(TimelineRewriteHistoryEvent, () => {
|
|
void this.rewriteHistoryFromDisplayTick();
|
|
});
|
|
|
|
this.eventBus.on(TimelineJumpEvent, () => {
|
|
// Time travel recreates PlayerView instances; drop cached references.
|
|
this.myPlayer = null;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Determines whether window closing should be prevented.
|
|
*
|
|
* Used to show a confirmation dialog when the user attempts to close
|
|
* the window or navigate away during an active game session.
|
|
*
|
|
* @returns {boolean} `true` if the window close should be prevented
|
|
* (when the player is alive in the game), `false` otherwise
|
|
* (when the player is not alive or doesn't exist)
|
|
*/
|
|
public shouldPreventWindowClose(): boolean {
|
|
// Show confirmation dialog if player is alive in the game
|
|
return !!this.myPlayer?.isAlive();
|
|
}
|
|
|
|
private async saveGame(update: WinUpdate) {
|
|
if (this.myPlayer === null) {
|
|
return;
|
|
}
|
|
const players: PlayerRecord[] = [
|
|
{
|
|
persistentID: getPersistentID(),
|
|
username: this.lobby.playerName,
|
|
clientID: this.clientID,
|
|
stats: update.allPlayersStats[this.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) =>
|
|
this.handleWorkerUpdate(gu),
|
|
);
|
|
|
|
const keepWorkerAlive = () => {
|
|
if (this.isActive) {
|
|
try {
|
|
this.worker.sendHeartbeat();
|
|
} catch {
|
|
// ignore (worker may be restarting)
|
|
}
|
|
requestAnimationFrame(keepWorkerAlive);
|
|
}
|
|
};
|
|
requestAnimationFrame(keepWorkerAlive);
|
|
|
|
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.clientID,
|
|
true,
|
|
false,
|
|
translateText("error_modal.spawn_failed.title"),
|
|
);
|
|
return;
|
|
}
|
|
|
|
this.eventBus.emit(new GoToPlayerEvent(myPlayer));
|
|
};
|
|
|
|
goToPlayer();
|
|
}
|
|
|
|
for (const turn of message.turns) {
|
|
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.clientID,
|
|
true,
|
|
false,
|
|
"error_modal.desync_notice",
|
|
);
|
|
}
|
|
if (message.type === "error") {
|
|
showErrorModal(
|
|
message.error,
|
|
message.message,
|
|
this.lobby.gameID,
|
|
this.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;
|
|
|
|
if (this.turnsSeen !== message.turn.turnNumber) {
|
|
console.error(
|
|
`got wrong turn have turns ${this.turnsSeen}, received turn ${message.turn.turnNumber}`,
|
|
);
|
|
} else {
|
|
this.worker.sendTurn(
|
|
// Filter out pause intents in replays
|
|
this.gameView.config().isReplay()
|
|
? {
|
|
...message.turn,
|
|
intents: message.turn.intents.filter(
|
|
(i) => i.type !== "toggle_pause",
|
|
),
|
|
}
|
|
: 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);
|
|
}
|
|
|
|
private handleWorkerUpdate(gu: GameUpdateViewData | ErrorUpdate): void {
|
|
if (this.suppressWorkerUpdates) {
|
|
return;
|
|
}
|
|
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.clientID,
|
|
);
|
|
console.error(gu.stack);
|
|
this.stop();
|
|
return;
|
|
}
|
|
this.transport.turnComplete();
|
|
gu.updates[GameUpdateType.Hash].forEach((hu: HashUpdate) => {
|
|
this.eventBus.emit(new SendHashEvent(hu.tick, hu.hash));
|
|
});
|
|
this.timeline.onWorkerUpdate(gu);
|
|
|
|
// Emit tick metrics event for performance overlay
|
|
this.eventBus.emit(
|
|
new TickMetricsEvent(gu.tickExecutionDuration, this.currentTickDelay),
|
|
);
|
|
|
|
// Reset tick delay for next measurement
|
|
this.currentTickDelay = undefined;
|
|
|
|
if (gu.updates[GameUpdateType.Win].length > 0) {
|
|
this.saveGame(gu.updates[GameUpdateType.Win][0]);
|
|
}
|
|
}
|
|
|
|
private async rewriteHistoryFromDisplayTick(): Promise<void> {
|
|
if (!this.isActive) return;
|
|
if (this.rewriteInProgress) return;
|
|
if (!this.transport.isLocal) return;
|
|
if (this.lobby.gameRecord !== undefined) return;
|
|
if (this.lobby.gameStartInfo === undefined) return;
|
|
|
|
this.rewriteInProgress = true;
|
|
this.suppressWorkerUpdates = true;
|
|
|
|
const targetTick = this.timeline.getDisplayTick();
|
|
|
|
const oldWorker = this.worker;
|
|
try {
|
|
this.transport.setRewriteFrozen(true);
|
|
await this.timeline.beginRewriteAtTick(targetTick);
|
|
this.transport.truncateLocalTurns(targetTick);
|
|
|
|
const worker = new WorkerClient(this.lobby.gameStartInfo, this.clientID);
|
|
await worker.initialize();
|
|
worker.start((gu: GameUpdateViewData | ErrorUpdate) =>
|
|
this.handleWorkerUpdate(gu),
|
|
);
|
|
|
|
this.worker = worker;
|
|
this.gameView.worker = worker;
|
|
this.timeline.replaceWorker(worker);
|
|
oldWorker.cleanup();
|
|
|
|
this.turnsSeen = 0;
|
|
this.suppressWorkerUpdates = false;
|
|
// Trigger a rejoin so the local server re-sends the truncated turn history.
|
|
this.transport.rejoinGame(0);
|
|
} catch (e) {
|
|
console.error("Failed to rewrite history:", e);
|
|
} finally {
|
|
this.transport.setRewriteFrozen(false);
|
|
this.suppressWorkerUpdates = false;
|
|
this.rewriteInProgress = false;
|
|
}
|
|
}
|
|
|
|
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 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.clientID);
|
|
if (myPlayer === null) return;
|
|
this.myPlayer = myPlayer;
|
|
}
|
|
this.myPlayer.actions(tile).then((actions) => {
|
|
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.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.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.clientID);
|
|
if (myPlayer === null) return;
|
|
this.myPlayer = myPlayer;
|
|
}
|
|
|
|
this.myPlayer.actions(tile).then((actions) => {
|
|
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.eventBus.emit(
|
|
new SendBoatAttackIntentEvent(
|
|
tile,
|
|
this.myPlayer.troops() * this.renderer.uiState.attackRatio,
|
|
),
|
|
);
|
|
}
|
|
|
|
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 | undefined,
|
|
closable = false,
|
|
showDiscord = true,
|
|
heading = "error_modal.crashed",
|
|
) {
|
|
if (document.querySelector("#error-modal")) {
|
|
return;
|
|
}
|
|
|
|
const translatedError = translateText(error);
|
|
const displayError = translatedError === error ? error : translatedError;
|
|
|
|
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: ${displayError}`,
|
|
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);
|
|
}
|