mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 11:50:42 +00:00
eb51853b05
## Description: Performance improvements. - **PlayerImpl**: for _nukeSpawn_, cache config to const. - **Other files**: for nukeSpawn and other functions doing the same, introduce findClosestBy function. - for **TradeShipExecution**, with the move from _distSortUnit_ to _findClosestBy_, also add check if port isActive, !_isMarkedForDeletion_ and !_isUnderConstruction_. These checks should have been there already, so now do it in one go to make use of the predicate isCandidate in findClosestBy. - for **TradeShipExectution.test.ts**, add mock functions for _isMarkedForDeletion_ and _isUnderConstruction_ because of the above. Also, set Unit tiles and Pathfinding node to actual valid TileRefs for the testing map. This prevents NaN as return value from manhattanDist. This problem was already present with the use of distSortUnit, but that function just did NaN - NaN, returned the first and only port unit in the array and called it a day. For findClosestBy we have to make sure the predicate manhattanDist actually returns a number instead of NaN so we need actually valid tiles. We now have a working test instead of a test that actually silently failed like before. - **PlayerImpl**: _warshipSpawn_ and _nukeSpawn_: Make use of the isCandidate predicate of findClosestBy to have warshipSpawn not return ports under construction or (smaller change) inactive. This fixes a bug i have seen right away (where Warship spawns from under construction Port). Same for _nukeSpawn_ silos, don't return inactive silo just to be sure now that we can easily add it to isCandidate predicate anyway. This costs no performance in the _nukeSpawn_ benchmarks actually. This should as a by-effecft fix an edge case bug i have seen, where a nuke is sent from a phantom silo. Some of this goes along with PR #3220 since playerImpl buildableUnits makes use of the underlying spawn functions via canBuild. Just like ConstructionExecution does. But i didn't want to add more to PR 3220 since there's already a lot in there. The new function _findClosestBy_ could also be applied to some other parts of code to benefit of it being faster, so i did that. _findClosestBy_ uses _findMinimumBy_, which is a little more generic in name. I think _findMinimumBy_ could be used by other parts of code, while _findClosestBy_ is more clear naming for what it does now. But we could ditch _findMinimumBy_ and just leave findClosestBy? Examples of synthetic benchmarks (not included in this PR): **BEFORE CHANGES (before Scamiv's PR #3241)** <img width="705" height="91" alt="image" src="https://github.com/user-attachments/assets/d6d91c08-39f1-4387-9ccc-e51951caa539" /> <img width="751" height="101" alt="image" src="https://github.com/user-attachments/assets/80d400ac-3408-4107-aa58-6d2a847311e9" /> **AFTER CHANGES (before Scamiv's PR #3241)**   **BEFORE CHANGES (after Scamiv's PR #3241)**   **AFTER CHANGES (after Scamiv's PR #3241)** <img width="717" height="96" alt="image" src="https://github.com/user-attachments/assets/5b106843-bf6e-4448-a8e8-94448fb30ced" /> <img width="767" height="92" alt="image" src="https://github.com/user-attachments/assets/e6714c7b-26c1-455b-adae-f0060f1cbc7b" /> _Also see more **BEFORE** and **AFTER** in this comment:_ https://github.com/openfrontio/OpenFrontIO/pull/3243#issuecomment-3949060395 _And here a comparison in the flame charts:_ - based on the same replay and tried to get the performance recording going at the same speed and length but always end up with small differences - because of a bug in replays currently, it puts you in with the same clientID/persistantID currently. This means we can also record part of what is normally only recordable with live human input (the playerActions/playerBuildables). **BEFORE** flame chart with nukeSpawn (human player) and maybeSendNuke (Nation players, uses nukeSpawn via canBuild):    **AFTER** flame chart with nukeSpawn (human player) and maybeSendNuke (Nation players, uses nukeSpawn via canBuild):      ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: tryout33
830 lines
24 KiB
TypeScript
830 lines
24 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, findClosestBy, replacer } from "../core/Util";
|
|
import { ServerConfig } from "../core/configuration/Config";
|
|
import { getConfig } from "../core/configuration/ConfigLoader";
|
|
import { BuildableUnit, Structures, 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";
|
|
|
|
export interface LobbyConfig {
|
|
serverConfig: ServerConfig;
|
|
cosmetics: PlayerCosmeticRefs;
|
|
playerName: string;
|
|
playerClanTag: string | null;
|
|
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 interface JoinLobbyResult {
|
|
stop: (force?: boolean) => boolean;
|
|
prestart: Promise<void>;
|
|
join: Promise<void>;
|
|
}
|
|
|
|
export function joinLobby(
|
|
eventBus: EventBus,
|
|
lobbyConfig: LobbyConfig,
|
|
): JoinLobbyResult {
|
|
// Mutable clientID state — assigned by server (multiplayer) or derived from gameStartInfo (singleplayer)
|
|
let clientID: ClientID | undefined;
|
|
|
|
let resolvePrestart: () => void;
|
|
let resolveJoin: () => void;
|
|
const prestartPromise = new Promise<void>((r) => (resolvePrestart = r));
|
|
const joinPromise = new Promise<void>((r) => (resolveJoin = r));
|
|
|
|
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,
|
|
);
|
|
resolvePrestart();
|
|
}
|
|
if (message.type === "start") {
|
|
// Trigger prestart for singleplayer games
|
|
resolvePrestart();
|
|
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;
|
|
resolveJoin();
|
|
// 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, cause: "full-lobby" },
|
|
bubbles: true,
|
|
composed: true,
|
|
}),
|
|
);
|
|
} else {
|
|
showErrorModal(
|
|
message.error,
|
|
message.message,
|
|
lobbyConfig.gameID,
|
|
clientID,
|
|
true,
|
|
false,
|
|
"error_modal.connection_error",
|
|
);
|
|
}
|
|
}
|
|
};
|
|
transport.connect(onconnect, onmessage);
|
|
return {
|
|
stop: (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;
|
|
},
|
|
prestart: prestartPromise,
|
|
join: joinPromise,
|
|
};
|
|
}
|
|
|
|
async function createClientGame(
|
|
lobbyConfig: LobbyConfig,
|
|
clientID: ClientID | undefined,
|
|
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.playerClanTag,
|
|
lobbyConfig.gameStartInfo.gameID,
|
|
lobbyConfig.gameStartInfo.players,
|
|
);
|
|
|
|
const canvas = createCanvas();
|
|
const gameRenderer = createRenderer(canvas, gameView, eventBus);
|
|
|
|
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,
|
|
);
|
|
}
|
|
|
|
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;
|
|
|
|
constructor(
|
|
private lobby: LobbyConfig,
|
|
private clientID: ClientID | undefined,
|
|
private eventBus: EventBus,
|
|
private renderer: GameRenderer,
|
|
private input: InputHandler,
|
|
private transport: Transport,
|
|
private worker: WorkerClient,
|
|
private gameView: GameView,
|
|
) {
|
|
this.lastMessageTime = Date.now();
|
|
}
|
|
|
|
/**
|
|
* 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.clientID) {
|
|
return;
|
|
}
|
|
const players: PlayerRecord[] = [
|
|
{
|
|
persistentID: getPersistentID(),
|
|
username: this.lobby.playerName,
|
|
clanTag: this.lobby.playerClanTag ?? null,
|
|
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) => {
|
|
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.gameView.update(gu);
|
|
this.renderer.tick();
|
|
|
|
// 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]);
|
|
}
|
|
});
|
|
|
|
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);
|
|
}
|
|
|
|
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) {
|
|
if (!this.clientID) return;
|
|
const myPlayer = this.gameView.playerByClientID(this.clientID);
|
|
if (myPlayer === null) return;
|
|
this.myPlayer = myPlayer;
|
|
}
|
|
this.myPlayer.actions(tile, [UnitType.TransportShip]).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.buildableUnits, 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) {
|
|
if (!this.clientID) return;
|
|
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, Structures.types).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) {
|
|
const bestUpgrade = findClosestBy(upgradeUnits, (u) => u.distance);
|
|
if (bestUpgrade) {
|
|
this.eventBus.emit(
|
|
new SendUpgradeStructureIntentEvent(
|
|
bestUpgrade.unitId,
|
|
bestUpgrade.unitType,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
private doBoatAttackUnderCursor(): void {
|
|
const tile = this.getTileUnderCursor();
|
|
if (tile === null) {
|
|
return;
|
|
}
|
|
|
|
if (this.myPlayer === null) {
|
|
if (!this.clientID) return;
|
|
const myPlayer = this.gameView.playerByClientID(this.clientID);
|
|
if (myPlayer === null) return;
|
|
this.myPlayer = myPlayer;
|
|
}
|
|
|
|
this.myPlayer
|
|
.buildables(tile, [UnitType.TransportShip])
|
|
.then((buildables) => {
|
|
if (this.canBoatAttack(buildables) !== false) {
|
|
this.sendBoatAttackIntent(tile);
|
|
} else {
|
|
console.warn(
|
|
"Boat attack triggered but can't send Transport Ship to tile",
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
private doGroundAttackUnderCursor(): void {
|
|
const tile = this.getTileUnderCursor();
|
|
if (tile === null) {
|
|
return;
|
|
}
|
|
|
|
if (this.myPlayer === null) {
|
|
if (!this.clientID) return;
|
|
const myPlayer = this.gameView.playerByClientID(this.clientID);
|
|
if (myPlayer === null) return;
|
|
this.myPlayer = myPlayer;
|
|
}
|
|
|
|
this.myPlayer.actions(tile, null).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(buildables: BuildableUnit[]): false | TileRef {
|
|
const bu = buildables.find((bu) => bu.type === UnitType.TransportShip);
|
|
return bu?.canBuild ?? false;
|
|
}
|
|
|
|
private sendBoatAttackIntent(tile: TileRef) {
|
|
if (!this.myPlayer) return;
|
|
|
|
this.eventBus.emit(
|
|
new SendBoatAttackIntentEvent(
|
|
tile,
|
|
this.myPlayer.troops() * this.renderer.uiState.attackRatio,
|
|
),
|
|
);
|
|
}
|
|
|
|
private canAutoBoat(buildables: BuildableUnit[], tile: TileRef): boolean {
|
|
if (!this.gameView.isLand(tile)) return false;
|
|
|
|
const canBuild = this.canBoatAttack(buildables);
|
|
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);
|
|
}
|