mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-01 04:53:26 +00:00
Merge branch 'main' into patterned-territory
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import { translateText } from "../client/Utils";
|
||||
import { consolex, initRemoteSender } from "../core/Consolex";
|
||||
import { EventBus } from "../core/EventBus";
|
||||
import {
|
||||
@@ -7,11 +8,12 @@ import {
|
||||
GameStartInfo,
|
||||
PlayerRecord,
|
||||
ServerMessage,
|
||||
Winner,
|
||||
} from "../core/Schemas";
|
||||
import { createGameRecord } from "../core/Util";
|
||||
import { ServerConfig } from "../core/configuration/Config";
|
||||
import { getConfig } from "../core/configuration/ConfigLoader";
|
||||
import { Cell, Team, UnitType } from "../core/game/Game";
|
||||
import { Cell, UnitType } from "../core/game/Game";
|
||||
import { TileRef } from "../core/game/GameMap";
|
||||
import {
|
||||
ErrorUpdate,
|
||||
@@ -26,7 +28,7 @@ import { UserSettings } from "../core/game/UserSettings";
|
||||
import { WorkerClient } from "../core/worker/WorkerClient";
|
||||
import { InputHandler, MouseMoveEvent, MouseUpEvent } from "./InputHandler";
|
||||
import { endGame, startGame, startTime } from "./LocalPersistantStats";
|
||||
import { getPersistentIDFromCookie } from "./Main";
|
||||
import { getPersistentID } from "./Main";
|
||||
import {
|
||||
SendAttackIntentEvent,
|
||||
SendBoatAttackIntentEvent,
|
||||
@@ -187,25 +189,26 @@ export class ClientGameRunner {
|
||||
this.lastMessageTime = Date.now();
|
||||
}
|
||||
|
||||
private getWinner(update: WinUpdate): Winner {
|
||||
if (update.winner[0] !== "player") return update.winner;
|
||||
const clientId = this.gameView.playerBySmallID(update.winner[1]).clientID();
|
||||
if (clientId === null) return;
|
||||
return ["player", clientId];
|
||||
}
|
||||
|
||||
private saveGame(update: WinUpdate) {
|
||||
if (this.myPlayer === null) throw new Error("Not initialized");
|
||||
if (this.myPlayer === null) {
|
||||
return;
|
||||
}
|
||||
const players: PlayerRecord[] = [
|
||||
{
|
||||
playerID: this.myPlayer.id(),
|
||||
persistentID: getPersistentIDFromCookie(),
|
||||
persistentID: getPersistentID(),
|
||||
username: this.lobby.playerName,
|
||||
clientID: this.lobby.clientID,
|
||||
stats: update.allPlayersStats[this.lobby.clientID],
|
||||
},
|
||||
];
|
||||
let winner: ClientID | Team | null = null;
|
||||
if (update.winnerType === "player") {
|
||||
winner = this.gameView
|
||||
.playerBySmallID(update.winner as number)
|
||||
.clientID();
|
||||
} else {
|
||||
winner = update.winner as Team;
|
||||
}
|
||||
const winner = this.getWinner(update);
|
||||
|
||||
if (this.lobby.gameStartInfo === undefined) {
|
||||
throw new Error("missing gameStartInfo");
|
||||
@@ -219,7 +222,6 @@ export class ClientGameRunner {
|
||||
startTime(),
|
||||
Date.now(),
|
||||
winner,
|
||||
update.winnerType,
|
||||
);
|
||||
endGame(record);
|
||||
}
|
||||
@@ -307,7 +309,7 @@ export class ClientGameRunner {
|
||||
this.lobby.gameStartInfo.gameID,
|
||||
this.lobby.clientID,
|
||||
true,
|
||||
"You are desynced from other players. What you see might differ from other players.",
|
||||
translateText("error_modal.desync_notice"),
|
||||
);
|
||||
}
|
||||
if (message.type === "turn") {
|
||||
@@ -496,7 +498,7 @@ function showErrorModal(
|
||||
gameID: GameID,
|
||||
clientID: ClientID,
|
||||
closable = false,
|
||||
heading = "Game crashed!",
|
||||
heading = translateText("error_modal.crashed"),
|
||||
) {
|
||||
const errorText = `Error: ${errMsg}\nStack: ${stack}`;
|
||||
|
||||
@@ -505,37 +507,37 @@ function showErrorModal(
|
||||
}
|
||||
|
||||
const modal = document.createElement("div");
|
||||
const content = `${heading}\n game id: ${gameID}, client id: ${clientID}\nPlease paste the following in your bug report in Discord:\n${errorText}`;
|
||||
|
||||
modal.id = "error-modal";
|
||||
|
||||
const content = `${translateText(heading)}\n game id: ${gameID}, client id: ${clientID}\n${translateText("error_modal.paste_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.textContent = translateText("error_modal.copy_clipboard");
|
||||
button.className = "copy-btn";
|
||||
button.addEventListener("click", () => {
|
||||
navigator.clipboard
|
||||
.writeText(content)
|
||||
.then(() => (button.textContent = "Copied!"))
|
||||
.catch(() => (button.textContent = "Failed to copy"));
|
||||
.then(() => (button.textContent = translateText("error_modal.copied")))
|
||||
.catch(
|
||||
() => (button.textContent = translateText("error_modal.failed_copy")),
|
||||
);
|
||||
});
|
||||
|
||||
const closeButton = document.createElement("button");
|
||||
closeButton.textContent = "X";
|
||||
closeButton.style.cssText =
|
||||
"color: white;top: 0px;right: 0px;cursor: pointer;background: red;margin-right: 0px;position: fixed;width: 40px;";
|
||||
closeButton.className = "close-btn";
|
||||
closeButton.addEventListener("click", () => {
|
||||
modal.style.display = "none";
|
||||
});
|
||||
|
||||
// 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);
|
||||
modal.id = "error-modal";
|
||||
if (closable) {
|
||||
modal.appendChild(closeButton);
|
||||
}
|
||||
|
||||
@@ -5,11 +5,13 @@ import "./LanguageModal";
|
||||
import ar from "../../resources/lang/ar.json";
|
||||
import bg from "../../resources/lang/bg.json";
|
||||
import bn from "../../resources/lang/bn.json";
|
||||
import cs from "../../resources/lang/cs.json";
|
||||
import de from "../../resources/lang/de.json";
|
||||
import en from "../../resources/lang/en.json";
|
||||
import eo from "../../resources/lang/eo.json";
|
||||
import es from "../../resources/lang/es.json";
|
||||
import fr from "../../resources/lang/fr.json";
|
||||
import he from "../../resources/lang/he.json";
|
||||
import hi from "../../resources/lang/hi.json";
|
||||
import it from "../../resources/lang/it.json";
|
||||
import ja from "../../resources/lang/ja.json";
|
||||
@@ -53,6 +55,8 @@ export class LangSelector extends LitElement {
|
||||
tr,
|
||||
tp,
|
||||
uk,
|
||||
cs,
|
||||
he,
|
||||
};
|
||||
|
||||
createRenderRoot() {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { consolex } from "../core/Consolex";
|
||||
import { GameConfig, GameID, GameRecord } from "../core/Schemas";
|
||||
import { replacer } from "../core/Util";
|
||||
|
||||
export interface LocalStatsData {
|
||||
[key: GameID]: {
|
||||
@@ -19,7 +20,7 @@ function getStats(): LocalStatsData {
|
||||
function save(stats: LocalStatsData) {
|
||||
// To execute asynchronously
|
||||
setTimeout(
|
||||
() => localStorage.setItem("game-records", JSON.stringify(stats)),
|
||||
() => localStorage.setItem("game-records", JSON.stringify(stats, replacer)),
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,9 +11,9 @@ import {
|
||||
ServerStartGameMessageSchema,
|
||||
Turn,
|
||||
} from "../core/Schemas";
|
||||
import { createGameRecord, decompressGameRecord } from "../core/Util";
|
||||
import { createGameRecord, decompressGameRecord, replacer } from "../core/Util";
|
||||
import { LobbyConfig } from "./ClientGameRunner";
|
||||
import { getPersistentIDFromCookie } from "./Main";
|
||||
import { getPersistentID } from "./Main";
|
||||
|
||||
export class LocalServer {
|
||||
// All turns from the game record on replay.
|
||||
@@ -176,8 +176,7 @@ export class LocalServer {
|
||||
}
|
||||
const players: PlayerRecord[] = [
|
||||
{
|
||||
playerID: this.lobbyConfig.clientID, // hack?
|
||||
persistentID: getPersistentIDFromCookie(),
|
||||
persistentID: getPersistentID(),
|
||||
username: this.lobbyConfig.playerName,
|
||||
clientID: this.lobbyConfig.clientID,
|
||||
stats: this.allPlayersStats[this.lobbyConfig.clientID],
|
||||
@@ -193,17 +192,19 @@ export class LocalServer {
|
||||
this.turns,
|
||||
this.startedAt,
|
||||
Date.now(),
|
||||
this.winner?.winner ?? null,
|
||||
this.winner?.winnerType ?? null,
|
||||
this.winner?.winner,
|
||||
);
|
||||
if (!saveFullGame) {
|
||||
// Clear turns because beacon only supports up to 64kb
|
||||
record.turns = [];
|
||||
}
|
||||
// For unload events, sendBeacon is the only reliable method
|
||||
const blob = new Blob([JSON.stringify(GameRecordSchema.parse(record))], {
|
||||
type: "application/json",
|
||||
});
|
||||
const blob = new Blob(
|
||||
[JSON.stringify(GameRecordSchema.parse(record), replacer)],
|
||||
{
|
||||
type: "application/json",
|
||||
},
|
||||
);
|
||||
const workerPath = this.lobbyConfig.serverConfig.workerPath(
|
||||
this.lobbyConfig.gameStartInfo.gameID,
|
||||
);
|
||||
|
||||
+20
-10
@@ -172,17 +172,16 @@ class Client {
|
||||
TerritoryModal.open();
|
||||
});
|
||||
|
||||
const claims = isLoggedIn();
|
||||
if (claims === false) {
|
||||
if (isLoggedIn() === false) {
|
||||
// Not logged in
|
||||
loginDiscordButton.disable = false;
|
||||
loginDiscordButton.translationKey = "main.login_discord";
|
||||
loginDiscordButton.addEventListener("click", discordLogin);
|
||||
logoutDiscordButton.hidden = true;
|
||||
} else {
|
||||
// JWT appears to be valid, assume we are logged in
|
||||
// JWT appears to be valid
|
||||
loginDiscordButton.disable = true;
|
||||
loginDiscordButton.translationKey = "main.logged_in";
|
||||
loginDiscordButton.translationKey = "main.checking_login";
|
||||
logoutDiscordButton.hidden = false;
|
||||
logoutDiscordButton.addEventListener("click", () => {
|
||||
// Log out
|
||||
@@ -204,6 +203,8 @@ class Client {
|
||||
return;
|
||||
}
|
||||
// TODO: Update the page for logged in user
|
||||
loginDiscordButton.translationKey = "main.logged_in";
|
||||
const { user, player } = userMeResponse;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -303,7 +304,7 @@ class Client {
|
||||
? ""
|
||||
: this.flagInput.getCurrentFlag(),
|
||||
playerName: this.usernameInput?.getCurrentUsername() ?? "",
|
||||
token: localStorage.getItem("token") ?? getPersistentIDFromCookie(),
|
||||
token: getPlayToken(),
|
||||
clientID: lobby.clientID,
|
||||
gameStartInfo: lobby.gameStartInfo ?? lobby.gameRecord?.info,
|
||||
gameRecord: lobby.gameRecord,
|
||||
@@ -382,12 +383,21 @@ function setFavicon(): void {
|
||||
}
|
||||
|
||||
// WARNING: DO NOT EXPOSE THIS ID
|
||||
export function getPersistentIDFromCookie(): string {
|
||||
const claims = isLoggedIn();
|
||||
if (claims !== false && claims.sub) {
|
||||
return claims.sub;
|
||||
}
|
||||
function getPlayToken(): string {
|
||||
const result = isLoggedIn();
|
||||
if (result !== false) return result.token;
|
||||
return getPersistentIDFromCookie();
|
||||
}
|
||||
|
||||
// WARNING: DO NOT EXPOSE THIS ID
|
||||
export function getPersistentID(): string {
|
||||
const result = isLoggedIn();
|
||||
if (result !== false) return result.claims.sub;
|
||||
return getPersistentIDFromCookie();
|
||||
}
|
||||
|
||||
// WARNING: DO NOT EXPOSE THIS ID
|
||||
function getPersistentIDFromCookie(): string {
|
||||
const COOKIE_NAME = "player_persistent_id";
|
||||
|
||||
// Try to get existing cookie
|
||||
|
||||
+34
-31
@@ -100,47 +100,50 @@ export class PublicLobby extends LitElement {
|
||||
<button
|
||||
@click=${() => this.lobbyClicked(lobby)}
|
||||
?disabled=${this.isButtonDebounced}
|
||||
class="w-full mx-auto p-4 md:p-6 ${this.isLobbyHighlighted
|
||||
class="isolate grid h-40 grid-cols-[100%] grid-rows-[100%] place-content-stretch w-full overflow-hidden ${this
|
||||
.isLobbyHighlighted
|
||||
? "bg-gradient-to-r from-green-600 to-green-500"
|
||||
: "bg-gradient-to-r from-blue-600 to-blue-500"} text-white font-medium rounded-xl transition-opacity duration-200 hover:opacity-90 ${this
|
||||
.isButtonDebounced
|
||||
? "opacity-70 cursor-not-allowed"
|
||||
: ""}"
|
||||
>
|
||||
<div class="text-lg md:text-2xl font-semibold mb-2">
|
||||
${translateText("public_lobby.join")}
|
||||
</div>
|
||||
<div class="flex">
|
||||
<img
|
||||
src="${getMapsImage(lobby.gameConfig.gameMap)}"
|
||||
alt="${lobby.gameConfig.gameMap}"
|
||||
class="w-1/3 md:w-1/5 md:h-[80px]"
|
||||
style="border: 1px solid rgba(255, 255, 255, 0.5)"
|
||||
/>
|
||||
<div
|
||||
class="w-full flex flex-col md:flex-row items-center justify-center md:justify-evenly"
|
||||
>
|
||||
<div class="flex flex-col items-center">
|
||||
<div class="text-md font-medium text-blue-100 mb-4">
|
||||
<!-- ${lobby.gameConfig.gameMap} -->
|
||||
${translateText(
|
||||
`map.${lobby.gameConfig.gameMap.toLowerCase().replace(/\s+/g, "")}`,
|
||||
)}
|
||||
</div>
|
||||
<div class="text-md font-medium text-blue-100">
|
||||
<img
|
||||
src="${getMapsImage(lobby.gameConfig.gameMap)}"
|
||||
alt="${lobby.gameConfig.gameMap}"
|
||||
class="place-self-start col-span-full row-span-full h-full -z-10"
|
||||
style="mask-image: linear-gradient(to left, transparent, #fff)"
|
||||
/>
|
||||
<div
|
||||
class="flex flex-col justify-between h-full col-span-full row-span-full p-4 md:p-6 text-right z-0"
|
||||
>
|
||||
<div>
|
||||
<div class="text-lg md:text-2xl font-semibold">
|
||||
${translateText("public_lobby.join")}
|
||||
</div>
|
||||
<div class="text-md font-medium text-blue-100">
|
||||
<span
|
||||
class="text-sm ${this.isLobbyHighlighted
|
||||
? "text-green-600"
|
||||
: "text-blue-600"} bg-white rounded-sm px-1"
|
||||
>
|
||||
${lobby.gameConfig.gameMode === GameMode.Team
|
||||
? translateText("public_lobby.teams", { num: teamCount ?? 0 })
|
||||
: translateText("game_mode.ffa")}
|
||||
</div>
|
||||
: translateText("game_mode.ffa")}</span
|
||||
>
|
||||
<span
|
||||
>${translateText(
|
||||
`map.${lobby.gameConfig.gameMap.toLowerCase().replace(/\s+/g, "")}`,
|
||||
)}</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex flex-col items-center">
|
||||
<div class="text-md font-medium text-blue-100 mb-4">
|
||||
${lobby.numClients} / ${lobby.gameConfig.maxPlayers}
|
||||
</div>
|
||||
<div class="text-md font-medium text-blue-100">
|
||||
${timeDisplay}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="text-md font-medium text-blue-100">
|
||||
${lobby.numClients} / ${lobby.gameConfig.maxPlayers}
|
||||
</div>
|
||||
<div class="text-md font-medium text-blue-100">${timeDisplay}</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -435,7 +435,6 @@ export class SinglePlayerModal extends LitElement {
|
||||
gameID: gameID,
|
||||
players: [
|
||||
{
|
||||
playerID: generateID(),
|
||||
clientID,
|
||||
username: usernameInput.getCurrentUsername(),
|
||||
flag:
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
GameType,
|
||||
PlayerID,
|
||||
PlayerType,
|
||||
Team,
|
||||
Tick,
|
||||
UnitType,
|
||||
} from "../core/game/Game";
|
||||
@@ -14,7 +13,6 @@ import { PlayerView } from "../core/game/GameView";
|
||||
import {
|
||||
AllPlayersStats,
|
||||
ClientHashMessage,
|
||||
ClientID,
|
||||
ClientIntentMessage,
|
||||
ClientJoinMessage,
|
||||
ClientLogMessage,
|
||||
@@ -23,7 +21,9 @@ import {
|
||||
Intent,
|
||||
ServerMessage,
|
||||
ServerMessageSchema,
|
||||
Winner,
|
||||
} from "../core/Schemas";
|
||||
import { replacer } from "../core/Util";
|
||||
import { LobbyConfig } from "./ClientGameRunner";
|
||||
import { LocalServer } from "./LocalServer";
|
||||
|
||||
@@ -142,9 +142,8 @@ export class SendSetTargetTroopRatioEvent implements GameEvent {
|
||||
|
||||
export class SendWinnerEvent implements GameEvent {
|
||||
constructor(
|
||||
public readonly winner: ClientID | Team,
|
||||
public readonly winner: Winner,
|
||||
public readonly allPlayersStats: AllPlayersStats,
|
||||
public readonly winnerType: "player" | "team",
|
||||
) {}
|
||||
}
|
||||
export class SendHashEvent implements GameEvent {
|
||||
@@ -539,9 +538,8 @@ export class Transport {
|
||||
type: "winner",
|
||||
winner: event.winner,
|
||||
allPlayersStats: event.allPlayersStats,
|
||||
winnerType: event.winnerType,
|
||||
} satisfies ClientSendWinnerMessage;
|
||||
this.sendMsg(JSON.stringify(msg));
|
||||
this.sendMsg(JSON.stringify(msg, replacer));
|
||||
} else {
|
||||
console.log(
|
||||
"WebSocket is not open. Current state:",
|
||||
|
||||
@@ -47,7 +47,7 @@ export class UsernameInput extends LitElement {
|
||||
/>
|
||||
${this.validationError
|
||||
? html`<div
|
||||
class="mt-2 px-3 py-1 text-lg border rounded bg-white text-red-600 border-red-600 dark:bg-gray-700 dark:text-red-300 dark:border-red-300"
|
||||
class="absolute w-full mt-2 px-3 py-1 text-lg border rounded bg-white text-red-600 border-red-600 dark:bg-gray-700 dark:text-red-300 dark:border-red-300"
|
||||
>
|
||||
${this.validationError}
|
||||
</div>`
|
||||
|
||||
@@ -6,6 +6,7 @@ import { getMapsImage } from "../utilities/Maps";
|
||||
// Add map descriptions
|
||||
export const MapDescription: Record<keyof typeof GameMapType, string> = {
|
||||
World: "World",
|
||||
WorldMapGiant: "Giant World Map",
|
||||
Europe: "Europe",
|
||||
EuropeClassic: "Europe Classic",
|
||||
Mena: "MENA",
|
||||
@@ -23,7 +24,6 @@ export const MapDescription: Record<keyof typeof GameMapType, string> = {
|
||||
Iceland: "Iceland",
|
||||
Japan: "Japan",
|
||||
BetweenTwoSeas: "Between Two Seas",
|
||||
KnownWorld: "Known World",
|
||||
FaroeIslands: "Faroe Islands",
|
||||
DeglaciatedAntarctica: "Deglaciated Antarctica",
|
||||
FalklandIslands: "Falkland Islands",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { LitElement, css, html } from "lit";
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import megaphone from "../../../resources/images/Megaphone.svg";
|
||||
import { NewsModal } from "../NewsModal";
|
||||
@@ -9,38 +9,6 @@ export class NewsButton extends LitElement {
|
||||
@property({ type: Boolean })
|
||||
hidden = false;
|
||||
|
||||
static styles = css`
|
||||
.news-button {
|
||||
opacity: 0.75;
|
||||
transition: opacity 0.2s ease;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.news-button:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.news-button img {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: block;
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
`;
|
||||
|
||||
private handleClick() {
|
||||
const newsModal = document.querySelector("news-modal") as NewsModal;
|
||||
if (newsModal) {
|
||||
@@ -50,9 +18,16 @@ export class NewsButton extends LitElement {
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div class="text-center mb-0.5 ${this.hidden ? "hidden" : ""}">
|
||||
<button class="news-button" @click=${this.handleClick}>
|
||||
<img src="${megaphone}" alt=${translateText("news.title")} />
|
||||
<div class="flex relative ${this.hidden ? "parent-hidden" : ""}">
|
||||
<button
|
||||
class="border p-[4px] rounded-lg flex cursor-pointer border-black/30 dark:border-gray-300/60 bg-white/70 dark:bg-[rgba(55,65,81,0.7)]"
|
||||
@click=${this.handleClick}
|
||||
>
|
||||
<img
|
||||
class="size-[48px]"
|
||||
src="${megaphone}"
|
||||
alt=${translateText("news.title")}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
"name": "Afghanistan"
|
||||
},
|
||||
{
|
||||
"code": "african union",
|
||||
"code": "African union",
|
||||
"continent": "Africa",
|
||||
"name": "African Union"
|
||||
},
|
||||
@@ -300,7 +300,7 @@
|
||||
"name": "British Indian Ocean Territory"
|
||||
},
|
||||
{
|
||||
"code": "Brittany",
|
||||
"code": "brittany",
|
||||
"continent": "Europe",
|
||||
"name": "Brittany"
|
||||
},
|
||||
|
||||
@@ -9,7 +9,7 @@ export class AnimatedSprite {
|
||||
private frameWidth: number,
|
||||
private frameCount: number,
|
||||
private frameDuration: number, // in milliseconds
|
||||
private looping: boolean = true,
|
||||
private looping: boolean = false,
|
||||
private originX: number,
|
||||
private originY: number,
|
||||
) {
|
||||
@@ -42,6 +42,13 @@ export class AnimatedSprite {
|
||||
return this.active;
|
||||
}
|
||||
|
||||
lifeTime(): number | undefined {
|
||||
if (this.looping) {
|
||||
return undefined;
|
||||
}
|
||||
return this.frameDuration * this.frameCount;
|
||||
}
|
||||
|
||||
draw(ctx: CanvasRenderingContext2D, x: number, y: number) {
|
||||
const drawX = x - this.originX;
|
||||
const drawY = y - this.originY;
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
import miniBigSmoke from "../../../resources/sprites/bigsmoke.png";
|
||||
import miniExplosion from "../../../resources/sprites/miniExplosion.png";
|
||||
import miniFire from "../../../resources/sprites/minifire.png";
|
||||
import nuke from "../../../resources/sprites/nukeExplosion.png";
|
||||
import SAMExplosion from "../../../resources/sprites/samExplosion.png";
|
||||
import sinkingShip from "../../../resources/sprites/sinkingShip.png";
|
||||
import miniSmoke from "../../../resources/sprites/smoke.png";
|
||||
import miniSmokeAndFire from "../../../resources/sprites/smokeAndFire.png";
|
||||
import unitExplosion from "../../../resources/sprites/unitExplosion.png";
|
||||
import { Theme } from "../../core/configuration/Config";
|
||||
import { PlayerView } from "../../core/game/GameView";
|
||||
import { AnimatedSprite } from "./AnimatedSprite";
|
||||
import { FxType } from "./fx/Fx";
|
||||
import { colorizeCanvas } from "./SpriteLoader";
|
||||
|
||||
type AnimatedSpriteConfig = {
|
||||
url: string;
|
||||
@@ -14,6 +24,69 @@ type AnimatedSpriteConfig = {
|
||||
};
|
||||
|
||||
const ANIMATED_SPRITE_CONFIG: Partial<Record<FxType, AnimatedSpriteConfig>> = {
|
||||
[FxType.MiniFire]: {
|
||||
url: miniFire,
|
||||
frameWidth: 7,
|
||||
frameCount: 6,
|
||||
frameDuration: 100,
|
||||
looping: true,
|
||||
originX: 3,
|
||||
originY: 11,
|
||||
},
|
||||
[FxType.MiniSmoke]: {
|
||||
url: miniSmoke,
|
||||
frameWidth: 11,
|
||||
frameCount: 4,
|
||||
frameDuration: 120,
|
||||
looping: true,
|
||||
originX: 2,
|
||||
originY: 10,
|
||||
},
|
||||
[FxType.MiniBigSmoke]: {
|
||||
url: miniBigSmoke,
|
||||
frameWidth: 24,
|
||||
frameCount: 5,
|
||||
frameDuration: 120,
|
||||
looping: true,
|
||||
originX: 9,
|
||||
originY: 14,
|
||||
},
|
||||
[FxType.MiniSmokeAndFire]: {
|
||||
url: miniSmokeAndFire,
|
||||
frameWidth: 24,
|
||||
frameCount: 5,
|
||||
frameDuration: 120,
|
||||
looping: true,
|
||||
originX: 9,
|
||||
originY: 14,
|
||||
},
|
||||
[FxType.MiniExplosion]: {
|
||||
url: miniExplosion,
|
||||
frameWidth: 13,
|
||||
frameCount: 4,
|
||||
frameDuration: 70,
|
||||
looping: false,
|
||||
originX: 6,
|
||||
originY: 6,
|
||||
},
|
||||
[FxType.UnitExplosion]: {
|
||||
url: unitExplosion,
|
||||
frameWidth: 19,
|
||||
frameCount: 4,
|
||||
frameDuration: 70,
|
||||
looping: false,
|
||||
originX: 9,
|
||||
originY: 9,
|
||||
},
|
||||
[FxType.SinkingShip]: {
|
||||
url: sinkingShip,
|
||||
frameWidth: 16,
|
||||
frameCount: 14,
|
||||
frameDuration: 90,
|
||||
looping: false,
|
||||
originX: 7,
|
||||
originY: 7,
|
||||
},
|
||||
[FxType.Nuke]: {
|
||||
url: nuke,
|
||||
frameWidth: 60,
|
||||
@@ -34,53 +107,115 @@ const ANIMATED_SPRITE_CONFIG: Partial<Record<FxType, AnimatedSpriteConfig>> = {
|
||||
},
|
||||
};
|
||||
|
||||
const animatedSpriteImageMap: Map<FxType, CanvasImageSource> = new Map();
|
||||
export class AnimatedSpriteLoader {
|
||||
private animatedSpriteImageMap: Map<FxType, HTMLCanvasElement> = new Map();
|
||||
// Do not color the same sprite twice
|
||||
private coloredAnimatedSpriteCache: Map<string, HTMLCanvasElement> =
|
||||
new Map();
|
||||
|
||||
export const loadAllAnimatedSpriteImages = async (): Promise<void> => {
|
||||
const entries = Object.entries(ANIMATED_SPRITE_CONFIG);
|
||||
public async loadAllAnimatedSpriteImages(): Promise<void> {
|
||||
const entries = Object.entries(ANIMATED_SPRITE_CONFIG);
|
||||
|
||||
await Promise.all(
|
||||
entries.map(async ([fxType, config]) => {
|
||||
const typedFxType = fxType as FxType;
|
||||
if (!config?.url) return;
|
||||
await Promise.all(
|
||||
entries.map(async ([fxType, config]) => {
|
||||
const typedFxType = fxType as FxType;
|
||||
if (!config?.url) return;
|
||||
|
||||
try {
|
||||
const img = new Image();
|
||||
img.crossOrigin = "anonymous";
|
||||
img.src = config.url;
|
||||
try {
|
||||
const img = new Image();
|
||||
img.crossOrigin = "anonymous";
|
||||
img.src = config.url;
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
img.onload = () => resolve();
|
||||
img.onerror = (e) => reject(e);
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
img.onload = () => resolve();
|
||||
img.onerror = (e) => reject(e);
|
||||
});
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = img.width;
|
||||
canvas.height = img.height;
|
||||
canvas.getContext("2d")!.drawImage(img, 0, 0);
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = img.width;
|
||||
canvas.height = img.height;
|
||||
canvas.getContext("2d")!.drawImage(img, 0, 0);
|
||||
|
||||
animatedSpriteImageMap.set(typedFxType, canvas);
|
||||
} catch (err) {
|
||||
console.error(`Failed to load sprite for ${typedFxType}:`, err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
};
|
||||
this.animatedSpriteImageMap.set(typedFxType, canvas);
|
||||
} catch (err) {
|
||||
console.error(`Failed to load sprite for ${typedFxType}:`, err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export const createAnimatedSpriteForUnit = (
|
||||
fxType: FxType,
|
||||
): AnimatedSprite | null => {
|
||||
const config = ANIMATED_SPRITE_CONFIG[fxType];
|
||||
const image = animatedSpriteImageMap.get(fxType);
|
||||
if (!config || !image) return null;
|
||||
private createRegularAnimatedSprite(fxType: FxType): AnimatedSprite | null {
|
||||
const config = ANIMATED_SPRITE_CONFIG[fxType];
|
||||
const image = this.animatedSpriteImageMap.get(fxType);
|
||||
if (!config || !image) return null;
|
||||
|
||||
return new AnimatedSprite(
|
||||
image,
|
||||
config.frameWidth,
|
||||
config.frameCount,
|
||||
config.frameDuration,
|
||||
config.looping ?? true,
|
||||
config.originX,
|
||||
config.originY,
|
||||
);
|
||||
};
|
||||
return new AnimatedSprite(
|
||||
image,
|
||||
config.frameWidth,
|
||||
config.frameCount,
|
||||
config.frameDuration,
|
||||
config.looping ?? true,
|
||||
config.originX,
|
||||
config.originY,
|
||||
);
|
||||
}
|
||||
|
||||
private getColoredAnimatedSprite(
|
||||
owner: PlayerView,
|
||||
fxType: FxType,
|
||||
theme: Theme,
|
||||
): HTMLCanvasElement | null {
|
||||
const baseImage = this.animatedSpriteImageMap.get(fxType);
|
||||
const config = ANIMATED_SPRITE_CONFIG[fxType];
|
||||
if (!baseImage || !config) return null;
|
||||
const territoryColor = theme.territoryColor(owner);
|
||||
const borderColor = theme.borderColor(owner);
|
||||
const spawnHighlightColor = theme.spawnHighlightColor();
|
||||
const key = `${fxType}-${owner.id()}`;
|
||||
let coloredCanvas: HTMLCanvasElement;
|
||||
if (this.coloredAnimatedSpriteCache.has(key)) {
|
||||
coloredCanvas = this.coloredAnimatedSpriteCache.get(key)!;
|
||||
} else {
|
||||
coloredCanvas = colorizeCanvas(
|
||||
baseImage,
|
||||
territoryColor,
|
||||
borderColor,
|
||||
spawnHighlightColor,
|
||||
);
|
||||
|
||||
this.coloredAnimatedSpriteCache.set(key, coloredCanvas);
|
||||
}
|
||||
return coloredCanvas;
|
||||
}
|
||||
|
||||
private createColoredAnimatedSpriteForUnit(
|
||||
fxType: FxType,
|
||||
owner: PlayerView,
|
||||
theme: Theme,
|
||||
): AnimatedSprite | null {
|
||||
const config = ANIMATED_SPRITE_CONFIG[fxType];
|
||||
const image = this.getColoredAnimatedSprite(owner, fxType, theme);
|
||||
if (!config || !image) return null;
|
||||
|
||||
return new AnimatedSprite(
|
||||
image,
|
||||
config.frameWidth,
|
||||
config.frameCount,
|
||||
config.frameDuration,
|
||||
config.looping ?? true,
|
||||
config.originX,
|
||||
config.originY,
|
||||
);
|
||||
}
|
||||
|
||||
public createAnimatedSprite(
|
||||
fxType: FxType,
|
||||
owner?: PlayerView,
|
||||
theme?: Theme,
|
||||
): AnimatedSprite | null {
|
||||
if (owner && theme) {
|
||||
return this.createColoredAnimatedSpriteForUnit(fxType, owner, theme);
|
||||
}
|
||||
return this.createRegularAnimatedSprite(fxType);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import { TerrainLayer } from "./layers/TerrainLayer";
|
||||
import { TerritoryLayer } from "./layers/TerritoryLayer";
|
||||
import { TopBar } from "./layers/TopBar";
|
||||
import { UILayer } from "./layers/UILayer";
|
||||
import { UnitInfoModal } from "./layers/UnitInfoModal";
|
||||
import { UnitLayer } from "./layers/UnitLayer";
|
||||
import { WinModal } from "./layers/WinModal";
|
||||
|
||||
@@ -171,10 +172,26 @@ export function createRenderer(
|
||||
}
|
||||
playerTeamLabel.game = game;
|
||||
|
||||
const unitInfoModal = document.querySelector(
|
||||
"unit-info-modal",
|
||||
) as UnitInfoModal;
|
||||
if (!(unitInfoModal instanceof UnitInfoModal)) {
|
||||
console.error("unit info modal not found");
|
||||
}
|
||||
unitInfoModal.game = game;
|
||||
const structureLayer = new StructureLayer(
|
||||
game,
|
||||
eventBus,
|
||||
transformHandler,
|
||||
unitInfoModal,
|
||||
);
|
||||
unitInfoModal.structureLayer = structureLayer;
|
||||
// unitInfoModal.eventBus = eventBus;
|
||||
|
||||
const layers: Layer[] = [
|
||||
new TerrainLayer(game, transformHandler),
|
||||
new TerritoryLayer(game, eventBus),
|
||||
new StructureLayer(game, eventBus),
|
||||
structureLayer,
|
||||
new UnitLayer(game, eventBus, clientID, transformHandler),
|
||||
new FxLayer(game),
|
||||
new UILayer(game, eventBus, clientID, transformHandler),
|
||||
@@ -203,6 +220,7 @@ export function createRenderer(
|
||||
topBar,
|
||||
playerPanel,
|
||||
playerTeamLabel,
|
||||
unitInfoModal,
|
||||
multiTabModal,
|
||||
];
|
||||
|
||||
|
||||
@@ -71,7 +71,53 @@ export const isSpriteReady = (unitType: UnitType): boolean => {
|
||||
|
||||
const coloredSpriteCache: Map<string, HTMLCanvasElement> = new Map();
|
||||
|
||||
// puts the sprite in an canvas colors it and caches the colored canvas
|
||||
/**
|
||||
* Load a canvas and replace grayscale with border colors
|
||||
*/
|
||||
export const colorizeCanvas = (
|
||||
source: CanvasImageSource & { width: number; height: number },
|
||||
colorA: Colord,
|
||||
colorB: Colord,
|
||||
colorC: Colord,
|
||||
): HTMLCanvasElement => {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = source.width;
|
||||
canvas.height = source.height;
|
||||
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
ctx.drawImage(source, 0, 0);
|
||||
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
const data = imageData.data;
|
||||
|
||||
const colorARgb = colorA.toRgb();
|
||||
const colorBRgb = colorB.toRgb();
|
||||
const colorCRgb = colorC.toRgb();
|
||||
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const r = data[i],
|
||||
g = data[i + 1],
|
||||
b = data[i + 2];
|
||||
|
||||
if (r === 180 && g === 180 && b === 180) {
|
||||
data[i] = colorARgb.r;
|
||||
data[i + 1] = colorARgb.g;
|
||||
data[i + 2] = colorARgb.b;
|
||||
} else if (r === 70 && g === 70 && b === 70) {
|
||||
data[i] = colorBRgb.r;
|
||||
data[i + 1] = colorBRgb.g;
|
||||
data[i + 2] = colorBRgb.b;
|
||||
} else if (r === 130 && g === 130 && b === 130) {
|
||||
data[i] = colorCRgb.r;
|
||||
data[i + 1] = colorCRgb.g;
|
||||
data[i + 2] = colorCRgb.b;
|
||||
}
|
||||
}
|
||||
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
return canvas;
|
||||
};
|
||||
|
||||
export const getColoredSprite = (
|
||||
unit: UnitView,
|
||||
theme: Theme,
|
||||
@@ -82,8 +128,7 @@ export const getColoredSprite = (
|
||||
const territoryColor = customTerritoryColor ?? theme.territoryColor(owner);
|
||||
const borderColor = customBorderColor ?? theme.borderColor(owner);
|
||||
const spawnHighlightColor = theme.spawnHighlightColor();
|
||||
const colorKey = territoryColor.toRgbString() + borderColor.toRgbString();
|
||||
const key = unit.type() + colorKey;
|
||||
const key = `${unit.type()}-${owner.id()}`;
|
||||
|
||||
if (coloredSpriteCache.has(key)) {
|
||||
return coloredSpriteCache.get(key)!;
|
||||
@@ -94,45 +139,13 @@ export const getColoredSprite = (
|
||||
throw new Error(`Failed to load sprite for ${unit.type()}`);
|
||||
}
|
||||
|
||||
const territoryRgb = territoryColor.toRgb();
|
||||
const borderRgb = borderColor.toRgb();
|
||||
const spawnHighlightRgb = spawnHighlightColor.toRgb();
|
||||
const coloredCanvas = colorizeCanvas(
|
||||
sprite,
|
||||
territoryColor,
|
||||
borderColor,
|
||||
spawnHighlightColor,
|
||||
);
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
canvas.width = sprite.width;
|
||||
canvas.height = sprite.height;
|
||||
|
||||
ctx.drawImage(sprite, 0, 0);
|
||||
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
const data = imageData.data;
|
||||
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const r = data[i];
|
||||
const g = data[i + 1];
|
||||
const b = data[i + 2];
|
||||
|
||||
if (r === 180 && g === 180 && b === 180) {
|
||||
data[i] = territoryRgb.r;
|
||||
data[i + 1] = territoryRgb.g;
|
||||
data[i + 2] = territoryRgb.b;
|
||||
}
|
||||
|
||||
if (r === 70 && g === 70 && b === 70) {
|
||||
data[i] = borderRgb.r;
|
||||
data[i + 1] = borderRgb.g;
|
||||
data[i + 2] = borderRgb.b;
|
||||
}
|
||||
|
||||
if (r === 130 && g === 130 && b === 130) {
|
||||
data[i] = spawnHighlightRgb.r;
|
||||
data[i + 1] = spawnHighlightRgb.g;
|
||||
data[i + 2] = spawnHighlightRgb.b;
|
||||
}
|
||||
}
|
||||
|
||||
ctx.putImageData(imageData, 0.5, 0.5);
|
||||
coloredSpriteCache.set(key, canvas);
|
||||
return canvas;
|
||||
coloredSpriteCache.set(key, coloredCanvas);
|
||||
return coloredCanvas;
|
||||
};
|
||||
|
||||
@@ -2,12 +2,21 @@ import { EventBus } from "../../core/EventBus";
|
||||
import { Cell } from "../../core/game/Game";
|
||||
import { GameView } from "../../core/game/GameView";
|
||||
import { CenterCameraEvent, DragEvent, ZoomEvent } from "../InputHandler";
|
||||
import { GoToPlayerEvent, GoToUnitEvent } from "./layers/Leaderboard";
|
||||
import {
|
||||
GoToPlayerEvent,
|
||||
GoToPositionEvent,
|
||||
GoToUnitEvent,
|
||||
} from "./layers/Leaderboard";
|
||||
|
||||
export const GOTO_INTERVAL_MS = 16;
|
||||
export const CAMERA_MAX_SPEED = 15;
|
||||
export const CAMERA_SMOOTHING = 0.03;
|
||||
|
||||
export class TransformHandler {
|
||||
public scale: number = 1.8;
|
||||
private offsetX: number = -350;
|
||||
private offsetY: number = -200;
|
||||
private lastGoToCallTime: number | null = null;
|
||||
|
||||
private target: Cell | null;
|
||||
private intervalID: NodeJS.Timeout | null = null;
|
||||
@@ -21,6 +30,7 @@ export class TransformHandler {
|
||||
this.eventBus.on(ZoomEvent, (e) => this.onZoom(e));
|
||||
this.eventBus.on(DragEvent, (e) => this.onMove(e));
|
||||
this.eventBus.on(GoToPlayerEvent, (e) => this.onGoToPlayer(e));
|
||||
this.eventBus.on(GoToPositionEvent, (e) => this.onGoToPosition(e));
|
||||
this.eventBus.on(GoToUnitEvent, (e) => this.onGoToUnit(e));
|
||||
this.eventBus.on(CenterCameraEvent, () => this.centerCamera());
|
||||
}
|
||||
@@ -146,7 +156,13 @@ export class TransformHandler {
|
||||
event.player.nameLocation().x,
|
||||
event.player.nameLocation().y,
|
||||
);
|
||||
this.intervalID = setInterval(() => this.goTo(), 1);
|
||||
this.intervalID = setInterval(() => this.goTo(), GOTO_INTERVAL_MS);
|
||||
}
|
||||
|
||||
onGoToPosition(event: GoToPositionEvent) {
|
||||
this.clearTarget();
|
||||
this.target = new Cell(event.x, event.y);
|
||||
this.intervalID = setInterval(() => this.goTo(), GOTO_INTERVAL_MS);
|
||||
}
|
||||
|
||||
onGoToUnit(event: GoToUnitEvent) {
|
||||
@@ -155,7 +171,7 @@ export class TransformHandler {
|
||||
this.game.x(event.unit.lastTile()),
|
||||
this.game.y(event.unit.lastTile()),
|
||||
);
|
||||
this.intervalID = setInterval(() => this.goTo(), 1);
|
||||
this.intervalID = setInterval(() => this.goTo(), GOTO_INTERVAL_MS);
|
||||
}
|
||||
|
||||
centerCamera() {
|
||||
@@ -163,43 +179,42 @@ export class TransformHandler {
|
||||
const player = this.game.myPlayer();
|
||||
if (!player || !player.nameLocation()) return;
|
||||
this.target = new Cell(player.nameLocation().x, player.nameLocation().y);
|
||||
this.intervalID = setInterval(() => this.goTo(), 1);
|
||||
this.intervalID = setInterval(() => this.goTo(), GOTO_INTERVAL_MS);
|
||||
}
|
||||
|
||||
private goTo() {
|
||||
const { screenX, screenY } = this.screenCenter();
|
||||
const screenMapCenter = new Cell(screenX, screenY);
|
||||
|
||||
if (this.target === null) throw new Error("null target");
|
||||
|
||||
if (
|
||||
this.game.manhattanDist(
|
||||
this.game.ref(screenX, screenY),
|
||||
this.game.ref(this.target.x, this.target.y),
|
||||
) < 2
|
||||
Math.abs(this.target.x - screenX) + Math.abs(this.target.y - screenY) <
|
||||
2
|
||||
) {
|
||||
this.clearTarget();
|
||||
return;
|
||||
}
|
||||
|
||||
const dX = Math.abs(screenMapCenter.x - this.target.x);
|
||||
if (dX > 2) {
|
||||
const offsetDx = Math.max(1, Math.floor(dX / 25));
|
||||
if (screenMapCenter.x > this.target.x) {
|
||||
this.offsetX -= offsetDx;
|
||||
} else {
|
||||
this.offsetX += offsetDx;
|
||||
}
|
||||
}
|
||||
const dY = Math.abs(screenMapCenter.y - this.target.y);
|
||||
if (dY > 2) {
|
||||
const offsetDy = Math.max(1, Math.floor(dY / 25));
|
||||
if (screenMapCenter.y > this.target.y) {
|
||||
this.offsetY -= offsetDy;
|
||||
} else {
|
||||
this.offsetY += offsetDy;
|
||||
}
|
||||
let dt: number;
|
||||
const now = window.performance.now();
|
||||
if (this.lastGoToCallTime === null) {
|
||||
dt = GOTO_INTERVAL_MS;
|
||||
} else {
|
||||
dt = now - this.lastGoToCallTime;
|
||||
}
|
||||
this.lastGoToCallTime = now;
|
||||
|
||||
const r = 1 - Math.pow(CAMERA_SMOOTHING, dt / 1000);
|
||||
|
||||
this.offsetX += Math.max(
|
||||
Math.min((this.target.x - screenX) * r, CAMERA_MAX_SPEED),
|
||||
-CAMERA_MAX_SPEED,
|
||||
);
|
||||
this.offsetY += Math.max(
|
||||
Math.min((this.target.y - screenY) * r, CAMERA_MAX_SPEED),
|
||||
-CAMERA_MAX_SPEED,
|
||||
);
|
||||
|
||||
this.changed = true;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,13 @@ export interface Fx {
|
||||
}
|
||||
|
||||
export enum FxType {
|
||||
MiniFire = "MiniFire",
|
||||
MiniSmoke = "MiniSmoke",
|
||||
MiniBigSmoke = "MiniBigSmoke",
|
||||
MiniSmokeAndFire = "MiniSmokeAndFire",
|
||||
MiniExplosion = "MiniExplosion",
|
||||
UnitExplosion = "UnitExplosion",
|
||||
SinkingShip = "SinkingShip",
|
||||
Nuke = "Nuke",
|
||||
SAMExplosion = "SAMExplosion",
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { AnimatedSprite } from "../AnimatedSprite";
|
||||
import { createAnimatedSpriteForUnit } from "../AnimatedSpriteLoader";
|
||||
import { GameView } from "../../../core/game/GameView";
|
||||
import { AnimatedSpriteLoader } from "../AnimatedSpriteLoader";
|
||||
import { Fx, FxType } from "./Fx";
|
||||
import { FadeFx, SpriteFx } from "./SpriteFx";
|
||||
|
||||
/**
|
||||
* Shockwave effect: draw a growing 1px white circle
|
||||
@@ -31,32 +32,79 @@ export class ShockwaveFx implements Fx {
|
||||
}
|
||||
|
||||
/**
|
||||
* Explosion effect: sprite animation of an explosion
|
||||
* Spawn @p number of @p type animation within a perimeter
|
||||
*/
|
||||
export class NukeExplosionFx implements Fx {
|
||||
private lifeTime: number = 0;
|
||||
private nukeExplosionSprite: AnimatedSprite | null;
|
||||
constructor(
|
||||
private x: number,
|
||||
private y: number,
|
||||
private duration: number,
|
||||
) {
|
||||
this.nukeExplosionSprite = createAnimatedSpriteForUnit(FxType.Nuke);
|
||||
}
|
||||
|
||||
renderTick(frameTime: number, ctx: CanvasRenderingContext2D): boolean {
|
||||
if (this.nukeExplosionSprite) {
|
||||
this.lifeTime += frameTime;
|
||||
if (this.lifeTime >= this.duration) {
|
||||
return false;
|
||||
}
|
||||
if (this.nukeExplosionSprite.isActive()) {
|
||||
this.nukeExplosionSprite.update(frameTime);
|
||||
this.nukeExplosionSprite.draw(ctx, this.x, this.y);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
function addSpriteInCircle(
|
||||
animatedSpriteLoader: AnimatedSpriteLoader,
|
||||
x: number,
|
||||
y: number,
|
||||
radius: number,
|
||||
num: number,
|
||||
type: FxType,
|
||||
result: Fx[],
|
||||
game: GameView,
|
||||
) {
|
||||
const count = Math.max(0, Math.floor(num));
|
||||
for (let i = 0; i < count; i++) {
|
||||
const angle = Math.random() * 2 * Math.PI;
|
||||
const distance = Math.random() * (radius / 2);
|
||||
const spawnX = Math.floor(x + Math.cos(angle) * distance);
|
||||
const spawnY = Math.floor(y + Math.sin(angle) * distance);
|
||||
if (
|
||||
game.isValidCoord(spawnX, spawnY) &&
|
||||
game.isLand(game.ref(spawnX, spawnY))
|
||||
) {
|
||||
const sprite = new FadeFx(
|
||||
new SpriteFx(animatedSpriteLoader, spawnX, spawnY, type, 6000),
|
||||
0.1,
|
||||
0.8,
|
||||
);
|
||||
result.push(sprite as Fx);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Explosion effect:
|
||||
* - explosion animation
|
||||
* - shockwave
|
||||
* - ruins and desolation fx
|
||||
*/
|
||||
export function nukeFxFactory(
|
||||
animatedSpriteLoader: AnimatedSpriteLoader,
|
||||
x: number,
|
||||
y: number,
|
||||
radius: number,
|
||||
game: GameView,
|
||||
): Fx[] {
|
||||
const nukeFx: Fx[] = [];
|
||||
// Explosion animation
|
||||
nukeFx.push(new SpriteFx(animatedSpriteLoader, x, y, FxType.Nuke));
|
||||
// Shockwave animation
|
||||
nukeFx.push(new ShockwaveFx(x, y, 1500, radius * 1.5));
|
||||
// Ruins and desolation sprites
|
||||
const debrisPlan: Array<{
|
||||
type: FxType;
|
||||
radiusFactor: number;
|
||||
density: number;
|
||||
}> = [
|
||||
{ type: FxType.MiniFire, radiusFactor: 1.0, density: 1 / 25 },
|
||||
{ type: FxType.MiniSmoke, radiusFactor: 1.0, density: 1 / 28 },
|
||||
{ type: FxType.MiniBigSmoke, radiusFactor: 0.9, density: 1 / 70 },
|
||||
{ type: FxType.MiniSmokeAndFire, radiusFactor: 0.9, density: 1 / 70 },
|
||||
];
|
||||
|
||||
for (const { type, radiusFactor, density } of debrisPlan) {
|
||||
addSpriteInCircle(
|
||||
animatedSpriteLoader,
|
||||
x,
|
||||
y,
|
||||
radius * radiusFactor,
|
||||
radius * density,
|
||||
type,
|
||||
nukeFx,
|
||||
game,
|
||||
);
|
||||
}
|
||||
return nukeFx;
|
||||
}
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import { AnimatedSprite } from "../AnimatedSprite";
|
||||
import { createAnimatedSpriteForUnit } from "../AnimatedSpriteLoader";
|
||||
import { Fx, FxType } from "./Fx";
|
||||
|
||||
/**
|
||||
* Explosion effect: sprite animation of an explosion
|
||||
*/
|
||||
export class SAMExplosionFx implements Fx {
|
||||
private lifeTime: number = 0;
|
||||
private explosionSprite: AnimatedSprite | null;
|
||||
constructor(
|
||||
private x: number,
|
||||
private y: number,
|
||||
private duration: number,
|
||||
) {
|
||||
this.explosionSprite = createAnimatedSpriteForUnit(FxType.SAMExplosion);
|
||||
}
|
||||
|
||||
renderTick(frameTime: number, ctx: CanvasRenderingContext2D): boolean {
|
||||
if (this.explosionSprite) {
|
||||
this.lifeTime += frameTime;
|
||||
if (this.lifeTime >= this.duration) {
|
||||
return false;
|
||||
}
|
||||
if (this.explosionSprite.isActive()) {
|
||||
this.explosionSprite.update(frameTime);
|
||||
this.explosionSprite.draw(ctx, this.x, this.y);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { Theme } from "../../../core/configuration/Config";
|
||||
import { consolex } from "../../../core/Consolex";
|
||||
import { PlayerView } from "../../../core/game/GameView";
|
||||
import { AnimatedSprite } from "../AnimatedSprite";
|
||||
import { AnimatedSpriteLoader } from "../AnimatedSpriteLoader";
|
||||
import { Fx, FxType } from "./Fx";
|
||||
|
||||
function fadeInOut(
|
||||
t: number,
|
||||
fadeIn: number = 0.3,
|
||||
fadeOut: number = 0.7,
|
||||
): number {
|
||||
if (t < fadeIn) {
|
||||
const f = t / fadeIn; // Map to [0, 1]
|
||||
return f * f;
|
||||
} else if (t < fadeOut) {
|
||||
return 1;
|
||||
} else {
|
||||
const f = (t - fadeOut) / (1 - fadeOut); // Map to [0, 1]
|
||||
return 1 - f * f;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Fade in/out another FX
|
||||
*/
|
||||
export class FadeFx implements Fx {
|
||||
constructor(
|
||||
private fxToFade: SpriteFx,
|
||||
private fadeIn: number,
|
||||
private fadeOut: number,
|
||||
) {}
|
||||
|
||||
renderTick(duration: number, ctx: CanvasRenderingContext2D): boolean {
|
||||
const t = this.fxToFade.getElapsedTime() / this.fxToFade.getDuration();
|
||||
ctx.save();
|
||||
ctx.globalAlpha = fadeInOut(t, this.fadeIn, this.fadeOut);
|
||||
const result = this.fxToFade.renderTick(duration, ctx);
|
||||
ctx.restore();
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Animated sprite. Can be colored if provided an owner/theme
|
||||
*/
|
||||
export class SpriteFx implements Fx {
|
||||
protected animatedSprite: AnimatedSprite | null;
|
||||
protected elapsedTime = 0;
|
||||
protected duration = 1000;
|
||||
constructor(
|
||||
animatedSpriteLoader: AnimatedSpriteLoader,
|
||||
protected x: number,
|
||||
protected y: number,
|
||||
fxType: FxType,
|
||||
duration?: number,
|
||||
private owner?: PlayerView,
|
||||
private theme?: Theme,
|
||||
) {
|
||||
this.animatedSprite = animatedSpriteLoader.createAnimatedSprite(
|
||||
fxType,
|
||||
owner,
|
||||
theme,
|
||||
);
|
||||
if (!this.animatedSprite) {
|
||||
consolex.error("Could not load animated sprite", fxType);
|
||||
} else {
|
||||
this.duration = duration ?? this.animatedSprite.lifeTime() ?? 1000;
|
||||
}
|
||||
}
|
||||
|
||||
renderTick(frameTime: number, ctx: CanvasRenderingContext2D): boolean {
|
||||
if (!this.animatedSprite) return false;
|
||||
|
||||
this.elapsedTime += frameTime;
|
||||
if (this.elapsedTime >= this.duration) return false;
|
||||
|
||||
if (!this.animatedSprite.isActive()) return false;
|
||||
|
||||
const t = this.elapsedTime / this.duration;
|
||||
this.animatedSprite.update(frameTime);
|
||||
this.animatedSprite.draw(ctx, this.x, this.y);
|
||||
return true;
|
||||
}
|
||||
|
||||
getElapsedTime(): number {
|
||||
return this.elapsedTime;
|
||||
}
|
||||
|
||||
getDuration(): number {
|
||||
return this.duration;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
type TimedTask = {
|
||||
delay: number;
|
||||
action: () => void;
|
||||
triggered: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Basic timeline to chain actions
|
||||
*/
|
||||
export class Timeline {
|
||||
private tasks: TimedTask[] = [];
|
||||
private timeElapsed = 0;
|
||||
|
||||
add(delay: number, action: () => void): Timeline {
|
||||
this.tasks.push({ delay, action, triggered: false });
|
||||
return this;
|
||||
}
|
||||
|
||||
update(dt: number) {
|
||||
this.timeElapsed += dt;
|
||||
|
||||
for (const task of this.tasks) {
|
||||
if (!task.triggered && this.timeElapsed >= task.delay) {
|
||||
task.action();
|
||||
task.triggered = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isComplete() {
|
||||
return this.tasks.every((t) => t.triggered);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { GameView } from "../../../core/game/GameView";
|
||||
import { AnimatedSpriteLoader } from "../AnimatedSpriteLoader";
|
||||
import { Fx, FxType } from "./Fx";
|
||||
import { SpriteFx } from "./SpriteFx";
|
||||
import { Timeline } from "./Timeline";
|
||||
|
||||
/**
|
||||
* Explosion Effect: a few timed explosions
|
||||
*/
|
||||
export class UnitExplosionFx implements Fx {
|
||||
private timeline = new Timeline();
|
||||
private explosions: Fx[] = [];
|
||||
|
||||
constructor(
|
||||
animatedSpriteLoader: AnimatedSpriteLoader,
|
||||
private x: number,
|
||||
private y: number,
|
||||
game: GameView,
|
||||
) {
|
||||
const config = [
|
||||
{ dx: 0, dy: 0, delay: 0, type: FxType.UnitExplosion },
|
||||
{ dx: 4, dy: -6, delay: 80, type: FxType.UnitExplosion },
|
||||
{ dx: -6, dy: 4, delay: 160, type: FxType.UnitExplosion },
|
||||
];
|
||||
for (const { dx, dy, delay, type } of config) {
|
||||
this.timeline.add(delay, () => {
|
||||
if (game.isValidCoord(x + dx, y + dy)) {
|
||||
this.explosions.push(
|
||||
new SpriteFx(animatedSpriteLoader, x + dx, y + dy, type),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
renderTick(frameTime: number, ctx: CanvasRenderingContext2D): boolean {
|
||||
this.timeline.update(frameTime);
|
||||
let allDone = true;
|
||||
for (const fx of this.explosions) {
|
||||
if (fx.renderTick(frameTime, ctx)) {
|
||||
allDone = false;
|
||||
}
|
||||
}
|
||||
|
||||
return !allDone || !this.timeline.isComplete();
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,11 @@ import { Layer } from "./Layer";
|
||||
import { GameView, PlayerView, UnitView } from "../../../core/game/GameView";
|
||||
import { onlyImages } from "../../../core/Util";
|
||||
import { renderTroops } from "../../Utils";
|
||||
import { GoToPlayerEvent, GoToUnitEvent } from "./Leaderboard";
|
||||
import {
|
||||
GoToPlayerEvent,
|
||||
GoToPositionEvent,
|
||||
GoToUnitEvent,
|
||||
} from "./Leaderboard";
|
||||
|
||||
import { translateText } from "../../Utils";
|
||||
|
||||
@@ -393,6 +397,10 @@ export class EventsDisplay extends LitElement implements Layer {
|
||||
this.eventBus.emit(new GoToPlayerEvent(attacker));
|
||||
}
|
||||
|
||||
emitGoToPositionEvent(x: number, y: number) {
|
||||
this.eventBus.emit(new GoToPositionEvent(x, y));
|
||||
}
|
||||
|
||||
emitGoToUnitEvent(unit: UnitView) {
|
||||
this.eventBus.emit(new GoToUnitEvent(unit));
|
||||
}
|
||||
@@ -476,6 +484,26 @@ export class EventsDisplay extends LitElement implements Layer {
|
||||
: event.description;
|
||||
}
|
||||
|
||||
private async attackWarningOnClick(attack: AttackUpdate) {
|
||||
const playerView = this.game.playerBySmallID(attack.attackerID);
|
||||
if (playerView !== undefined) {
|
||||
if (playerView instanceof PlayerView) {
|
||||
const averagePosition = await playerView.attackAveragePosition(
|
||||
attack.attackerID,
|
||||
attack.id,
|
||||
);
|
||||
|
||||
if (averagePosition === null) {
|
||||
this.emitGoToPlayerEvent(attack.attackerID);
|
||||
} else {
|
||||
this.emitGoToPositionEvent(averagePosition.x, averagePosition.y);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.emitGoToPlayerEvent(attack.attackerID);
|
||||
}
|
||||
}
|
||||
|
||||
private renderIncomingAttacks() {
|
||||
return html`
|
||||
${this.incomingAttacks.length > 0
|
||||
@@ -487,10 +515,7 @@ export class EventsDisplay extends LitElement implements Layer {
|
||||
<button
|
||||
translate="no"
|
||||
class="ml-2"
|
||||
@click=${() => {
|
||||
attack.attackerID &&
|
||||
this.emitGoToPlayerEvent(attack.attackerID);
|
||||
}}
|
||||
@click=${() => this.attackWarningOnClick(attack)}
|
||||
>
|
||||
${renderTroops(attack.troops)}
|
||||
${(
|
||||
@@ -520,7 +545,7 @@ export class EventsDisplay extends LitElement implements Layer {
|
||||
<button
|
||||
translate="no"
|
||||
class="ml-2"
|
||||
@click=${() => this.emitGoToPlayerEvent(attack.targetID)}
|
||||
@click=${async () => this.attackWarningOnClick(attack)}
|
||||
>
|
||||
${renderTroops(attack.troops)}
|
||||
${(
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Theme } from "../../../core/configuration/Config";
|
||||
import { UnitType } from "../../../core/game/Game";
|
||||
import { GameUpdateType } from "../../../core/game/GameUpdates";
|
||||
import { GameView, UnitView } from "../../../core/game/GameView";
|
||||
import { loadAllAnimatedSpriteImages } from "../AnimatedSpriteLoader";
|
||||
import { Fx } from "../fx/Fx";
|
||||
import { NukeExplosionFx, ShockwaveFx } from "../fx/NukeFx";
|
||||
import { SAMExplosionFx } from "../fx/SAMExplosionFx";
|
||||
import { AnimatedSpriteLoader } from "../AnimatedSpriteLoader";
|
||||
import { Fx, FxType } from "../fx/Fx";
|
||||
import { nukeFxFactory, ShockwaveFx } from "../fx/NukeFx";
|
||||
import { SpriteFx } from "../fx/SpriteFx";
|
||||
import { UnitExplosionFx } from "../fx/UnitExplosionFx";
|
||||
import { Layer } from "./Layer";
|
||||
|
||||
export class FxLayer implements Layer {
|
||||
@@ -13,10 +15,15 @@ export class FxLayer implements Layer {
|
||||
|
||||
private lastRefresh: number = 0;
|
||||
private refreshRate: number = 10;
|
||||
private theme: Theme;
|
||||
private animatedSpriteLoader: AnimatedSpriteLoader =
|
||||
new AnimatedSpriteLoader();
|
||||
|
||||
private allFx: Fx[] = [];
|
||||
|
||||
constructor(private game: GameView) {}
|
||||
constructor(private game: GameView) {
|
||||
this.theme = this.game.config().theme();
|
||||
}
|
||||
|
||||
shouldTransform(): boolean {
|
||||
return true;
|
||||
@@ -36,47 +43,102 @@ export class FxLayer implements Layer {
|
||||
switch (unit.type()) {
|
||||
case UnitType.AtomBomb:
|
||||
case UnitType.MIRVWarhead:
|
||||
this.handleNukes(unit, 70);
|
||||
this.onNukeEvent(unit, 70);
|
||||
break;
|
||||
case UnitType.HydrogenBomb:
|
||||
this.handleNukes(unit, 250);
|
||||
this.onNukeEvent(unit, 160);
|
||||
break;
|
||||
case UnitType.Warship:
|
||||
this.onWarshipEvent(unit);
|
||||
break;
|
||||
case UnitType.Shell:
|
||||
this.onShellEvent(unit);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
handleNukes(unit: UnitView, shockwaveRadius: number) {
|
||||
onShellEvent(unit: UnitView) {
|
||||
if (!unit.isActive()) {
|
||||
if (unit.wasInterceptedBySAM()) {
|
||||
this.handleSAMInterception(unit);
|
||||
} else {
|
||||
// Kaboom
|
||||
this.handleNukeExplosion(unit, shockwaveRadius);
|
||||
if (unit.reachedTarget()) {
|
||||
const x = this.game.x(unit.lastTile());
|
||||
const y = this.game.y(unit.lastTile());
|
||||
const shipExplosion = new SpriteFx(
|
||||
this.animatedSpriteLoader,
|
||||
x,
|
||||
y,
|
||||
FxType.MiniExplosion,
|
||||
);
|
||||
this.allFx.push(shipExplosion);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleNukeExplosion(unit: UnitView, shockwaveRadius: number) {
|
||||
onWarshipEvent(unit: UnitView) {
|
||||
if (!unit.isActive()) {
|
||||
const x = this.game.x(unit.lastTile());
|
||||
const y = this.game.y(unit.lastTile());
|
||||
const shipExplosion = new UnitExplosionFx(
|
||||
this.animatedSpriteLoader,
|
||||
x,
|
||||
y,
|
||||
this.game,
|
||||
);
|
||||
this.allFx.push(shipExplosion);
|
||||
const sinkingShip = new SpriteFx(
|
||||
this.animatedSpriteLoader,
|
||||
x,
|
||||
y,
|
||||
FxType.SinkingShip,
|
||||
undefined,
|
||||
unit.owner(),
|
||||
this.theme,
|
||||
);
|
||||
this.allFx.push(sinkingShip);
|
||||
}
|
||||
}
|
||||
|
||||
onNukeEvent(unit: UnitView, radius: number) {
|
||||
if (!unit.isActive()) {
|
||||
if (!unit.reachedTarget()) {
|
||||
this.handleSAMInterception(unit);
|
||||
} else {
|
||||
// Kaboom
|
||||
this.handleNukeExplosion(unit, radius);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleNukeExplosion(unit: UnitView, radius: number) {
|
||||
const x = this.game.x(unit.lastTile());
|
||||
const y = this.game.y(unit.lastTile());
|
||||
const nuke = new NukeExplosionFx(x, y, 1000);
|
||||
this.allFx.push(nuke as Fx);
|
||||
const shockwave = new ShockwaveFx(x, y, 1500, shockwaveRadius);
|
||||
this.allFx.push(shockwave as Fx);
|
||||
const nukeFx = nukeFxFactory(
|
||||
this.animatedSpriteLoader,
|
||||
x,
|
||||
y,
|
||||
radius,
|
||||
this.game,
|
||||
);
|
||||
this.allFx = this.allFx.concat(nukeFx);
|
||||
}
|
||||
|
||||
handleSAMInterception(unit: UnitView) {
|
||||
const x = this.game.x(unit.lastTile());
|
||||
const y = this.game.y(unit.lastTile());
|
||||
const interception = new SAMExplosionFx(x, y, 1000);
|
||||
this.allFx.push(interception as Fx);
|
||||
const explosion = new SpriteFx(
|
||||
this.animatedSpriteLoader,
|
||||
x,
|
||||
y,
|
||||
FxType.SAMExplosion,
|
||||
);
|
||||
this.allFx.push(explosion);
|
||||
const shockwave = new ShockwaveFx(x, y, 800, 40);
|
||||
this.allFx.push(shockwave as Fx);
|
||||
this.allFx.push(shockwave);
|
||||
}
|
||||
|
||||
async init() {
|
||||
this.redraw();
|
||||
try {
|
||||
await loadAllAnimatedSpriteImages();
|
||||
this.animatedSpriteLoader.loadAllAnimatedSpriteImages();
|
||||
console.log("FX sprites loaded successfully");
|
||||
} catch (err) {
|
||||
console.error("Failed to load FX sprites:", err);
|
||||
|
||||
@@ -22,6 +22,13 @@ export class GoToPlayerEvent implements GameEvent {
|
||||
constructor(public player: PlayerView) {}
|
||||
}
|
||||
|
||||
export class GoToPositionEvent implements GameEvent {
|
||||
constructor(
|
||||
public x: number,
|
||||
public y: number,
|
||||
) {}
|
||||
}
|
||||
|
||||
export class GoToUnitEvent implements GameEvent {
|
||||
constructor(public unit: UnitView) {}
|
||||
}
|
||||
|
||||
@@ -308,6 +308,16 @@ export class PlayerPanel extends LitElement implements Layer {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Betrayals -->
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="text-white text-opacity-80 text-sm px-2">
|
||||
${translateText("player_panel.betrayals")}
|
||||
</div>
|
||||
<div class="bg-opacity-50 bg-gray-700 rounded p-2 text-white">
|
||||
${other.data.betrayals ?? 0}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Embargo -->
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="text-white text-opacity-80 text-sm px-2">
|
||||
|
||||
@@ -63,7 +63,7 @@ export class SpawnTimer implements Layer {
|
||||
let x = 0;
|
||||
let filledRatio = 0;
|
||||
for (let i = 0; i < this.ratios.length && i < this.colors.length; i++) {
|
||||
const ratio = this.ratios[i];
|
||||
const ratio = this.ratios[i] ?? 1 - filledRatio;
|
||||
const segmentWidth = barWidth * ratio;
|
||||
|
||||
context.fillStyle = this.colors[i];
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { colord, Colord } from "colord";
|
||||
import { Theme } from "../../../core/configuration/Config";
|
||||
import { EventBus } from "../../../core/EventBus";
|
||||
import { MouseUpEvent } from "../../InputHandler";
|
||||
import { TransformHandler } from "../TransformHandler";
|
||||
import { Layer } from "./Layer";
|
||||
import { UnitInfoModal } from "./UnitInfoModal";
|
||||
|
||||
import cityIcon from "../../../../resources/images/buildings/cityAlt1.png";
|
||||
import shieldIcon from "../../../../resources/images/buildings/fortAlt2.png";
|
||||
@@ -22,6 +25,7 @@ import { GameView, UnitView } from "../../../core/game/GameView";
|
||||
|
||||
const underConstructionColor = colord({ r: 150, g: 150, b: 150 });
|
||||
const reloadingColor = colord({ r: 255, g: 0, b: 0 });
|
||||
const selectedUnitColor = colord({ r: 0, g: 255, b: 255 });
|
||||
|
||||
type DistanceFunction = typeof euclDistFN;
|
||||
|
||||
@@ -44,6 +48,8 @@ export class StructureLayer implements Layer {
|
||||
private context: CanvasRenderingContext2D;
|
||||
private unitIcons: Map<string, ImageData> = new Map();
|
||||
private theme: Theme;
|
||||
private selectedStructureUnit: UnitView | null = null;
|
||||
private previouslySelected: UnitView | null = null;
|
||||
|
||||
// Configuration for supported unit types only
|
||||
private readonly unitConfigs: Partial<Record<UnitType, UnitRenderConfig>> = {
|
||||
@@ -82,7 +88,15 @@ export class StructureLayer implements Layer {
|
||||
constructor(
|
||||
private game: GameView,
|
||||
private eventBus: EventBus,
|
||||
private transformHandler: TransformHandler,
|
||||
private unitInfoModal: UnitInfoModal | null,
|
||||
) {
|
||||
if (!unitInfoModal) {
|
||||
throw new Error(
|
||||
"UnitInfoModal instance must be provided to StructureLayer.",
|
||||
);
|
||||
}
|
||||
this.unitInfoModal = unitInfoModal;
|
||||
this.theme = game.config().theme();
|
||||
this.loadIconData();
|
||||
this.loadIcon("reloadingSam", {
|
||||
@@ -147,6 +161,7 @@ export class StructureLayer implements Layer {
|
||||
|
||||
init() {
|
||||
this.redraw();
|
||||
this.eventBus.on(MouseUpEvent, (e) => this.onMouseUp(e));
|
||||
}
|
||||
|
||||
redraw() {
|
||||
@@ -265,6 +280,10 @@ export class StructureLayer implements Layer {
|
||||
borderColor = underConstructionColor;
|
||||
}
|
||||
|
||||
if (this.selectedStructureUnit === unit) {
|
||||
borderColor = selectedUnitColor;
|
||||
}
|
||||
|
||||
this.drawBorder(unit, borderColor, config, drawFunction);
|
||||
|
||||
const startX = this.game.x(unit.tile()) - Math.floor(icon.width / 2);
|
||||
@@ -316,4 +335,80 @@ export class StructureLayer implements Layer {
|
||||
clearCell(cell: Cell) {
|
||||
this.context.clearRect(cell.x, cell.y, 1, 1);
|
||||
}
|
||||
|
||||
private findStructureUnitAtCell(
|
||||
cell: { x: number; y: number },
|
||||
maxDistance: number = 10,
|
||||
): UnitView | null {
|
||||
const targetRef = this.game.ref(cell.x, cell.y);
|
||||
|
||||
const allUnitTypes = Object.values(UnitType);
|
||||
|
||||
const nearby = this.game.nearbyUnits(targetRef, maxDistance, allUnitTypes);
|
||||
|
||||
for (const { unit } of nearby) {
|
||||
if (unit.isActive() && this.isUnitTypeSupported(unit.type())) {
|
||||
return unit;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private onMouseUp(event: MouseUpEvent) {
|
||||
const cell = this.transformHandler.screenToWorldCoordinates(
|
||||
event.x,
|
||||
event.y,
|
||||
);
|
||||
if (!this.game.isValidCoord(cell.x, cell.y)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const clickedUnit = this.findStructureUnitAtCell(cell);
|
||||
this.previouslySelected = this.selectedStructureUnit;
|
||||
|
||||
if (clickedUnit) {
|
||||
const wasSelected = this.previouslySelected === clickedUnit;
|
||||
if (wasSelected) {
|
||||
this.selectedStructureUnit = null;
|
||||
if (this.previouslySelected) {
|
||||
this.handleUnitRendering(this.previouslySelected);
|
||||
}
|
||||
this.unitInfoModal?.onCloseStructureModal();
|
||||
} else {
|
||||
this.selectedStructureUnit = clickedUnit;
|
||||
if (
|
||||
this.previouslySelected &&
|
||||
this.previouslySelected !== clickedUnit
|
||||
) {
|
||||
this.handleUnitRendering(this.previouslySelected);
|
||||
}
|
||||
this.handleUnitRendering(clickedUnit);
|
||||
|
||||
const screenPos = this.transformHandler.worldToScreenCoordinates(cell);
|
||||
const unitTile = clickedUnit.tile();
|
||||
this.unitInfoModal?.onOpenStructureModal({
|
||||
unit: clickedUnit,
|
||||
x: screenPos.x,
|
||||
y: screenPos.y,
|
||||
tileX: this.game.x(unitTile),
|
||||
tileY: this.game.y(unitTile),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
this.selectedStructureUnit = null;
|
||||
if (this.previouslySelected) {
|
||||
this.handleUnitRendering(this.previouslySelected);
|
||||
}
|
||||
this.unitInfoModal?.onCloseStructureModal();
|
||||
}
|
||||
}
|
||||
|
||||
public unSelectStructureUnit() {
|
||||
if (this.selectedStructureUnit) {
|
||||
this.previouslySelected = this.selectedStructureUnit;
|
||||
this.selectedStructureUnit = null;
|
||||
this.handleUnitRendering(this.previouslySelected);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { LitElement, css, html } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { UnitType } from "../../../core/game/Game";
|
||||
import { GameView, UnitView } from "../../../core/game/GameView";
|
||||
import { Layer } from "./Layer";
|
||||
import { StructureLayer } from "./StructureLayer";
|
||||
|
||||
@customElement("unit-info-modal")
|
||||
export class UnitInfoModal extends LitElement implements Layer {
|
||||
@property({ type: Boolean }) open = false;
|
||||
@property({ type: Number }) x = 0;
|
||||
@property({ type: Number }) y = 0;
|
||||
@property({ type: Object }) unit: UnitView | null = null;
|
||||
|
||||
public game: GameView;
|
||||
public structureLayer: StructureLayer | null = null;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
init() {}
|
||||
|
||||
tick() {
|
||||
if (this.unit) {
|
||||
this.requestUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
public onOpenStructureModal = ({
|
||||
unit,
|
||||
x,
|
||||
y,
|
||||
tileX,
|
||||
tileY,
|
||||
}: {
|
||||
unit: UnitView;
|
||||
x: number;
|
||||
y: number;
|
||||
tileX: number;
|
||||
tileY: number;
|
||||
}) => {
|
||||
if (!this.game) return;
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
const targetRef = this.game.ref(tileX, tileY);
|
||||
|
||||
const allUnitTypes = Object.values(UnitType);
|
||||
const matchingUnits = this.game
|
||||
.nearbyUnits(targetRef, 10, allUnitTypes)
|
||||
.filter(({ unit }) => unit.isActive());
|
||||
|
||||
if (matchingUnits.length > 0) {
|
||||
matchingUnits.sort((a, b) => a.distSquared - b.distSquared);
|
||||
this.unit = matchingUnits[0].unit;
|
||||
} else {
|
||||
this.unit = null;
|
||||
}
|
||||
this.open = this.unit !== null;
|
||||
};
|
||||
|
||||
public onCloseStructureModal = () => {
|
||||
this.open = false;
|
||||
this.unit = null;
|
||||
};
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
position: fixed;
|
||||
pointer-events: none;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal {
|
||||
pointer-events: auto;
|
||||
background: rgba(30, 30, 30, 0.95);
|
||||
color: #f8f8f8;
|
||||
border: 1px solid #555;
|
||||
padding: 12px 18px;
|
||||
border-radius: 8px;
|
||||
min-width: 220px;
|
||||
max-width: 300px;
|
||||
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.5);
|
||||
font-family: "Segoe UI", sans-serif;
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
backdrop-filter: blur(6px);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.modal strong {
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.close-button {
|
||||
background: #d00;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
padding: 6px 12px;
|
||||
}
|
||||
|
||||
.close-button:hover {
|
||||
background: #a00;
|
||||
}
|
||||
`;
|
||||
|
||||
render() {
|
||||
if (!this.unit) return null;
|
||||
|
||||
const cooldown = this.unit.ticksLeftInCooldown() ?? 0;
|
||||
const secondsLeft = Math.ceil(cooldown / 10);
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="modal"
|
||||
style="display: ${this.open ? "block" : "none"}; left: ${this
|
||||
.x}px; top: ${this.y}px; position: absolute;"
|
||||
>
|
||||
<div style="margin-bottom: 8px; font-size: 16px; font-weight: bold;">
|
||||
Structure Info
|
||||
</div>
|
||||
<div style="margin-bottom: 4px;">
|
||||
<strong>Type:</strong> ${this.unit.type?.() ?? "Unknown"}
|
||||
</div>
|
||||
${secondsLeft > 0
|
||||
? html`<div style="margin-bottom: 4px;">
|
||||
<strong>Cooldown:</strong> ${secondsLeft}s
|
||||
</div>`
|
||||
: ""}
|
||||
<div style="margin-top: 14px; display: flex; justify-content: center;">
|
||||
<button
|
||||
@click=${() => {
|
||||
this.onCloseStructureModal();
|
||||
if (this.structureLayer) {
|
||||
this.structureLayer.unSelectStructureUnit();
|
||||
}
|
||||
}}
|
||||
class="close-button"
|
||||
title="Close"
|
||||
style="width: 100px; height: 32px;"
|
||||
>
|
||||
CLOSE
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,8 @@ import { LitElement, css, html } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import { translateText } from "../../../client/Utils";
|
||||
import { EventBus } from "../../../core/EventBus";
|
||||
import { Team } from "../../../core/game/Game";
|
||||
import { GameUpdateType } from "../../../core/game/GameUpdates";
|
||||
import { GameView, PlayerView } from "../../../core/game/GameView";
|
||||
import { GameView } from "../../../core/game/GameView";
|
||||
import { SendWinnerEvent } from "../../Transport";
|
||||
import { Layer } from "./Layer";
|
||||
|
||||
@@ -185,32 +184,32 @@ export class WinModal extends LitElement implements Layer {
|
||||
const updates = this.game.updatesSinceLastTick();
|
||||
const winUpdates = updates !== null ? updates[GameUpdateType.Win] : [];
|
||||
winUpdates.forEach((wu) => {
|
||||
if (wu.winnerType === "team") {
|
||||
this.eventBus.emit(
|
||||
new SendWinnerEvent(wu.winner as Team, wu.allPlayersStats, "team"),
|
||||
);
|
||||
if (wu.winner === this.game.myPlayer()?.team()) {
|
||||
if (wu.winner[0] === "team") {
|
||||
this.eventBus.emit(new SendWinnerEvent(wu.winner, wu.allPlayersStats));
|
||||
if (wu.winner[1] === this.game.myPlayer()?.team()) {
|
||||
this._title = translateText("win_modal.your_team");
|
||||
} else {
|
||||
this._title = translateText("win_modal.other_team", {
|
||||
team: wu.winner,
|
||||
team: wu.winner[1],
|
||||
});
|
||||
}
|
||||
this.show();
|
||||
} else {
|
||||
const winner = this.game.playerBySmallID(
|
||||
wu.winner as number,
|
||||
) as PlayerView;
|
||||
const winner = this.game.playerBySmallID(wu.winner[1]);
|
||||
if (!winner.isPlayer()) return;
|
||||
const winnerClient = winner.clientID();
|
||||
if (winnerClient !== null) {
|
||||
this.eventBus.emit(
|
||||
new SendWinnerEvent(winnerClient, wu.allPlayersStats, "player"),
|
||||
new SendWinnerEvent(["player", winnerClient], wu.allPlayersStats),
|
||||
);
|
||||
}
|
||||
if (winner === this.game.myPlayer()) {
|
||||
if (
|
||||
winnerClient !== null &&
|
||||
winnerClient === this.game.myPlayer()?.clientID()
|
||||
) {
|
||||
this._title = translateText("win_modal.you_won");
|
||||
} else {
|
||||
this._title = translateText("win_modal.you_won", {
|
||||
this._title = translateText("win_modal.other_won", {
|
||||
player: winner.name(),
|
||||
});
|
||||
}
|
||||
|
||||
+13
-43
@@ -135,25 +135,6 @@
|
||||
|
||||
gtag("config", "G-WQGQQ8RDN4");
|
||||
</script>
|
||||
<!-- AdinPlay Ads -->
|
||||
<script>
|
||||
var aiptag = aiptag || {};
|
||||
aiptag.cmd = aiptag.cmd || [];
|
||||
aiptag.cmd.display = aiptag.cmd.display || [];
|
||||
aiptag.cmd.player = aiptag.cmd.player || [];
|
||||
|
||||
//CMP tool settings
|
||||
aiptag.cmp = {
|
||||
show: true,
|
||||
button: true,
|
||||
buttonText: "Privacy settings",
|
||||
buttonPosition: "top-right", //bottom-left, bottom-right, top-left, top-right
|
||||
};
|
||||
</script>
|
||||
<script
|
||||
async
|
||||
src="//api.adinplay.com/libs/aiptag/pub/OFI/openfront.io/tag.min.js"
|
||||
></script>
|
||||
</head>
|
||||
|
||||
<body
|
||||
@@ -222,26 +203,6 @@
|
||||
</header>
|
||||
<div class="bg-image"></div>
|
||||
|
||||
<!-- Left gutter ad placement - full height, no empty space -->
|
||||
<div class="left-gutter-ad ad">
|
||||
<div id="openfront-io_300x600">
|
||||
<script type="text/javascript">
|
||||
aiptag.cmd.display.push(function () {
|
||||
aipDisplayTag.display("openfront-io_300x600");
|
||||
});
|
||||
</script>
|
||||
</div>
|
||||
</div>
|
||||
<div class="right-gutter-ad ad">
|
||||
<div id="openfront-io_300x600_2">
|
||||
<script type="text/javascript">
|
||||
aiptag.cmd.display.push(function () {
|
||||
aipDisplayTag.display("openfront-io_300x600_2");
|
||||
});
|
||||
</script>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main container with responsive padding -->
|
||||
<main class="flex justify-center flex-grow">
|
||||
<div class="container pt-12">
|
||||
@@ -269,12 +230,12 @@
|
||||
title="Pick a pattern!"
|
||||
></button>
|
||||
</territory-patterns-modal>
|
||||
<username-input class="w-full"></username-input>
|
||||
<news-button class="mt-3"></news-button>
|
||||
<username-input class="relative w-full"></username-input>
|
||||
<news-button class="w-[20%] md:w-[15%]"></news-button>
|
||||
</div>
|
||||
<div></div>
|
||||
<div>
|
||||
<public-lobby class="w-full"></public-lobby>
|
||||
<public-lobby class="block"></public-lobby>
|
||||
</div>
|
||||
<div class="container__row container__row--equal">
|
||||
<o-button
|
||||
@@ -396,10 +357,17 @@
|
||||
<div class="l-footer__col t-text-white">
|
||||
<a
|
||||
href="https://github.com/openfrontio/OpenFrontIO"
|
||||
class="t-link"
|
||||
class="t-link inline-flex items-center space-x-2"
|
||||
target="_blank"
|
||||
>
|
||||
©2025 OpenFront™
|
||||
<img
|
||||
src="../../resources/icons/github-mark-white.svg"
|
||||
alt="GitHub"
|
||||
width="20"
|
||||
height="20"
|
||||
class="ml-2 mr-4"
|
||||
/>
|
||||
</a>
|
||||
<a href="/privacy-policy.html" class="t-link" target="_blank">
|
||||
Privacy Policy
|
||||
@@ -427,7 +395,9 @@
|
||||
<chat-modal></chat-modal>
|
||||
<user-setting></user-setting>
|
||||
<multi-tab-modal></multi-tab-modal>
|
||||
<unit-info-modal></unit-info-modal>
|
||||
<news-modal></news-modal>
|
||||
<left-in-game-ad></left-in-game-ad>
|
||||
<div
|
||||
id="language-modal"
|
||||
class="fixed inset-0 bg-black bg-opacity-50 z-50 hidden flex justify-center items-center"
|
||||
|
||||
+10
-4
@@ -65,14 +65,17 @@ export async function logOut(allSessions: boolean = false) {
|
||||
return true;
|
||||
}
|
||||
|
||||
let __isLoggedIn: TokenPayload | false | undefined = undefined;
|
||||
export function isLoggedIn(): TokenPayload | false {
|
||||
export type IsLoggedInResponse =
|
||||
| { token: string; claims: TokenPayload }
|
||||
| false;
|
||||
let __isLoggedIn: IsLoggedInResponse | undefined = undefined;
|
||||
export function isLoggedIn(): IsLoggedInResponse {
|
||||
if (__isLoggedIn === undefined) {
|
||||
__isLoggedIn = _isLoggedIn();
|
||||
}
|
||||
return __isLoggedIn;
|
||||
}
|
||||
export function _isLoggedIn(): TokenPayload | false {
|
||||
function _isLoggedIn(): IsLoggedInResponse {
|
||||
try {
|
||||
const token = getToken();
|
||||
if (!token) {
|
||||
@@ -144,7 +147,8 @@ export function _isLoggedIn(): TokenPayload | false {
|
||||
return false;
|
||||
}
|
||||
|
||||
return result.data;
|
||||
const claims = result.data;
|
||||
return { token, claims };
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
return false;
|
||||
@@ -177,6 +181,7 @@ export async function postRefresh(): Promise<boolean> {
|
||||
localStorage.setItem("token", result.data.token);
|
||||
return true;
|
||||
} catch (e) {
|
||||
__isLoggedIn = false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -205,6 +210,7 @@ export async function getUserMe(): Promise<UserMeResponse | false> {
|
||||
}
|
||||
return result.data;
|
||||
} catch (e) {
|
||||
__isLoggedIn = false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -453,6 +453,56 @@ label.option-card:hover {
|
||||
max-width: 100vw;
|
||||
width: 100%;
|
||||
}
|
||||
#error-modal {
|
||||
max-width: 575px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 480px) {
|
||||
#error-modal {
|
||||
max-width: 350px;
|
||||
}
|
||||
}
|
||||
|
||||
#error-modal {
|
||||
position: fixed;
|
||||
padding: 20px;
|
||||
background: white;
|
||||
border: 1px solid black;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 9999;
|
||||
width: 87%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#error-modal pre {
|
||||
overflow-x: auto;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
#error-modal button.copy-btn {
|
||||
padding: 8px 16px;
|
||||
margin-top: 10px;
|
||||
background: #4caf50;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#error-modal button.close-btn {
|
||||
color: white;
|
||||
top: 0px;
|
||||
right: 0px;
|
||||
cursor: pointer;
|
||||
background: red;
|
||||
margin-right: 0px;
|
||||
position: fixed;
|
||||
width: 40px;
|
||||
}
|
||||
|
||||
.start-game-button-container {
|
||||
|
||||
@@ -14,13 +14,13 @@ import gatewayToTheAtlantic from "../../../resources/maps/GatewayToTheAtlanticTh
|
||||
import halkidiki from "../../../resources/maps/HalkidikiThumb.webp";
|
||||
import iceland from "../../../resources/maps/IcelandThumb.webp";
|
||||
import japan from "../../../resources/maps/JapanThumb.webp";
|
||||
import knownworld from "../../../resources/maps/KnownWorldThumb.webp";
|
||||
import mars from "../../../resources/maps/MarsThumb.webp";
|
||||
import mena from "../../../resources/maps/MenaThumb.webp";
|
||||
import northAmerica from "../../../resources/maps/NorthAmericaThumb.webp";
|
||||
import oceania from "../../../resources/maps/OceaniaThumb.webp";
|
||||
import pangaea from "../../../resources/maps/PangaeaThumb.webp";
|
||||
import southAmerica from "../../../resources/maps/SouthAmericaThumb.webp";
|
||||
import worldmapgiant from "../../../resources/maps/WorldMapGiantThumb.webp";
|
||||
import world from "../../../resources/maps/WorldMapThumb.webp";
|
||||
|
||||
import { GameMapType } from "../../core/game/Game";
|
||||
@@ -29,6 +29,8 @@ export function getMapsImage(map: GameMapType): string {
|
||||
switch (map) {
|
||||
case GameMapType.World:
|
||||
return world;
|
||||
case GameMapType.WorldMapGiant:
|
||||
return worldmapgiant;
|
||||
case GameMapType.Oceania:
|
||||
return oceania;
|
||||
case GameMapType.Europe:
|
||||
@@ -63,8 +65,6 @@ export function getMapsImage(map: GameMapType): string {
|
||||
return japan;
|
||||
case GameMapType.BetweenTwoSeas:
|
||||
return betweenTwoSeas;
|
||||
case GameMapType.KnownWorld:
|
||||
return knownworld;
|
||||
case GameMapType.FaroeIslands:
|
||||
return faroeislands;
|
||||
case GameMapType.DeglaciatedAntarctica:
|
||||
|
||||
+22
-1
@@ -49,7 +49,7 @@ export async function createGameRunner(
|
||||
: fixProfaneUsername(sanitize(p.username)),
|
||||
PlayerType.Human,
|
||||
p.clientID,
|
||||
p.playerID,
|
||||
random.nextID(),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -227,6 +227,27 @@ export class GameRunner {
|
||||
borderTiles: player.borderTiles(),
|
||||
} as PlayerBorderTiles;
|
||||
}
|
||||
|
||||
public attackAveragePosition(
|
||||
playerID: number,
|
||||
attackID: string,
|
||||
): Cell | null {
|
||||
const player = this.game.playerBySmallID(playerID);
|
||||
if (!player.isPlayer()) {
|
||||
throw new Error(`player with id ${playerID} not found`);
|
||||
}
|
||||
|
||||
const condition = (a) => a.id() === attackID;
|
||||
const attack =
|
||||
player.outgoingAttacks().find(condition) ??
|
||||
player.incomingAttacks().find(condition);
|
||||
if (attack === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return attack.averagePosition();
|
||||
}
|
||||
|
||||
public bestTransportShipSpawn(
|
||||
playerID: PlayerID,
|
||||
targetTile: TileRef,
|
||||
|
||||
+19
-29
@@ -136,33 +136,18 @@ const SafeString = z
|
||||
)
|
||||
.max(1000);
|
||||
|
||||
const jwtRegex = /^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]*$/;
|
||||
// Copied from zod, modified to remove their erroneous `typ` header requirement
|
||||
function isValidJWT(jwt: string, alg?: string): boolean {
|
||||
if (!jwtRegex.test(jwt)) return false;
|
||||
try {
|
||||
const [header] = jwt.split(".");
|
||||
// Convert base64url to base64
|
||||
const base64 = header
|
||||
.replace(/-/g, "+")
|
||||
.replace(/_/g, "/")
|
||||
.padEnd(header.length + ((4 - (header.length % 4)) % 4), "=");
|
||||
const decoded = JSON.parse(atob(base64));
|
||||
if (typeof decoded !== "object" || decoded === null) return false;
|
||||
if (!decoded.alg) return false;
|
||||
if (alg && decoded.alg !== alg) return false;
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const PersistentIdSchema = z.string().uuid();
|
||||
const JwtTokenSchema = z.string().jwt();
|
||||
const TokenSchema = z
|
||||
.string()
|
||||
.refine((v) => PersistentIdSchema.safeParse(v).success || isValidJWT(v), {
|
||||
message: "Token must be a valid UUID or JWT",
|
||||
});
|
||||
.refine(
|
||||
(v) =>
|
||||
PersistentIdSchema.safeParse(v).success ||
|
||||
JwtTokenSchema.safeParse(v).success,
|
||||
{
|
||||
message: "Token must be a valid UUID or JWT",
|
||||
},
|
||||
);
|
||||
|
||||
const EmojiSchema = z
|
||||
.number()
|
||||
@@ -354,7 +339,6 @@ export const ServerPrestartMessageSchema = ServerBaseMessageSchema.extend({
|
||||
});
|
||||
|
||||
export const PlayerSchema = z.object({
|
||||
playerID: ID,
|
||||
clientID: ID,
|
||||
username: SafeString,
|
||||
flag: SafeString.optional(),
|
||||
@@ -393,11 +377,18 @@ export const ServerMessageSchema = z.union([
|
||||
|
||||
// Client
|
||||
|
||||
export const WinnerSchema = z
|
||||
.union([
|
||||
z.tuple([z.literal("player"), ID]),
|
||||
z.tuple([z.literal("team"), SafeString]),
|
||||
])
|
||||
.optional();
|
||||
export type Winner = z.infer<typeof WinnerSchema>;
|
||||
|
||||
export const ClientSendWinnerSchema = z.object({
|
||||
type: z.literal("winner"),
|
||||
winner: z.union([ID, TeamSchema]).nullable(),
|
||||
winner: WinnerSchema,
|
||||
allPlayersStats: AllPlayersStatsSchema,
|
||||
winnerType: z.enum(["player", "team"]),
|
||||
});
|
||||
|
||||
export const ClientHashSchema = z.object({
|
||||
@@ -454,8 +445,7 @@ export const GameEndInfoSchema = GameStartInfoSchema.extend({
|
||||
end: z.number(),
|
||||
duration: z.number().nonnegative(),
|
||||
num_turns: z.number(),
|
||||
winner: z.union([ID, SafeString]).nullable().optional(),
|
||||
winnerType: z.enum(["player", "team"]).nullable().optional(),
|
||||
winner: WinnerSchema,
|
||||
});
|
||||
export type GameEndInfo = z.infer<typeof GameEndInfoSchema>;
|
||||
|
||||
|
||||
@@ -84,13 +84,19 @@ export const OTHER_INDEX_DESTROY = 1; // Structures and warships destroyed
|
||||
export const OTHER_INDEX_CAPTURE = 2; // Structures captured
|
||||
export const OTHER_INDEX_LOST = 3; // Structures/warships destroyed/captured by others
|
||||
|
||||
const AtLeastOneNumberSchema = z.number().array().min(1);
|
||||
const BigIntStringSchema = z.preprocess((val) => {
|
||||
if (typeof val === "string" && /^\d+$/.test(val)) return BigInt(val);
|
||||
if (typeof val === "bigint") return val;
|
||||
return val;
|
||||
}, z.bigint());
|
||||
|
||||
const AtLeastOneNumberSchema = BigIntStringSchema.array().min(1);
|
||||
export type AtLeastOneNumber = z.infer<typeof AtLeastOneNumberSchema>;
|
||||
|
||||
export const PlayerStatsSchema = z
|
||||
.object({
|
||||
attacks: AtLeastOneNumberSchema.optional(),
|
||||
betrayals: z.number().positive().optional(),
|
||||
betrayals: BigIntStringSchema.optional(),
|
||||
boats: z.record(BoatUnitSchema, AtLeastOneNumberSchema).optional(),
|
||||
bombs: z.record(BombUnitSchema, AtLeastOneNumberSchema).optional(),
|
||||
gold: AtLeastOneNumberSchema.optional(),
|
||||
|
||||
+10
-5
@@ -1,15 +1,15 @@
|
||||
import DOMPurify from "dompurify";
|
||||
import { customAlphabet } from "nanoid";
|
||||
import twemoji from "twemoji";
|
||||
import { Cell, Team, Unit } from "./game/Game";
|
||||
import { Cell, Unit } from "./game/Game";
|
||||
import { GameMap, TileRef } from "./game/GameMap";
|
||||
import {
|
||||
ClientID,
|
||||
GameConfig,
|
||||
GameID,
|
||||
GameRecord,
|
||||
PlayerRecord,
|
||||
Turn,
|
||||
Winner,
|
||||
} from "./Schemas";
|
||||
|
||||
import {
|
||||
@@ -191,8 +191,7 @@ export function createGameRecord(
|
||||
allTurns: Turn[],
|
||||
start: number,
|
||||
end: number,
|
||||
winner: ClientID | Team | null,
|
||||
winnerType: "player" | "team" | null,
|
||||
winner: Winner,
|
||||
): GameRecord {
|
||||
const duration = Math.floor((end - start) / 1000);
|
||||
const version = "v0.0.2";
|
||||
@@ -211,7 +210,6 @@ export function createGameRecord(
|
||||
duration,
|
||||
num_turns,
|
||||
winner,
|
||||
winnerType,
|
||||
},
|
||||
version,
|
||||
gitCommit,
|
||||
@@ -310,3 +308,10 @@ export const emojiTable: string[][] = [
|
||||
];
|
||||
// 2d to 1d array
|
||||
export const flattenedEmojiTable: string[] = emojiTable.flat();
|
||||
|
||||
/**
|
||||
* JSON.stringify replacer function that converts bigint values to strings.
|
||||
*/
|
||||
export function replacer(_key: string, value: any): any {
|
||||
return typeof value === "bigint" ? value.toString() : value;
|
||||
}
|
||||
|
||||
@@ -23,8 +23,8 @@ import { UserSettings } from "../game/UserSettings";
|
||||
import { GameConfig, GameID } from "../Schemas";
|
||||
import { assertNever, simpleHash, within } from "../Util";
|
||||
import { Config, GameEnv, NukeMagnitude, ServerConfig, Theme } from "./Config";
|
||||
import { pastelTheme } from "./PastelTheme";
|
||||
import { pastelThemeDark } from "./PastelThemeDark";
|
||||
import { PastelTheme } from "./PastelTheme";
|
||||
import { PastelThemeDark } from "./PastelThemeDark";
|
||||
|
||||
const JwksSchema = z.object({
|
||||
keys: z
|
||||
@@ -61,14 +61,14 @@ const numPlayersConfig = {
|
||||
[GameMapType.BlackSea]: [40, 50, 30],
|
||||
[GameMapType.Pangaea]: [40, 20, 30],
|
||||
[GameMapType.World]: [150, 80, 50],
|
||||
[GameMapType.KnownWorld]: [50, 40, 30],
|
||||
[GameMapType.WorldMapGiant]: [150, 100, 60],
|
||||
[GameMapType.Halkidiki]: [50, 40, 30],
|
||||
} as const satisfies Record<GameMapType, [number, number, number]>;
|
||||
|
||||
const TERRAIN_EFFECTS = {
|
||||
[TerrainType.Plains]: { mag: 0.85, speed: 0.8 },
|
||||
[TerrainType.Highland]: { mag: 1, speed: 1 },
|
||||
[TerrainType.Mountain]: { mag: 1.2, speed: 1.3 },
|
||||
[TerrainType.Plains]: { mag: 0.9, speed: 0.8 },
|
||||
[TerrainType.Highland]: { mag: 1.1, speed: 1 },
|
||||
[TerrainType.Mountain]: { mag: 1.3, speed: 1.25 },
|
||||
} as const;
|
||||
|
||||
export abstract class DefaultServerConfig implements ServerConfig {
|
||||
@@ -165,6 +165,8 @@ export abstract class DefaultServerConfig implements ServerConfig {
|
||||
}
|
||||
|
||||
export class DefaultConfig implements Config {
|
||||
private pastelTheme: PastelTheme = new PastelTheme();
|
||||
private pastelThemeDark: PastelThemeDark = new PastelThemeDark();
|
||||
constructor(
|
||||
private _serverConfig: ServerConfig,
|
||||
private _gameConfig: GameConfig,
|
||||
@@ -274,7 +276,7 @@ export class DefaultConfig implements Config {
|
||||
return 10000 + 150 * Math.pow(dist, 1.1);
|
||||
}
|
||||
tradeShipSpawnRate(numberOfPorts: number): number {
|
||||
return Math.round(10 * Math.pow(numberOfPorts, 0.6));
|
||||
return Math.round(10 * Math.pow(numberOfPorts, 0.5));
|
||||
}
|
||||
|
||||
unitInfo(type: UnitType): UnitInfo {
|
||||
@@ -460,7 +462,9 @@ export class DefaultConfig implements Config {
|
||||
return this.bots();
|
||||
}
|
||||
theme(): Theme {
|
||||
return this.userSettings()?.darkMode() ? pastelThemeDark : pastelTheme;
|
||||
return this.userSettings()?.darkMode()
|
||||
? this.pastelThemeDark
|
||||
: this.pastelTheme;
|
||||
}
|
||||
|
||||
attackLogic(
|
||||
@@ -643,7 +647,7 @@ export class DefaultConfig implements Config {
|
||||
// smaller countries recieve a boost to pop growth to speed up early game
|
||||
const baseAdditionRate = 10;
|
||||
const basePopGrowthRate = 1300 / max + 1 / 140;
|
||||
const reproductionPop = 0.8 * player.troops() + 1.2 * player.workers();
|
||||
const reproductionPop = 0.85 * player.troops() + 1.15 * player.workers();
|
||||
let toAdd = baseAdditionRate + basePopGrowthRate * reproductionPop;
|
||||
const totalPop = player.totalPopulation();
|
||||
const ratio = 1 - totalPop / max;
|
||||
|
||||
@@ -19,7 +19,10 @@ import {
|
||||
} from "./Colors";
|
||||
import { Theme } from "./Config";
|
||||
|
||||
export const pastelTheme = new (class implements Theme {
|
||||
type ColorCache = Map<string, Colord>;
|
||||
|
||||
export class PastelTheme implements Theme {
|
||||
private borderColorCache: ColorCache = new Map<string, Colord>();
|
||||
private rand = new PseudoRandom(123);
|
||||
|
||||
private background = colord({ r: 60, g: 60, b: 60 });
|
||||
@@ -69,19 +72,17 @@ export const pastelTheme = new (class implements Theme {
|
||||
if (team !== null) {
|
||||
return this.teamColor(team);
|
||||
}
|
||||
if (player.info().playerType === PlayerType.Human) {
|
||||
if (player.type() === PlayerType.Human) {
|
||||
return humanColors[simpleHash(player.id()) % humanColors.length];
|
||||
}
|
||||
if (player.info().playerType === PlayerType.Bot) {
|
||||
if (player.type() === PlayerType.Bot) {
|
||||
return botColors[simpleHash(player.id()) % botColors.length];
|
||||
}
|
||||
return territoryColors[simpleHash(player.id()) % territoryColors.length];
|
||||
}
|
||||
|
||||
textColor(player: PlayerView): string {
|
||||
return player.info().playerType === PlayerType.Human
|
||||
? "#000000"
|
||||
: "#4D4D4D";
|
||||
return player.type() === PlayerType.Human ? "#000000" : "#4D4D4D";
|
||||
}
|
||||
|
||||
specialBuildingColor(player: PlayerView): Colord {
|
||||
@@ -94,12 +95,18 @@ export const pastelTheme = new (class implements Theme {
|
||||
}
|
||||
|
||||
borderColor(player: PlayerView): Colord {
|
||||
if (this.borderColorCache.has(player.id())) {
|
||||
return this.borderColorCache.get(player.id())!;
|
||||
}
|
||||
const tc = this.territoryColor(player).rgba;
|
||||
return colord({
|
||||
const color = colord({
|
||||
r: Math.max(tc.r - 40, 0),
|
||||
g: Math.max(tc.g - 40, 0),
|
||||
b: Math.max(tc.b - 40, 0),
|
||||
});
|
||||
|
||||
this.borderColorCache.set(player.id(), color);
|
||||
return color;
|
||||
}
|
||||
|
||||
defendedBorderColors(player: PlayerView): { light: Colord; dark: Colord } {
|
||||
@@ -177,4 +184,4 @@ export const pastelTheme = new (class implements Theme {
|
||||
spawnHighlightColor(): Colord {
|
||||
return this._spawnHighlightColor;
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
@@ -19,7 +19,10 @@ import {
|
||||
} from "./Colors";
|
||||
import { Theme } from "./Config";
|
||||
|
||||
export const pastelThemeDark = new (class implements Theme {
|
||||
type ColorCache = Map<string, Colord>;
|
||||
|
||||
export class PastelThemeDark implements Theme {
|
||||
private borderColorCache: ColorCache = new Map<string, Colord>();
|
||||
private rand = new PseudoRandom(123);
|
||||
|
||||
private background = colord({ r: 0, g: 0, b: 0 });
|
||||
@@ -69,19 +72,17 @@ export const pastelThemeDark = new (class implements Theme {
|
||||
if (team !== null) {
|
||||
return this.teamColor(team);
|
||||
}
|
||||
if (player.info().playerType === PlayerType.Human) {
|
||||
if (player.type() === PlayerType.Human) {
|
||||
return humanColors[simpleHash(player.id()) % humanColors.length];
|
||||
}
|
||||
if (player.info().playerType === PlayerType.Bot) {
|
||||
if (player.type() === PlayerType.Bot) {
|
||||
return botColors[simpleHash(player.id()) % botColors.length];
|
||||
}
|
||||
return territoryColors[simpleHash(player.id()) % territoryColors.length];
|
||||
}
|
||||
|
||||
textColor(player: PlayerView): string {
|
||||
return player.info().playerType === PlayerType.Human
|
||||
? "#ffffff"
|
||||
: "#e6e6e6";
|
||||
return player.type() === PlayerType.Human ? "#ffffff" : "#e6e6e6";
|
||||
}
|
||||
|
||||
specialBuildingColor(player: PlayerView): Colord {
|
||||
@@ -94,12 +95,18 @@ export const pastelThemeDark = new (class implements Theme {
|
||||
}
|
||||
|
||||
borderColor(player: PlayerView): Colord {
|
||||
if (this.borderColorCache.has(player.id())) {
|
||||
return this.borderColorCache.get(player.id())!;
|
||||
}
|
||||
const tc = this.territoryColor(player).rgba;
|
||||
return colord({
|
||||
const color = colord({
|
||||
r: Math.max(tc.r - 40, 0),
|
||||
g: Math.max(tc.g - 40, 0),
|
||||
b: Math.max(tc.b - 40, 0),
|
||||
});
|
||||
|
||||
this.borderColorCache.set(player.id(), color);
|
||||
return color;
|
||||
}
|
||||
|
||||
defendedBorderColors(player: PlayerView): { light: Colord; dark: Colord } {
|
||||
@@ -179,4 +186,4 @@ export const pastelThemeDark = new (class implements Theme {
|
||||
spawnHighlightColor(): Colord {
|
||||
return this._spawnHighlightColor;
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
@@ -27,8 +27,6 @@ export class AttackExecution implements Execution {
|
||||
|
||||
private mg: Game;
|
||||
|
||||
private border = new Set<TileRef>();
|
||||
|
||||
private attack: Attack | null = null;
|
||||
|
||||
constructor(
|
||||
@@ -87,15 +85,23 @@ export class AttackExecution implements Execution {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
this.target.isPlayer() &&
|
||||
this.mg.config().numSpawnPhaseTurns() +
|
||||
this.mg.config().spawnImmunityDuration() >
|
||||
if (this.target.isPlayer()) {
|
||||
if (
|
||||
this.mg.config().numSpawnPhaseTurns() +
|
||||
this.mg.config().spawnImmunityDuration() >
|
||||
this.mg.ticks()
|
||||
) {
|
||||
console.warn("cannot attack player during immunity phase");
|
||||
this.active = false;
|
||||
return;
|
||||
) {
|
||||
console.warn("cannot attack player during immunity phase");
|
||||
this.active = false;
|
||||
return;
|
||||
}
|
||||
if (this._owner.isOnSameTeam(this.target)) {
|
||||
console.warn(
|
||||
`${this._owner.displayName()} cannot attack ${this.target.displayName()} because they are on the same team`,
|
||||
);
|
||||
this.active = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.startTroops === null) {
|
||||
@@ -111,8 +117,15 @@ export class AttackExecution implements Execution {
|
||||
this.target,
|
||||
this.startTroops,
|
||||
this.sourceTile,
|
||||
new Set<TileRef>(),
|
||||
);
|
||||
|
||||
if (this.sourceTile !== null) {
|
||||
this.addNeighbors(this.sourceTile);
|
||||
} else {
|
||||
this.refreshToConquer();
|
||||
}
|
||||
|
||||
// Record stats
|
||||
this.mg.stats().attack(this._owner, this.target, this.startTroops);
|
||||
|
||||
@@ -144,12 +157,6 @@ export class AttackExecution implements Execution {
|
||||
}
|
||||
}
|
||||
|
||||
if (this.sourceTile !== null) {
|
||||
this.addNeighbors(this.sourceTile);
|
||||
} else {
|
||||
this.refreshToConquer();
|
||||
}
|
||||
|
||||
if (this.target.isPlayer()) {
|
||||
if (this._owner.isAlliedWith(this.target)) {
|
||||
// No updates should happen in init.
|
||||
@@ -160,8 +167,12 @@ export class AttackExecution implements Execution {
|
||||
}
|
||||
|
||||
private refreshToConquer() {
|
||||
if (this.attack === null) {
|
||||
throw new Error("Attack not initialized");
|
||||
}
|
||||
|
||||
this.toConquer.clear();
|
||||
this.border.clear();
|
||||
this.attack.clearBorder();
|
||||
for (const tile of this._owner.borderTiles()) {
|
||||
this.addNeighbors(tile);
|
||||
}
|
||||
@@ -235,7 +246,7 @@ export class AttackExecution implements Execution {
|
||||
troopCount,
|
||||
this._owner,
|
||||
this.target,
|
||||
this.border.size + this.random.nextInt(0, 5),
|
||||
this.attack.borderSize() + this.random.nextInt(0, 5),
|
||||
);
|
||||
|
||||
while (numTilesPerTick > 0) {
|
||||
@@ -252,7 +263,7 @@ export class AttackExecution implements Execution {
|
||||
}
|
||||
|
||||
const [tileToConquer] = this.toConquer.dequeue();
|
||||
this.border.delete(tileToConquer);
|
||||
this.attack.removeBorderTile(tileToConquer);
|
||||
|
||||
let onBorder = false;
|
||||
for (const n of this.mg.neighbors(tileToConquer)) {
|
||||
@@ -286,6 +297,10 @@ export class AttackExecution implements Execution {
|
||||
}
|
||||
|
||||
private addNeighbors(tile: TileRef) {
|
||||
if (this.attack === null) {
|
||||
throw new Error("Attack not initialized");
|
||||
}
|
||||
|
||||
const tickNow = this.mg.ticks(); // cache tick
|
||||
|
||||
for (const neighbor of this.mg.neighbors(tile)) {
|
||||
@@ -295,7 +310,7 @@ export class AttackExecution implements Execution {
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
this.border.add(neighbor);
|
||||
this.attack.addBorderTile(neighbor);
|
||||
let numOwnedByMe = 0;
|
||||
for (const n of this.mg.neighbors(neighbor)) {
|
||||
if (this.mg.owner(n) === this._owner) {
|
||||
|
||||
@@ -104,7 +104,9 @@ export class ConstructionExecution implements Execution {
|
||||
this.mg.addExecution(new MirvExecution(player.id(), this.tile));
|
||||
break;
|
||||
case UnitType.Warship:
|
||||
this.mg.addExecution(new WarshipExecution(player.id(), this.tile));
|
||||
this.mg.addExecution(
|
||||
new WarshipExecution({ owner: player, patrolTile: this.tile }),
|
||||
);
|
||||
break;
|
||||
case UnitType.Port:
|
||||
this.mg.addExecution(new PortExecution(player.id(), this.tile));
|
||||
|
||||
@@ -41,7 +41,7 @@ export class EmojiExecution implements Execution {
|
||||
}
|
||||
|
||||
tick(ticks: number): void {
|
||||
const emojiString = flattenedEmojiTable.at(this.emoji);
|
||||
const emojiString = flattenedEmojiTable[this.emoji];
|
||||
if (emojiString === undefined) {
|
||||
consolex.warn(
|
||||
`cannot send emoji ${this.emoji} from ${this.requestor} to ${this.recipient}`,
|
||||
|
||||
@@ -63,7 +63,7 @@ export class Executor {
|
||||
case "cancel_boat":
|
||||
return new BoatRetreatExecution(playerID, intent.unitID);
|
||||
case "move_warship":
|
||||
return new MoveWarshipExecution(intent.unitId, intent.tile);
|
||||
return new MoveWarshipExecution(player, intent.unitId, intent.tile);
|
||||
case "spawn":
|
||||
return new SpawnExecution(
|
||||
player.info(),
|
||||
|
||||
@@ -53,7 +53,8 @@ export class MissileSiloExecution implements Execution {
|
||||
}
|
||||
}
|
||||
|
||||
if (this.silo.ticksLeftInCooldown() === 0) {
|
||||
const cooldown = this.silo.ticksLeftInCooldown();
|
||||
if (typeof cooldown === "number" && cooldown >= 0) {
|
||||
this.silo.touch();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,35 +1,33 @@
|
||||
import { Execution, Game } from "../game/Game";
|
||||
|
||||
const cancelDelay = 2;
|
||||
import { Execution, Game, Player, UnitType } from "../game/Game";
|
||||
import { TileRef } from "../game/GameMap";
|
||||
|
||||
export class MoveWarshipExecution implements Execution {
|
||||
private active = true;
|
||||
private mg: Game | null = null;
|
||||
|
||||
constructor(
|
||||
public readonly unitId: number,
|
||||
public readonly position: number,
|
||||
private readonly owner: Player,
|
||||
private readonly unitId: number,
|
||||
private readonly position: TileRef,
|
||||
) {}
|
||||
|
||||
init(mg: Game, ticks: number): void {
|
||||
this.mg = mg;
|
||||
}
|
||||
|
||||
tick(ticks: number): void {
|
||||
if (this.mg === null) {
|
||||
throw new Error("Not initialized");
|
||||
}
|
||||
const warship = this.mg.units().find((u) => u.id() === this.unitId);
|
||||
const warship = this.owner
|
||||
.units(UnitType.Warship)
|
||||
.find((u) => u.id() === this.unitId);
|
||||
if (!warship) {
|
||||
console.log("MoveWarshipExecution: warship is already dead");
|
||||
console.warn("MoveWarshipExecution: warship not found");
|
||||
return;
|
||||
}
|
||||
warship.setTargetTile(this.position);
|
||||
this.active = false;
|
||||
if (!warship.isActive()) {
|
||||
console.warn("MoveWarshipExecution: warship is not active");
|
||||
return;
|
||||
}
|
||||
warship.setPatrolTile(this.position);
|
||||
warship.setTargetTile(undefined);
|
||||
}
|
||||
|
||||
tick(ticks: number): void {}
|
||||
|
||||
isActive(): boolean {
|
||||
return this.active;
|
||||
return false;
|
||||
}
|
||||
|
||||
activeDuringSpawnPhase(): boolean {
|
||||
|
||||
@@ -119,7 +119,7 @@ export class NukeExecution implements Execution {
|
||||
this.type !== UnitType.MIRVWarhead,
|
||||
);
|
||||
this.nuke = this.player.buildUnit(this.type, spawn, {
|
||||
detonationDst: this.dst,
|
||||
targetTile: this.dst,
|
||||
});
|
||||
if (this.mg.hasOwner(this.dst)) {
|
||||
const target = this.mg.owner(this.dst);
|
||||
@@ -239,6 +239,7 @@ export class NukeExecution implements Execution {
|
||||
}
|
||||
}
|
||||
this.active = false;
|
||||
this.nuke.setReachedTarget();
|
||||
this.nuke.delete(false);
|
||||
|
||||
// Record stats
|
||||
|
||||
@@ -46,17 +46,12 @@ export class PlayerExecution implements Execution {
|
||||
throw new Error("Not initialized");
|
||||
}
|
||||
this.player.decayRelations();
|
||||
const hasPort = this.player.units(UnitType.Port).length > 0;
|
||||
this.player.units().forEach((u) => {
|
||||
if (hasPort && u.type() === UnitType.Warship) {
|
||||
u.modifyHealth(1);
|
||||
}
|
||||
if (this.mg === null) return;
|
||||
const tileOwner = this.mg.owner(u.tile());
|
||||
const tileOwner = this.mg!.owner(u.tile());
|
||||
if (u.info().territoryBound) {
|
||||
if (tileOwner.isPlayer()) {
|
||||
if (tileOwner !== this.player) {
|
||||
this.mg.player(tileOwner.id()).captureUnit(u);
|
||||
this.mg!.player(tileOwner.id()).captureUnit(u);
|
||||
}
|
||||
} else {
|
||||
u.delete();
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
UnitType,
|
||||
} from "../game/Game";
|
||||
import { TileRef } from "../game/GameMap";
|
||||
import { PathFinder } from "../pathfinding/PathFinding";
|
||||
import { PseudoRandom } from "../PseudoRandom";
|
||||
import { TradeShipExecution } from "./TradeShipExecution";
|
||||
|
||||
@@ -79,9 +78,8 @@ export class PortExecution implements Execution {
|
||||
}
|
||||
|
||||
const port = this.random.randElement(ports);
|
||||
const pf = PathFinder.Mini(this.mg, 2500);
|
||||
this.mg.addExecution(
|
||||
new TradeShipExecution(this.player().id(), this.port, port, pf),
|
||||
new TradeShipExecution(this.player().id(), this.port, port),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -149,8 +149,8 @@ export class SAMLauncherExecution implements Execution {
|
||||
target = this.getSingleTarget();
|
||||
}
|
||||
|
||||
if (this.sam.ticksLeftInCooldown() === 0) {
|
||||
// Touch SAM to update sprite to show not in cooldown.
|
||||
const cooldown = this.sam.ticksLeftInCooldown();
|
||||
if (typeof cooldown === "number" && cooldown >= 0) {
|
||||
this.sam.touch();
|
||||
}
|
||||
|
||||
@@ -181,7 +181,6 @@ export class SAMLauncherExecution implements Execution {
|
||||
);
|
||||
// Delete warheads
|
||||
mirvWarheadTargets.forEach((u) => {
|
||||
u.setInterceptedBySam();
|
||||
u.delete();
|
||||
});
|
||||
} else if (target !== null) {
|
||||
|
||||
@@ -66,7 +66,6 @@ export class SAMMissileExecution implements Execution {
|
||||
this._owner.id(),
|
||||
);
|
||||
this.active = false;
|
||||
this.target.setInterceptedBySam();
|
||||
this.target.delete(true, this._owner);
|
||||
this.SAMMissile.delete(false);
|
||||
|
||||
|
||||
@@ -52,6 +52,7 @@ export class ShellExecution implements Execution {
|
||||
if (result === true) {
|
||||
this.active = false;
|
||||
this.target.modifyHealth(-this.effectOnTarget(), this._owner);
|
||||
this.shell.setReachedTarget();
|
||||
this.shell.delete(false);
|
||||
return;
|
||||
} else {
|
||||
|
||||
@@ -16,30 +16,26 @@ import { distSortUnit } from "../Util";
|
||||
|
||||
export class TradeShipExecution implements Execution {
|
||||
private active = true;
|
||||
private mg: Game | null = null;
|
||||
private origOwner: Player | null = null;
|
||||
private tradeShip: Unit | null = null;
|
||||
private index = 0;
|
||||
private mg: Game;
|
||||
private origOwner: Player;
|
||||
private tradeShip: Unit | undefined;
|
||||
private wasCaptured = false;
|
||||
private tilesTraveled = 0;
|
||||
private pathFinder: PathFinder;
|
||||
|
||||
constructor(
|
||||
private _owner: PlayerID,
|
||||
private srcPort: Unit,
|
||||
private _dstPort: Unit,
|
||||
private pathFinder: PathFinder,
|
||||
) {}
|
||||
|
||||
init(mg: Game, ticks: number): void {
|
||||
this.mg = mg;
|
||||
this.origOwner = mg.player(this._owner);
|
||||
this.pathFinder = PathFinder.Mini(mg, 2500);
|
||||
}
|
||||
|
||||
tick(ticks: number): void {
|
||||
if (this.mg === null || this.origOwner === null) {
|
||||
throw new Error("Not initialized");
|
||||
}
|
||||
if (this.tradeShip === null) {
|
||||
if (this.tradeShip === undefined) {
|
||||
const spawn = this.origOwner.canBuild(
|
||||
UnitType.TradeShip,
|
||||
this.srcPort.tile(),
|
||||
@@ -50,12 +46,9 @@ export class TradeShipExecution implements Execution {
|
||||
return;
|
||||
}
|
||||
this.tradeShip = this.origOwner.buildUnit(UnitType.TradeShip, spawn, {
|
||||
dstPort: this._dstPort,
|
||||
targetUnit: this._dstPort,
|
||||
lastSetSafeFromPirates: ticks,
|
||||
});
|
||||
|
||||
// Record stats
|
||||
this.mg.stats().boatSendTrade(this.origOwner, this._dstPort.owner());
|
||||
}
|
||||
|
||||
if (!this.tradeShip.isActive()) {
|
||||
@@ -101,19 +94,6 @@ export class TradeShipExecution implements Execution {
|
||||
}
|
||||
}
|
||||
|
||||
const cachedNextTile = this._dstPort.cacheGet(this.tradeShip.tile());
|
||||
if (cachedNextTile !== undefined) {
|
||||
if (
|
||||
this.mg.isWater(cachedNextTile) &&
|
||||
this.mg.isShoreline(cachedNextTile)
|
||||
) {
|
||||
this.tradeShip.setSafeFromPirates();
|
||||
}
|
||||
this.tradeShip.move(cachedNextTile);
|
||||
this.tilesTraveled++;
|
||||
return;
|
||||
}
|
||||
|
||||
const result = this.pathFinder.nextTile(
|
||||
this.tradeShip.tile(),
|
||||
this._dstPort.tile(),
|
||||
@@ -125,16 +105,14 @@ export class TradeShipExecution implements Execution {
|
||||
break;
|
||||
case PathFindResultType.Pending:
|
||||
// Fire unit event to rerender.
|
||||
this.tradeShip.touch();
|
||||
this.tradeShip.move(this.tradeShip.tile());
|
||||
break;
|
||||
case PathFindResultType.NextTile:
|
||||
this._dstPort.cachePut(this.tradeShip.tile(), result.tile);
|
||||
// Update safeFromPirates status
|
||||
if (this.mg.isWater(result.tile) && this.mg.isShoreline(result.tile)) {
|
||||
this.tradeShip.setSafeFromPirates();
|
||||
}
|
||||
this.tradeShip.move(result.tile);
|
||||
this.tilesTraveled++;
|
||||
break;
|
||||
case PathFindResultType.PathNotFound:
|
||||
consolex.warn("captured trade ship cannot find route");
|
||||
@@ -147,25 +125,21 @@ export class TradeShipExecution implements Execution {
|
||||
}
|
||||
|
||||
private complete() {
|
||||
if (this.mg === null || this.origOwner === null) {
|
||||
throw new Error("Not initialized");
|
||||
}
|
||||
if (this.tradeShip === null) return;
|
||||
this.active = false;
|
||||
this.tradeShip.delete(false);
|
||||
const gold = this.mg.config().tradeShipGold(this.tilesTraveled);
|
||||
this.tradeShip!.delete(false);
|
||||
const gold = this.mg
|
||||
.config()
|
||||
.tradeShipGold(
|
||||
this.mg.manhattanDist(this.srcPort.tile(), this._dstPort.tile()),
|
||||
);
|
||||
|
||||
if (this.wasCaptured) {
|
||||
const player = this.tradeShip.owner();
|
||||
player.addGold(gold);
|
||||
this.tradeShip!.owner().addGold(gold);
|
||||
this.mg.displayMessage(
|
||||
`Received ${renderNumber(gold)} gold from ship captured from ${this.origOwner.displayName()}`,
|
||||
MessageType.SUCCESS,
|
||||
this.tradeShip.owner().id(),
|
||||
this.tradeShip!.owner().id(),
|
||||
);
|
||||
|
||||
// Record stats
|
||||
this.mg.stats().boatCapturedTrade(player, this.origOwner, gold);
|
||||
} else {
|
||||
this.srcPort.owner().addGold(gold);
|
||||
this._dstPort.owner().addGold(gold);
|
||||
@@ -179,11 +153,6 @@ export class TradeShipExecution implements Execution {
|
||||
MessageType.SUCCESS,
|
||||
this.srcPort.owner().id(),
|
||||
);
|
||||
|
||||
// Record stats
|
||||
this.mg
|
||||
.stats()
|
||||
.boatArriveTrade(this.srcPort.owner(), this._dstPort.owner(), gold);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -215,7 +215,8 @@ export class TransportShipExecution implements Execution {
|
||||
break;
|
||||
case PathFindResultType.PathNotFound:
|
||||
// TODO: add to poisoned port list
|
||||
consolex.warn(`path not found tot dst`);
|
||||
consolex.warn(`path not found to dst`);
|
||||
this.attacker.addTroops(this.troops);
|
||||
this.boat.delete(false);
|
||||
this.active = false;
|
||||
return;
|
||||
|
||||
@@ -2,9 +2,10 @@ import { consolex } from "../Consolex";
|
||||
import {
|
||||
Execution,
|
||||
Game,
|
||||
Player,
|
||||
PlayerID,
|
||||
isUnit,
|
||||
OwnerComp,
|
||||
Unit,
|
||||
UnitParams,
|
||||
UnitType,
|
||||
} from "../game/Game";
|
||||
import { TileRef } from "../game/GameMap";
|
||||
@@ -15,178 +16,91 @@ import { ShellExecution } from "./ShellExecution";
|
||||
|
||||
export class WarshipExecution implements Execution {
|
||||
private random: PseudoRandom;
|
||||
|
||||
private _owner: Player;
|
||||
private active = true;
|
||||
private warship: Unit | null = null;
|
||||
private warship: Unit;
|
||||
private mg: Game;
|
||||
|
||||
private target: Unit | undefined = undefined;
|
||||
private pathfinder: PathFinder | null = null;
|
||||
|
||||
private patrolTile: TileRef | undefined;
|
||||
|
||||
private pathfinder: PathFinder;
|
||||
private lastShellAttack = 0;
|
||||
private alreadySentShell = new Set<Unit>();
|
||||
|
||||
constructor(
|
||||
private playerID: PlayerID,
|
||||
private patrolCenterTile: TileRef,
|
||||
private input: (UnitParams<UnitType.Warship> & OwnerComp) | Unit,
|
||||
) {}
|
||||
|
||||
init(mg: Game, ticks: number): void {
|
||||
this.mg = mg;
|
||||
if (!mg.hasPlayer(this.playerID)) {
|
||||
console.log(`WarshipExecution: player ${this.playerID} not found`);
|
||||
this.active = false;
|
||||
return;
|
||||
}
|
||||
this.pathfinder = PathFinder.Mini(mg, 5000);
|
||||
this._owner = mg.player(this.playerID);
|
||||
this.patrolTile = this.patrolCenterTile;
|
||||
this.random = new PseudoRandom(mg.ticks());
|
||||
}
|
||||
|
||||
// Only for warships with "moveTarget" set
|
||||
goToMoveTarget(target: TileRef) {
|
||||
if (this.warship === null || this.pathfinder === null) {
|
||||
throw new Error("Warship not initialized");
|
||||
}
|
||||
// Patrol unless we are hunting down a tradeship
|
||||
const result = this.pathfinder.nextTile(this.warship.tile(), target);
|
||||
switch (result.type) {
|
||||
case PathFindResultType.Completed:
|
||||
this.warship.setTargetTile(undefined);
|
||||
this.warship.touch();
|
||||
return;
|
||||
case PathFindResultType.NextTile:
|
||||
this.warship.move(result.tile);
|
||||
break;
|
||||
case PathFindResultType.Pending:
|
||||
this.warship.touch();
|
||||
break;
|
||||
case PathFindResultType.PathNotFound:
|
||||
consolex.log(`path not found to target`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private shoot() {
|
||||
if (
|
||||
this.mg === null ||
|
||||
this.warship === null ||
|
||||
this.target === undefined
|
||||
) {
|
||||
throw new Error("Warship not initialized");
|
||||
}
|
||||
const shellAttackRate = this.mg.config().warshipShellAttackRate();
|
||||
if (this.mg.ticks() - this.lastShellAttack > shellAttackRate) {
|
||||
this.lastShellAttack = this.mg.ticks();
|
||||
this.mg.addExecution(
|
||||
new ShellExecution(
|
||||
this.warship.tile(),
|
||||
this.warship.owner(),
|
||||
this.warship,
|
||||
this.target,
|
||||
),
|
||||
if (isUnit(this.input)) {
|
||||
this.warship = this.input;
|
||||
} else {
|
||||
const spawn = this.input.owner.canBuild(
|
||||
UnitType.Warship,
|
||||
this.input.patrolTile,
|
||||
);
|
||||
if (!this.target.hasHealth()) {
|
||||
// Don't send multiple shells to target that can be oneshotted
|
||||
this.alreadySentShell.add(this.target);
|
||||
this.target = undefined;
|
||||
if (spawn === false) {
|
||||
console.warn(
|
||||
`Failed to spawn warship for ${this.input.owner.name()} at ${this.input.patrolTile}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private patrol() {
|
||||
if (this.warship === null || this.pathfinder === null) {
|
||||
throw new Error("Warship not initialized");
|
||||
}
|
||||
if (this.patrolTile === undefined) {
|
||||
this.patrolTile = this.randomTile();
|
||||
if (this.patrolTile === undefined) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.warship.setTargetUnit(this.target);
|
||||
if (
|
||||
this.target === undefined ||
|
||||
this.target.type() !== UnitType.TradeShip
|
||||
) {
|
||||
// Patrol unless we are hunting down a tradeship
|
||||
const result = this.pathfinder.nextTile(
|
||||
this.warship.tile(),
|
||||
this.patrolTile,
|
||||
this.warship = this.input.owner.buildUnit(
|
||||
UnitType.Warship,
|
||||
spawn,
|
||||
this.input,
|
||||
);
|
||||
switch (result.type) {
|
||||
case PathFindResultType.Completed:
|
||||
this.patrolTile = undefined;
|
||||
this.warship.touch();
|
||||
break;
|
||||
case PathFindResultType.NextTile:
|
||||
this.warship.move(result.tile);
|
||||
break;
|
||||
case PathFindResultType.Pending:
|
||||
this.warship.touch();
|
||||
return;
|
||||
case PathFindResultType.PathNotFound:
|
||||
consolex.log(`path not found to patrol tile`);
|
||||
this.patrolTile = undefined;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tick(ticks: number): void {
|
||||
if (this.pathfinder === null) throw new Error("Warship not initialized");
|
||||
if (this.warship === null) {
|
||||
if (this.patrolTile === undefined) {
|
||||
console.log(
|
||||
`WarshipExecution: no patrol tile for ${this._owner.name()}`,
|
||||
);
|
||||
this.active = false;
|
||||
return;
|
||||
}
|
||||
const spawn = this._owner.canBuild(UnitType.Warship, this.patrolTile);
|
||||
if (spawn === false) {
|
||||
this.active = false;
|
||||
return;
|
||||
}
|
||||
this.warship = this._owner.buildUnit(UnitType.Warship, spawn, {});
|
||||
if (this.warship.health() <= 0) {
|
||||
this.warship.delete();
|
||||
return;
|
||||
}
|
||||
if (!this.warship.isActive()) {
|
||||
this.active = false;
|
||||
const hasPort = this.warship.owner().units(UnitType.Port).length > 0;
|
||||
if (hasPort) {
|
||||
this.warship.modifyHealth(1);
|
||||
}
|
||||
|
||||
this.warship.setTargetUnit(this.findTargetUnit());
|
||||
if (this.warship.targetUnit()?.type() === UnitType.TradeShip) {
|
||||
this.huntDownTradeShip();
|
||||
return;
|
||||
}
|
||||
if (this.target !== undefined && !this.target.isActive()) {
|
||||
this.target = undefined;
|
||||
|
||||
this.patrol();
|
||||
|
||||
if (this.warship.targetUnit() !== undefined) {
|
||||
this.shootTarget();
|
||||
return;
|
||||
}
|
||||
const hasPort = this._owner.units(UnitType.Port).length > 0;
|
||||
const warship = this.warship;
|
||||
if (warship === undefined) throw new Error("Warship not initialized");
|
||||
}
|
||||
|
||||
private findTargetUnit(): Unit | undefined {
|
||||
const hasPort = this.warship.owner().units(UnitType.Port).length > 0;
|
||||
const patrolRangeSquared = this.mg.config().warshipPatrolRange() ** 2;
|
||||
|
||||
const ships = this.mg
|
||||
.nearbyUnits(
|
||||
this.warship.tile(),
|
||||
this.warship.patrolTile()!,
|
||||
this.mg.config().warshipTargettingRange(),
|
||||
[UnitType.TransportShip, UnitType.Warship, UnitType.TradeShip],
|
||||
)
|
||||
.filter(
|
||||
({ unit }) =>
|
||||
unit.owner() !== warship.owner() &&
|
||||
unit !== warship &&
|
||||
!unit.owner().isFriendly(warship.owner()) &&
|
||||
unit.owner() !== this.warship.owner() &&
|
||||
unit !== this.warship &&
|
||||
!unit.owner().isFriendly(this.warship.owner()) &&
|
||||
!this.alreadySentShell.has(unit) &&
|
||||
(unit.type() !== UnitType.TradeShip ||
|
||||
(hasPort &&
|
||||
this.warship !== null &&
|
||||
this.mg.euclideanDistSquared(this.warship.tile(), unit.tile()) <=
|
||||
patrolRangeSquared &&
|
||||
unit.targetUnit()?.owner() !== this.warship.owner() &&
|
||||
!unit.targetUnit()?.owner().isFriendly(this.warship.owner()) &&
|
||||
unit.isSafeFromPirates() !== true)),
|
||||
);
|
||||
|
||||
this.target = ships.sort((a, b) => {
|
||||
return ships.sort((a, b) => {
|
||||
const { unit: unitA, distSquared: distA } = a;
|
||||
const { unit: unitB, distSquared: distB } = b;
|
||||
|
||||
@@ -217,60 +131,48 @@ export class WarshipExecution implements Execution {
|
||||
// If both are the same type, sort by distance (lower `distSquared` means closer)
|
||||
return distA - distB;
|
||||
})[0]?.unit;
|
||||
}
|
||||
|
||||
const moveTarget = this.warship.targetTile();
|
||||
if (moveTarget) {
|
||||
this.goToMoveTarget(moveTarget);
|
||||
// If we have a "move target" then we cannot target trade ships as it
|
||||
// requires moving.
|
||||
if (this.target && this.target.type() === UnitType.TradeShip) {
|
||||
this.target = undefined;
|
||||
private shootTarget() {
|
||||
const shellAttackRate = this.mg.config().warshipShellAttackRate();
|
||||
if (this.mg.ticks() - this.lastShellAttack > shellAttackRate) {
|
||||
this.lastShellAttack = this.mg.ticks();
|
||||
this.mg.addExecution(
|
||||
new ShellExecution(
|
||||
this.warship.tile(),
|
||||
this.warship.owner(),
|
||||
this.warship,
|
||||
this.warship.targetUnit()!,
|
||||
),
|
||||
);
|
||||
if (!this.warship.targetUnit()!.hasHealth()) {
|
||||
// Don't send multiple shells to target that can be oneshotted
|
||||
this.alreadySentShell.add(this.warship.targetUnit()!);
|
||||
this.warship.setTargetUnit(undefined);
|
||||
return;
|
||||
}
|
||||
} else if (!this.target || this.target.type() !== UnitType.TradeShip) {
|
||||
this.patrol();
|
||||
}
|
||||
|
||||
if (
|
||||
this.target === undefined ||
|
||||
!this.target.isActive() ||
|
||||
this.target.owner() === this._owner ||
|
||||
this.target.isSafeFromPirates() === true
|
||||
) {
|
||||
// In case another warship captured or destroyed target, or the target escaped into safe waters
|
||||
this.target = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
this.warship.setTargetUnit(this.target);
|
||||
|
||||
// If we have a move target we do not want to go after trading ships
|
||||
if (!this.target) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.target.type() !== UnitType.TradeShip) {
|
||||
this.shoot();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private huntDownTradeShip() {
|
||||
for (let i = 0; i < 2; i++) {
|
||||
// target is trade ship so capture it.
|
||||
const result = this.pathfinder.nextTile(
|
||||
this.warship.tile(),
|
||||
this.target.tile(),
|
||||
this.warship.targetUnit()!.tile(),
|
||||
5,
|
||||
);
|
||||
switch (result.type) {
|
||||
case PathFindResultType.Completed:
|
||||
this._owner.captureUnit(this.target);
|
||||
this.target = undefined;
|
||||
this.warship.owner().captureUnit(this.warship.targetUnit()!);
|
||||
this.warship.setTargetUnit(undefined);
|
||||
this.warship.move(this.warship.tile());
|
||||
return;
|
||||
case PathFindResultType.NextTile:
|
||||
this.warship.move(result.tile);
|
||||
break;
|
||||
case PathFindResultType.Pending:
|
||||
this.warship.move(this.warship.tile());
|
||||
this.warship.touch();
|
||||
break;
|
||||
case PathFindResultType.PathNotFound:
|
||||
consolex.log(`path not found to target`);
|
||||
@@ -279,8 +181,38 @@ export class WarshipExecution implements Execution {
|
||||
}
|
||||
}
|
||||
|
||||
private patrol() {
|
||||
if (this.warship.targetTile() === undefined) {
|
||||
this.warship.setTargetTile(this.randomTile());
|
||||
if (this.warship.targetTile() === undefined) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const result = this.pathfinder.nextTile(
|
||||
this.warship.tile(),
|
||||
this.warship.targetTile()!,
|
||||
);
|
||||
switch (result.type) {
|
||||
case PathFindResultType.Completed:
|
||||
this.warship.setTargetTile(undefined);
|
||||
this.warship.move(result.tile);
|
||||
break;
|
||||
case PathFindResultType.NextTile:
|
||||
this.warship.move(result.tile);
|
||||
break;
|
||||
case PathFindResultType.Pending:
|
||||
this.warship.touch();
|
||||
return;
|
||||
case PathFindResultType.PathNotFound:
|
||||
consolex.warn(`path not found to target tile`);
|
||||
this.warship.setTargetTile(undefined);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
isActive(): boolean {
|
||||
return this.active;
|
||||
return this.warship?.isActive();
|
||||
}
|
||||
|
||||
activeDuringSpawnPhase(): boolean {
|
||||
@@ -288,19 +220,16 @@ export class WarshipExecution implements Execution {
|
||||
}
|
||||
|
||||
randomTile(allowShoreline: boolean = false): TileRef | undefined {
|
||||
if (this.mg === null) {
|
||||
throw new Error("Warship not initialized");
|
||||
}
|
||||
let warshipPatrolRange = this.mg.config().warshipPatrolRange();
|
||||
const maxAttemptBeforeExpand: number = 500;
|
||||
let attempts: number = 0;
|
||||
let expandCount: number = 0;
|
||||
while (expandCount < 3) {
|
||||
const x =
|
||||
this.mg.x(this.patrolCenterTile) +
|
||||
this.mg.x(this.warship.patrolTile()!) +
|
||||
this.random.nextInt(-warshipPatrolRange / 2, warshipPatrolRange / 2);
|
||||
const y =
|
||||
this.mg.y(this.patrolCenterTile) +
|
||||
this.mg.y(this.warship.patrolTile()!) +
|
||||
this.random.nextInt(-warshipPatrolRange / 2, warshipPatrolRange / 2);
|
||||
if (!this.mg.isValidCoord(x, y)) {
|
||||
continue;
|
||||
@@ -322,7 +251,7 @@ export class WarshipExecution implements Execution {
|
||||
return tile;
|
||||
}
|
||||
console.warn(
|
||||
`Failed to find random tile for warship for ${this._owner.name()}`,
|
||||
`Failed to find random tile for warship for ${this.warship.owner().name()}`,
|
||||
);
|
||||
if (!allowShoreline) {
|
||||
// If we failed to find a tile on the ocean, try again but allow shoreline
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { Attack, Player, TerraNullius } from "./Game";
|
||||
import { Attack, Cell, Player, TerraNullius } from "./Game";
|
||||
import { GameImpl } from "./GameImpl";
|
||||
import { TileRef } from "./GameMap";
|
||||
import { PlayerImpl } from "./PlayerImpl";
|
||||
|
||||
export class AttackImpl implements Attack {
|
||||
private _isActive = true;
|
||||
private _borderSize = 0;
|
||||
public _retreating = false;
|
||||
public _retreated = false;
|
||||
|
||||
@@ -13,6 +15,8 @@ export class AttackImpl implements Attack {
|
||||
private _attacker: Player,
|
||||
private _troops: number,
|
||||
private _sourceTile: TileRef | null,
|
||||
private _border: Set<number>,
|
||||
private _mg: GameImpl,
|
||||
) {}
|
||||
|
||||
sourceTile(): TileRef | null {
|
||||
@@ -69,4 +73,52 @@ export class AttackImpl implements Attack {
|
||||
retreated(): boolean {
|
||||
return this._retreated;
|
||||
}
|
||||
|
||||
borderSize(): number {
|
||||
return this._borderSize;
|
||||
}
|
||||
|
||||
clearBorder(): void {
|
||||
this._borderSize = 0;
|
||||
this._border.clear();
|
||||
}
|
||||
|
||||
addBorderTile(tile: TileRef): void {
|
||||
if (!this._border.has(tile)) {
|
||||
this._borderSize += 1;
|
||||
this._border.add(tile);
|
||||
}
|
||||
}
|
||||
|
||||
removeBorderTile(tile: TileRef): void {
|
||||
if (this._border.has(tile)) {
|
||||
this._borderSize -= 1;
|
||||
this._border.delete(tile);
|
||||
}
|
||||
}
|
||||
|
||||
averagePosition(): Cell | null {
|
||||
if (this._borderSize === 0) {
|
||||
if (this.sourceTile() === null) {
|
||||
// No border tiles and no source tile—return a default position or throw an error
|
||||
return null;
|
||||
}
|
||||
// No border tiles yet—use the source tile's location
|
||||
const tile: number = this.sourceTile()!;
|
||||
return new Cell(this._mg.map().x(tile), this._mg.map().y(tile));
|
||||
}
|
||||
|
||||
let averageX = 0;
|
||||
let averageY = 0;
|
||||
|
||||
for (const t of this._border) {
|
||||
averageX += this._mg.map().x(t);
|
||||
averageY += this._mg.map().y(t);
|
||||
}
|
||||
|
||||
averageX = averageX / this._borderSize;
|
||||
averageY = averageY / this._borderSize;
|
||||
|
||||
return new Cell(averageX, averageY);
|
||||
}
|
||||
}
|
||||
|
||||
+38
-14
@@ -54,6 +54,7 @@ export const ColoredTeams: Record<string, Team> = {
|
||||
|
||||
export enum GameMapType {
|
||||
World = "World",
|
||||
WorldMapGiant = "Giant World Map",
|
||||
Europe = "Europe",
|
||||
EuropeClassic = "Europe Classic",
|
||||
Mena = "Mena",
|
||||
@@ -71,7 +72,6 @@ export enum GameMapType {
|
||||
Iceland = "Iceland",
|
||||
Japan = "Japan",
|
||||
BetweenTwoSeas = "Between Two Seas",
|
||||
KnownWorld = "Known World",
|
||||
FaroeIslands = "Faroe Islands",
|
||||
DeglaciatedAntarctica = "Deglaciated Antarctica",
|
||||
FalklandIslands = "Falkland Islands",
|
||||
@@ -82,6 +82,7 @@ export enum GameMapType {
|
||||
export const mapCategories: Record<string, GameMapType[]> = {
|
||||
continental: [
|
||||
GameMapType.World,
|
||||
GameMapType.WorldMapGiant,
|
||||
GameMapType.NorthAmerica,
|
||||
GameMapType.SouthAmerica,
|
||||
GameMapType.Europe,
|
||||
@@ -107,7 +108,6 @@ export const mapCategories: Record<string, GameMapType[]> = {
|
||||
fantasy: [
|
||||
GameMapType.Pangaea,
|
||||
GameMapType.Mars,
|
||||
GameMapType.KnownWorld,
|
||||
GameMapType.DeglaciatedAntarctica,
|
||||
],
|
||||
};
|
||||
@@ -150,13 +150,19 @@ export enum UnitType {
|
||||
Construction = "Construction",
|
||||
}
|
||||
|
||||
export interface OwnerComp {
|
||||
owner: Player;
|
||||
}
|
||||
|
||||
export interface UnitParamsMap {
|
||||
[UnitType.TransportShip]: {
|
||||
troops?: number;
|
||||
destination?: TileRef;
|
||||
};
|
||||
|
||||
[UnitType.Warship]: {};
|
||||
[UnitType.Warship]: {
|
||||
patrolTile: TileRef;
|
||||
};
|
||||
|
||||
[UnitType.Shell]: {};
|
||||
|
||||
@@ -164,12 +170,16 @@ export interface UnitParamsMap {
|
||||
|
||||
[UnitType.Port]: {};
|
||||
|
||||
[UnitType.AtomBomb]: {};
|
||||
[UnitType.AtomBomb]: {
|
||||
targetTile?: number;
|
||||
};
|
||||
|
||||
[UnitType.HydrogenBomb]: {};
|
||||
[UnitType.HydrogenBomb]: {
|
||||
targetTile?: number;
|
||||
};
|
||||
|
||||
[UnitType.TradeShip]: {
|
||||
dstPort: Unit;
|
||||
targetUnit: Unit;
|
||||
lastSetSafeFromPirates?: number;
|
||||
};
|
||||
|
||||
@@ -185,7 +195,9 @@ export interface UnitParamsMap {
|
||||
|
||||
[UnitType.MIRV]: {};
|
||||
|
||||
[UnitType.MIRVWarhead]: {};
|
||||
[UnitType.MIRVWarhead]: {
|
||||
targetTile?: number;
|
||||
};
|
||||
|
||||
[UnitType.Construction]: {};
|
||||
}
|
||||
@@ -276,6 +288,11 @@ export interface Attack {
|
||||
delete(): void;
|
||||
// The tile the attack originated from, mostly used for boat attacks.
|
||||
sourceTile(): TileRef | null;
|
||||
addBorderTile(tile: TileRef): void;
|
||||
removeBorderTile(tile: TileRef): void;
|
||||
clearBorder(): void;
|
||||
borderSize(): number;
|
||||
averagePosition(): Cell | null;
|
||||
}
|
||||
|
||||
export interface AllianceRequest {
|
||||
@@ -316,14 +333,18 @@ export class PlayerInfo {
|
||||
if (!name.startsWith("[") || !name.includes("]")) {
|
||||
this.clan = null;
|
||||
} else {
|
||||
const clanMatch = name.match(/^\[([A-Z]{2,5})\]/);
|
||||
const clanMatch = name.match(/^\[([a-zA-Z]{2,5})\]/);
|
||||
this.clan = clanMatch ? clanMatch[1] : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function isUnit(unit: Unit | UnitParams<UnitType>): unit is Unit {
|
||||
return "isUnit" in unit && typeof unit.isUnit === "function" && unit.isUnit();
|
||||
}
|
||||
|
||||
export interface Unit {
|
||||
hash(): number;
|
||||
isUnit(): this is Unit;
|
||||
|
||||
// Common properties.
|
||||
id(): number;
|
||||
@@ -337,6 +358,7 @@ export interface Unit {
|
||||
isActive(): boolean;
|
||||
setOwner(owner: Player): void;
|
||||
touch(): void;
|
||||
hash(): number;
|
||||
toUpdate(): UnitUpdate;
|
||||
|
||||
// Targeting
|
||||
@@ -346,8 +368,8 @@ export interface Unit {
|
||||
targetUnit(): Unit | undefined;
|
||||
setTargetedBySAM(targeted: boolean): void;
|
||||
targetedBySAM(): boolean;
|
||||
setInterceptedBySam(): void;
|
||||
interceptedBySam(): boolean;
|
||||
setReachedTarget(): void;
|
||||
reachedTarget(): boolean;
|
||||
|
||||
// Health
|
||||
hasHealth(): boolean;
|
||||
@@ -375,9 +397,9 @@ export interface Unit {
|
||||
constructionType(): UnitType | null;
|
||||
setConstructionType(type: UnitType): void;
|
||||
|
||||
// Ports
|
||||
cachePut(from: TileRef, to: TileRef): void;
|
||||
cacheGet(from: TileRef): TileRef | undefined;
|
||||
// Warships
|
||||
setPatrolTile(tile: TileRef): void;
|
||||
patrolTile(): TileRef | undefined;
|
||||
}
|
||||
|
||||
export interface TerraNullius {
|
||||
@@ -500,10 +522,12 @@ export interface Player {
|
||||
|
||||
// Attacking.
|
||||
canAttack(tile: TileRef): boolean;
|
||||
|
||||
createAttack(
|
||||
target: Player | TerraNullius,
|
||||
troops: number,
|
||||
sourceTile: TileRef | null,
|
||||
border: Set<number>,
|
||||
): Attack;
|
||||
outgoingAttacks(): Attack[];
|
||||
incomingAttacks(): Attack[];
|
||||
|
||||
@@ -286,8 +286,8 @@ export class GameImpl implements Game {
|
||||
this.updates = createGameUpdatesMap();
|
||||
this.execs.forEach((e) => {
|
||||
if (
|
||||
e.isActive() &&
|
||||
(!this.inSpawnPhase() || e.activeDuringSpawnPhase())
|
||||
(!this.inSpawnPhase() || e.activeDuringSpawnPhase()) &&
|
||||
e.isActive()
|
||||
) {
|
||||
e.tick(this._ticks);
|
||||
}
|
||||
@@ -594,8 +594,10 @@ export class GameImpl implements Game {
|
||||
setWinner(winner: Player | Team, allPlayersStats: AllPlayersStats): void {
|
||||
this.addUpdate({
|
||||
type: GameUpdateType.Win,
|
||||
winner: typeof winner === "string" ? winner : winner.smallID(),
|
||||
winnerType: typeof winner === "string" ? "team" : "player",
|
||||
winner:
|
||||
typeof winner === "string"
|
||||
? ["team", winner]
|
||||
: ["player", winner.smallID()],
|
||||
allPlayersStats,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ export interface UnitUpdate {
|
||||
pos: TileRef;
|
||||
lastPos: TileRef;
|
||||
isActive: boolean;
|
||||
wasIntercepted: boolean;
|
||||
reachedTarget: boolean;
|
||||
retreating: boolean;
|
||||
targetUnitId?: number; // Only for trade ships
|
||||
targetTile?: TileRef; // Only for nukes
|
||||
@@ -119,6 +119,7 @@ export interface PlayerUpdate {
|
||||
incomingAttacks: AttackUpdate[];
|
||||
outgoingAllianceRequests: PlayerID[];
|
||||
hasSpawned: boolean;
|
||||
betrayals?: bigint;
|
||||
}
|
||||
|
||||
export interface AllianceRequestUpdate {
|
||||
@@ -178,8 +179,7 @@ export interface WinUpdate {
|
||||
type: GameUpdateType.Win;
|
||||
allPlayersStats: AllPlayersStats;
|
||||
// Player id or team name.
|
||||
winner: number | Team;
|
||||
winnerType: "player" | "team";
|
||||
winner: ["player", number] | ["team", Team];
|
||||
}
|
||||
|
||||
export interface HashUpdate {
|
||||
|
||||
@@ -93,8 +93,8 @@ export class UnitView {
|
||||
isActive(): boolean {
|
||||
return this.data.isActive;
|
||||
}
|
||||
wasInterceptedBySAM(): boolean {
|
||||
return this.data.wasIntercepted;
|
||||
reachedTarget(): boolean {
|
||||
return this.data.reachedTarget;
|
||||
}
|
||||
hasHealth(): boolean {
|
||||
return this.data.health !== undefined;
|
||||
@@ -158,6 +158,13 @@ export class PlayerView {
|
||||
return this.data.incomingAttacks;
|
||||
}
|
||||
|
||||
async attackAveragePosition(
|
||||
playerID: number,
|
||||
attackID: string,
|
||||
): Promise<Cell | null> {
|
||||
return this.game.worker.attackAveragePosition(playerID, attackID);
|
||||
}
|
||||
|
||||
units(...types: UnitType[]): UnitView[] {
|
||||
return this.game
|
||||
.units(...types)
|
||||
@@ -180,12 +187,12 @@ export class PlayerView {
|
||||
}
|
||||
|
||||
name(): string {
|
||||
return userSettings.anonymousNames() && this.anonymousName !== null
|
||||
return this.anonymousName !== null && userSettings.anonymousNames()
|
||||
? this.anonymousName
|
||||
: this.data.name;
|
||||
}
|
||||
displayName(): string {
|
||||
return userSettings.anonymousNames() && this.anonymousName !== null
|
||||
return this.anonymousName !== null && userSettings.anonymousNames()
|
||||
? this.anonymousName
|
||||
: this.data.name;
|
||||
}
|
||||
|
||||
+23
-20
@@ -121,6 +121,7 @@ export class PlayerImpl implements Player {
|
||||
const outgoingAllianceRequests = this.outgoingAllianceRequests().map((ar) =>
|
||||
ar.recipient().id(),
|
||||
);
|
||||
const stats = this.mg.stats().getPlayerStats(this);
|
||||
|
||||
return {
|
||||
type: GameUpdateType.Player,
|
||||
@@ -146,28 +147,27 @@ export class PlayerImpl implements Player {
|
||||
isTraitor: this.isTraitor(),
|
||||
targets: this.targets().map((p) => p.smallID()),
|
||||
outgoingEmojis: this.outgoingEmojis(),
|
||||
outgoingAttacks: this._outgoingAttacks.map(
|
||||
(a) =>
|
||||
({
|
||||
attackerID: a.attacker().smallID(),
|
||||
targetID: a.target().smallID(),
|
||||
troops: a.troops(),
|
||||
id: a.id(),
|
||||
retreating: a.retreating(),
|
||||
}) as AttackUpdate,
|
||||
),
|
||||
incomingAttacks: this._incomingAttacks.map(
|
||||
(a) =>
|
||||
({
|
||||
attackerID: a.attacker().smallID(),
|
||||
targetID: a.target().smallID(),
|
||||
troops: a.troops(),
|
||||
id: a.id(),
|
||||
retreating: a.retreating(),
|
||||
}) as AttackUpdate,
|
||||
),
|
||||
outgoingAttacks: this._outgoingAttacks.map((a) => {
|
||||
return {
|
||||
attackerID: a.attacker().smallID(),
|
||||
targetID: a.target().smallID(),
|
||||
troops: a.troops(),
|
||||
id: a.id(),
|
||||
retreating: a.retreating(),
|
||||
} as AttackUpdate;
|
||||
}),
|
||||
incomingAttacks: this._incomingAttacks.map((a) => {
|
||||
return {
|
||||
attackerID: a.attacker().smallID(),
|
||||
targetID: a.target().smallID(),
|
||||
troops: a.troops(),
|
||||
id: a.id(),
|
||||
retreating: a.retreating(),
|
||||
} as AttackUpdate;
|
||||
}),
|
||||
outgoingAllianceRequests: outgoingAllianceRequests,
|
||||
hasSpawned: this.hasSpawned(),
|
||||
betrayals: stats?.betrayals,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -959,6 +959,7 @@ export class PlayerImpl implements Player {
|
||||
target: Player | TerraNullius,
|
||||
troops: number,
|
||||
sourceTile: TileRef | null,
|
||||
border: Set<number>,
|
||||
): Attack {
|
||||
const attack = new AttackImpl(
|
||||
this._pseudo_random.nextID(),
|
||||
@@ -966,6 +967,8 @@ export class PlayerImpl implements Player {
|
||||
this,
|
||||
troops,
|
||||
sourceTile,
|
||||
border,
|
||||
this.mg,
|
||||
);
|
||||
this._outgoingAttacks.push(attack);
|
||||
if (target.isPlayer()) {
|
||||
|
||||
+21
-9
@@ -7,13 +7,17 @@ export interface Stats {
|
||||
stats(): AllPlayersStats;
|
||||
|
||||
// Player attacks target
|
||||
attack(player: Player, target: Player | TerraNullius, troops: number): void;
|
||||
attack(
|
||||
player: Player,
|
||||
target: Player | TerraNullius,
|
||||
troops: number | bigint,
|
||||
): void;
|
||||
|
||||
// Player cancels attack on target
|
||||
attackCancel(
|
||||
player: Player,
|
||||
target: Player | TerraNullius,
|
||||
troops: number,
|
||||
troops: number | bigint,
|
||||
): void;
|
||||
|
||||
// Player betrays another player
|
||||
@@ -23,10 +27,14 @@ export interface Stats {
|
||||
boatSendTrade(player: Player, target: Player): void;
|
||||
|
||||
// Player's trade ship arrives at target, both players earn gold
|
||||
boatArriveTrade(player: Player, target: Player, gold: number): void;
|
||||
boatArriveTrade(player: Player, target: Player, gold: number | bigint): void;
|
||||
|
||||
// Player's trade ship, captured from target, arrives. Player earns gold.
|
||||
boatCapturedTrade(player: Player, target: Player, gold: number): void;
|
||||
boatCapturedTrade(
|
||||
player: Player,
|
||||
target: Player,
|
||||
gold: number | bigint,
|
||||
): void;
|
||||
|
||||
// Player destroys target's trade ship
|
||||
boatDestroyTrade(player: Player, target: Player): void;
|
||||
@@ -35,18 +43,22 @@ export interface Stats {
|
||||
boatSendTroops(
|
||||
player: Player,
|
||||
target: Player | TerraNullius,
|
||||
troops: number,
|
||||
troops: number | bigint,
|
||||
): void;
|
||||
|
||||
// Player's transport ship arrives at target with troops
|
||||
boatArriveTroops(
|
||||
player: Player,
|
||||
target: Player | TerraNullius,
|
||||
troops: number,
|
||||
troops: number | bigint,
|
||||
): void;
|
||||
|
||||
// Player destroys target's transport ship with troops
|
||||
boatDestroyTroops(player: Player, target: Player, troops: number): void;
|
||||
boatDestroyTroops(
|
||||
player: Player,
|
||||
target: Player,
|
||||
troops: number | bigint,
|
||||
): void;
|
||||
|
||||
// Player launches bomb at target
|
||||
bombLaunch(
|
||||
@@ -62,10 +74,10 @@ export interface Stats {
|
||||
bombIntercept(player: Player, attacker: Player, type: NukeType): void;
|
||||
|
||||
// Player earns gold from conquering tiles or trade ships from captured
|
||||
goldWar(player: Player, captured: Player, gold: number): void;
|
||||
goldWar(player: Player, captured: Player, gold: number | bigint): void;
|
||||
|
||||
// Player earns gold from workers
|
||||
goldWork(player: Player, gold: number): void;
|
||||
goldWork(player: Player, gold: number | bigint): void;
|
||||
|
||||
// Player builds a unit of type
|
||||
unitBuild(player: Player, type: OtherUnitType): void;
|
||||
|
||||
+49
-35
@@ -28,6 +28,16 @@ import {
|
||||
import { Player, TerraNullius } from "./Game";
|
||||
import { Stats } from "./Stats";
|
||||
|
||||
type BigIntLike = bigint | number;
|
||||
function _bigint(value: BigIntLike): bigint {
|
||||
switch (typeof value) {
|
||||
case "bigint":
|
||||
return value;
|
||||
case "number":
|
||||
return BigInt(Math.floor(value));
|
||||
}
|
||||
}
|
||||
|
||||
export class StatsImpl implements Stats {
|
||||
private readonly data: AllPlayersStats = {};
|
||||
|
||||
@@ -52,21 +62,21 @@ export class StatsImpl implements Stats {
|
||||
return data;
|
||||
}
|
||||
|
||||
private _addAttack(player: Player, index: number, value: number) {
|
||||
private _addAttack(player: Player, index: number, value: BigIntLike) {
|
||||
const p = this._makePlayerStats(player);
|
||||
if (p === undefined) return;
|
||||
if (p.attacks === undefined) p.attacks = [0];
|
||||
while (p.attacks.length < index) p.attacks.push(0);
|
||||
p.attacks[index] += value;
|
||||
if (p.attacks === undefined) p.attacks = [0n];
|
||||
while (p.attacks.length <= index) p.attacks.push(0n);
|
||||
p.attacks[index] += _bigint(value);
|
||||
}
|
||||
|
||||
private _addBetrayal(player: Player, value: number) {
|
||||
private _addBetrayal(player: Player, value: BigIntLike) {
|
||||
const data = this._makePlayerStats(player);
|
||||
if (data === undefined) return;
|
||||
if (data.betrayals === undefined) {
|
||||
data.betrayals = value;
|
||||
data.betrayals = _bigint(value);
|
||||
} else {
|
||||
data.betrayals += value;
|
||||
data.betrayals += _bigint(value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,55 +84,59 @@ export class StatsImpl implements Stats {
|
||||
player: Player,
|
||||
type: BoatUnit,
|
||||
index: number,
|
||||
value: number,
|
||||
value: BigIntLike,
|
||||
) {
|
||||
const p = this._makePlayerStats(player);
|
||||
if (p === undefined) return;
|
||||
if (p.boats === undefined) p.boats = { [type]: [0] };
|
||||
if (p.boats[type] === undefined) p.boats[type] = [0];
|
||||
while (p.boats[type].length < index) p.boats[type].push(0);
|
||||
p.boats[type][index] += value;
|
||||
if (p.boats === undefined) p.boats = { [type]: [0n] };
|
||||
if (p.boats[type] === undefined) p.boats[type] = [0n];
|
||||
while (p.boats[type].length <= index) p.boats[type].push(0n);
|
||||
p.boats[type][index] += _bigint(value);
|
||||
}
|
||||
|
||||
private _addBomb(
|
||||
player: Player,
|
||||
nukeType: NukeType,
|
||||
index: number,
|
||||
value: number,
|
||||
value: BigIntLike,
|
||||
): void {
|
||||
const type = unitTypeToBombUnit[nukeType];
|
||||
const p = this._makePlayerStats(player);
|
||||
if (p === undefined) return;
|
||||
if (p.bombs === undefined) p.bombs = { [type]: [0] };
|
||||
if (p.bombs[type] === undefined) p.bombs[type] = [0];
|
||||
while (p.bombs[type].length < index) p.bombs[type].push(0);
|
||||
p.bombs[type][index] += value;
|
||||
if (p.bombs === undefined) p.bombs = { [type]: [0n] };
|
||||
if (p.bombs[type] === undefined) p.bombs[type] = [0n];
|
||||
while (p.bombs[type].length <= index) p.bombs[type].push(0n);
|
||||
p.bombs[type][index] += _bigint(value);
|
||||
}
|
||||
|
||||
private _addGold(player: Player, index: number, value: number) {
|
||||
private _addGold(player: Player, index: number, value: BigIntLike) {
|
||||
const p = this._makePlayerStats(player);
|
||||
if (p === undefined) return;
|
||||
if (p.gold === undefined) p.gold = [0];
|
||||
while (p.gold.length < index) p.gold.push(0);
|
||||
p.gold[index] += value;
|
||||
if (p.gold === undefined) p.gold = [0n];
|
||||
while (p.gold.length <= index) p.gold.push(0n);
|
||||
p.gold[index] += _bigint(value);
|
||||
}
|
||||
|
||||
private _addOtherUnit(
|
||||
player: Player,
|
||||
otherUnitType: OtherUnitType,
|
||||
index: number,
|
||||
value: number,
|
||||
value: BigIntLike,
|
||||
) {
|
||||
const type = unitTypeToOtherUnit[otherUnitType];
|
||||
const p = this._makePlayerStats(player);
|
||||
if (p === undefined) return;
|
||||
if (p.units === undefined) p.units = { [type]: [0] };
|
||||
if (p.units[type] === undefined) p.units[type] = [0];
|
||||
while (p.units[type].length < index) p.units[type].push(0);
|
||||
p.units[type][index] += value;
|
||||
if (p.units === undefined) p.units = { [type]: [0n] };
|
||||
if (p.units[type] === undefined) p.units[type] = [0n];
|
||||
while (p.units[type].length <= index) p.units[type].push(0n);
|
||||
p.units[type][index] += _bigint(value);
|
||||
}
|
||||
|
||||
attack(player: Player, target: Player | TerraNullius, troops: number): void {
|
||||
attack(
|
||||
player: Player,
|
||||
target: Player | TerraNullius,
|
||||
troops: BigIntLike,
|
||||
): void {
|
||||
this._addAttack(player, ATTACK_INDEX_SENT, troops);
|
||||
if (target.isPlayer()) {
|
||||
this._addAttack(target, ATTACK_INDEX_RECV, troops);
|
||||
@@ -132,7 +146,7 @@ export class StatsImpl implements Stats {
|
||||
attackCancel(
|
||||
player: Player,
|
||||
target: Player | TerraNullius,
|
||||
troops: number,
|
||||
troops: BigIntLike,
|
||||
): void {
|
||||
this._addAttack(player, ATTACK_INDEX_CANCEL, troops);
|
||||
this._addAttack(player, ATTACK_INDEX_SENT, -troops);
|
||||
@@ -149,13 +163,13 @@ export class StatsImpl implements Stats {
|
||||
this._addBoat(player, "trade", BOAT_INDEX_SENT, 1);
|
||||
}
|
||||
|
||||
boatArriveTrade(player: Player, target: Player, gold: number): void {
|
||||
boatArriveTrade(player: Player, target: Player, gold: BigIntLike): void {
|
||||
this._addBoat(player, "trade", BOAT_INDEX_ARRIVE, 1);
|
||||
this._addGold(player, GOLD_INDEX_TRADE, gold);
|
||||
this._addGold(target, GOLD_INDEX_TRADE, gold);
|
||||
}
|
||||
|
||||
boatCapturedTrade(player: Player, target: Player, gold: number): void {
|
||||
boatCapturedTrade(player: Player, target: Player, gold: BigIntLike): void {
|
||||
this._addBoat(player, "trade", BOAT_INDEX_CAPTURE, 1);
|
||||
this._addGold(player, GOLD_INDEX_STEAL, gold);
|
||||
}
|
||||
@@ -167,7 +181,7 @@ export class StatsImpl implements Stats {
|
||||
boatSendTroops(
|
||||
player: Player,
|
||||
target: Player | TerraNullius,
|
||||
troops: number,
|
||||
troops: BigIntLike,
|
||||
): void {
|
||||
this._addBoat(player, "trans", BOAT_INDEX_SENT, 1);
|
||||
}
|
||||
@@ -175,12 +189,12 @@ export class StatsImpl implements Stats {
|
||||
boatArriveTroops(
|
||||
player: Player,
|
||||
target: Player | TerraNullius,
|
||||
troops: number,
|
||||
troops: BigIntLike,
|
||||
): void {
|
||||
this._addBoat(player, "trans", BOAT_INDEX_ARRIVE, 1);
|
||||
}
|
||||
|
||||
boatDestroyTroops(player: Player, target: Player, troops: number): void {
|
||||
boatDestroyTroops(player: Player, target: Player, troops: BigIntLike): void {
|
||||
this._addBoat(player, "trans", BOAT_INDEX_DESTROY, 1);
|
||||
}
|
||||
|
||||
@@ -204,11 +218,11 @@ export class StatsImpl implements Stats {
|
||||
this._addBomb(player, type, BOMB_INDEX_INTERCEPT, 1);
|
||||
}
|
||||
|
||||
goldWork(player: Player, gold: number): void {
|
||||
goldWork(player: Player, gold: BigIntLike): void {
|
||||
this._addGold(player, GOLD_INDEX_WORK, gold);
|
||||
}
|
||||
|
||||
goldWar(player: Player, captured: Player, gold: number): void {
|
||||
goldWar(player: Player, captured: Player, gold: BigIntLike): void {
|
||||
this._addGold(player, GOLD_INDEX_WAR, gold);
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ interface NationMapModule {
|
||||
// Mapping from GameMap enum values to file names
|
||||
const MAP_FILE_NAMES: Record<GameMapType, string> = {
|
||||
[GameMapType.World]: "WorldMap",
|
||||
[GameMapType.WorldMapGiant]: "WorldMapGiant",
|
||||
[GameMapType.Europe]: "Europe",
|
||||
[GameMapType.Mena]: "Mena",
|
||||
[GameMapType.NorthAmerica]: "NorthAmerica",
|
||||
@@ -40,7 +41,6 @@ const MAP_FILE_NAMES: Record<GameMapType, string> = {
|
||||
[GameMapType.Iceland]: "Iceland",
|
||||
[GameMapType.Japan]: "Japan",
|
||||
[GameMapType.BetweenTwoSeas]: "BetweenTwoSeas",
|
||||
[GameMapType.KnownWorld]: "KnownWorld",
|
||||
[GameMapType.FaroeIslands]: "FaroeIslands",
|
||||
[GameMapType.DeglaciatedAntarctica]: "DeglaciatedAntarctica",
|
||||
[GameMapType.EuropeClassic]: "EuropeClassic",
|
||||
|
||||
+26
-16
@@ -21,14 +21,13 @@ export class UnitImpl implements Unit {
|
||||
private _lastTile: TileRef;
|
||||
private _retreating: boolean = false;
|
||||
private _targetedBySAM = false;
|
||||
private _interceptedBySAM = false;
|
||||
private _reachedTarget = false;
|
||||
private _lastSetSafeFromPirates: number; // Only for trade ships
|
||||
private _constructionType: UnitType | undefined;
|
||||
private _lastOwner: PlayerImpl | null = null;
|
||||
private _troops: number;
|
||||
private _cooldownStartTick: Tick | null = null;
|
||||
private _pathCache: Map<TileRef, TileRef> = new Map();
|
||||
|
||||
private _patrolTile: TileRef | undefined;
|
||||
constructor(
|
||||
private _type: UnitType,
|
||||
private mg: GameImpl,
|
||||
@@ -39,12 +38,17 @@ export class UnitImpl implements Unit {
|
||||
) {
|
||||
this._lastTile = _tile;
|
||||
this._health = toInt(this.mg.unitInfo(_type).maxHealth ?? 1);
|
||||
|
||||
this._targetTile =
|
||||
"targetTile" in params ? (params.targetTile ?? undefined) : undefined;
|
||||
this._troops = "troops" in params ? (params.troops ?? 0) : 0;
|
||||
this._lastSetSafeFromPirates =
|
||||
"lastSetSafeFromPirates" in params
|
||||
? (params.lastSetSafeFromPirates ?? 0)
|
||||
: 0;
|
||||
this._patrolTile =
|
||||
"patrolTile" in params ? (params.patrolTile ?? undefined) : undefined;
|
||||
this._targetUnit =
|
||||
"targetUnit" in params ? (params.targetUnit ?? undefined) : undefined;
|
||||
|
||||
switch (this._type) {
|
||||
case UnitType.Warship:
|
||||
@@ -56,6 +60,19 @@ export class UnitImpl implements Unit {
|
||||
this.mg.stats().unitBuild(_owner, this._type);
|
||||
}
|
||||
}
|
||||
|
||||
setPatrolTile(tile: TileRef): void {
|
||||
this._patrolTile = tile;
|
||||
}
|
||||
|
||||
patrolTile(): TileRef | undefined {
|
||||
return this._patrolTile;
|
||||
}
|
||||
|
||||
isUnit(): this is Unit {
|
||||
return true;
|
||||
}
|
||||
|
||||
touch(): void {
|
||||
this.mg.addUpdate(this.toUpdate());
|
||||
}
|
||||
@@ -66,13 +83,6 @@ export class UnitImpl implements Unit {
|
||||
return this._targetTile;
|
||||
}
|
||||
|
||||
cachePut(from: TileRef, to: TileRef): void {
|
||||
this._pathCache.set(from, to);
|
||||
}
|
||||
cacheGet(from: TileRef): TileRef | undefined {
|
||||
return this._pathCache.get(from);
|
||||
}
|
||||
|
||||
id() {
|
||||
return this._id;
|
||||
}
|
||||
@@ -86,7 +96,7 @@ export class UnitImpl implements Unit {
|
||||
ownerID: this._owner.smallID(),
|
||||
lastOwnerID: this._lastOwner?.smallID(),
|
||||
isActive: this._active,
|
||||
wasIntercepted: this._interceptedBySAM,
|
||||
reachedTarget: this._reachedTarget,
|
||||
retreating: this._retreating,
|
||||
pos: this._tile,
|
||||
lastPos: this._lastTile,
|
||||
@@ -306,12 +316,12 @@ export class UnitImpl implements Unit {
|
||||
return this._targetedBySAM;
|
||||
}
|
||||
|
||||
setInterceptedBySam(): void {
|
||||
this._interceptedBySAM = true;
|
||||
setReachedTarget(): void {
|
||||
this._reachedTarget = true;
|
||||
}
|
||||
|
||||
interceptedBySam(): boolean {
|
||||
return this._interceptedBySAM;
|
||||
reachedTarget(): boolean {
|
||||
return this._reachedTarget;
|
||||
}
|
||||
|
||||
setSafeFromPirates(): void {
|
||||
|
||||
@@ -9,7 +9,7 @@ export class MiniAStar implements AStar {
|
||||
constructor(
|
||||
private gameMap: GameMap,
|
||||
private miniMap: GameMap,
|
||||
src: TileRef | TileRef[],
|
||||
private src: TileRef | TileRef[],
|
||||
private dst: TileRef,
|
||||
iterations: number,
|
||||
maxTries: number,
|
||||
@@ -41,16 +41,52 @@ export class MiniAStar implements AStar {
|
||||
}
|
||||
|
||||
reconstructPath(): TileRef[] {
|
||||
const upscaled = upscalePath(
|
||||
this.aStar
|
||||
.reconstructPath()
|
||||
.map((tr) => new Cell(this.miniMap.x(tr), this.miniMap.y(tr))),
|
||||
let cellSrc: Cell | undefined;
|
||||
if (!Array.isArray(this.src)) {
|
||||
cellSrc = new Cell(this.gameMap.x(this.src), this.gameMap.y(this.src));
|
||||
}
|
||||
const cellDst = new Cell(
|
||||
this.gameMap.x(this.dst),
|
||||
this.gameMap.y(this.dst),
|
||||
);
|
||||
const upscaled = fixExtremes(
|
||||
upscalePath(
|
||||
this.aStar
|
||||
.reconstructPath()
|
||||
.map((tr) => new Cell(this.miniMap.x(tr), this.miniMap.y(tr))),
|
||||
),
|
||||
cellDst,
|
||||
cellSrc,
|
||||
);
|
||||
upscaled.push(new Cell(this.gameMap.x(this.dst), this.gameMap.y(this.dst)));
|
||||
return upscaled.map((c) => this.gameMap.ref(c.x, c.y));
|
||||
}
|
||||
}
|
||||
|
||||
function fixExtremes(upscaled: Cell[], cellDst: Cell, cellSrc?: Cell): Cell[] {
|
||||
if (cellSrc !== undefined) {
|
||||
const srcIndex = findCell(upscaled, cellSrc);
|
||||
if (srcIndex === -1) {
|
||||
// didnt find the start tile in the path
|
||||
upscaled.unshift(cellSrc);
|
||||
} else if (srcIndex !== 0) {
|
||||
// found start tile but not at the start
|
||||
// remove all tiles before the start tile
|
||||
upscaled = upscaled.slice(srcIndex);
|
||||
}
|
||||
}
|
||||
|
||||
const dstIndex = findCell(upscaled, cellDst);
|
||||
if (dstIndex === -1) {
|
||||
// didnt find the dst tile in the path
|
||||
upscaled.push(cellDst);
|
||||
} else if (dstIndex !== upscaled.length - 1) {
|
||||
// found dst tile but not at the end
|
||||
// remove all tiles after the dst tile
|
||||
upscaled = upscaled.slice(0, dstIndex + 1);
|
||||
}
|
||||
return upscaled;
|
||||
}
|
||||
|
||||
function upscalePath(path: Cell[], scaleFactor: number = 2): Cell[] {
|
||||
// Scale up each point
|
||||
const scaledPath = path.map(
|
||||
@@ -92,3 +128,12 @@ function upscalePath(path: Cell[], scaleFactor: number = 2): Cell[] {
|
||||
|
||||
return smoothPath;
|
||||
}
|
||||
|
||||
function findCell(upscaled: Cell[], cellDst: Cell): number {
|
||||
for (let i = 0; i < upscaled.length; i++) {
|
||||
if (upscaled[i].x === cellDst.x && upscaled[i].y === cellDst.y) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createGameRunner, GameRunner } from "../GameRunner";
|
||||
import { GameUpdateViewData } from "../game/GameUpdates";
|
||||
import {
|
||||
AttackAveragePositionResultMessage,
|
||||
InitializedMessage,
|
||||
MainThreadMessage,
|
||||
PlayerActionsResultMessage,
|
||||
@@ -121,6 +122,27 @@ ctx.addEventListener("message", async (e: MessageEvent<MainThreadMessage>) => {
|
||||
throw error;
|
||||
}
|
||||
break;
|
||||
case "attack_average_position":
|
||||
if (!gameRunner) {
|
||||
throw new Error("Game runner not initialized");
|
||||
}
|
||||
|
||||
try {
|
||||
const averagePosition = (await gameRunner).attackAveragePosition(
|
||||
message.playerID,
|
||||
message.attackID,
|
||||
);
|
||||
sendMessage({
|
||||
type: "attack_average_position_result",
|
||||
id: message.id,
|
||||
x: averagePosition ? averagePosition.x : null,
|
||||
y: averagePosition ? averagePosition.y : null,
|
||||
} as AttackAveragePositionResultMessage);
|
||||
} catch (error) {
|
||||
console.error("Failed to get attack average position:", error);
|
||||
throw error;
|
||||
}
|
||||
break;
|
||||
case "transport_ship_spawn":
|
||||
if (!gameRunner) {
|
||||
throw new Error("Game runner not initialized");
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
Cell,
|
||||
PlayerActions,
|
||||
PlayerBorderTiles,
|
||||
PlayerID,
|
||||
@@ -189,6 +190,41 @@ export class WorkerClient {
|
||||
});
|
||||
}
|
||||
|
||||
attackAveragePosition(
|
||||
playerID: number,
|
||||
attackID: string,
|
||||
): Promise<Cell | null> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.isInitialized) {
|
||||
reject(new Error("Worker not initialized"));
|
||||
return;
|
||||
}
|
||||
|
||||
const messageId = generateID();
|
||||
|
||||
this.messageHandlers.set(messageId, (message) => {
|
||||
if (
|
||||
message.type === "attack_average_position_result" &&
|
||||
message.x !== undefined &&
|
||||
message.y !== undefined
|
||||
) {
|
||||
if (message.x === null || message.y === null) {
|
||||
resolve(null);
|
||||
} else {
|
||||
resolve(new Cell(message.x, message.y));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.worker.postMessage({
|
||||
type: "attack_average_position",
|
||||
id: messageId,
|
||||
playerID: playerID,
|
||||
attackID: attackID,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
transportShipSpawn(
|
||||
playerID: PlayerID,
|
||||
targetTile: TileRef,
|
||||
|
||||
@@ -20,6 +20,8 @@ export type WorkerMessageType =
|
||||
| "player_profile_result"
|
||||
| "player_border_tiles"
|
||||
| "player_border_tiles_result"
|
||||
| "attack_average_position"
|
||||
| "attack_average_position_result"
|
||||
| "transport_ship_spawn"
|
||||
| "transport_ship_spawn_result";
|
||||
|
||||
@@ -87,6 +89,18 @@ export interface PlayerBorderTilesResultMessage extends BaseWorkerMessage {
|
||||
result: PlayerBorderTiles;
|
||||
}
|
||||
|
||||
export interface AttackAveragePositionMessage extends BaseWorkerMessage {
|
||||
type: "attack_average_position";
|
||||
playerID: number;
|
||||
attackID: string;
|
||||
}
|
||||
|
||||
export interface AttackAveragePositionResultMessage extends BaseWorkerMessage {
|
||||
type: "attack_average_position_result";
|
||||
x: number | null;
|
||||
y: number | null;
|
||||
}
|
||||
|
||||
export interface TransportShipSpawnMessage extends BaseWorkerMessage {
|
||||
type: "transport_ship_spawn";
|
||||
playerID: PlayerID;
|
||||
@@ -106,6 +120,7 @@ export type MainThreadMessage =
|
||||
| PlayerActionsMessage
|
||||
| PlayerProfileMessage
|
||||
| PlayerBorderTilesMessage
|
||||
| AttackAveragePositionMessage
|
||||
| TransportShipSpawnMessage;
|
||||
|
||||
// Message send from worker
|
||||
@@ -115,4 +130,5 @@ export type WorkerMessage =
|
||||
| PlayerActionsResultMessage
|
||||
| PlayerProfileResultMessage
|
||||
| PlayerBorderTilesResultMessage
|
||||
| AttackAveragePositionResultMessage
|
||||
| TransportShipSpawnResultMessage;
|
||||
|
||||
@@ -7,6 +7,7 @@ const maps = [
|
||||
"Africa",
|
||||
"Asia",
|
||||
"WorldMap",
|
||||
"WorldMapGiant",
|
||||
"BlackSea",
|
||||
"Europe",
|
||||
"EuropeClassic",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { S3 } from "@aws-sdk/client-s3";
|
||||
import { getServerConfigFromServer } from "../core/configuration/ConfigLoader";
|
||||
import { AnalyticsRecord, GameID, GameRecord } from "../core/Schemas";
|
||||
import { replacer } from "../core/Util";
|
||||
import { logger } from "./Logger";
|
||||
|
||||
const config = getServerConfigFromServer();
|
||||
@@ -60,7 +61,7 @@ async function archiveAnalyticsToR2(gameRecord: GameRecord) {
|
||||
await r2.putObject({
|
||||
Bucket: bucket,
|
||||
Key: `${analyticsFolder}/${analyticsKey}`,
|
||||
Body: JSON.stringify(analyticsData),
|
||||
Body: JSON.stringify(analyticsData, replacer),
|
||||
ContentType: "application/json",
|
||||
});
|
||||
|
||||
@@ -78,19 +79,18 @@ async function archiveAnalyticsToR2(gameRecord: GameRecord) {
|
||||
|
||||
async function archiveFullGameToR2(gameRecord: GameRecord) {
|
||||
// Create a deep copy to avoid modifying the original
|
||||
const recordCopy = JSON.parse(JSON.stringify(gameRecord));
|
||||
const recordCopy = structuredClone(gameRecord);
|
||||
|
||||
// Players may see this so make sure to clear PII
|
||||
recordCopy.players.forEach((p) => {
|
||||
p.ip = "REDACTED";
|
||||
recordCopy.info.players.forEach((p) => {
|
||||
p.persistentID = "REDACTED";
|
||||
});
|
||||
|
||||
try {
|
||||
await r2.putObject({
|
||||
Bucket: bucket,
|
||||
Key: `${gameFolder}/${recordCopy.id}`,
|
||||
Body: JSON.stringify(recordCopy),
|
||||
Key: `${gameFolder}/${recordCopy.info.gameID}`,
|
||||
Body: JSON.stringify(recordCopy, replacer),
|
||||
ContentType: "application/json",
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
import WebSocket from "ws";
|
||||
import { TokenPayload } from "../core/ApiSchemas";
|
||||
import { PlayerID, Tick } from "../core/game/Game";
|
||||
import { Tick } from "../core/game/Game";
|
||||
import { ClientID } from "../core/Schemas";
|
||||
import { generateID } from "../core/Util";
|
||||
|
||||
export class Client {
|
||||
public lastPing: number;
|
||||
|
||||
public hashes: Map<Tick, number> = new Map();
|
||||
|
||||
public readonly playerID: PlayerID = generateID();
|
||||
|
||||
constructor(
|
||||
public readonly clientID: ClientID,
|
||||
public readonly persistentID: string,
|
||||
|
||||
+49
-32
@@ -56,6 +56,7 @@ export class GameServer {
|
||||
private _hasPrestarted = false;
|
||||
|
||||
private kickedClients: Set<ClientID> = new Set();
|
||||
private outOfSyncClients: Set<ClientID> = new Set();
|
||||
|
||||
constructor(
|
||||
public readonly id: string,
|
||||
@@ -200,7 +201,15 @@ export class GameServer {
|
||||
client.hashes.set(clientMsg.turnNumber, clientMsg.hash);
|
||||
}
|
||||
if (clientMsg.type === "winner") {
|
||||
if (
|
||||
this.outOfSyncClients.has(client.clientID) ||
|
||||
this.kickedClients.has(client.clientID) ||
|
||||
this.winner !== null
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.winner = clientMsg;
|
||||
this.archiveGame();
|
||||
}
|
||||
} catch (error) {
|
||||
this.log.info(
|
||||
@@ -291,7 +300,6 @@ export class GameServer {
|
||||
gameID: this.id,
|
||||
config: this.gameConfig,
|
||||
players: this.activeClients.map((c) => ({
|
||||
playerID: c.playerID,
|
||||
username: c.username,
|
||||
clientID: c.clientID,
|
||||
pattern: c.pattern,
|
||||
@@ -383,40 +391,16 @@ export class GameServer {
|
||||
}
|
||||
this.log.info(`ending game with ${this.turns.length} turns`);
|
||||
try {
|
||||
if (this.allClients.size > 0) {
|
||||
const playerRecords: PlayerRecord[] = Array.from(
|
||||
this.allClients.values(),
|
||||
).map((client) => {
|
||||
const stats = this.winner?.allPlayersStats[client.clientID];
|
||||
if (stats === undefined) {
|
||||
this.log.warn(
|
||||
`Unable to find stats for clientID ${client.clientID}`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
playerID: client.playerID,
|
||||
clientID: client.clientID,
|
||||
username: client.username,
|
||||
persistentID: client.persistentID,
|
||||
stats,
|
||||
} satisfies PlayerRecord;
|
||||
});
|
||||
archive(
|
||||
createGameRecord(
|
||||
this.id,
|
||||
this.gameStartInfo.config,
|
||||
playerRecords,
|
||||
this.turns,
|
||||
this._startTime ?? 0,
|
||||
Date.now(),
|
||||
this.winner?.winner ?? null,
|
||||
this.winner?.winnerType ?? null,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
if (this.allClients.size === 0) {
|
||||
this.log.info("no clients joined, not archiving game", {
|
||||
gameID: this.id,
|
||||
});
|
||||
} else if (this.winner !== null) {
|
||||
this.log.info("game already archived", {
|
||||
gameID: this.id,
|
||||
});
|
||||
} else {
|
||||
this.archiveGame();
|
||||
}
|
||||
} catch (error) {
|
||||
let errorDetails;
|
||||
@@ -550,6 +534,38 @@ export class GameServer {
|
||||
}
|
||||
}
|
||||
|
||||
private archiveGame() {
|
||||
this.log.info("archiving game", {
|
||||
gameID: this.id,
|
||||
winner: this.winner?.winner,
|
||||
});
|
||||
const playerRecords: PlayerRecord[] = Array.from(
|
||||
this.allClients.values(),
|
||||
).map((client) => {
|
||||
const stats = this.winner?.allPlayersStats[client.clientID];
|
||||
if (stats === undefined) {
|
||||
this.log.warn(`Unable to find stats for clientID ${client.clientID}`);
|
||||
}
|
||||
return {
|
||||
clientID: client.clientID,
|
||||
username: client.username,
|
||||
persistentID: client.persistentID,
|
||||
stats,
|
||||
} satisfies PlayerRecord;
|
||||
});
|
||||
archive(
|
||||
createGameRecord(
|
||||
this.id,
|
||||
this.gameStartInfo.config,
|
||||
playerRecords,
|
||||
this.turns,
|
||||
this._startTime ?? 0,
|
||||
Date.now(),
|
||||
this.winner?.winner,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private handleSynchronization() {
|
||||
if (this.activeClients.length <= 1) {
|
||||
return;
|
||||
@@ -587,6 +603,7 @@ export class GameServer {
|
||||
|
||||
const desyncMsg = JSON.stringify(serverDesync.data);
|
||||
for (const c of outOfSyncClients) {
|
||||
this.outOfSyncClients.add(c.clientID);
|
||||
if (this.sentDesyncMessageClients.has(c.clientID)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user