mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-02 15:18:11 +00:00
Merge branch 'main' into canbuildtransport-perf
This commit is contained in:
@@ -390,7 +390,7 @@ export class AccountButton extends LitElement {
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="fixed top-4 right-4 z-[9999]">
|
||||
<div class="fixed top-4 right-4 z-[9998]">
|
||||
<button
|
||||
@click="${this.open}"
|
||||
class="w-12 h-12 bg-blue-600 hover:bg-blue-700 text-white rounded-full shadow-2xl hover:shadow-3xl transition-all duration-200 flex items-center justify-center text-xl focus:outline-none focus:ring-4 focus:ring-blue-500 focus:ring-offset-4"
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
InputHandler,
|
||||
MouseMoveEvent,
|
||||
MouseUpEvent,
|
||||
TickMetricsEvent,
|
||||
} from "./InputHandler";
|
||||
import { endGame, startGame, startTime } from "./LocalPersistantStats";
|
||||
import { getPersistentID } from "./Main";
|
||||
@@ -51,6 +52,7 @@ import {
|
||||
} from "./Transport";
|
||||
import { createCanvas } from "./Utils";
|
||||
import { createRenderer, GameRenderer } from "./graphics/GameRenderer";
|
||||
import { GoToPlayerEvent } from "./graphics/layers/Leaderboard";
|
||||
import SoundManager from "./sound/SoundManager";
|
||||
|
||||
export interface LobbyConfig {
|
||||
@@ -205,6 +207,10 @@ export class ClientGameRunner {
|
||||
|
||||
private lastMessageTime: number = 0;
|
||||
private connectionCheckInterval: NodeJS.Timeout | null = null;
|
||||
private goToPlayerTimeout: NodeJS.Timeout | null = null;
|
||||
|
||||
private lastTickReceiveTime: number = 0;
|
||||
private currentTickDelay: number | undefined = undefined;
|
||||
|
||||
constructor(
|
||||
private lobby: LobbyConfig,
|
||||
@@ -243,6 +249,7 @@ export class ClientGameRunner {
|
||||
startTime(),
|
||||
Date.now(),
|
||||
update.winner,
|
||||
this.lobby.gameStartInfo.lobbyCreatedAt,
|
||||
);
|
||||
endGame(record);
|
||||
}
|
||||
@@ -296,6 +303,14 @@ export class ClientGameRunner {
|
||||
this.gameView.update(gu);
|
||||
this.renderer.tick();
|
||||
|
||||
// Emit tick metrics event for performance overlay
|
||||
this.eventBus.emit(
|
||||
new TickMetricsEvent(gu.tickExecutionDuration, this.currentTickDelay),
|
||||
);
|
||||
|
||||
// Reset tick delay for next measurement
|
||||
this.currentTickDelay = undefined;
|
||||
|
||||
if (gu.updates[GameUpdateType.Win].length > 0) {
|
||||
this.saveGame(gu.updates[GameUpdateType.Win][0]);
|
||||
}
|
||||
@@ -318,6 +333,39 @@ export class ClientGameRunner {
|
||||
if (message.type === "start") {
|
||||
this.hasJoined = true;
|
||||
console.log("starting game!");
|
||||
|
||||
if (this.gameView.config().isRandomSpawn()) {
|
||||
const goToPlayer = () => {
|
||||
const myPlayer = this.gameView.myPlayer();
|
||||
|
||||
if (this.gameView.inSpawnPhase() && !myPlayer?.hasSpawned()) {
|
||||
this.goToPlayerTimeout = setTimeout(goToPlayer, 1000);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!myPlayer) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.gameView.inSpawnPhase() && !myPlayer.hasSpawned()) {
|
||||
showErrorModal(
|
||||
"spawn_failed",
|
||||
translateText("error_modal.spawn_failed.description"),
|
||||
this.lobby.gameID,
|
||||
this.lobby.clientID,
|
||||
true,
|
||||
false,
|
||||
translateText("error_modal.spawn_failed.title"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.eventBus.emit(new GoToPlayerEvent(myPlayer));
|
||||
};
|
||||
|
||||
goToPlayer();
|
||||
}
|
||||
|
||||
for (const turn of message.turns) {
|
||||
if (turn.turnNumber < this.turnsSeen) {
|
||||
continue;
|
||||
@@ -363,6 +411,14 @@ export class ClientGameRunner {
|
||||
this.transport.joinGame(0);
|
||||
return;
|
||||
}
|
||||
// Track when we receive the turn to calculate delay
|
||||
const now = Date.now();
|
||||
if (this.lastTickReceiveTime > 0) {
|
||||
// Calculate delay between receiving turn messages
|
||||
this.currentTickDelay = now - this.lastTickReceiveTime;
|
||||
}
|
||||
this.lastTickReceiveTime = now;
|
||||
|
||||
if (this.turnsSeen !== message.turn.turnNumber) {
|
||||
console.error(
|
||||
`got wrong turn have turns ${this.turnsSeen}, received turn ${message.turn.turnNumber}`,
|
||||
@@ -387,6 +443,10 @@ export class ClientGameRunner {
|
||||
clearInterval(this.connectionCheckInterval);
|
||||
this.connectionCheckInterval = null;
|
||||
}
|
||||
if (this.goToPlayerTimeout) {
|
||||
clearTimeout(this.goToPlayerTimeout);
|
||||
this.goToPlayerTimeout = null;
|
||||
}
|
||||
}
|
||||
|
||||
private inputEvent(event: MouseUpEvent) {
|
||||
@@ -405,7 +465,8 @@ export class ClientGameRunner {
|
||||
if (
|
||||
this.gameView.isLand(tile) &&
|
||||
!this.gameView.hasOwner(tile) &&
|
||||
this.gameView.inSpawnPhase()
|
||||
this.gameView.inSpawnPhase() &&
|
||||
!this.gameView.config().isRandomSpawn()
|
||||
) {
|
||||
this.eventBus.emit(new SendSpawnIntentEvent(tile));
|
||||
return;
|
||||
|
||||
@@ -93,7 +93,7 @@ export class GameStartingModal extends LitElement {
|
||||
}
|
||||
|
||||
.copyright {
|
||||
font-size: 32px;
|
||||
font-size: 20px;
|
||||
margin-top: 20px;
|
||||
margin-bottom: 10px;
|
||||
opacity: 1;
|
||||
@@ -118,7 +118,7 @@ export class GameStartingModal extends LitElement {
|
||||
render() {
|
||||
return html`
|
||||
<div class="modal ${this.isVisible ? "visible" : ""}">
|
||||
<div class="copyright">© OpenFront</div>
|
||||
<div class="copyright">© OpenFront and Contributors</div>
|
||||
<a
|
||||
href="https://github.com/openfrontio/OpenFrontIO/blob/main/CREDITS.md"
|
||||
target="_blank"
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
import { generateID } from "../core/Util";
|
||||
import "./components/baseComponents/Modal";
|
||||
import "./components/Difficulties";
|
||||
import "./components/LobbyTeamView";
|
||||
import "./components/Maps";
|
||||
import { JoinLobbyEvent } from "./Main";
|
||||
import { renderUnitTypeOptions } from "./utilities/RenderUnitTypeOptions";
|
||||
@@ -48,6 +49,7 @@ export class HostLobbyModal extends LitElement {
|
||||
@state() private maxTimer: boolean = false;
|
||||
@state() private maxTimerValue: number | undefined = undefined;
|
||||
@state() private instantBuild: boolean = false;
|
||||
@state() private randomSpawn: boolean = false;
|
||||
@state() private compactMap: boolean = false;
|
||||
@state() private lobbyId = "";
|
||||
@state() private copySuccess = false;
|
||||
@@ -390,6 +392,22 @@ export class HostLobbyModal extends LitElement {
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label
|
||||
for="random-spawn"
|
||||
class="option-card ${this.randomSpawn ? "selected" : ""}"
|
||||
>
|
||||
<div class="checkbox-icon"></div>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="random-spawn"
|
||||
@change=${this.handleRandomSpawnChange}
|
||||
.checked=${this.randomSpawn}
|
||||
/>
|
||||
<div class="option-card-title">
|
||||
${translateText("host_modal.random_spawn")}
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label
|
||||
for="donate-gold"
|
||||
class="option-card ${this.donateGold ? "selected" : ""}"
|
||||
@@ -537,27 +555,13 @@ export class HostLobbyModal extends LitElement {
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="players-list">
|
||||
${this.clients.map(
|
||||
(client) => html`
|
||||
<span class="player-tag">
|
||||
${client.username}
|
||||
${client.clientID === this.lobbyCreatorClientID
|
||||
? html`<span class="host-badge"
|
||||
>(${translateText("host_modal.host_badge")})</span
|
||||
>`
|
||||
: html`
|
||||
<button
|
||||
class="remove-player-btn"
|
||||
@click=${() => this.kickPlayer(client.clientID)}
|
||||
title="Remove ${client.username}"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
`}
|
||||
</span>
|
||||
`,
|
||||
)}
|
||||
<lobby-team-view
|
||||
.gameMode=${this.gameMode}
|
||||
.clients=${this.clients}
|
||||
.lobbyCreatorClientID=${this.lobbyCreatorClientID}
|
||||
.teamCount=${this.teamCount}
|
||||
.onKickPlayer=${(clientID: string) => this.kickPlayer(clientID)}
|
||||
></lobby-team-view>
|
||||
</div>
|
||||
|
||||
<div class="start-game-button-container">
|
||||
@@ -668,6 +672,11 @@ export class HostLobbyModal extends LitElement {
|
||||
this.putGameConfig();
|
||||
}
|
||||
|
||||
private handleRandomSpawnChange(e: Event) {
|
||||
this.randomSpawn = Boolean((e.target as HTMLInputElement).checked);
|
||||
this.putGameConfig();
|
||||
}
|
||||
|
||||
private handleInfiniteGoldChange(e: Event) {
|
||||
this.infiniteGold = Boolean((e.target as HTMLInputElement).checked);
|
||||
this.putGameConfig();
|
||||
@@ -749,6 +758,7 @@ export class HostLobbyModal extends LitElement {
|
||||
infiniteTroops: this.infiniteTroops,
|
||||
donateTroops: this.donateTroops,
|
||||
instantBuild: this.instantBuild,
|
||||
randomSpawn: this.randomSpawn,
|
||||
gameMode: this.gameMode,
|
||||
disabledUnits: this.disabledUnits,
|
||||
playerTeams: this.teamCount,
|
||||
|
||||
+35
-13
@@ -18,6 +18,12 @@ export class MouseOverEvent implements GameEvent {
|
||||
public readonly y: number,
|
||||
) {}
|
||||
}
|
||||
export class TouchEvent implements GameEvent {
|
||||
constructor(
|
||||
public readonly x: number,
|
||||
public readonly y: number,
|
||||
) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Event emitted when a unit is selected or deselected
|
||||
@@ -79,6 +85,10 @@ export class ToggleStructureEvent implements GameEvent {
|
||||
constructor(public readonly structureTypes: UnitType[] | null) {}
|
||||
}
|
||||
|
||||
export class GhostStructureChangedEvent implements GameEvent {
|
||||
constructor(public readonly ghostStructure: UnitType | null) {}
|
||||
}
|
||||
|
||||
export class ShowBuildMenuEvent implements GameEvent {
|
||||
constructor(
|
||||
public readonly x: number,
|
||||
@@ -115,6 +125,13 @@ export class AutoUpgradeEvent implements GameEvent {
|
||||
) {}
|
||||
}
|
||||
|
||||
export class TickMetricsEvent implements GameEvent {
|
||||
constructor(
|
||||
public readonly tickExecutionDuration?: number,
|
||||
public readonly tickDelay?: number,
|
||||
) {}
|
||||
}
|
||||
|
||||
export class InputHandler {
|
||||
private lastPointerX: number = 0;
|
||||
private lastPointerY: number = 0;
|
||||
@@ -284,7 +301,7 @@ export class InputHandler {
|
||||
if (e.code === "Escape") {
|
||||
e.preventDefault();
|
||||
this.eventBus.emit(new CloseViewEvent());
|
||||
this.uiState.ghostStructure = null;
|
||||
this.setGhostStructure(null);
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -352,52 +369,52 @@ export class InputHandler {
|
||||
|
||||
if (e.code === this.keybinds.buildCity) {
|
||||
e.preventDefault();
|
||||
this.uiState.ghostStructure = UnitType.City;
|
||||
this.setGhostStructure(UnitType.City);
|
||||
}
|
||||
|
||||
if (e.code === this.keybinds.buildFactory) {
|
||||
e.preventDefault();
|
||||
this.uiState.ghostStructure = UnitType.Factory;
|
||||
this.setGhostStructure(UnitType.Factory);
|
||||
}
|
||||
|
||||
if (e.code === this.keybinds.buildPort) {
|
||||
e.preventDefault();
|
||||
this.uiState.ghostStructure = UnitType.Port;
|
||||
this.setGhostStructure(UnitType.Port);
|
||||
}
|
||||
|
||||
if (e.code === this.keybinds.buildDefensePost) {
|
||||
e.preventDefault();
|
||||
this.uiState.ghostStructure = UnitType.DefensePost;
|
||||
this.setGhostStructure(UnitType.DefensePost);
|
||||
}
|
||||
|
||||
if (e.code === this.keybinds.buildMissileSilo) {
|
||||
e.preventDefault();
|
||||
this.uiState.ghostStructure = UnitType.MissileSilo;
|
||||
this.setGhostStructure(UnitType.MissileSilo);
|
||||
}
|
||||
|
||||
if (e.code === this.keybinds.buildSamLauncher) {
|
||||
e.preventDefault();
|
||||
this.uiState.ghostStructure = UnitType.SAMLauncher;
|
||||
this.setGhostStructure(UnitType.SAMLauncher);
|
||||
}
|
||||
|
||||
if (e.code === this.keybinds.buildAtomBomb) {
|
||||
e.preventDefault();
|
||||
this.uiState.ghostStructure = UnitType.AtomBomb;
|
||||
this.setGhostStructure(UnitType.AtomBomb);
|
||||
}
|
||||
|
||||
if (e.code === this.keybinds.buildHydrogenBomb) {
|
||||
e.preventDefault();
|
||||
this.uiState.ghostStructure = UnitType.HydrogenBomb;
|
||||
this.setGhostStructure(UnitType.HydrogenBomb);
|
||||
}
|
||||
|
||||
if (e.code === this.keybinds.buildWarship) {
|
||||
e.preventDefault();
|
||||
this.uiState.ghostStructure = UnitType.Warship;
|
||||
this.setGhostStructure(UnitType.Warship);
|
||||
}
|
||||
|
||||
if (e.code === this.keybinds.buildMIRV) {
|
||||
e.preventDefault();
|
||||
this.uiState.ghostStructure = UnitType.MIRV;
|
||||
this.setGhostStructure(UnitType.MIRV);
|
||||
}
|
||||
|
||||
// Shift-D to toggle performance overlay
|
||||
@@ -465,7 +482,7 @@ export class InputHandler {
|
||||
Math.abs(event.y - this.lastPointerDownY);
|
||||
if (dist < 10) {
|
||||
if (event.pointerType === "touch") {
|
||||
this.eventBus.emit(new ContextMenuEvent(event.clientX, event.clientY));
|
||||
this.eventBus.emit(new TouchEvent(event.x, event.y));
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
@@ -538,12 +555,17 @@ export class InputHandler {
|
||||
private onContextMenu(event: MouseEvent) {
|
||||
event.preventDefault();
|
||||
if (this.uiState.ghostStructure !== null) {
|
||||
this.uiState.ghostStructure = null;
|
||||
this.setGhostStructure(null);
|
||||
return;
|
||||
}
|
||||
this.eventBus.emit(new ContextMenuEvent(event.clientX, event.clientY));
|
||||
}
|
||||
|
||||
private setGhostStructure(ghostStructure: UnitType | null) {
|
||||
this.uiState.ghostStructure = ghostStructure;
|
||||
this.eventBus.emit(new GhostStructureChangedEvent(ghostStructure));
|
||||
}
|
||||
|
||||
private getPinchDistance(): number {
|
||||
const pointerEvents = Array.from(this.pointers.values());
|
||||
const dx = pointerEvents[0].clientX - pointerEvents[1].clientX;
|
||||
|
||||
@@ -8,9 +8,11 @@ import bn from "../../resources/lang/bn.json";
|
||||
import cs from "../../resources/lang/cs.json";
|
||||
import da from "../../resources/lang/da.json";
|
||||
import de from "../../resources/lang/de.json";
|
||||
import el from "../../resources/lang/el.json";
|
||||
import en from "../../resources/lang/en.json";
|
||||
import eo from "../../resources/lang/eo.json";
|
||||
import es from "../../resources/lang/es.json";
|
||||
import fa from "../../resources/lang/fa.json";
|
||||
import fi from "../../resources/lang/fi.json";
|
||||
import fr from "../../resources/lang/fr.json";
|
||||
import gl from "../../resources/lang/gl.json";
|
||||
@@ -51,6 +53,7 @@ export class LangSelector extends LitElement {
|
||||
bg,
|
||||
bn,
|
||||
de,
|
||||
el,
|
||||
en,
|
||||
es,
|
||||
eo,
|
||||
@@ -71,6 +74,7 @@ export class LangSelector extends LitElement {
|
||||
cs,
|
||||
he,
|
||||
da,
|
||||
fa,
|
||||
fi,
|
||||
"sv-SE": sv_SE,
|
||||
"zh-CN": zh_CN,
|
||||
|
||||
@@ -64,7 +64,7 @@ export class LanguageModal extends LitElement {
|
||||
|
||||
return html`
|
||||
<aside
|
||||
class="fixed p-4 z-[1000] inset-0 bg-black/50 overflow-y-auto flex items-center justify-center"
|
||||
class="fixed p-4 z-[9999] inset-0 bg-black/50 overflow-y-auto flex items-center justify-center"
|
||||
>
|
||||
<div
|
||||
class="bg-gray-800/80 dark:bg-gray-900/90 backdrop-blur-md rounded-lg min-w-[340px] max-w-[480px] w-full"
|
||||
|
||||
@@ -84,6 +84,7 @@ export class LocalServer {
|
||||
type: "start",
|
||||
gameStartInfo: this.lobbyConfig.gameStartInfo,
|
||||
turns: [],
|
||||
lobbyCreatedAt: this.lobbyConfig.gameStartInfo.lobbyCreatedAt,
|
||||
} satisfies ServerStartGameMessage);
|
||||
}
|
||||
|
||||
@@ -235,7 +236,7 @@ export class LocalServer {
|
||||
}
|
||||
}
|
||||
|
||||
async function compress(data: string): Promise<Uint8Array> {
|
||||
async function compress(data: string): Promise<ArrayBuffer> {
|
||||
const stream = new CompressionStream("gzip");
|
||||
const writer = stream.writable.getWriter();
|
||||
const reader = stream.readable.getReader();
|
||||
@@ -264,5 +265,5 @@ async function compress(data: string): Promise<Uint8Array> {
|
||||
offset += chunk.length;
|
||||
}
|
||||
|
||||
return compressedData;
|
||||
return compressedData.buffer;
|
||||
}
|
||||
|
||||
+3
-17
@@ -23,10 +23,11 @@ import { LangSelector } from "./LangSelector";
|
||||
import { LanguageModal } from "./LanguageModal";
|
||||
import "./Matchmaking";
|
||||
import { MatchmakingModal } from "./Matchmaking";
|
||||
import { NewsModal } from "./NewsModal";
|
||||
import "./NewsModal";
|
||||
import "./PublicLobby";
|
||||
import { PublicLobby } from "./PublicLobby";
|
||||
import { SinglePlayerModal } from "./SinglePlayerModal";
|
||||
import "./StatsModal";
|
||||
import { TerritoryPatternsModal } from "./TerritoryPatternsModal";
|
||||
import { TokenLoginModal } from "./TokenLoginModal";
|
||||
import { SendKickPlayerIntentEvent } from "./Transport";
|
||||
@@ -38,8 +39,6 @@ import {
|
||||
incrementGamesPlayed,
|
||||
isInIframe,
|
||||
} from "./Utils";
|
||||
import "./components/NewsButton";
|
||||
import { NewsButton } from "./components/NewsButton";
|
||||
import "./components/baseComponents/Button";
|
||||
import "./components/baseComponents/Modal";
|
||||
import { getUserMe, isLoggedIn } from "./jwt";
|
||||
@@ -117,20 +116,6 @@ class Client {
|
||||
}
|
||||
gameVersion.innerText = version;
|
||||
|
||||
const newsModal = document.querySelector("news-modal") as NewsModal;
|
||||
if (!newsModal || !(newsModal instanceof NewsModal)) {
|
||||
console.warn("News modal element not found");
|
||||
}
|
||||
const newsButton = document.querySelector("news-button") as NewsButton;
|
||||
if (!newsButton) {
|
||||
console.warn("News button element not found");
|
||||
} else {
|
||||
console.log("News button element found");
|
||||
}
|
||||
|
||||
// Comment out to show news button.
|
||||
// newsButton.hidden = true;
|
||||
|
||||
const langSelector = document.querySelector(
|
||||
"lang-selector",
|
||||
) as LangSelector;
|
||||
@@ -524,6 +509,7 @@ class Client {
|
||||
"news-modal",
|
||||
"flag-input-modal",
|
||||
"account-button",
|
||||
"stats-button",
|
||||
"token-login",
|
||||
"matchmaking-modal",
|
||||
].forEach((tag) => {
|
||||
|
||||
@@ -2,6 +2,8 @@ import { LitElement, css, html } from "lit";
|
||||
import { resolveMarkdown } from "lit-markdown";
|
||||
import { customElement, property, query } from "lit/decorators.js";
|
||||
import changelog from "../../resources/changelog.md";
|
||||
import megaphone from "../../resources/images/Megaphone.svg";
|
||||
import version from "../../resources/version.txt";
|
||||
import { translateText } from "../client/Utils";
|
||||
import "./components/baseComponents/Button";
|
||||
import "./components/baseComponents/Modal";
|
||||
@@ -128,3 +130,49 @@ export class NewsModal extends LitElement {
|
||||
this.modalEl?.close();
|
||||
}
|
||||
}
|
||||
|
||||
@customElement("news-button")
|
||||
export class NewsButton extends LitElement {
|
||||
@query("news-modal") private newsModal!: NewsModal;
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.checkForNewVersion();
|
||||
}
|
||||
|
||||
private checkForNewVersion() {
|
||||
const lastSeenVersion = localStorage.getItem("last-seen-version");
|
||||
if (lastSeenVersion !== null && lastSeenVersion !== version) {
|
||||
setTimeout(() => {
|
||||
this.openNewsModel();
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
private openNewsModel() {
|
||||
localStorage.setItem("last-seen-version", version);
|
||||
this.newsModal.open();
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div class="flex relative">
|
||||
<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.openNewsModel}
|
||||
>
|
||||
<img
|
||||
class="size-[48px] dark:invert"
|
||||
src="${megaphone}"
|
||||
alt=${translateText("news.title")}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<news-modal></news-modal>
|
||||
`;
|
||||
}
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import { renderDuration, translateText } from "../client/Utils";
|
||||
import { GameMapType, GameMode } from "../core/game/Game";
|
||||
import { GameMapType, GameMode, HumansVsNations } from "../core/game/Game";
|
||||
import { GameID, GameInfo } from "../core/Schemas";
|
||||
import { generateID } from "../core/Util";
|
||||
import { JoinLobbyEvent } from "./Main";
|
||||
@@ -161,7 +161,9 @@ export class PublicLobby extends LitElement {
|
||||
>
|
||||
${lobby.gameConfig.gameMode === GameMode.Team
|
||||
? typeof teamCount === "string"
|
||||
? translateText(`public_lobby.teams_${teamCount}`)
|
||||
? teamCount === HumansVsNations
|
||||
? translateText("public_lobby.teams_hvn")
|
||||
: translateText(`public_lobby.teams_${teamCount}`)
|
||||
: translateText("public_lobby.teams", {
|
||||
num: teamCount ?? 0,
|
||||
})
|
||||
|
||||
@@ -44,6 +44,7 @@ export class SinglePlayerModal extends LitElement {
|
||||
@state() private maxTimer: boolean = false;
|
||||
@state() private maxTimerValue: number | undefined = undefined;
|
||||
@state() private instantBuild: boolean = false;
|
||||
@state() private randomSpawn: boolean = false;
|
||||
@state() private useRandomMap: boolean = false;
|
||||
@state() private gameMode: GameMode = GameMode.FFA;
|
||||
@state() private teamCount: TeamCountConfig = 2;
|
||||
@@ -293,6 +294,22 @@ export class SinglePlayerModal extends LitElement {
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label
|
||||
for="singleplayer-modal-random-spawn"
|
||||
class="option-card ${this.randomSpawn ? "selected" : ""}"
|
||||
>
|
||||
<div class="checkbox-icon"></div>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="singleplayer-modal-random-spawn"
|
||||
@change=${this.handleRandomSpawnChange}
|
||||
.checked=${this.randomSpawn}
|
||||
/>
|
||||
<div class="option-card-title">
|
||||
${translateText("single_modal.random_spawn")}
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label
|
||||
for="singleplayer-modal-infinite-gold"
|
||||
class="option-card ${this.infiniteGold ? "selected" : ""}"
|
||||
@@ -440,6 +457,10 @@ export class SinglePlayerModal extends LitElement {
|
||||
this.instantBuild = Boolean((e.target as HTMLInputElement).checked);
|
||||
}
|
||||
|
||||
private handleRandomSpawnChange(e: Event) {
|
||||
this.randomSpawn = Boolean((e.target as HTMLInputElement).checked);
|
||||
}
|
||||
|
||||
private handleInfiniteGoldChange(e: Event) {
|
||||
this.infiniteGold = Boolean((e.target as HTMLInputElement).checked);
|
||||
}
|
||||
@@ -563,6 +584,7 @@ export class SinglePlayerModal extends LitElement {
|
||||
donateTroops: true,
|
||||
infiniteTroops: this.infiniteTroops,
|
||||
instantBuild: this.instantBuild,
|
||||
randomSpawn: this.randomSpawn,
|
||||
disabledUnits: this.disabledUnits
|
||||
.map((u) => Object.values(UnitType).find((ut) => ut === u))
|
||||
.filter((ut): ut is UnitType => ut !== undefined),
|
||||
@@ -575,6 +597,7 @@ export class SinglePlayerModal extends LitElement {
|
||||
disableNPCs: this.disableNPCs,
|
||||
}),
|
||||
},
|
||||
lobbyCreatedAt: Date.now(), // ms; server should be authoritative in MP
|
||||
},
|
||||
} satisfies JoinLobbyEvent,
|
||||
bubbles: true,
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, query, state } from "lit/decorators.js";
|
||||
import {
|
||||
ClanLeaderboardResponse,
|
||||
ClanLeaderboardResponseSchema,
|
||||
} from "../core/ApiSchemas";
|
||||
import { getApiBase } from "./jwt";
|
||||
import { translateText } from "./Utils";
|
||||
|
||||
@customElement("stats-modal")
|
||||
export class StatsModal extends LitElement {
|
||||
@query("o-modal")
|
||||
private modalEl!: HTMLElement & {
|
||||
open: () => void;
|
||||
close: () => void;
|
||||
};
|
||||
|
||||
@state() private isLoading: boolean = false;
|
||||
@state() private error: string | null = null;
|
||||
@state() private data: ClanLeaderboardResponse | null = null;
|
||||
|
||||
private hasLoaded = false;
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
public open() {
|
||||
this.modalEl?.open();
|
||||
if (!this.hasLoaded && !this.isLoading) {
|
||||
void this.loadLeaderboard();
|
||||
}
|
||||
}
|
||||
|
||||
public close() {
|
||||
this.modalEl?.close();
|
||||
}
|
||||
|
||||
private async loadLeaderboard() {
|
||||
this.isLoading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${getApiBase()}/public/clans/leaderboard`, {
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Unexpected status ${res.status}`);
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
const parsed = ClanLeaderboardResponseSchema.safeParse(json);
|
||||
if (!parsed.success) {
|
||||
console.warn(
|
||||
"ClanLeaderboardModal: invalid response schema",
|
||||
parsed.error,
|
||||
);
|
||||
throw new Error("Invalid response format");
|
||||
}
|
||||
|
||||
this.data = parsed.data;
|
||||
this.hasLoaded = true;
|
||||
} catch (err) {
|
||||
console.warn("ClanLeaderboardModal: failed to load leaderboard", err);
|
||||
this.error = translateText("stats_modal.error");
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
this.requestUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private renderBody() {
|
||||
if (this.isLoading) {
|
||||
return html`
|
||||
<div class="flex flex-col items-center justify-center p-6 text-white">
|
||||
<p class="mb-2 text-lg font-semibold">
|
||||
${translateText("stats_modal.loading")}
|
||||
</p>
|
||||
<div
|
||||
class="w-6 h-6 border-4 border-blue-500 border-t-transparent rounded-full animate-spin"
|
||||
></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (this.error) {
|
||||
return html`
|
||||
<div class="flex flex-col items-center justify-center p-6 text-white">
|
||||
<p class="mb-4 text-center">${this.error}</p>
|
||||
<button
|
||||
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded text-sm font-medium"
|
||||
@click=${() => this.loadLeaderboard()}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (!this.data || this.data.clans.length === 0) {
|
||||
return html`
|
||||
<div class="p-6 text-center text-gray-200">
|
||||
<p class="text-lg font-semibold mb-2">
|
||||
${translateText("stats_modal.no_stats")}
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const { start, end, clans } = this.data;
|
||||
const startDate = new Date(start);
|
||||
const endDate = new Date(end);
|
||||
|
||||
return html`
|
||||
<div class="p-4 md:p-6 text-gray-200">
|
||||
<div
|
||||
class="flex flex-col md:flex-row md:items-center md:justify-between mb-4 gap-2"
|
||||
>
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold">
|
||||
${translateText("stats_modal.clan_stats")}
|
||||
</h2>
|
||||
<p class="text-xs text-gray-400 mt-1">
|
||||
${startDate.toLocaleDateString()} ·
|
||||
${endDate.toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full text-xs md:text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-700 text-gray-300">
|
||||
<th class="py-2 pr-3 text-left">
|
||||
${translateText("stats_modal.clan")}
|
||||
</th>
|
||||
<th class="py-2 px-2 text-right">
|
||||
${translateText("stats_modal.games")}
|
||||
</th>
|
||||
<th class="py-2 px-2 text-right">
|
||||
${translateText("stats_modal.win_score")}
|
||||
</th>
|
||||
<th class="py-2 px-2 text-right">
|
||||
${translateText("stats_modal.loss_score")}
|
||||
</th>
|
||||
<th class="py-2 pl-2 text-right">
|
||||
${translateText("stats_modal.win_loss_ratio")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${clans.map(
|
||||
(clan) => html`
|
||||
<tr class="border-b border-gray-800 last:border-b-0">
|
||||
<td class="py-2 pr-3 font-semibold text-left">
|
||||
${clan.clanTag}
|
||||
</td>
|
||||
<td class="py-2 px-2 text-right">
|
||||
${clan.games.toLocaleString()}
|
||||
</td>
|
||||
<td class="py-2 px-2 text-right">${clan.weightedWins}</td>
|
||||
<td class="py-2 px-2 text-right">${clan.weightedLosses}</td>
|
||||
<td class="py-2 pl-2 text-right">
|
||||
${clan.weightedWLRatio}
|
||||
</td>
|
||||
</tr>
|
||||
`,
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<o-modal id="stats-modal" title="${translateText("stats_modal.title")}">
|
||||
${this.renderBody()}
|
||||
</o-modal>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement("stats-button")
|
||||
export class StatsButton extends LitElement {
|
||||
@query("stats-modal") private statsModal: StatsModal;
|
||||
@state() private isVisible: boolean = true;
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
`;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.isVisible) {
|
||||
return html``;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="fixed top-20 right-4 z-[9998]">
|
||||
<button
|
||||
@click="${this.open}"
|
||||
class="w-12 h-12 bg-blue-600 hover:bg-blue-700 text-white rounded-full shadow-2xl hover:shadow-3xl transition-all duration-200 flex items-center justify-center text-xl focus:outline-none focus:ring-4 focus:ring-blue-500 focus:ring-offset-4"
|
||||
title="${translateText("stats_modal.title")}"
|
||||
>
|
||||
<img src="/icons/stats.svg" alt="Stats" class="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
<stats-modal></stats-modal>
|
||||
`;
|
||||
}
|
||||
|
||||
private open() {
|
||||
this.isVisible = true;
|
||||
this.requestUpdate();
|
||||
this.statsModal?.open();
|
||||
}
|
||||
|
||||
public close() {
|
||||
this.statsModal?.close();
|
||||
this.isVisible = false;
|
||||
this.requestUpdate();
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ export class TerritoryPatternsModal extends LitElement {
|
||||
@state() private selectedColor: string | null = null;
|
||||
|
||||
@state() private activeTab: "patterns" | "colors" = "patterns";
|
||||
@state() private showOnlyOwned: boolean = false;
|
||||
|
||||
private cosmetics: Cosmetics | null = null;
|
||||
|
||||
@@ -112,6 +113,9 @@ export class TerritoryPatternsModal extends LitElement {
|
||||
if (rel === "blocked") {
|
||||
continue;
|
||||
}
|
||||
if (this.showOnlyOwned && rel !== "owned") {
|
||||
continue;
|
||||
}
|
||||
buttons.push(html`
|
||||
<pattern-button
|
||||
.pattern=${pattern}
|
||||
@@ -128,19 +132,34 @@ export class TerritoryPatternsModal extends LitElement {
|
||||
}
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="flex flex-wrap gap-4 p-2"
|
||||
style="justify-content: center; align-items: flex-start;"
|
||||
>
|
||||
${this.affiliateCode === null
|
||||
? html`
|
||||
<pattern-button
|
||||
.pattern=${null}
|
||||
.onSelect=${(p: Pattern | null) => this.selectPattern(null)}
|
||||
></pattern-button>
|
||||
`
|
||||
: html``}
|
||||
${buttons}
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex justify-center">
|
||||
<button
|
||||
class="px-4 py-2 text-sm font-medium transition-colors duration-200 rounded-lg ${this
|
||||
.showOnlyOwned
|
||||
? "bg-blue-500 text-white hover:bg-blue-600"
|
||||
: "bg-gray-700 text-gray-300 hover:bg-gray-600"}"
|
||||
@click=${() => {
|
||||
this.showOnlyOwned = !this.showOnlyOwned;
|
||||
}}
|
||||
>
|
||||
${translateText("territory_patterns.show_only_owned")}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-wrap gap-4 p-2"
|
||||
style="justify-content: center; align-items: flex-start;"
|
||||
>
|
||||
${this.affiliateCode === null
|
||||
? html`
|
||||
<pattern-button
|
||||
.pattern=${null}
|
||||
.onSelect=${(p: Pattern | null) => this.selectPattern(null)}
|
||||
></pattern-button>
|
||||
`
|
||||
: html``}
|
||||
${buttons}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -590,7 +590,7 @@ export class Transport {
|
||||
} else {
|
||||
console.log(
|
||||
"WebSocket is not open. Current state:",
|
||||
this.socket!.readyState,
|
||||
this.socket?.readyState,
|
||||
);
|
||||
console.log("attempting reconnect");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import { repeat } from "lit/directives/repeat.js";
|
||||
import { PastelTheme } from "../../core/configuration/PastelTheme";
|
||||
import {
|
||||
ColoredTeams,
|
||||
Duos,
|
||||
GameMode,
|
||||
HumansVsNations,
|
||||
PlayerInfo,
|
||||
PlayerType,
|
||||
Quads,
|
||||
Team,
|
||||
Trios,
|
||||
} from "../../core/game/Game";
|
||||
import { assignTeams } from "../../core/game/TeamAssignment";
|
||||
import { ClientInfo, TeamCountConfig } from "../../core/Schemas";
|
||||
import { translateText } from "../Utils";
|
||||
|
||||
export interface TeamPreviewData {
|
||||
team: Team;
|
||||
players: ClientInfo[];
|
||||
}
|
||||
|
||||
@customElement("lobby-team-view")
|
||||
export class LobbyTeamView extends LitElement {
|
||||
@property({ type: String }) gameMode: GameMode = GameMode.FFA;
|
||||
@property({ type: Array }) clients: ClientInfo[] = [];
|
||||
@state() private teamPreview: TeamPreviewData[] = [];
|
||||
@state() private teamMaxSize: number = 0;
|
||||
@property({ type: String }) lobbyCreatorClientID: string = "";
|
||||
@property({ attribute: "team-count" }) teamCount: TeamCountConfig = 2;
|
||||
@property({ type: Function }) onKickPlayer?: (clientID: string) => void;
|
||||
|
||||
private theme: PastelTheme = new PastelTheme();
|
||||
@state() private showTeamColors: boolean = false;
|
||||
|
||||
willUpdate(changedProperties: Map<string, any>) {
|
||||
// Recompute team preview when relevant properties change
|
||||
if (
|
||||
changedProperties.has("gameMode") ||
|
||||
changedProperties.has("clients") ||
|
||||
changedProperties.has("teamCount")
|
||||
) {
|
||||
this.computeTeamPreview();
|
||||
this.showTeamColors = this.getTeamList().length <= 7;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`<div class="players-list">
|
||||
${this.gameMode === GameMode.Team
|
||||
? this.renderTeamMode()
|
||||
: this.renderFreeForAll()}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
private renderTeamMode() {
|
||||
const active = this.teamPreview.filter((t) => t.players.length > 0);
|
||||
const empty = this.teamPreview.filter((t) => t.players.length === 0);
|
||||
return html` <div
|
||||
class="flex flex-col md:flex-row gap-3 md:gap-4 items-stretch max-h-[65vh]"
|
||||
>
|
||||
<div
|
||||
class="w-full md:w-60 bg-gray-800 p-2 border border-gray-700 rounded-lg max-h-40 md:max-h-[65vh] overflow-auto"
|
||||
>
|
||||
<div class="font-bold mb-1.5 text-gray-300 text-sm">
|
||||
${translateText("host_modal.players")}
|
||||
</div>
|
||||
${repeat(
|
||||
this.clients,
|
||||
(c) => c.clientID ?? c.username,
|
||||
(client) =>
|
||||
html`<div class="px-2 py-1 rounded bg-gray-700/70 mb-1 text-xs">
|
||||
${client.username}
|
||||
</div>`,
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
class="flex-1 flex flex-col gap-3 md:gap-4 overflow-auto max-h-[65vh] md:pr-1"
|
||||
>
|
||||
<div>
|
||||
<div class="font-semibold text-gray-200 mb-1 text-sm">
|
||||
${translateText("host_modal.assigned_teams")}
|
||||
</div>
|
||||
<div class="w-full grid grid-cols-1 sm:grid-cols-2 gap-2 md:gap-3">
|
||||
${repeat(
|
||||
active,
|
||||
(p) => p.team,
|
||||
(preview) => this.renderTeamCard(preview, false),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-semibold text-gray-200 mb-1 text-sm">
|
||||
${translateText("host_modal.empty_teams")}
|
||||
</div>
|
||||
<div class="w-full grid grid-cols-1 sm:grid-cols-2 gap-2 md:gap-3">
|
||||
${repeat(
|
||||
empty,
|
||||
(p) => p.team,
|
||||
(preview) => this.renderTeamCard(preview, true),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private renderFreeForAll() {
|
||||
return html`${repeat(
|
||||
this.clients,
|
||||
(c) => c.clientID ?? c.username,
|
||||
(client) =>
|
||||
html`<span class="player-tag">
|
||||
${client.username}
|
||||
${client.clientID === this.lobbyCreatorClientID
|
||||
? html`<span class="host-badge"
|
||||
>(${translateText("host_modal.host_badge")})</span
|
||||
>`
|
||||
: html`<button
|
||||
class="remove-player-btn"
|
||||
@click=${() => this.onKickPlayer?.(client.clientID)}
|
||||
aria-label=${translateText("host_modal.remove_player", {
|
||||
username: client.username,
|
||||
})}
|
||||
>
|
||||
×
|
||||
</button>`}
|
||||
</span>`,
|
||||
)} `;
|
||||
}
|
||||
|
||||
private renderTeamCard(preview: TeamPreviewData, isEmpty: boolean = false) {
|
||||
return html`
|
||||
<div class="bg-gray-800 border border-gray-700 rounded-xl flex flex-col">
|
||||
<div
|
||||
class="px-2 py-1 font-bold flex items-center justify-between text-white rounded-t-xl text-[13px] gap-2 bg-gray-700/70"
|
||||
>
|
||||
${this.showTeamColors
|
||||
? html`<span
|
||||
class="inline-block w-2.5 h-2.5 rounded-full border-2 border-white/90 shadow-inner"
|
||||
style="background:${this.teamHeaderColor(preview.team)};"
|
||||
></span>`
|
||||
: null}
|
||||
<span class="truncate">${preview.team}</span>
|
||||
<span class="text-white/90"
|
||||
>${preview.players.length}/${this.teamMaxSize}</span
|
||||
>
|
||||
</div>
|
||||
<div class="p-2 ${isEmpty ? "" : "flex flex-col gap-1.5"}">
|
||||
${isEmpty
|
||||
? html`<div class="text-[11px] italic text-gray-400">
|
||||
${translateText("host_modal.empty_team")}
|
||||
</div>`
|
||||
: repeat(
|
||||
preview.players,
|
||||
(p) => p.clientID ?? p.username,
|
||||
(p) =>
|
||||
html` <div
|
||||
class="bg-gray-700/70 px-2 py-1 rounded text-xs flex items-center justify-between"
|
||||
>
|
||||
<span class="truncate">${p.username}</span>
|
||||
${p.clientID === this.lobbyCreatorClientID
|
||||
? html`<span class="ml-2 text-[11px] text-green-300"
|
||||
>(${translateText("host_modal.host_badge")})</span
|
||||
>`
|
||||
: html`<button
|
||||
class="remove-player-btn ml-2"
|
||||
@click=${() => this.onKickPlayer?.(p.clientID)}
|
||||
aria-label=${translateText(
|
||||
"host_modal.remove_player",
|
||||
{
|
||||
username: p.username,
|
||||
},
|
||||
)}
|
||||
>
|
||||
×
|
||||
</button>`}
|
||||
</div>`,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private getTeamList(): Team[] {
|
||||
if (this.gameMode !== GameMode.Team) return [];
|
||||
const playerCount = this.clients.length;
|
||||
const config = this.teamCount;
|
||||
|
||||
if (config === HumansVsNations) {
|
||||
return [ColoredTeams.Humans, ColoredTeams.Nations];
|
||||
}
|
||||
|
||||
let numTeams: number;
|
||||
if (typeof config === "number") {
|
||||
numTeams = Math.max(2, config);
|
||||
} else {
|
||||
const divisor =
|
||||
config === Duos ? 2 : config === Trios ? 3 : config === Quads ? 4 : 2;
|
||||
numTeams = Math.max(2, Math.ceil(playerCount / divisor));
|
||||
}
|
||||
|
||||
if (numTeams < 8) {
|
||||
const ordered: Team[] = [
|
||||
ColoredTeams.Red,
|
||||
ColoredTeams.Blue,
|
||||
ColoredTeams.Yellow,
|
||||
ColoredTeams.Green,
|
||||
ColoredTeams.Purple,
|
||||
ColoredTeams.Orange,
|
||||
ColoredTeams.Teal,
|
||||
];
|
||||
return ordered.slice(0, numTeams);
|
||||
}
|
||||
|
||||
return Array.from({ length: numTeams }, (_, i) => `Team ${i + 1}`);
|
||||
}
|
||||
|
||||
private teamHeaderColor(team: Team): string {
|
||||
try {
|
||||
return this.theme.teamColor(team).toHex();
|
||||
} catch {
|
||||
return "#3b3f46"; // Default gray for unknown teams
|
||||
}
|
||||
}
|
||||
|
||||
private computeTeamPreview() {
|
||||
if (this.gameMode !== GameMode.Team) {
|
||||
this.teamPreview = [];
|
||||
this.teamMaxSize = 0;
|
||||
return;
|
||||
}
|
||||
const teams = this.getTeamList();
|
||||
|
||||
// HumansVsNations: show all clients under Humans initially
|
||||
if (this.teamCount === HumansVsNations) {
|
||||
this.teamMaxSize = this.clients.length;
|
||||
this.teamPreview = [
|
||||
{ team: ColoredTeams.Humans, players: [...this.clients] },
|
||||
{ team: ColoredTeams.Nations, players: [] },
|
||||
];
|
||||
return;
|
||||
}
|
||||
|
||||
const players = this.clients.map(
|
||||
(c) =>
|
||||
new PlayerInfo(c.username, PlayerType.Human, c.clientID, c.clientID),
|
||||
);
|
||||
const assignment = assignTeams(players, teams);
|
||||
const buckets = new Map<Team, ClientInfo[]>();
|
||||
for (const t of teams) buckets.set(t, []);
|
||||
|
||||
for (const [p, team] of assignment.entries()) {
|
||||
if (team === "kicked") continue;
|
||||
const bucket = buckets.get(team);
|
||||
if (!bucket) continue;
|
||||
const client =
|
||||
this.clients.find((c) => c.clientID === p.clientID) ??
|
||||
this.clients.find((c) => c.username === p.name);
|
||||
if (client) bucket.push(client);
|
||||
}
|
||||
|
||||
// Compute per-team capacity safely and align with common team sizes
|
||||
if (this.teamCount === Duos) {
|
||||
this.teamMaxSize = 2;
|
||||
} else if (this.teamCount === Trios) {
|
||||
this.teamMaxSize = 3;
|
||||
} else if (this.teamCount === Quads) {
|
||||
this.teamMaxSize = 4;
|
||||
} else {
|
||||
// Fallback: divide players across teams; guard against 0 and empty lobbies
|
||||
this.teamMaxSize = Math.max(
|
||||
1,
|
||||
Math.ceil(this.clients.length / teams.length),
|
||||
);
|
||||
}
|
||||
this.teamPreview = teams.map((t) => ({
|
||||
team: t,
|
||||
players: buckets.get(t) ?? [],
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -33,11 +33,11 @@ export const MapDescription: Record<keyof typeof GameMapType, string> = {
|
||||
StraitOfGibraltar: "Strait of Gibraltar",
|
||||
Italia: "Italia",
|
||||
Japan: "Japan",
|
||||
Yenisei: "Yenisei",
|
||||
Pluto: "Pluto",
|
||||
Montreal: "Montreal",
|
||||
Achiran: "Achiran",
|
||||
BaikalNukeWars: "Baikal (Nuke Wars)",
|
||||
FourIslands: "Four Islands",
|
||||
};
|
||||
|
||||
@customElement("map-display")
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import megaphone from "../../../resources/images/Megaphone.svg";
|
||||
import version from "../../../resources/version.txt";
|
||||
import { NewsModal } from "../NewsModal";
|
||||
import { translateText } from "../Utils";
|
||||
|
||||
@customElement("news-button")
|
||||
export class NewsButton extends LitElement {
|
||||
@property({ type: Boolean }) hidden = false;
|
||||
@state() private isActive = false;
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.checkForNewVersion();
|
||||
}
|
||||
|
||||
private checkForNewVersion() {
|
||||
try {
|
||||
const lastSeenVersion = localStorage.getItem("version");
|
||||
this.isActive = lastSeenVersion !== version;
|
||||
} catch (error) {
|
||||
// Fallback to NOT showing notification if localStorage fails
|
||||
this.isActive = false;
|
||||
}
|
||||
}
|
||||
|
||||
private handleClick() {
|
||||
localStorage.setItem("version", version);
|
||||
this.isActive = false;
|
||||
|
||||
const newsModal = document.querySelector("news-modal") as NewsModal;
|
||||
if (newsModal) {
|
||||
newsModal.open();
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div
|
||||
class="flex relative ${this.hidden ? "parent-hidden" : ""} ${this
|
||||
.isActive
|
||||
? "active"
|
||||
: ""}"
|
||||
>
|
||||
<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] dark:invert"
|
||||
src="${megaphone}"
|
||||
alt=${translateText("news.title")}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -979,6 +979,11 @@
|
||||
"continent": "Asia",
|
||||
"name": "Iran"
|
||||
},
|
||||
{
|
||||
"code": "Pahlavi Iran",
|
||||
"continent": "Asia",
|
||||
"name": "Pahlavi Iran"
|
||||
},
|
||||
{
|
||||
"code": "ie",
|
||||
"continent": "Europe",
|
||||
|
||||
@@ -1,21 +1,15 @@
|
||||
import miniBigSmoke from "../../../resources/sprites/bigsmoke.png";
|
||||
import buildingExplosion from "../../../resources/sprites/buildingExplosion.png";
|
||||
import conquestSword from "../../../resources/sprites/conquestSword.png";
|
||||
import dust from "../../../resources/sprites/dust.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 bats from "../../../resources/sprites/halloween/bats.png";
|
||||
import bubble from "../../../resources/sprites/halloween/bubble.png";
|
||||
import ghost from "../../../resources/sprites/halloween/ghost.png";
|
||||
import minifireGreen from "../../../resources/sprites/halloween/minifireGreen.png";
|
||||
import shark from "../../../resources/sprites/halloween/shark.png";
|
||||
import skull from "../../../resources/sprites/halloween/skull.png";
|
||||
import skullNuke from "../../../resources/sprites/halloween/skullNuke.png";
|
||||
import miniSmokeAndFireGreen from "../../../resources/sprites/halloween/smokeAndFireGreen.png";
|
||||
import tentacle from "../../../resources/sprites/halloween/tentacle.png";
|
||||
import tornado from "../../../resources/sprites/halloween/tornado.png";
|
||||
|
||||
import { Theme } from "../../core/configuration/Config";
|
||||
import { PlayerView } from "../../core/game/GameView";
|
||||
import { AnimatedSprite } from "./AnimatedSprite";
|
||||
@@ -34,7 +28,7 @@ type AnimatedSpriteConfig = {
|
||||
|
||||
const ANIMATED_SPRITE_CONFIG: Partial<Record<FxType, AnimatedSpriteConfig>> = {
|
||||
[FxType.MiniFire]: {
|
||||
url: minifireGreen,
|
||||
url: miniFire,
|
||||
frameWidth: 7,
|
||||
frameCount: 6,
|
||||
frameDuration: 100,
|
||||
@@ -43,28 +37,28 @@ const ANIMATED_SPRITE_CONFIG: Partial<Record<FxType, AnimatedSpriteConfig>> = {
|
||||
originY: 11,
|
||||
},
|
||||
[FxType.MiniSmoke]: {
|
||||
url: ghost,
|
||||
frameWidth: 10,
|
||||
frameCount: 5,
|
||||
frameDuration: 100,
|
||||
url: miniSmoke,
|
||||
frameWidth: 11,
|
||||
frameCount: 4,
|
||||
frameDuration: 120,
|
||||
looping: true,
|
||||
originX: 4,
|
||||
originX: 2,
|
||||
originY: 10,
|
||||
},
|
||||
[FxType.MiniBigSmoke]: {
|
||||
url: bats,
|
||||
frameWidth: 21,
|
||||
frameCount: 6,
|
||||
url: miniBigSmoke,
|
||||
frameWidth: 24,
|
||||
frameCount: 5,
|
||||
frameDuration: 120,
|
||||
looping: true,
|
||||
originX: 9,
|
||||
originY: 14,
|
||||
},
|
||||
[FxType.MiniSmokeAndFire]: {
|
||||
url: miniSmokeAndFireGreen,
|
||||
url: miniSmokeAndFire,
|
||||
frameWidth: 24,
|
||||
frameCount: 5,
|
||||
frameDuration: 90,
|
||||
frameDuration: 120,
|
||||
looping: true,
|
||||
originX: 9,
|
||||
originY: 14,
|
||||
@@ -96,15 +90,6 @@ const ANIMATED_SPRITE_CONFIG: Partial<Record<FxType, AnimatedSpriteConfig>> = {
|
||||
originX: 9,
|
||||
originY: 9,
|
||||
},
|
||||
[FxType.SinkingShip]: {
|
||||
url: sinkingShip,
|
||||
frameWidth: 16,
|
||||
frameCount: 14,
|
||||
frameDuration: 90,
|
||||
looping: false,
|
||||
originX: 7,
|
||||
originY: 7,
|
||||
},
|
||||
[FxType.BuildingExplosion]: {
|
||||
url: buildingExplosion,
|
||||
frameWidth: 17,
|
||||
@@ -114,14 +99,23 @@ const ANIMATED_SPRITE_CONFIG: Partial<Record<FxType, AnimatedSpriteConfig>> = {
|
||||
originX: 8,
|
||||
originY: 8,
|
||||
},
|
||||
[FxType.Nuke]: {
|
||||
url: skullNuke,
|
||||
frameWidth: 42,
|
||||
frameCount: 19,
|
||||
frameDuration: 50,
|
||||
[FxType.SinkingShip]: {
|
||||
url: sinkingShip,
|
||||
frameWidth: 16,
|
||||
frameCount: 14,
|
||||
frameDuration: 90,
|
||||
looping: false,
|
||||
originX: 20,
|
||||
originY: 21,
|
||||
originX: 7,
|
||||
originY: 7,
|
||||
},
|
||||
[FxType.Nuke]: {
|
||||
url: nuke,
|
||||
frameWidth: 60,
|
||||
frameCount: 9,
|
||||
frameDuration: 70,
|
||||
looping: false,
|
||||
originX: 30,
|
||||
originY: 30,
|
||||
},
|
||||
[FxType.SAMExplosion]: {
|
||||
url: SAMExplosion,
|
||||
@@ -133,51 +127,16 @@ const ANIMATED_SPRITE_CONFIG: Partial<Record<FxType, AnimatedSpriteConfig>> = {
|
||||
originY: 19,
|
||||
},
|
||||
[FxType.Conquest]: {
|
||||
url: skull,
|
||||
frameWidth: 14,
|
||||
frameCount: 14,
|
||||
frameDuration: 90,
|
||||
looping: false,
|
||||
originX: 7,
|
||||
originY: 23,
|
||||
},
|
||||
[FxType.Tentacle]: {
|
||||
url: tentacle,
|
||||
frameWidth: 22,
|
||||
frameCount: 26,
|
||||
frameDuration: 90,
|
||||
looping: false,
|
||||
originX: 13,
|
||||
originY: 28,
|
||||
},
|
||||
[FxType.Shark]: {
|
||||
url: shark,
|
||||
frameWidth: 25,
|
||||
frameCount: 14,
|
||||
frameDuration: 90,
|
||||
looping: false,
|
||||
originX: 13,
|
||||
originY: 8,
|
||||
},
|
||||
[FxType.Bubble]: {
|
||||
url: bubble,
|
||||
frameWidth: 22,
|
||||
frameCount: 13,
|
||||
frameDuration: 80,
|
||||
looping: false,
|
||||
originX: 13,
|
||||
originY: 8,
|
||||
},
|
||||
[FxType.Tornado]: {
|
||||
url: tornado,
|
||||
frameWidth: 30,
|
||||
url: conquestSword,
|
||||
frameWidth: 21,
|
||||
frameCount: 10,
|
||||
frameDuration: 80,
|
||||
looping: true,
|
||||
originX: 11,
|
||||
originY: 22,
|
||||
frameDuration: 90,
|
||||
looping: false,
|
||||
originX: 10,
|
||||
originY: 16,
|
||||
},
|
||||
};
|
||||
|
||||
export class AnimatedSpriteLoader {
|
||||
private animatedSpriteImageMap: Map<FxType, HTMLCanvasElement> = new Map();
|
||||
// Do not color the same sprite twice
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
export class FrameProfiler {
|
||||
private static timings: Record<string, number> = {};
|
||||
private static enabled: boolean = false;
|
||||
|
||||
/**
|
||||
* Enable or disable profiling.
|
||||
*/
|
||||
static setEnabled(enabled: boolean): void {
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if profiling is enabled.
|
||||
*/
|
||||
static isEnabled(): boolean {
|
||||
return this.enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all accumulated timings for the current frame.
|
||||
*/
|
||||
static clear(): void {
|
||||
if (!this.enabled) return;
|
||||
this.timings = {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a duration (in ms) for a named span.
|
||||
*/
|
||||
static record(name: string, duration: number): void {
|
||||
if (!this.enabled || !Number.isFinite(duration)) return;
|
||||
this.timings[name] = (this.timings[name] ?? 0) + duration;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience helper to start a span.
|
||||
* Returns a high-resolution timestamp to be passed into end().
|
||||
*/
|
||||
static start(): number {
|
||||
if (!this.enabled) return 0;
|
||||
return performance.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience helper to end a span started with start().
|
||||
*/
|
||||
static end(name: string, startTime: number): void {
|
||||
if (!this.enabled || startTime === 0) return;
|
||||
const duration = performance.now() - startTime;
|
||||
this.record(name, duration);
|
||||
}
|
||||
|
||||
/**
|
||||
* Consume and reset all timings collected so far.
|
||||
*/
|
||||
static consume(): Record<string, number> {
|
||||
if (!this.enabled) return {};
|
||||
const copy = { ...this.timings };
|
||||
this.timings = {};
|
||||
return copy;
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { GameView } from "../../core/game/GameView";
|
||||
import { UserSettings } from "../../core/game/UserSettings";
|
||||
import { GameStartingModal } from "../GameStartingModal";
|
||||
import { RefreshGraphicsEvent as RedrawGraphicsEvent } from "../InputHandler";
|
||||
import { FrameProfiler } from "./FrameProfiler";
|
||||
import { TransformHandler } from "./TransformHandler";
|
||||
import { UIState } from "./UIState";
|
||||
import { AdTimer } from "./layers/AdTimer";
|
||||
@@ -13,7 +14,6 @@ import { ChatModal } from "./layers/ChatModal";
|
||||
import { ControlPanel } from "./layers/ControlPanel";
|
||||
import { EmojiTable } from "./layers/EmojiTable";
|
||||
import { EventsDisplay } from "./layers/EventsDisplay";
|
||||
import { FPSDisplay } from "./layers/FPSDisplay";
|
||||
import { FxLayer } from "./layers/FxLayer";
|
||||
import { GameLeftSidebar } from "./layers/GameLeftSidebar";
|
||||
import { GameRightSidebar } from "./layers/GameRightSidebar";
|
||||
@@ -23,6 +23,8 @@ import { Leaderboard } from "./layers/Leaderboard";
|
||||
import { MainRadialMenu } from "./layers/MainRadialMenu";
|
||||
import { MultiTabModal } from "./layers/MultiTabModal";
|
||||
import { NameLayer } from "./layers/NameLayer";
|
||||
import { NukeTrajectoryPreviewLayer } from "./layers/NukeTrajectoryPreviewLayer";
|
||||
import { PerformanceOverlay } from "./layers/PerformanceOverlay";
|
||||
import { PlayerInfoOverlay } from "./layers/PlayerInfoOverlay";
|
||||
import { PlayerPanel } from "./layers/PlayerPanel";
|
||||
import { RailroadLayer } from "./layers/RailroadLayer";
|
||||
@@ -209,12 +211,14 @@ export function createRenderer(
|
||||
uiState,
|
||||
);
|
||||
|
||||
const fpsDisplay = document.querySelector("fps-display") as FPSDisplay;
|
||||
if (!(fpsDisplay instanceof FPSDisplay)) {
|
||||
console.error("fps display not found");
|
||||
const performanceOverlay = document.querySelector(
|
||||
"performance-overlay",
|
||||
) as PerformanceOverlay;
|
||||
if (!(performanceOverlay instanceof PerformanceOverlay)) {
|
||||
console.error("performance overlay not found");
|
||||
}
|
||||
fpsDisplay.eventBus = eventBus;
|
||||
fpsDisplay.userSettings = userSettings;
|
||||
performanceOverlay.eventBus = eventBus;
|
||||
performanceOverlay.userSettings = userSettings;
|
||||
|
||||
const alertFrame = document.querySelector("alert-frame") as AlertFrame;
|
||||
if (!(alertFrame instanceof AlertFrame)) {
|
||||
@@ -241,6 +245,7 @@ export function createRenderer(
|
||||
new UnitLayer(game, eventBus, transformHandler),
|
||||
new FxLayer(game),
|
||||
new UILayer(game, eventBus, transformHandler),
|
||||
new NukeTrajectoryPreviewLayer(game, eventBus, transformHandler),
|
||||
new StructureIconsLayer(game, eventBus, uiState, transformHandler),
|
||||
new NameLayer(game, transformHandler, eventBus),
|
||||
eventsDisplay,
|
||||
@@ -271,7 +276,7 @@ export function createRenderer(
|
||||
multiTabModal,
|
||||
new AdTimer(game),
|
||||
alertFrame,
|
||||
fpsDisplay,
|
||||
performanceOverlay,
|
||||
];
|
||||
|
||||
return new GameRenderer(
|
||||
@@ -281,7 +286,7 @@ export function createRenderer(
|
||||
transformHandler,
|
||||
uiState,
|
||||
layers,
|
||||
fpsDisplay,
|
||||
performanceOverlay,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -295,7 +300,7 @@ export class GameRenderer {
|
||||
public transformHandler: TransformHandler,
|
||||
public uiState: UIState,
|
||||
private layers: Layer[],
|
||||
private fpsDisplay: FPSDisplay,
|
||||
private performanceOverlay: PerformanceOverlay,
|
||||
) {
|
||||
const context = canvas.getContext("2d");
|
||||
if (context === null) throw new Error("2d context not supported");
|
||||
@@ -339,6 +344,7 @@ export class GameRenderer {
|
||||
}
|
||||
|
||||
renderGame() {
|
||||
FrameProfiler.clear();
|
||||
const start = performance.now();
|
||||
// Set background
|
||||
this.context.fillStyle = this.game
|
||||
@@ -371,7 +377,10 @@ export class GameRenderer {
|
||||
needsTransform,
|
||||
isTransformActive,
|
||||
);
|
||||
|
||||
const layerStart = FrameProfiler.start();
|
||||
layer.renderLayer?.(this.context);
|
||||
FrameProfiler.end(layer.constructor?.name ?? "UnknownLayer", layerStart);
|
||||
}
|
||||
handleTransformState(false, isTransformActive); // Ensure context is clean after rendering
|
||||
this.transformHandler.resetChanged();
|
||||
@@ -379,7 +388,8 @@ export class GameRenderer {
|
||||
requestAnimationFrame(() => this.renderGame());
|
||||
const duration = performance.now() - start;
|
||||
|
||||
this.fpsDisplay.updateFPS(duration);
|
||||
const layerDurations = FrameProfiler.consume();
|
||||
this.performanceOverlay.updateFrameMetrics(duration, layerDurations);
|
||||
|
||||
if (duration > 50) {
|
||||
console.warn(
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
import allianceIcon from "../../../resources/images/AllianceIcon.svg";
|
||||
import allianceIconFaded from "../../../resources/images/AllianceIconFaded.svg";
|
||||
import allianceRequestBlackIcon from "../../../resources/images/AllianceRequestBlackIcon.svg";
|
||||
import allianceRequestWhiteIcon from "../../../resources/images/AllianceRequestWhiteIcon.svg";
|
||||
import crownIcon from "../../../resources/images/CrownIcon.svg";
|
||||
import disconnectedIcon from "../../../resources/images/DisconnectedIcon.svg";
|
||||
import embargoBlackIcon from "../../../resources/images/EmbargoBlackIcon.svg";
|
||||
import embargoWhiteIcon from "../../../resources/images/EmbargoWhiteIcon.svg";
|
||||
import nukeRedIcon from "../../../resources/images/NukeIconRed.svg";
|
||||
import nukeWhiteIcon from "../../../resources/images/NukeIconWhite.svg";
|
||||
import questionMarkIcon from "../../../resources/images/QuestionMarkIcon.svg";
|
||||
import targetIcon from "../../../resources/images/TargetIcon.svg";
|
||||
import traitorIcon from "../../../resources/images/TraitorIcon.svg";
|
||||
import { AllPlayers, nukeTypes } from "../../core/game/Game";
|
||||
import { GameView, PlayerView } from "../../core/game/GameView";
|
||||
|
||||
export type PlayerIconId =
|
||||
| "crown"
|
||||
| "traitor"
|
||||
| "disconnected"
|
||||
| "alliance"
|
||||
| "alliance-request"
|
||||
| "target"
|
||||
| "emoji"
|
||||
| "embargo"
|
||||
| "nuke";
|
||||
|
||||
export type PlayerIconKind = "image" | "emoji";
|
||||
|
||||
export interface PlayerIconDescriptor {
|
||||
id: PlayerIconId;
|
||||
kind: PlayerIconKind;
|
||||
/** Image URL for image icons */
|
||||
src?: string;
|
||||
/** Text content for emoji icons */
|
||||
text?: string;
|
||||
/** Whether the icon should be visually centered over the name */
|
||||
center?: boolean;
|
||||
}
|
||||
|
||||
export interface PlayerIconParams {
|
||||
game: GameView;
|
||||
player: PlayerView;
|
||||
/** Whether the alliance icon (handshake) should be included */
|
||||
includeAllianceIcon: boolean;
|
||||
/** Player currently in first place, used for the crown icon */
|
||||
firstPlace: PlayerView | null;
|
||||
}
|
||||
|
||||
export function getFirstPlacePlayer(game: GameView): PlayerView | null {
|
||||
const sorted = game
|
||||
.playerViews()
|
||||
.sort((a, b) => b.numTilesOwned() - a.numTilesOwned());
|
||||
|
||||
return sorted.length > 0 ? sorted[0] : null;
|
||||
}
|
||||
|
||||
export function getPlayerIcons(
|
||||
params: PlayerIconParams,
|
||||
): PlayerIconDescriptor[] {
|
||||
const { game, player, includeAllianceIcon, firstPlace } = params;
|
||||
|
||||
const myPlayer = game.myPlayer();
|
||||
const userSettings = game.config().userSettings();
|
||||
const isDarkMode = userSettings?.darkMode() ?? false;
|
||||
const emojisEnabled = userSettings?.emojis() ?? false;
|
||||
|
||||
const icons: PlayerIconDescriptor[] = [];
|
||||
|
||||
// Crown icon for first place
|
||||
if (player === firstPlace) {
|
||||
icons.push({ id: "crown", kind: "image", src: crownIcon });
|
||||
}
|
||||
|
||||
// Traitor icon
|
||||
if (player.isTraitor()) {
|
||||
icons.push({ id: "traitor", kind: "image", src: traitorIcon });
|
||||
}
|
||||
|
||||
// Disconnected icon
|
||||
if (player.isDisconnected()) {
|
||||
icons.push({ id: "disconnected", kind: "image", src: disconnectedIcon });
|
||||
}
|
||||
|
||||
// Alliance icon
|
||||
if (
|
||||
includeAllianceIcon &&
|
||||
myPlayer !== null &&
|
||||
myPlayer.isAlliedWith(player)
|
||||
) {
|
||||
icons.push({ id: "alliance", kind: "image", src: allianceIcon });
|
||||
}
|
||||
|
||||
// Alliance request icon (theme dependent)
|
||||
if (myPlayer !== null && player.isRequestingAllianceWith(myPlayer)) {
|
||||
const allianceRequestIcon = isDarkMode
|
||||
? allianceRequestWhiteIcon
|
||||
: allianceRequestBlackIcon;
|
||||
icons.push({
|
||||
id: "alliance-request",
|
||||
kind: "image",
|
||||
src: allianceRequestIcon,
|
||||
});
|
||||
}
|
||||
|
||||
// Target icon (centered on the map, but regular in overlays)
|
||||
if (myPlayer !== null && new Set(myPlayer.transitiveTargets()).has(player)) {
|
||||
icons.push({ id: "target", kind: "image", src: targetIcon, center: true });
|
||||
}
|
||||
|
||||
// Emoji handling
|
||||
if (emojisEnabled) {
|
||||
const emojis = player
|
||||
.outgoingEmojis()
|
||||
.filter(
|
||||
(emoji) =>
|
||||
emoji.recipientID === AllPlayers ||
|
||||
emoji.recipientID === myPlayer?.smallID(),
|
||||
);
|
||||
|
||||
if (emojis.length > 0) {
|
||||
icons.push({
|
||||
id: "emoji",
|
||||
kind: "emoji",
|
||||
text: emojis[0].message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Embargo icon (theme dependent)
|
||||
if (myPlayer?.hasEmbargo(player)) {
|
||||
const embargoIcon = isDarkMode ? embargoWhiteIcon : embargoBlackIcon;
|
||||
icons.push({ id: "embargo", kind: "image", src: embargoIcon });
|
||||
}
|
||||
|
||||
// Nuke icon (different color depending on whether the local player is the target)
|
||||
const nukesSentByOtherPlayer = game.units(...nukeTypes).filter((unit) => {
|
||||
const isSendingNuke = player.id() === unit.owner().id();
|
||||
const notMyPlayer = !myPlayer || unit.owner().id() !== myPlayer.id();
|
||||
return isSendingNuke && notMyPlayer && unit.isActive();
|
||||
});
|
||||
|
||||
const isMyPlayerTarget = nukesSentByOtherPlayer.some((unit) => {
|
||||
const detonationDst = unit.targetTile();
|
||||
if (!detonationDst || !myPlayer) return false;
|
||||
const targetId = game.owner(detonationDst).id();
|
||||
return targetId === myPlayer.id();
|
||||
});
|
||||
|
||||
if (nukesSentByOtherPlayer.length > 0) {
|
||||
const icon = isMyPlayerTarget ? nukeRedIcon : nukeWhiteIcon;
|
||||
icons.push({ id: "nuke", kind: "image", src: icon });
|
||||
}
|
||||
|
||||
return icons;
|
||||
}
|
||||
|
||||
export function createAllianceProgressIcon(
|
||||
size: number,
|
||||
fraction: number,
|
||||
hasExtensionRequest: boolean,
|
||||
darkMode: boolean,
|
||||
): HTMLDivElement {
|
||||
// Wrapper
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.setAttribute("data-icon", "alliance");
|
||||
wrapper.setAttribute("dark-mode", darkMode.toString());
|
||||
wrapper.style.position = "relative";
|
||||
wrapper.style.width = `${size}px`;
|
||||
wrapper.style.height = `${size}px`;
|
||||
wrapper.style.display = "inline-block";
|
||||
wrapper.style.flexShrink = "0";
|
||||
|
||||
// Base faded icon (full)
|
||||
const base = document.createElement("img");
|
||||
base.src = allianceIconFaded;
|
||||
base.style.width = `${size}px`;
|
||||
base.style.height = `${size}px`;
|
||||
base.style.display = "block";
|
||||
base.setAttribute("dark-mode", darkMode.toString());
|
||||
wrapper.appendChild(base);
|
||||
|
||||
// Overlay container for green portion, clipped from the top via clip-path
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "alliance-progress-overlay";
|
||||
overlay.style.position = "absolute";
|
||||
overlay.style.left = "0";
|
||||
overlay.style.top = "0";
|
||||
overlay.style.width = "100%";
|
||||
overlay.style.height = "100%";
|
||||
overlay.style.clipPath = computeAllianceClipPath(fraction);
|
||||
|
||||
const colored = document.createElement("img");
|
||||
colored.src = allianceIcon; // green icon
|
||||
colored.style.width = `${size}px`;
|
||||
colored.style.height = `${size}px`;
|
||||
colored.style.display = "block";
|
||||
colored.setAttribute("dark-mode", darkMode.toString());
|
||||
overlay.appendChild(colored);
|
||||
|
||||
wrapper.appendChild(overlay);
|
||||
|
||||
// Question mark overlay (shown when there's a pending extension request)
|
||||
const questionMark = document.createElement("img");
|
||||
questionMark.className = "alliance-question-mark";
|
||||
questionMark.src = questionMarkIcon;
|
||||
questionMark.style.position = "absolute";
|
||||
questionMark.style.left = "0";
|
||||
questionMark.style.top = "0";
|
||||
questionMark.style.width = `${size}px`;
|
||||
questionMark.style.height = `${size}px`;
|
||||
questionMark.style.display = hasExtensionRequest ? "block" : "none";
|
||||
questionMark.style.pointerEvents = "none";
|
||||
questionMark.setAttribute("dark-mode", darkMode.toString());
|
||||
wrapper.appendChild(questionMark);
|
||||
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
export function computeAllianceClipPath(fraction: number): string {
|
||||
const topCut = 20 + (1 - fraction) * 80 * 0.78; // min 20%, max 82.40%
|
||||
return `inset(${topCut.toFixed(2)}% -2px 0 -2px)`;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Colord } from "colord";
|
||||
import miniPumpkin from "../../../resources/sprites/halloween/miniPumpkin.png";
|
||||
import pumpkin from "../../../resources/sprites/halloween/pumpkin.png";
|
||||
import atomBombSprite from "../../../resources/sprites/atombomb.png";
|
||||
import hydrogenBombSprite from "../../../resources/sprites/hydrogenbomb.png";
|
||||
import mirvSprite from "../../../resources/sprites/mirv2.png";
|
||||
import samMissileSprite from "../../../resources/sprites/samMissile.png";
|
||||
import tradeShipSprite from "../../../resources/sprites/tradeship.png";
|
||||
@@ -26,8 +26,8 @@ const SPRITE_CONFIG: Partial<Record<UnitType | TrainTypeSprite, string>> = {
|
||||
[UnitType.TransportShip]: transportShipSprite,
|
||||
[UnitType.Warship]: warshipSprite,
|
||||
[UnitType.SAMMissile]: samMissileSprite,
|
||||
[UnitType.AtomBomb]: miniPumpkin,
|
||||
[UnitType.HydrogenBomb]: pumpkin,
|
||||
[UnitType.AtomBomb]: atomBombSprite,
|
||||
[UnitType.HydrogenBomb]: hydrogenBombSprite,
|
||||
[UnitType.TradeShip]: tradeShipSprite,
|
||||
[UnitType.MIRV]: mirvSprite,
|
||||
[TrainTypeSprite.Engine]: trainEngineSprite,
|
||||
|
||||
@@ -26,6 +26,7 @@ export function conquestFxFactory(
|
||||
x,
|
||||
y,
|
||||
FxType.Conquest,
|
||||
2500,
|
||||
);
|
||||
const fadeAnimation = new FadeFx(swordAnimation, 0.1, 0.6);
|
||||
conquestFx.push(fadeAnimation);
|
||||
|
||||
@@ -16,8 +16,4 @@ export enum FxType {
|
||||
UnderConstruction = "UnderConstruction",
|
||||
Dust = "Dust",
|
||||
Conquest = "Conquest",
|
||||
Tentacle = "Tentacle",
|
||||
Shark = "Shark",
|
||||
Bubble = "Bubble",
|
||||
Tornado = "Tornado",
|
||||
}
|
||||
|
||||
@@ -19,35 +19,6 @@ function fadeInOut(
|
||||
return 1 - f * f;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Move a sprite around
|
||||
*/
|
||||
export class MoveSpriteFx implements Fx {
|
||||
private originX: number;
|
||||
private originY: number;
|
||||
constructor(
|
||||
private fxToMove: SpriteFx,
|
||||
private toX: number,
|
||||
private toY: number,
|
||||
private fadeIn: number = 0.1,
|
||||
private fadeOut: number = 0.9,
|
||||
) {
|
||||
this.originX = fxToMove.x;
|
||||
this.originY = fxToMove.y;
|
||||
}
|
||||
|
||||
renderTick(duration: number, ctx: CanvasRenderingContext2D): boolean {
|
||||
const t = this.fxToMove.getElapsedTime() / this.fxToMove.getDuration();
|
||||
this.fxToMove.x = Math.floor(this.originX * (1 - t) + this.toX * t);
|
||||
this.fxToMove.y = Math.floor(this.originY * (1 - t) + this.toY * t);
|
||||
ctx.save();
|
||||
ctx.globalAlpha = fadeInOut(t, this.fadeIn, this.fadeOut);
|
||||
const result = this.fxToMove.renderTick(duration, ctx);
|
||||
ctx.restore();
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fade in/out another FX
|
||||
*/
|
||||
@@ -78,8 +49,8 @@ export class SpriteFx implements Fx {
|
||||
protected waitToTheEnd = false;
|
||||
constructor(
|
||||
animatedSpriteLoader: AnimatedSpriteLoader,
|
||||
public x: number,
|
||||
public y: number,
|
||||
protected x: number,
|
||||
protected y: number,
|
||||
fxType: FxType,
|
||||
duration?: number,
|
||||
owner?: PlayerView,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { GameView } from "../../../core/game/GameView";
|
||||
import { Layer } from "./Layer";
|
||||
|
||||
const AD_SHOW_TICKS = 60 * 10; // 1 minute
|
||||
const AD_SHOW_TICKS = 2 * 60 * 10; // 2 minutes
|
||||
|
||||
export class AdTimer implements Layer {
|
||||
private isHidden: boolean = false;
|
||||
|
||||
@@ -11,6 +11,8 @@ import { Layer } from "./Layer";
|
||||
// Parameters for the alert animation
|
||||
const ALERT_SPEED = 1.6;
|
||||
const ALERT_COUNT = 2;
|
||||
const RETALIATION_WINDOW_TICKS = 15 * 10; // 15 seconds
|
||||
const ALERT_COOLDOWN_TICKS = 15 * 10; // 15 seconds
|
||||
|
||||
@customElement("alert-frame")
|
||||
export class AlertFrame extends LitElement implements Layer {
|
||||
@@ -21,6 +23,10 @@ export class AlertFrame extends LitElement implements Layer {
|
||||
private isActive = false;
|
||||
|
||||
private animationTimeout: number | null = null;
|
||||
private seenAttackIds: Set<string> = new Set();
|
||||
private lastAlertTick: number = -1;
|
||||
// Map of player ID -> tick when we last attacked them
|
||||
private outgoingAttackTicks: Map<number, number> = new Map();
|
||||
|
||||
static styles = css`
|
||||
.alert-border {
|
||||
@@ -76,12 +82,28 @@ export class AlertFrame extends LitElement implements Layer {
|
||||
return; // Game not initialized yet
|
||||
}
|
||||
|
||||
const myPlayer = this.game.myPlayer();
|
||||
|
||||
// Clear tracked attacks if player dies or doesn't exist
|
||||
if (!myPlayer || !myPlayer.isAlive()) {
|
||||
this.seenAttackIds.clear();
|
||||
this.outgoingAttackTicks.clear();
|
||||
this.lastAlertTick = -1;
|
||||
return;
|
||||
}
|
||||
|
||||
// Track outgoing attacks to detect retaliation
|
||||
this.trackOutgoingAttacks();
|
||||
|
||||
// Check for BrokeAllianceUpdate events
|
||||
this.game
|
||||
.updatesSinceLastTick()
|
||||
?.[GameUpdateType.BrokeAlliance]?.forEach((update) => {
|
||||
this.onBrokeAllianceUpdate(update as BrokeAllianceUpdate);
|
||||
});
|
||||
|
||||
// Check for new incoming attacks
|
||||
this.checkForNewAttacks();
|
||||
}
|
||||
|
||||
// The alert frame is not affected by the camera transform
|
||||
@@ -104,10 +126,101 @@ export class AlertFrame extends LitElement implements Layer {
|
||||
private activateAlert() {
|
||||
if (this.userSettings.alertFrame()) {
|
||||
this.isActive = true;
|
||||
this.lastAlertTick = this.game.ticks();
|
||||
this.requestUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private trackOutgoingAttacks() {
|
||||
const myPlayer = this.game.myPlayer();
|
||||
if (!myPlayer || !myPlayer.isAlive()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentTick = this.game.ticks();
|
||||
const outgoingAttacks = myPlayer.outgoingAttacks();
|
||||
|
||||
// Track when we attack other players (not terra nullius)
|
||||
for (const attack of outgoingAttacks) {
|
||||
// Only track attacks on players (targetID !== 0 means it's a player, not unclaimed land)
|
||||
if (attack.targetID !== 0 && !attack.retreating) {
|
||||
const existingTick = this.outgoingAttackTicks.get(attack.targetID);
|
||||
|
||||
// Only update timestamp if:
|
||||
// 1. This is a new attack (not in map yet), OR
|
||||
// 2. The existing entry has expired (older than retaliation window)
|
||||
if (
|
||||
existingTick === undefined ||
|
||||
currentTick - existingTick >= RETALIATION_WINDOW_TICKS
|
||||
) {
|
||||
this.outgoingAttackTicks.set(attack.targetID, currentTick);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up old entries (older than retaliation window)
|
||||
for (const [playerID, tick] of this.outgoingAttackTicks.entries()) {
|
||||
if (currentTick - tick > RETALIATION_WINDOW_TICKS) {
|
||||
this.outgoingAttackTicks.delete(playerID);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private checkForNewAttacks() {
|
||||
const myPlayer = this.game.myPlayer();
|
||||
if (!myPlayer || !myPlayer.isAlive()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const incomingAttacks = myPlayer.incomingAttacks();
|
||||
const currentTick = this.game.ticks();
|
||||
|
||||
// Check if we're in cooldown (within 10 seconds of last alert)
|
||||
const inCooldown =
|
||||
this.lastAlertTick !== -1 &&
|
||||
currentTick - this.lastAlertTick < ALERT_COOLDOWN_TICKS;
|
||||
|
||||
// Find new attacks that we haven't seen yet
|
||||
const playerTroops = myPlayer.troops();
|
||||
const minAttackTroopsThreshold = playerTroops / 5; // 1/5 of current troops
|
||||
|
||||
for (const attack of incomingAttacks) {
|
||||
// Only alert for non-retreating attacks
|
||||
if (!attack.retreating && !this.seenAttackIds.has(attack.id)) {
|
||||
// Check if this is a retaliation (we attacked them recently)
|
||||
const ourAttackTick = this.outgoingAttackTicks.get(attack.attackerID);
|
||||
const isRetaliation =
|
||||
ourAttackTick !== undefined &&
|
||||
currentTick - ourAttackTick < RETALIATION_WINDOW_TICKS;
|
||||
|
||||
// Check if attack is too small (less than 1/5 of our troops)
|
||||
const isSmallAttack = attack.troops < minAttackTroopsThreshold;
|
||||
|
||||
// Don't alert if:
|
||||
// 1. We're in cooldown from a recent alert
|
||||
// 2. This is a retaliation (we attacked them within 15 seconds)
|
||||
// 3. The attack is too small (less than 1/5 of our troops)
|
||||
if (!inCooldown && !isRetaliation && !isSmallAttack) {
|
||||
this.seenAttackIds.add(attack.id);
|
||||
this.activateAlert();
|
||||
} else {
|
||||
// Still mark as seen so we don't alert later
|
||||
this.seenAttackIds.add(attack.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up IDs for attacks that are no longer active (retreating or completed)
|
||||
const activeAttackIds = new Set(incomingAttacks.map((a) => a.id));
|
||||
|
||||
// Remove IDs for attacks that are no longer in the incoming attacks list
|
||||
for (const attackId of this.seenAttackIds) {
|
||||
if (!activeAttackIds.has(attackId)) {
|
||||
this.seenAttackIds.delete(attackId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public dismissAlert() {
|
||||
this.isActive = false;
|
||||
if (this.animationTimeout) {
|
||||
|
||||
@@ -190,13 +190,22 @@ export class ControlPanel extends LitElement implements Layer {
|
||||
</div>
|
||||
|
||||
<div class="relative mb-0 sm:mb-4">
|
||||
<label class="block text-white mb-1" translate="no"
|
||||
>${translateText("control_panel.attack_ratio")}:
|
||||
${(this.attackRatio * 100).toFixed(0)}%
|
||||
(${renderTroops(
|
||||
(this.game?.myPlayer()?.troops() ?? 0) * this.attackRatio,
|
||||
)})</label
|
||||
>
|
||||
<label class="block text-white mb-1">
|
||||
${translateText("control_panel.attack_ratio")}:
|
||||
<span
|
||||
class="inline-flex items-center gap-1"
|
||||
dir="ltr"
|
||||
style="unicode-bidi: isolate;"
|
||||
translate="no"
|
||||
>
|
||||
<span>${(this.attackRatio * 100).toFixed(0)}%</span>
|
||||
<span>
|
||||
(${renderTroops(
|
||||
(this.game?.myPlayer()?.troops() ?? 0) * this.attackRatio,
|
||||
)})
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<div class="relative h-8">
|
||||
<!-- Background track -->
|
||||
<div
|
||||
|
||||
@@ -4,7 +4,7 @@ import { EventBus } from "../../../core/EventBus";
|
||||
import { AllPlayers } from "../../../core/game/Game";
|
||||
import { GameView, PlayerView } from "../../../core/game/GameView";
|
||||
import { TerraNulliusImpl } from "../../../core/game/TerraNulliusImpl";
|
||||
import { Emoji, emojiTable, flattenedEmojiTable } from "../../../core/Util";
|
||||
import { Emoji, flattenedEmojiTable } from "../../../core/Util";
|
||||
import { CloseViewEvent, ShowEmojiMenuEvent } from "../../InputHandler";
|
||||
import { SendEmojiIntentEvent } from "../../Transport";
|
||||
import { TransformHandler } from "../TransformHandler";
|
||||
@@ -57,6 +57,15 @@ export class EmojiTable extends LitElement {
|
||||
|
||||
private onEmojiClicked: (emoji: string) => void = () => {};
|
||||
|
||||
private handleBackdropClick = (e: MouseEvent) => {
|
||||
const panelContent = this.querySelector(
|
||||
'div[class*="bg-zinc-900"]',
|
||||
) as HTMLElement;
|
||||
if (panelContent && !panelContent.contains(e.target as Node)) {
|
||||
this.hideTable();
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
if (!this.isVisible) {
|
||||
return null;
|
||||
@@ -64,43 +73,41 @@ export class EmojiTable extends LitElement {
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="bg-slate-800 max-w-[95vw] max-h-[95vh] pt-[15px] pb-[15px] fixed flex flex-col -translate-x-1/2 -translate-y-1/2
|
||||
items-center rounded-[10px] z-[9999] top-[50%] left-[50%] justify-center"
|
||||
@contextmenu=${(e: MouseEvent) => e.preventDefault()}
|
||||
@wheel=${(e: WheelEvent) => e.stopPropagation()}
|
||||
class="fixed inset-0 bg-black/15 backdrop-brightness-110 flex items-start sm:items-center justify-center z-[10002] pt-4 sm:pt-0"
|
||||
@click=${this.handleBackdropClick}
|
||||
>
|
||||
<!-- Close button -->
|
||||
<button
|
||||
class="absolute -top-2 -right-2 w-6 h-6 flex items-center justify-center
|
||||
bg-red-500 hover:bg-red-900 text-white rounded-full
|
||||
text-sm font-bold transition-colors"
|
||||
@click=${this.hideTable}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
<div
|
||||
class="flex flex-col overflow-y-auto"
|
||||
style="scrollbar-gutter: stable both-edges;"
|
||||
>
|
||||
${emojiTable.map(
|
||||
(row) => html`
|
||||
<div class="w-full justify-center flex">
|
||||
${row.map(
|
||||
(emoji) => html`
|
||||
<button
|
||||
class="flex transition-transform duration-300 ease justify-center items-center cursor-pointer
|
||||
border border-solid border-slate-500 rounded-[12px] bg-slate-700 hover:bg-slate-600 active:bg-slate-500
|
||||
md:m-[8px] md:text-[60px] md:w-[80px] md:h-[80px] hover:scale-[1.1] active:scale-[0.95]
|
||||
sm:w-[60px] sm:h-[60px] sm:text-[32px] sm:m-[5px] text-[28px] w-[50px] h-[50px] m-[3px]"
|
||||
@click=${() => this.onEmojiClicked(emoji)}
|
||||
>
|
||||
${emoji}
|
||||
</button>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
<div class="relative">
|
||||
<!-- Close button -->
|
||||
<button
|
||||
class="absolute -top-3 -right-3 w-7 h-7 flex items-center justify-center
|
||||
bg-zinc-700 hover:bg-red-500 text-white rounded-full shadow transition-colors z-[10004]"
|
||||
@click=${this.hideTable}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
|
||||
<div
|
||||
class="bg-zinc-900/95 p-2 sm:p-3 rounded-[10px] z-[10003] shadow-2xl shadow-black/50 ring-1 ring-white/5
|
||||
w-[calc(100vw-32px)] sm:w-[400px] max-h-[calc(100vh-60px)] overflow-y-auto"
|
||||
@contextmenu=${(e: MouseEvent) => e.preventDefault()}
|
||||
@wheel=${(e: WheelEvent) => e.stopPropagation()}
|
||||
@click=${(e: MouseEvent) => e.stopPropagation()}
|
||||
>
|
||||
<div class="grid grid-cols-5 gap-1 sm:gap-2">
|
||||
${flattenedEmojiTable.map(
|
||||
(emoji) => html`
|
||||
<button
|
||||
class="flex items-center justify-center cursor-pointer aspect-square
|
||||
border border-solid border-zinc-600 rounded-lg bg-zinc-800 hover:bg-zinc-700 active:bg-zinc-600
|
||||
text-3xl sm:text-4xl transition-transform duration-300 hover:scale-110 active:scale-95"
|
||||
@click=${() => this.onEmojiClicked(emoji)}
|
||||
>
|
||||
${emoji}
|
||||
</button>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
CancelBoatIntentEvent,
|
||||
SendAllianceExtensionIntentEvent,
|
||||
SendAllianceReplyIntentEvent,
|
||||
SendAttackIntentEvent,
|
||||
} from "../../Transport";
|
||||
import { Layer } from "./Layer";
|
||||
|
||||
@@ -736,28 +737,54 @@ export class EventsDisplay extends LitElement implements Layer {
|
||||
}
|
||||
}
|
||||
|
||||
private handleRetaliate(attack: AttackUpdate) {
|
||||
const attacker = this.game.playerBySmallID(attack.attackerID) as PlayerView;
|
||||
if (!attacker) return;
|
||||
|
||||
const myPlayer = this.game.myPlayer();
|
||||
if (!myPlayer) return;
|
||||
|
||||
// Launch counterattack with the same number of troops as the incoming attack
|
||||
this.eventBus.emit(new SendAttackIntentEvent(attacker.id(), attack.troops));
|
||||
}
|
||||
|
||||
private renderIncomingAttacks() {
|
||||
return html`
|
||||
${this.incomingAttacks.length > 0
|
||||
? html`
|
||||
${this.incomingAttacks.map(
|
||||
(attack) => html`
|
||||
${this.renderButton({
|
||||
content: html`
|
||||
${renderTroops(attack.troops)}
|
||||
${(
|
||||
this.game.playerBySmallID(attack.attackerID) as PlayerView
|
||||
)?.name()}
|
||||
${attack.retreating
|
||||
? `(${translateText("events_display.retreating")}...)`
|
||||
<div class="flex flex-wrap gap-y-1 gap-x-2">
|
||||
${this.incomingAttacks.map(
|
||||
(attack) => html`
|
||||
<div class="inline-flex items-center gap-1">
|
||||
${this.renderButton({
|
||||
content: html`
|
||||
${renderTroops(attack.troops)}
|
||||
${(
|
||||
this.game.playerBySmallID(
|
||||
attack.attackerID,
|
||||
) as PlayerView
|
||||
)?.name()}
|
||||
${attack.retreating
|
||||
? `(${translateText("events_display.retreating")}...)`
|
||||
: ""}
|
||||
`,
|
||||
onClick: () => this.attackWarningOnClick(attack),
|
||||
className: "text-left text-red-400",
|
||||
translate: false,
|
||||
})}
|
||||
${!attack.retreating
|
||||
? this.renderButton({
|
||||
content: translateText("events_display.retaliate"),
|
||||
onClick: () => this.handleRetaliate(attack),
|
||||
className:
|
||||
"inline-block px-3 py-1 text-white rounded text-md md:text-sm cursor-pointer transition-colors duration-300 bg-red-600 hover:bg-red-700",
|
||||
translate: true,
|
||||
})
|
||||
: ""}
|
||||
`,
|
||||
onClick: () => this.attackWarningOnClick(attack),
|
||||
className: "text-left text-red-400",
|
||||
translate: false,
|
||||
})}
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
`
|
||||
: ""}
|
||||
`;
|
||||
@@ -877,6 +904,30 @@ export class EventsDisplay extends LitElement implements Layer {
|
||||
`;
|
||||
}
|
||||
|
||||
private renderBetrayalDebuffTimer() {
|
||||
const myPlayer = this.game.myPlayer();
|
||||
if (!myPlayer || !myPlayer.isTraitor()) {
|
||||
return html``;
|
||||
}
|
||||
|
||||
const remainingTicks = myPlayer.getTraitorRemainingTicks();
|
||||
const remainingSeconds = Math.ceil(remainingTicks / 10);
|
||||
|
||||
if (remainingSeconds <= 0) {
|
||||
return html``;
|
||||
}
|
||||
|
||||
return html`
|
||||
${this.renderButton({
|
||||
content: html`${translateText("events_display.betrayal_debuff_ends", {
|
||||
time: remainingSeconds,
|
||||
})}`,
|
||||
className: "text-left text-yellow-400",
|
||||
translate: false,
|
||||
})}
|
||||
`;
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.active || !this._isVisible) {
|
||||
return html``;
|
||||
@@ -1081,6 +1132,24 @@ export class EventsDisplay extends LitElement implements Layer {
|
||||
`
|
||||
: ""}
|
||||
|
||||
<!--- Betrayal debuff timer row -->
|
||||
${(() => {
|
||||
const myPlayer = this.game.myPlayer();
|
||||
return (
|
||||
myPlayer &&
|
||||
myPlayer.isTraitor() &&
|
||||
myPlayer.getTraitorRemainingTicks() > 0
|
||||
);
|
||||
})()
|
||||
? html`
|
||||
<tr class="lg:px-2 lg:py-1 p-1">
|
||||
<td class="lg:px-2 lg:py-1 p-1 text-left">
|
||||
${this.renderBetrayalDebuffTimer()}
|
||||
</td>
|
||||
</tr>
|
||||
`
|
||||
: ""}
|
||||
|
||||
<!--- Outgoing attacks row -->
|
||||
${this.outgoingAttacks.length > 0
|
||||
? html`
|
||||
@@ -1119,7 +1188,15 @@ export class EventsDisplay extends LitElement implements Layer {
|
||||
this.incomingAttacks.length === 0 &&
|
||||
this.outgoingAttacks.length === 0 &&
|
||||
this.outgoingLandAttacks.length === 0 &&
|
||||
this.outgoingBoats.length === 0
|
||||
this.outgoingBoats.length === 0 &&
|
||||
!(() => {
|
||||
const myPlayer = this.game.myPlayer();
|
||||
return (
|
||||
myPlayer &&
|
||||
myPlayer.isTraitor() &&
|
||||
myPlayer.getTraitorRemainingTicks() > 0
|
||||
);
|
||||
})()
|
||||
? html`
|
||||
<tr>
|
||||
<td
|
||||
|
||||
@@ -1,268 +0,0 @@
|
||||
import { LitElement, css, html } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import { EventBus } from "../../../core/EventBus";
|
||||
import { UserSettings } from "../../../core/game/UserSettings";
|
||||
import { TogglePerformanceOverlayEvent } from "../../InputHandler";
|
||||
import { Layer } from "./Layer";
|
||||
|
||||
@customElement("fps-display")
|
||||
export class FPSDisplay extends LitElement implements Layer {
|
||||
@property({ type: Object })
|
||||
public eventBus!: EventBus;
|
||||
|
||||
@property({ type: Object })
|
||||
public userSettings!: UserSettings;
|
||||
|
||||
@state()
|
||||
private currentFPS: number = 0;
|
||||
|
||||
@state()
|
||||
private averageFPS: number = 0;
|
||||
|
||||
@state()
|
||||
private frameTime: number = 0;
|
||||
|
||||
@state()
|
||||
private isVisible: boolean = false;
|
||||
|
||||
@state()
|
||||
private isDragging: boolean = false;
|
||||
|
||||
@state()
|
||||
private position: { x: number; y: number } = { x: 50, y: 20 }; // Percentage values
|
||||
|
||||
private frameCount: number = 0;
|
||||
private lastTime: number = 0;
|
||||
private frameTimes: number[] = [];
|
||||
private fpsHistory: number[] = [];
|
||||
private lastSecondTime: number = 0;
|
||||
private framesThisSecond: number = 0;
|
||||
private dragStart: { x: number; y: number } = { x: 0, y: 0 };
|
||||
|
||||
static styles = css`
|
||||
.fps-display {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
color: white;
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
z-index: 9999;
|
||||
user-select: none;
|
||||
cursor: move;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.fps-display.dragging {
|
||||
cursor: grabbing;
|
||||
transition: none;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.fps-line {
|
||||
margin: 2px 0;
|
||||
}
|
||||
|
||||
.fps-good {
|
||||
color: #4ade80; /* green-400 */
|
||||
}
|
||||
|
||||
.fps-warning {
|
||||
color: #fbbf24; /* amber-400 */
|
||||
}
|
||||
|
||||
.fps-bad {
|
||||
color: #f87171; /* red-400 */
|
||||
}
|
||||
|
||||
.close-button {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
user-select: none;
|
||||
pointer-events: auto;
|
||||
}
|
||||
`;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.eventBus.on(TogglePerformanceOverlayEvent, () => {
|
||||
this.userSettings.togglePerformanceOverlay();
|
||||
});
|
||||
}
|
||||
|
||||
setVisible(visible: boolean) {
|
||||
this.isVisible = visible;
|
||||
}
|
||||
|
||||
private handleClose() {
|
||||
this.userSettings.togglePerformanceOverlay();
|
||||
}
|
||||
|
||||
private handleMouseDown = (e: MouseEvent) => {
|
||||
// Don't start dragging if clicking on close button
|
||||
if ((e.target as HTMLElement).classList.contains("close-button")) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isDragging = true;
|
||||
this.dragStart = {
|
||||
x: e.clientX - this.position.x,
|
||||
y: e.clientY - this.position.y,
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", this.handleMouseMove);
|
||||
document.addEventListener("mouseup", this.handleMouseUp);
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
private handleMouseMove = (e: MouseEvent) => {
|
||||
if (!this.isDragging) return;
|
||||
|
||||
const newX = e.clientX - this.dragStart.x;
|
||||
const newY = e.clientY - this.dragStart.y;
|
||||
|
||||
// Convert to percentage of viewport
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
|
||||
this.position = {
|
||||
x: Math.max(0, Math.min(viewportWidth - 100, newX)), // Keep within viewport bounds
|
||||
y: Math.max(0, Math.min(viewportHeight - 100, newY)),
|
||||
};
|
||||
|
||||
this.requestUpdate();
|
||||
};
|
||||
|
||||
private handleMouseUp = () => {
|
||||
this.isDragging = false;
|
||||
document.removeEventListener("mousemove", this.handleMouseMove);
|
||||
document.removeEventListener("mouseup", this.handleMouseUp);
|
||||
};
|
||||
|
||||
updateFPS(frameDuration: number) {
|
||||
this.isVisible = this.userSettings.performanceOverlay();
|
||||
|
||||
if (!this.isVisible) return;
|
||||
|
||||
const now = performance.now();
|
||||
|
||||
// Initialize timing on first call
|
||||
if (this.lastTime === 0) {
|
||||
this.lastTime = now;
|
||||
this.lastSecondTime = now;
|
||||
return;
|
||||
}
|
||||
|
||||
const deltaTime = now - this.lastTime;
|
||||
|
||||
// Track frame times for current FPS calculation (last 60 frames)
|
||||
this.frameTimes.push(deltaTime);
|
||||
if (this.frameTimes.length > 60) {
|
||||
this.frameTimes.shift();
|
||||
}
|
||||
|
||||
// Calculate current FPS based on average frame time
|
||||
if (this.frameTimes.length > 0) {
|
||||
const avgFrameTime =
|
||||
this.frameTimes.reduce((a, b) => a + b, 0) / this.frameTimes.length;
|
||||
this.currentFPS = Math.round(1000 / avgFrameTime);
|
||||
this.frameTime = Math.round(avgFrameTime);
|
||||
}
|
||||
|
||||
// Track FPS for 60-second average
|
||||
this.framesThisSecond++;
|
||||
|
||||
// Update every second
|
||||
if (now - this.lastSecondTime >= 1000) {
|
||||
this.fpsHistory.push(this.framesThisSecond);
|
||||
if (this.fpsHistory.length > 60) {
|
||||
this.fpsHistory.shift();
|
||||
}
|
||||
|
||||
// Calculate 60-second average
|
||||
if (this.fpsHistory.length > 0) {
|
||||
this.averageFPS = Math.round(
|
||||
this.fpsHistory.reduce((a, b) => a + b, 0) / this.fpsHistory.length,
|
||||
);
|
||||
}
|
||||
|
||||
this.framesThisSecond = 0;
|
||||
this.lastSecondTime = now;
|
||||
}
|
||||
|
||||
this.lastTime = now;
|
||||
this.frameCount++;
|
||||
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
shouldTransform(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
private getFPSColor(fps: number): string {
|
||||
if (fps >= 55) return "fps-good";
|
||||
if (fps >= 30) return "fps-warning";
|
||||
return "fps-bad";
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.isVisible) {
|
||||
return html``;
|
||||
}
|
||||
|
||||
const style = `
|
||||
left: ${this.position.x}px;
|
||||
top: ${this.position.y}px;
|
||||
transform: none;
|
||||
`;
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="fps-display ${this.isDragging ? "dragging" : ""}"
|
||||
style="${style}"
|
||||
@mousedown="${this.handleMouseDown}"
|
||||
>
|
||||
<button class="close-button" @click="${this.handleClose}">×</button>
|
||||
<div class="fps-line">
|
||||
FPS:
|
||||
<span class="${this.getFPSColor(this.currentFPS)}"
|
||||
>${this.currentFPS}</span
|
||||
>
|
||||
</div>
|
||||
<div class="fps-line">
|
||||
Avg (60s):
|
||||
<span class="${this.getFPSColor(this.averageFPS)}"
|
||||
>${this.averageFPS}</span
|
||||
>
|
||||
</div>
|
||||
<div class="fps-line">
|
||||
Frame:
|
||||
<span class="${this.getFPSColor(1000 / this.frameTime)}"
|
||||
>${this.frameTime}ms</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ import { conquestFxFactory } from "../fx/ConquestFx";
|
||||
import { Fx, FxType } from "../fx/Fx";
|
||||
import { NukeAreaFx } from "../fx/NukeAreaFx";
|
||||
import { nukeFxFactory, ShockwaveFx } from "../fx/NukeFx";
|
||||
import { FadeFx, MoveSpriteFx, SpriteFx } from "../fx/SpriteFx";
|
||||
import { SpriteFx } from "../fx/SpriteFx";
|
||||
import { TargetFx } from "../fx/TargetFx";
|
||||
import { TextFx } from "../fx/TextFx";
|
||||
import { UnitExplosionFx } from "../fx/UnitExplosionFx";
|
||||
@@ -22,8 +22,6 @@ import { Layer } from "./Layer";
|
||||
export class FxLayer implements Layer {
|
||||
private canvas: HTMLCanvasElement;
|
||||
private context: CanvasRenderingContext2D;
|
||||
private lastRandomEvent: number = 0;
|
||||
private randomEventRate: number = 8;
|
||||
|
||||
private lastRefresh: number = 0;
|
||||
private refreshRate: number = 10;
|
||||
@@ -44,14 +42,6 @@ export class FxLayer implements Layer {
|
||||
}
|
||||
|
||||
tick() {
|
||||
if (!this.game.config().userSettings()?.fxLayer()) {
|
||||
return;
|
||||
}
|
||||
this.lastRandomEvent += 1;
|
||||
if (this.lastRandomEvent > this.randomEventRate) {
|
||||
this.lastRandomEvent = 0;
|
||||
this.randomEvent();
|
||||
}
|
||||
this.manageBoatTargetFx();
|
||||
this.game
|
||||
.updatesSinceLastTick()
|
||||
@@ -154,72 +144,6 @@ export class FxLayer implements Layer {
|
||||
this.allFx.push(textFx);
|
||||
}
|
||||
|
||||
randomEvent() {
|
||||
const randX = Math.floor(Math.random() * this.game.width());
|
||||
const randY = Math.floor(Math.random() * this.game.height());
|
||||
const ref = this.game.ref(randX, randY);
|
||||
if (this.game.isOcean(ref) && !this.game.isShoreline(ref)) {
|
||||
const animation = Math.floor(Math.random() * 4);
|
||||
if (animation === 0) {
|
||||
const fx = new SpriteFx(
|
||||
this.animatedSpriteLoader,
|
||||
randX,
|
||||
randY,
|
||||
FxType.Shark,
|
||||
);
|
||||
this.allFx.push(fx);
|
||||
} else if (animation === 1) {
|
||||
const fx = new SpriteFx(
|
||||
this.animatedSpriteLoader,
|
||||
randX,
|
||||
randY,
|
||||
FxType.Bubble,
|
||||
);
|
||||
this.allFx.push(fx);
|
||||
} else if (animation === 2) {
|
||||
const fx = new MoveSpriteFx(
|
||||
new SpriteFx(
|
||||
this.animatedSpriteLoader,
|
||||
randX,
|
||||
randY,
|
||||
FxType.Tornado,
|
||||
6000,
|
||||
),
|
||||
randX - 40,
|
||||
randY,
|
||||
0.1,
|
||||
0.8,
|
||||
);
|
||||
this.allFx.push(fx);
|
||||
} else if (animation === 3) {
|
||||
const fx = new FadeFx(
|
||||
new SpriteFx(
|
||||
this.animatedSpriteLoader,
|
||||
randX,
|
||||
randY,
|
||||
FxType.Tentacle,
|
||||
),
|
||||
0.1,
|
||||
0.8,
|
||||
);
|
||||
this.allFx.push(fx);
|
||||
}
|
||||
} else {
|
||||
const ghost = new FadeFx(
|
||||
new SpriteFx(
|
||||
this.animatedSpriteLoader,
|
||||
randX,
|
||||
randY,
|
||||
FxType.MiniSmoke,
|
||||
4000,
|
||||
),
|
||||
0.1,
|
||||
0.8,
|
||||
);
|
||||
this.allFx.push(ghost);
|
||||
}
|
||||
}
|
||||
|
||||
onUnitEvent(unit: UnitView) {
|
||||
switch (unit.type()) {
|
||||
case UnitType.TransportShip: {
|
||||
|
||||
@@ -40,7 +40,9 @@ export class HeadsUpMessage extends LitElement implements Layer {
|
||||
backdrop-blur-md text-white text-md lg:text-xl p-1 lg:p-2"
|
||||
@contextmenu=${(e: MouseEvent) => e.preventDefault()}
|
||||
>
|
||||
${translateText("heads_up_message.choose_spawn")}
|
||||
${this.game.config().isRandomSpawn()
|
||||
? translateText("heads_up_message.random_spawn")
|
||||
: translateText("heads_up_message.choose_spawn")}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -116,7 +116,9 @@ export class Leaderboard extends LitElement implements Layer {
|
||||
gold: renderNumber(player.gold()),
|
||||
troops: renderNumber(troops),
|
||||
isMyPlayer: player === myPlayer,
|
||||
isOnSameTeam: player === myPlayer || player.isOnSameTeam(myPlayer!),
|
||||
isOnSameTeam:
|
||||
myPlayer !== null &&
|
||||
(player === myPlayer || player.isOnSameTeam(myPlayer)),
|
||||
player: player,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,29 +1,25 @@
|
||||
import allianceIcon from "../../../../resources/images/AllianceIcon.svg";
|
||||
import allianceRequestBlackIcon from "../../../../resources/images/AllianceRequestBlackIcon.svg";
|
||||
import allianceRequestWhiteIcon from "../../../../resources/images/AllianceRequestWhiteIcon.svg";
|
||||
import crownIcon from "../../../../resources/images/CrownIcon.svg";
|
||||
import disconnectedIcon from "../../../../resources/images/DisconnectedIcon.svg";
|
||||
import embargoBlackIcon from "../../../../resources/images/EmbargoBlackIcon.svg";
|
||||
import embargoWhiteIcon from "../../../../resources/images/EmbargoWhiteIcon.svg";
|
||||
import nukeRedIcon from "../../../../resources/images/NukeIconRed.svg";
|
||||
import nukeWhiteIcon from "../../../../resources/images/NukeIconWhite.svg";
|
||||
import shieldIcon from "../../../../resources/images/ShieldIconBlack.svg";
|
||||
import targetIcon from "../../../../resources/images/TargetIcon.svg";
|
||||
import traitorIcon from "../../../../resources/images/TraitorIcon.svg";
|
||||
import { renderPlayerFlag } from "../../../core/CustomFlag";
|
||||
import { EventBus } from "../../../core/EventBus";
|
||||
import { PseudoRandom } from "../../../core/PseudoRandom";
|
||||
import { Theme } from "../../../core/configuration/Config";
|
||||
import { AllPlayers, Cell, nukeTypes } from "../../../core/game/Game";
|
||||
import { Cell } from "../../../core/game/Game";
|
||||
import { GameView, PlayerView } from "../../../core/game/GameView";
|
||||
import { UserSettings } from "../../../core/game/UserSettings";
|
||||
import { AlternateViewEvent } from "../../InputHandler";
|
||||
import { createCanvas, renderNumber, renderTroops } from "../../Utils";
|
||||
import {
|
||||
computeAllianceClipPath,
|
||||
createAllianceProgressIcon,
|
||||
getFirstPlacePlayer,
|
||||
getPlayerIcons,
|
||||
PlayerIconId,
|
||||
} from "../PlayerIcons";
|
||||
import { TransformHandler } from "../TransformHandler";
|
||||
import { Layer } from "./Layer";
|
||||
|
||||
class RenderInfo {
|
||||
public icons: Map<string, HTMLImageElement> = new Map(); // Track icon elements
|
||||
public icons: Map<PlayerIconId, HTMLElement> = new Map(); // Track icon elements
|
||||
|
||||
constructor(
|
||||
public player: PlayerView,
|
||||
@@ -43,51 +39,20 @@ export class NameLayer implements Layer {
|
||||
private rand = new PseudoRandom(10);
|
||||
private renders: RenderInfo[] = [];
|
||||
private seenPlayers: Set<PlayerView> = new Set();
|
||||
private traitorIconImage: HTMLImageElement;
|
||||
private disconnectedIconImage: HTMLImageElement;
|
||||
private allianceRequestBlackIconImage: HTMLImageElement;
|
||||
private allianceRequestWhiteIconImage: HTMLImageElement;
|
||||
private allianceIconImage: HTMLImageElement;
|
||||
private targetIconImage: HTMLImageElement;
|
||||
private crownIconImage: HTMLImageElement;
|
||||
private embargoBlackIconImage: HTMLImageElement;
|
||||
private embargoWhiteIconImage: HTMLImageElement;
|
||||
private nukeWhiteIconImage: HTMLImageElement;
|
||||
private nukeRedIconImage: HTMLImageElement;
|
||||
private shieldIconImage: HTMLImageElement;
|
||||
private container: HTMLDivElement;
|
||||
private firstPlace: PlayerView | null = null;
|
||||
private theme: Theme = this.game.config().theme();
|
||||
private userSettings: UserSettings = new UserSettings();
|
||||
private isVisible: boolean = true;
|
||||
private firstPlace: PlayerView | null = null;
|
||||
|
||||
constructor(
|
||||
private game: GameView,
|
||||
private transformHandler: TransformHandler,
|
||||
private eventBus: EventBus,
|
||||
) {
|
||||
this.traitorIconImage = new Image();
|
||||
this.traitorIconImage.src = traitorIcon;
|
||||
this.disconnectedIconImage = new Image();
|
||||
this.disconnectedIconImage.src = disconnectedIcon;
|
||||
this.allianceIconImage = new Image();
|
||||
this.allianceIconImage.src = allianceIcon;
|
||||
this.allianceRequestBlackIconImage = new Image();
|
||||
this.allianceRequestBlackIconImage.src = allianceRequestBlackIcon;
|
||||
this.allianceRequestWhiteIconImage = new Image();
|
||||
this.allianceRequestWhiteIconImage.src = allianceRequestWhiteIcon;
|
||||
this.crownIconImage = new Image();
|
||||
this.crownIconImage.src = crownIcon;
|
||||
this.targetIconImage = new Image();
|
||||
this.targetIconImage.src = targetIcon;
|
||||
this.embargoBlackIconImage = new Image();
|
||||
this.embargoBlackIconImage.src = embargoBlackIcon;
|
||||
this.embargoWhiteIconImage = new Image();
|
||||
this.embargoWhiteIconImage.src = embargoWhiteIcon;
|
||||
this.nukeWhiteIconImage = new Image();
|
||||
this.nukeWhiteIconImage.src = nukeWhiteIcon;
|
||||
this.nukeRedIconImage = new Image();
|
||||
this.nukeRedIconImage.src = nukeRedIcon;
|
||||
this.shieldIconImage = new Image();
|
||||
this.shieldIconImage.src = shieldIcon;
|
||||
this.shieldIconImage = new Image();
|
||||
this.shieldIconImage.src = shieldIcon;
|
||||
}
|
||||
@@ -118,6 +83,21 @@ export class NameLayer implements Layer {
|
||||
this.container.style.zIndex = "2";
|
||||
document.body.appendChild(this.container);
|
||||
|
||||
// Add CSS keyframes for traitor icon flashing animation
|
||||
// Append to container instead of document.head to keep styles scoped to this component
|
||||
const style = document.createElement("style");
|
||||
style.textContent = `
|
||||
@keyframes traitorFlash {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
`;
|
||||
this.container.appendChild(style);
|
||||
|
||||
this.eventBus.on(AlternateViewEvent, (e) => this.onAlternateViewChange(e));
|
||||
}
|
||||
|
||||
@@ -157,12 +137,9 @@ export class NameLayer implements Layer {
|
||||
if (this.game.ticks() % 10 !== 0) {
|
||||
return;
|
||||
}
|
||||
const sorted = this.game
|
||||
.playerViews()
|
||||
.sort((a, b) => b.numTilesOwned() - a.numTilesOwned());
|
||||
if (sorted.length > 0) {
|
||||
this.firstPlace = sorted[0];
|
||||
}
|
||||
|
||||
// Precompute the first-place player for performance
|
||||
this.firstPlace = getFirstPlacePlayer(this.game);
|
||||
|
||||
for (const player of this.game.playerViews()) {
|
||||
if (player.isAlive()) {
|
||||
@@ -389,223 +366,153 @@ export class NameLayer implements Layer {
|
||||
".player-icons",
|
||||
) as HTMLDivElement;
|
||||
const iconSize = Math.min(render.fontSize * 1.5, 48);
|
||||
const myPlayer = this.game.myPlayer();
|
||||
const isDarkMode = this.userSettings.darkMode();
|
||||
|
||||
// Crown icon
|
||||
const existingCrown = iconsDiv.querySelector('[data-icon="crown"]');
|
||||
if (render.player === this.firstPlace) {
|
||||
if (!existingCrown) {
|
||||
iconsDiv.appendChild(
|
||||
this.createIconElement(
|
||||
this.crownIconImage.src,
|
||||
iconSize,
|
||||
"crown",
|
||||
false,
|
||||
),
|
||||
);
|
||||
}
|
||||
} else if (existingCrown) {
|
||||
existingCrown.remove();
|
||||
}
|
||||
|
||||
// Traitor icon
|
||||
const existingTraitor = iconsDiv.querySelector('[data-icon="traitor"]');
|
||||
if (render.player.isTraitor()) {
|
||||
if (!existingTraitor) {
|
||||
iconsDiv.appendChild(
|
||||
this.createIconElement(
|
||||
this.traitorIconImage.src,
|
||||
iconSize,
|
||||
"traitor",
|
||||
),
|
||||
);
|
||||
}
|
||||
} else if (existingTraitor) {
|
||||
existingTraitor.remove();
|
||||
}
|
||||
|
||||
// Disconnected icon
|
||||
const existingDisconnected = iconsDiv.querySelector(
|
||||
'[data-icon="disconnected"]',
|
||||
);
|
||||
if (render.player.isDisconnected()) {
|
||||
if (!existingDisconnected) {
|
||||
iconsDiv.appendChild(
|
||||
this.createIconElement(
|
||||
this.disconnectedIconImage.src,
|
||||
iconSize,
|
||||
"disconnected",
|
||||
),
|
||||
);
|
||||
}
|
||||
} else if (existingDisconnected) {
|
||||
existingDisconnected.remove();
|
||||
}
|
||||
|
||||
// Alliance icon
|
||||
const existingAlliance = iconsDiv.querySelector('[data-icon="alliance"]');
|
||||
if (myPlayer !== null && myPlayer.isAlliedWith(render.player)) {
|
||||
if (!existingAlliance) {
|
||||
iconsDiv.appendChild(
|
||||
this.createIconElement(
|
||||
this.allianceIconImage.src,
|
||||
iconSize,
|
||||
"alliance",
|
||||
),
|
||||
);
|
||||
}
|
||||
} else if (existingAlliance) {
|
||||
existingAlliance.remove();
|
||||
}
|
||||
|
||||
// Alliance request icon
|
||||
let existingRequestAlliance = iconsDiv.querySelector(
|
||||
'[data-icon="alliance-request"]',
|
||||
);
|
||||
const isThemeAllianceRequestIcon =
|
||||
existingRequestAlliance?.getAttribute("dark-mode") ===
|
||||
isDarkMode.toString();
|
||||
const AllianceRequestIconImageSrc = isDarkMode
|
||||
? this.allianceRequestWhiteIconImage.src
|
||||
: this.allianceRequestBlackIconImage.src;
|
||||
|
||||
if (myPlayer !== null && render.player.isRequestingAllianceWith(myPlayer)) {
|
||||
// Create new icon to match theme
|
||||
if (existingRequestAlliance && !isThemeAllianceRequestIcon) {
|
||||
existingRequestAlliance.remove();
|
||||
existingRequestAlliance = null;
|
||||
}
|
||||
|
||||
if (!existingRequestAlliance) {
|
||||
iconsDiv.appendChild(
|
||||
this.createIconElement(
|
||||
AllianceRequestIconImageSrc,
|
||||
iconSize,
|
||||
"alliance-request",
|
||||
),
|
||||
);
|
||||
}
|
||||
} else if (existingRequestAlliance) {
|
||||
existingRequestAlliance.remove();
|
||||
}
|
||||
|
||||
// Target icon
|
||||
const existingTarget = iconsDiv.querySelector('[data-icon="target"]');
|
||||
if (
|
||||
myPlayer !== null &&
|
||||
new Set(myPlayer.transitiveTargets()).has(render.player)
|
||||
) {
|
||||
if (!existingTarget) {
|
||||
iconsDiv.appendChild(
|
||||
this.createIconElement(
|
||||
this.targetIconImage.src,
|
||||
iconSize,
|
||||
"target",
|
||||
true,
|
||||
),
|
||||
);
|
||||
}
|
||||
} else if (existingTarget) {
|
||||
existingTarget.remove();
|
||||
}
|
||||
|
||||
// Emoji handling
|
||||
const existingEmoji = iconsDiv.querySelector('[data-icon="emoji"]');
|
||||
const emojis = render.player
|
||||
.outgoingEmojis()
|
||||
.filter(
|
||||
(emoji) =>
|
||||
emoji.recipientID === AllPlayers ||
|
||||
emoji.recipientID === myPlayer?.smallID(),
|
||||
);
|
||||
|
||||
if (this.game.config().userSettings()?.emojis() && emojis.length > 0) {
|
||||
if (!existingEmoji) {
|
||||
const emojiDiv = document.createElement("div");
|
||||
emojiDiv.setAttribute("data-icon", "emoji");
|
||||
emojiDiv.style.fontSize = `${iconSize}px`;
|
||||
emojiDiv.textContent = emojis[0].message;
|
||||
emojiDiv.style.position = "absolute";
|
||||
emojiDiv.style.top = "50%";
|
||||
emojiDiv.style.transform = "translateY(-50%)";
|
||||
iconsDiv.appendChild(emojiDiv);
|
||||
}
|
||||
} else if (existingEmoji) {
|
||||
existingEmoji.remove();
|
||||
}
|
||||
|
||||
// Embargo icon
|
||||
let existingEmbargo = iconsDiv.querySelector('[data-icon="embargo"]');
|
||||
const isThemeEmbargoIcon =
|
||||
existingEmbargo?.getAttribute("dark-mode") === isDarkMode.toString();
|
||||
const embargoIconImageSrc = isDarkMode
|
||||
? this.embargoWhiteIconImage.src
|
||||
: this.embargoBlackIconImage.src;
|
||||
|
||||
if (myPlayer?.hasEmbargo(render.player)) {
|
||||
// Create new icon to match theme
|
||||
if (existingEmbargo && !isThemeEmbargoIcon) {
|
||||
existingEmbargo.remove();
|
||||
existingEmbargo = null;
|
||||
}
|
||||
|
||||
if (!existingEmbargo) {
|
||||
iconsDiv.appendChild(
|
||||
this.createIconElement(embargoIconImageSrc, iconSize, "embargo"),
|
||||
);
|
||||
}
|
||||
} else if (existingEmbargo) {
|
||||
existingEmbargo.remove();
|
||||
}
|
||||
|
||||
const nukesSentByOtherPlayer = this.game.units().filter((unit) => {
|
||||
const isSendingNuke = render.player.id() === unit.owner().id();
|
||||
const notMyPlayer = !myPlayer || unit.owner().id() !== myPlayer.id();
|
||||
return (
|
||||
nukeTypes.includes(unit.type()) &&
|
||||
isSendingNuke &&
|
||||
notMyPlayer &&
|
||||
unit.isActive()
|
||||
);
|
||||
// Compute which icons should be shown for this player using shared logic
|
||||
const icons = getPlayerIcons({
|
||||
game: this.game,
|
||||
player: render.player,
|
||||
includeAllianceIcon: true,
|
||||
firstPlace: this.firstPlace,
|
||||
});
|
||||
const isMyPlayerTarget = nukesSentByOtherPlayer.find((unit) => {
|
||||
const detonationDst = unit.targetTile();
|
||||
if (detonationDst === undefined) return false;
|
||||
const targetId = this.game.owner(detonationDst).id();
|
||||
return myPlayer && targetId === myPlayer.id();
|
||||
});
|
||||
const existingNuke = iconsDiv.querySelector(
|
||||
'[data-icon="nuke"]',
|
||||
) as HTMLImageElement;
|
||||
|
||||
if (existingNuke) {
|
||||
if (nukesSentByOtherPlayer.length === 0) {
|
||||
existingNuke.remove();
|
||||
} else if (
|
||||
isMyPlayerTarget &&
|
||||
existingNuke.src !== this.nukeRedIconImage.src
|
||||
) {
|
||||
existingNuke.src = this.nukeRedIconImage.src;
|
||||
} else if (
|
||||
!isMyPlayerTarget &&
|
||||
existingNuke.src !== this.nukeWhiteIconImage.src
|
||||
) {
|
||||
existingNuke.src = this.nukeWhiteIconImage.src;
|
||||
}
|
||||
} else if (nukesSentByOtherPlayer.length > 0) {
|
||||
if (!existingNuke) {
|
||||
const icon = isMyPlayerTarget
|
||||
? this.nukeRedIconImage.src
|
||||
: this.nukeWhiteIconImage.src;
|
||||
iconsDiv.appendChild(this.createIconElement(icon, iconSize, "nuke"));
|
||||
// Build a set of desired icon IDs
|
||||
const desiredIconIds = new Set(icons.map((icon) => icon.id));
|
||||
|
||||
// Remove any icons that are no longer needed
|
||||
for (const [id, element] of render.icons) {
|
||||
if (!desiredIconIds.has(id)) {
|
||||
element.remove();
|
||||
render.icons.delete(id);
|
||||
}
|
||||
}
|
||||
// Update all icon sizes
|
||||
const icons = iconsDiv.getElementsByTagName("img");
|
||||
|
||||
// Add or update icons that should be shown
|
||||
for (const icon of icons) {
|
||||
icon.style.width = `${iconSize}px`;
|
||||
icon.style.height = `${iconSize}px`;
|
||||
if (icon.kind === "emoji" && icon.text) {
|
||||
let emojiDiv = render.icons.get(icon.id) as HTMLDivElement | undefined;
|
||||
|
||||
if (!emojiDiv) {
|
||||
emojiDiv = document.createElement("div");
|
||||
emojiDiv.style.position = "absolute";
|
||||
emojiDiv.style.top = "50%";
|
||||
emojiDiv.style.transform = "translateY(-50%)";
|
||||
iconsDiv.appendChild(emojiDiv);
|
||||
render.icons.set(icon.id, emojiDiv);
|
||||
}
|
||||
|
||||
emojiDiv.textContent = icon.text;
|
||||
emojiDiv.style.fontSize = `${iconSize}px`;
|
||||
} else if (icon.kind === "image" && icon.src) {
|
||||
// Special handling for alliance icon with progress indicator
|
||||
if (icon.id === "alliance") {
|
||||
let allianceWrapper = render.icons.get(icon.id) as
|
||||
| HTMLDivElement
|
||||
| undefined;
|
||||
|
||||
const myPlayer = this.game.myPlayer();
|
||||
const allianceView = myPlayer
|
||||
?.alliances()
|
||||
.find((a) => a.other === render.player.id());
|
||||
|
||||
let fraction = 0;
|
||||
let hasExtensionRequest = false;
|
||||
if (allianceView) {
|
||||
const remaining = Math.max(
|
||||
0,
|
||||
allianceView.expiresAt - this.game.ticks(),
|
||||
);
|
||||
const duration = Math.max(1, this.game.config().allianceDuration());
|
||||
fraction = Math.max(0, Math.min(1, remaining / duration));
|
||||
hasExtensionRequest = allianceView.hasExtensionRequest;
|
||||
}
|
||||
|
||||
if (!allianceWrapper) {
|
||||
allianceWrapper = createAllianceProgressIcon(
|
||||
iconSize,
|
||||
fraction,
|
||||
hasExtensionRequest,
|
||||
this.userSettings.darkMode(),
|
||||
);
|
||||
iconsDiv.appendChild(allianceWrapper);
|
||||
render.icons.set(icon.id, allianceWrapper);
|
||||
} else {
|
||||
// Update existing alliance icon
|
||||
allianceWrapper.style.width = `${iconSize}px`;
|
||||
allianceWrapper.style.height = `${iconSize}px`;
|
||||
allianceWrapper.style.flexShrink = "0";
|
||||
|
||||
const overlay = allianceWrapper.querySelector(
|
||||
".alliance-progress-overlay",
|
||||
) as HTMLDivElement | null;
|
||||
if (overlay) {
|
||||
overlay.style.clipPath = computeAllianceClipPath(fraction);
|
||||
}
|
||||
|
||||
const questionMark = allianceWrapper.querySelector(
|
||||
".alliance-question-mark",
|
||||
) as HTMLImageElement | null;
|
||||
if (questionMark) {
|
||||
questionMark.style.display = hasExtensionRequest
|
||||
? "block"
|
||||
: "none";
|
||||
}
|
||||
|
||||
// Update inner image sizes
|
||||
const imgs = allianceWrapper.getElementsByTagName("img");
|
||||
for (const img of imgs) {
|
||||
img.style.width = `${iconSize}px`;
|
||||
img.style.height = `${iconSize}px`;
|
||||
}
|
||||
}
|
||||
continue; // Skip regular image handling
|
||||
}
|
||||
|
||||
let imgElement = render.icons.get(icon.id) as
|
||||
| HTMLImageElement
|
||||
| undefined;
|
||||
|
||||
if (!imgElement) {
|
||||
imgElement = this.createIconElement(icon.src, iconSize, icon.center);
|
||||
iconsDiv.appendChild(imgElement);
|
||||
render.icons.set(icon.id, imgElement);
|
||||
}
|
||||
|
||||
// Update src if it changed (e.g., nuke red/white or dark-mode icons)
|
||||
if (imgElement.src !== icon.src) {
|
||||
imgElement.src = icon.src;
|
||||
}
|
||||
|
||||
imgElement.style.width = `${iconSize}px`;
|
||||
imgElement.style.height = `${iconSize}px`;
|
||||
|
||||
// Traitor flashing - smooth speed increase starting at 15s
|
||||
if (icon.id === "traitor") {
|
||||
const remainingTicks = render.player.getTraitorRemainingTicks();
|
||||
// Use precise seconds (not rounded) for smoother transitions, rounded to 0.5s intervals
|
||||
const remainingSeconds = Math.round((remainingTicks / 10) * 2) / 2;
|
||||
|
||||
if (remainingSeconds <= 15) {
|
||||
// Smooth transition: starts at 1s at 15 seconds, decreases to 0.2s at 0 seconds
|
||||
// Using cubic ease-out for slower, more gradual acceleration
|
||||
const clampedSeconds = Math.max(0, Math.min(15, remainingSeconds));
|
||||
const normalizedTime = clampedSeconds / 15; // 0 to 1 (1 = 15s remaining, 0 = 0s remaining)
|
||||
|
||||
// Cubic ease-out: slower acceleration, smoother transition
|
||||
const easedProgress = 1 - Math.pow(1 - normalizedTime, 3);
|
||||
const maxDuration = 1.0; // Slow flash at 15 seconds
|
||||
const minDuration = 0.2; // Fast flash at 0 seconds
|
||||
const duration =
|
||||
minDuration + (maxDuration - minDuration) * easedProgress;
|
||||
const animationDuration = `${duration.toFixed(2)}s`;
|
||||
|
||||
imgElement.style.animation = `traitorFlash ${animationDuration} infinite`;
|
||||
imgElement.style.animationTimingFunction = "ease-in-out";
|
||||
} else {
|
||||
// Don't flash if more than 15 seconds remaining
|
||||
imgElement.style.animation = "none";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Position element with scale
|
||||
@@ -618,14 +525,12 @@ export class NameLayer implements Layer {
|
||||
private createIconElement(
|
||||
src: string,
|
||||
size: number,
|
||||
id: string,
|
||||
center: boolean = false,
|
||||
): HTMLImageElement {
|
||||
const icon = document.createElement("img");
|
||||
icon.src = src;
|
||||
icon.style.width = `${size}px`;
|
||||
icon.style.height = `${size}px`;
|
||||
icon.setAttribute("data-icon", id);
|
||||
icon.setAttribute("dark-mode", this.userSettings.darkMode().toString());
|
||||
if (center) {
|
||||
icon.style.position = "absolute";
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
import { EventBus } from "../../../core/EventBus";
|
||||
import { UnitType } from "../../../core/game/Game";
|
||||
import { TileRef } from "../../../core/game/GameMap";
|
||||
import { GameView } from "../../../core/game/GameView";
|
||||
import { ParabolaPathFinder } from "../../../core/pathfinding/PathFinding";
|
||||
import { GhostStructureChangedEvent, MouseMoveEvent } from "../../InputHandler";
|
||||
import { TransformHandler } from "../TransformHandler";
|
||||
import { Layer } from "./Layer";
|
||||
|
||||
/**
|
||||
* Layer responsible for rendering the nuke trajectory preview line
|
||||
* when a nuke type (AtomBomb or HydrogenBomb) is selected and the user hovers over potential targets.
|
||||
*/
|
||||
export class NukeTrajectoryPreviewLayer implements Layer {
|
||||
// Trajectory preview state
|
||||
private mousePos = { x: 0, y: 0 };
|
||||
private trajectoryPoints: TileRef[] = [];
|
||||
private lastTrajectoryUpdate: number = 0;
|
||||
private lastTargetTile: TileRef | null = null;
|
||||
private currentGhostStructure: UnitType | null = null;
|
||||
private cachedSpawnTile: TileRef | null = null; // Cache spawn tile to avoid expensive player.actions() calls
|
||||
|
||||
constructor(
|
||||
private game: GameView,
|
||||
private eventBus: EventBus,
|
||||
private transformHandler: TransformHandler,
|
||||
) {}
|
||||
|
||||
shouldTransform(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
init() {
|
||||
this.eventBus.on(MouseMoveEvent, (e) => {
|
||||
this.mousePos.x = e.x;
|
||||
this.mousePos.y = e.y;
|
||||
});
|
||||
this.eventBus.on(GhostStructureChangedEvent, (e) => {
|
||||
this.currentGhostStructure = e.ghostStructure;
|
||||
// Clear trajectory if ghost structure changed
|
||||
if (
|
||||
e.ghostStructure !== UnitType.AtomBomb &&
|
||||
e.ghostStructure !== UnitType.HydrogenBomb
|
||||
) {
|
||||
this.trajectoryPoints = [];
|
||||
this.lastTargetTile = null;
|
||||
this.cachedSpawnTile = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
tick() {
|
||||
this.updateTrajectoryPreview();
|
||||
}
|
||||
|
||||
renderLayer(context: CanvasRenderingContext2D) {
|
||||
// Update trajectory path each frame for smooth responsiveness
|
||||
this.updateTrajectoryPath();
|
||||
this.drawTrajectoryPreview(context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update trajectory preview - called from tick() to cache spawn tile via expensive player.actions() call
|
||||
* This only runs when target tile changes, minimizing worker thread communication
|
||||
*/
|
||||
private updateTrajectoryPreview() {
|
||||
const ghostStructure = this.currentGhostStructure;
|
||||
const isNukeType =
|
||||
ghostStructure === UnitType.AtomBomb ||
|
||||
ghostStructure === UnitType.HydrogenBomb;
|
||||
|
||||
// Clear trajectory if not a nuke type
|
||||
if (!isNukeType) {
|
||||
this.cachedSpawnTile = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// Throttle updates (similar to StructureIconsLayer.renderGhost)
|
||||
const now = performance.now();
|
||||
if (now - this.lastTrajectoryUpdate < 50) {
|
||||
return;
|
||||
}
|
||||
this.lastTrajectoryUpdate = now;
|
||||
|
||||
const player = this.game.myPlayer();
|
||||
if (!player) {
|
||||
this.trajectoryPoints = [];
|
||||
this.lastTargetTile = null;
|
||||
this.cachedSpawnTile = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert mouse position to world coordinates
|
||||
const rect = this.transformHandler.boundingRect();
|
||||
if (!rect) {
|
||||
this.trajectoryPoints = [];
|
||||
this.cachedSpawnTile = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const localX = this.mousePos.x - rect.left;
|
||||
const localY = this.mousePos.y - rect.top;
|
||||
const worldCoords = this.transformHandler.screenToWorldCoordinates(
|
||||
localX,
|
||||
localY,
|
||||
);
|
||||
|
||||
if (!this.game.isValidCoord(worldCoords.x, worldCoords.y)) {
|
||||
this.trajectoryPoints = [];
|
||||
this.lastTargetTile = null;
|
||||
this.cachedSpawnTile = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const targetTile = this.game.ref(worldCoords.x, worldCoords.y);
|
||||
|
||||
// Only recalculate if target tile changed
|
||||
if (this.lastTargetTile === targetTile) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.lastTargetTile = targetTile;
|
||||
|
||||
// Get buildable units to find spawn tile (expensive call - only on tick when tile changes)
|
||||
player
|
||||
.actions(targetTile)
|
||||
.then((actions) => {
|
||||
// Ignore stale results if target changed
|
||||
if (this.lastTargetTile !== targetTile) {
|
||||
return;
|
||||
}
|
||||
|
||||
const buildableUnit = actions.buildableUnits.find(
|
||||
(bu) => bu.type === ghostStructure,
|
||||
);
|
||||
|
||||
if (!buildableUnit || buildableUnit.canBuild === false) {
|
||||
this.cachedSpawnTile = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const spawnTile = buildableUnit.canBuild;
|
||||
if (!spawnTile) {
|
||||
this.cachedSpawnTile = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// Cache the spawn tile for use in updateTrajectoryPath()
|
||||
this.cachedSpawnTile = spawnTile;
|
||||
})
|
||||
.catch(() => {
|
||||
// Handle errors silently
|
||||
this.cachedSpawnTile = null;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update trajectory path - called from renderLayer() each frame for smooth visual feedback
|
||||
* Uses cached spawn tile to avoid expensive player.actions() calls
|
||||
*/
|
||||
private updateTrajectoryPath() {
|
||||
const ghostStructure = this.currentGhostStructure;
|
||||
const isNukeType =
|
||||
ghostStructure === UnitType.AtomBomb ||
|
||||
ghostStructure === UnitType.HydrogenBomb;
|
||||
|
||||
// Clear trajectory if not a nuke type or no cached spawn tile
|
||||
if (!isNukeType || !this.cachedSpawnTile) {
|
||||
this.trajectoryPoints = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const player = this.game.myPlayer();
|
||||
if (!player) {
|
||||
this.trajectoryPoints = [];
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert mouse position to world coordinates
|
||||
const rect = this.transformHandler.boundingRect();
|
||||
if (!rect) {
|
||||
this.trajectoryPoints = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const localX = this.mousePos.x - rect.left;
|
||||
const localY = this.mousePos.y - rect.top;
|
||||
const worldCoords = this.transformHandler.screenToWorldCoordinates(
|
||||
localX,
|
||||
localY,
|
||||
);
|
||||
|
||||
if (!this.game.isValidCoord(worldCoords.x, worldCoords.y)) {
|
||||
this.trajectoryPoints = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const targetTile = this.game.ref(worldCoords.x, worldCoords.y);
|
||||
|
||||
// Calculate trajectory using ParabolaPathFinder with cached spawn tile
|
||||
const pathFinder = new ParabolaPathFinder(this.game);
|
||||
const speed = this.game.config().defaultNukeSpeed();
|
||||
const distanceBasedHeight = true; // AtomBomb/HydrogenBomb use distance-based height
|
||||
|
||||
pathFinder.computeControlPoints(
|
||||
this.cachedSpawnTile,
|
||||
targetTile,
|
||||
speed,
|
||||
distanceBasedHeight,
|
||||
);
|
||||
|
||||
this.trajectoryPoints = pathFinder.allTiles();
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw trajectory preview line on the canvas
|
||||
*/
|
||||
private drawTrajectoryPreview(context: CanvasRenderingContext2D) {
|
||||
const ghostStructure = this.currentGhostStructure;
|
||||
const isNukeType =
|
||||
ghostStructure === UnitType.AtomBomb ||
|
||||
ghostStructure === UnitType.HydrogenBomb;
|
||||
|
||||
if (!isNukeType || this.trajectoryPoints.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const player = this.game.myPlayer();
|
||||
if (!player) {
|
||||
return;
|
||||
}
|
||||
|
||||
const territoryColor = player.territoryColor();
|
||||
const lineColor = territoryColor.alpha(0.7).toRgbString();
|
||||
|
||||
// Calculate offset to center coordinates (same as canvas drawing)
|
||||
const offsetX = -this.game.width() / 2;
|
||||
const offsetY = -this.game.height() / 2;
|
||||
|
||||
context.save();
|
||||
context.strokeStyle = lineColor;
|
||||
context.lineWidth = 1.5;
|
||||
context.setLineDash([8, 4]);
|
||||
context.beginPath();
|
||||
|
||||
// Draw line connecting trajectory points
|
||||
for (let i = 0; i < this.trajectoryPoints.length; i++) {
|
||||
const tile = this.trajectoryPoints[i];
|
||||
const x = this.game.x(tile) + offsetX;
|
||||
const y = this.game.y(tile) + offsetY;
|
||||
|
||||
if (i === 0) {
|
||||
context.moveTo(x, y);
|
||||
} else {
|
||||
context.lineTo(x, y);
|
||||
}
|
||||
}
|
||||
|
||||
context.stroke();
|
||||
context.restore();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,630 @@
|
||||
import { LitElement, css, html } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import { EventBus } from "../../../core/EventBus";
|
||||
import { UserSettings } from "../../../core/game/UserSettings";
|
||||
import {
|
||||
TickMetricsEvent,
|
||||
TogglePerformanceOverlayEvent,
|
||||
} from "../../InputHandler";
|
||||
import { translateText } from "../../Utils";
|
||||
import { FrameProfiler } from "../FrameProfiler";
|
||||
import { Layer } from "./Layer";
|
||||
|
||||
@customElement("performance-overlay")
|
||||
export class PerformanceOverlay extends LitElement implements Layer {
|
||||
@property({ type: Object })
|
||||
public eventBus!: EventBus;
|
||||
|
||||
@property({ type: Object })
|
||||
public userSettings!: UserSettings;
|
||||
|
||||
@state()
|
||||
private currentFPS: number = 0;
|
||||
|
||||
@state()
|
||||
private averageFPS: number = 0;
|
||||
|
||||
@state()
|
||||
private frameTime: number = 0;
|
||||
|
||||
@state()
|
||||
private tickExecutionAvg: number = 0;
|
||||
|
||||
@state()
|
||||
private tickExecutionMax: number = 0;
|
||||
|
||||
@state()
|
||||
private tickDelayAvg: number = 0;
|
||||
|
||||
@state()
|
||||
private tickDelayMax: number = 0;
|
||||
|
||||
@state()
|
||||
private isVisible: boolean = false;
|
||||
|
||||
@state()
|
||||
private isDragging: boolean = false;
|
||||
|
||||
@state()
|
||||
private position: { x: number; y: number } = { x: 50, y: 20 }; // Percentage values
|
||||
|
||||
@state()
|
||||
private copyStatus: "idle" | "success" | "error" = "idle";
|
||||
|
||||
private frameCount: number = 0;
|
||||
private lastTime: number = 0;
|
||||
private frameTimes: number[] = [];
|
||||
private fpsHistory: number[] = [];
|
||||
private lastSecondTime: number = 0;
|
||||
private framesThisSecond: number = 0;
|
||||
private dragStart: { x: number; y: number } = { x: 0, y: 0 };
|
||||
private tickExecutionTimes: number[] = [];
|
||||
private tickDelayTimes: number[] = [];
|
||||
|
||||
private copyStatusTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// Smoothed per-layer render timings (EMA over recent frames)
|
||||
private layerStats: Map<
|
||||
string,
|
||||
{ avg: number; max: number; last: number; total: number }
|
||||
> = new Map();
|
||||
|
||||
@state()
|
||||
private layerBreakdown: {
|
||||
name: string;
|
||||
avg: number;
|
||||
max: number;
|
||||
total: number;
|
||||
}[] = [];
|
||||
|
||||
static styles = css`
|
||||
.performance-overlay {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
color: white;
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
z-index: 9999;
|
||||
user-select: none;
|
||||
cursor: move;
|
||||
transition: none;
|
||||
min-width: 420px;
|
||||
}
|
||||
|
||||
.performance-overlay.dragging {
|
||||
cursor: grabbing;
|
||||
transition: none;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.performance-line {
|
||||
margin: 2px 0;
|
||||
}
|
||||
|
||||
.performance-good {
|
||||
color: #4ade80; /* green-400 */
|
||||
}
|
||||
|
||||
.performance-warning {
|
||||
color: #fbbf24; /* amber-400 */
|
||||
}
|
||||
|
||||
.performance-bad {
|
||||
color: #f87171; /* red-400 */
|
||||
}
|
||||
|
||||
.close-button {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
user-select: none;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.reset-button {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
height: 20px;
|
||||
padding: 0 6px;
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
font-size: 10px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
user-select: none;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.copy-json-button {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 70px;
|
||||
height: 20px;
|
||||
padding: 0 6px;
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
font-size: 10px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
user-select: none;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.layers-section {
|
||||
margin-top: 4px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.layer-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.layer-name {
|
||||
flex: 0 0 280px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.layer-bar {
|
||||
flex: 1;
|
||||
height: 6px;
|
||||
background: rgba(148, 163, 184, 0.25);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.layer-bar-fill {
|
||||
height: 100%;
|
||||
background: #38bdf8;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.layer-metrics {
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
`;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.eventBus.on(TogglePerformanceOverlayEvent, () => {
|
||||
this.userSettings.togglePerformanceOverlay();
|
||||
this.setVisible(this.userSettings.performanceOverlay());
|
||||
});
|
||||
this.eventBus.on(TickMetricsEvent, (event: TickMetricsEvent) => {
|
||||
this.updateTickMetrics(event.tickExecutionDuration, event.tickDelay);
|
||||
});
|
||||
}
|
||||
|
||||
setVisible(visible: boolean) {
|
||||
this.isVisible = visible;
|
||||
FrameProfiler.setEnabled(visible);
|
||||
}
|
||||
|
||||
private handleClose() {
|
||||
this.userSettings.togglePerformanceOverlay();
|
||||
}
|
||||
|
||||
private handleMouseDown = (e: MouseEvent) => {
|
||||
// Don't start dragging if clicking on close button
|
||||
const target = e.target as HTMLElement;
|
||||
if (
|
||||
target.classList.contains("close-button") ||
|
||||
target.classList.contains("reset-button") ||
|
||||
target.classList.contains("copy-json-button")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isDragging = true;
|
||||
this.dragStart = {
|
||||
x: e.clientX - this.position.x,
|
||||
y: e.clientY - this.position.y,
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", this.handleMouseMove);
|
||||
document.addEventListener("mouseup", this.handleMouseUp);
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
private handleMouseMove = (e: MouseEvent) => {
|
||||
if (!this.isDragging) return;
|
||||
|
||||
const newX = e.clientX - this.dragStart.x;
|
||||
const newY = e.clientY - this.dragStart.y;
|
||||
|
||||
// Convert to percentage of viewport
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
|
||||
this.position = {
|
||||
x: Math.max(0, Math.min(viewportWidth - 100, newX)), // Keep within viewport bounds
|
||||
y: Math.max(0, Math.min(viewportHeight - 100, newY)),
|
||||
};
|
||||
|
||||
this.requestUpdate();
|
||||
};
|
||||
|
||||
private handleMouseUp = () => {
|
||||
this.isDragging = false;
|
||||
document.removeEventListener("mousemove", this.handleMouseMove);
|
||||
document.removeEventListener("mouseup", this.handleMouseUp);
|
||||
};
|
||||
|
||||
private handleReset = () => {
|
||||
// reset FPS / frame stats
|
||||
this.frameCount = 0;
|
||||
this.lastTime = 0;
|
||||
this.frameTimes = [];
|
||||
this.fpsHistory = [];
|
||||
this.lastSecondTime = 0;
|
||||
this.framesThisSecond = 0;
|
||||
this.currentFPS = 0;
|
||||
this.averageFPS = 0;
|
||||
this.frameTime = 0;
|
||||
|
||||
// reset tick metrics
|
||||
this.tickExecutionTimes = [];
|
||||
this.tickDelayTimes = [];
|
||||
this.tickExecutionAvg = 0;
|
||||
this.tickExecutionMax = 0;
|
||||
this.tickDelayAvg = 0;
|
||||
this.tickDelayMax = 0;
|
||||
|
||||
// reset layer breakdown
|
||||
this.layerStats.clear();
|
||||
this.layerBreakdown = [];
|
||||
|
||||
this.requestUpdate();
|
||||
};
|
||||
|
||||
updateFrameMetrics(
|
||||
frameDuration: number,
|
||||
layerDurations?: Record<string, number>,
|
||||
) {
|
||||
const wasVisible = this.isVisible;
|
||||
this.isVisible = this.userSettings.performanceOverlay();
|
||||
|
||||
// Update FrameProfiler enabled state when visibility changes
|
||||
if (wasVisible !== this.isVisible) {
|
||||
FrameProfiler.setEnabled(this.isVisible);
|
||||
}
|
||||
|
||||
if (!this.isVisible) return;
|
||||
|
||||
const now = performance.now();
|
||||
|
||||
// Initialize timing on first call
|
||||
if (this.lastTime === 0) {
|
||||
this.lastTime = now;
|
||||
this.lastSecondTime = now;
|
||||
return;
|
||||
}
|
||||
|
||||
const deltaTime = now - this.lastTime;
|
||||
|
||||
// Track frame times for current FPS calculation (last 60 frames)
|
||||
this.frameTimes.push(deltaTime);
|
||||
if (this.frameTimes.length > 60) {
|
||||
this.frameTimes.shift();
|
||||
}
|
||||
|
||||
// Calculate current FPS based on average frame time
|
||||
if (this.frameTimes.length > 0) {
|
||||
const avgFrameTime =
|
||||
this.frameTimes.reduce((a, b) => a + b, 0) / this.frameTimes.length;
|
||||
this.currentFPS = Math.round(1000 / avgFrameTime);
|
||||
this.frameTime = Math.round(avgFrameTime);
|
||||
}
|
||||
|
||||
// Track FPS for 60-second average
|
||||
this.framesThisSecond++;
|
||||
|
||||
// Update every second
|
||||
if (now - this.lastSecondTime >= 1000) {
|
||||
this.fpsHistory.push(this.framesThisSecond);
|
||||
if (this.fpsHistory.length > 60) {
|
||||
this.fpsHistory.shift();
|
||||
}
|
||||
|
||||
// Calculate 60-second average
|
||||
if (this.fpsHistory.length > 0) {
|
||||
this.averageFPS = Math.round(
|
||||
this.fpsHistory.reduce((a, b) => a + b, 0) / this.fpsHistory.length,
|
||||
);
|
||||
}
|
||||
|
||||
this.framesThisSecond = 0;
|
||||
this.lastSecondTime = now;
|
||||
}
|
||||
|
||||
this.lastTime = now;
|
||||
this.frameCount++;
|
||||
|
||||
if (layerDurations) {
|
||||
this.updateLayerStats(layerDurations);
|
||||
}
|
||||
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private updateLayerStats(layerDurations: Record<string, number>) {
|
||||
const alpha = 0.2; // smoothing factor for EMA
|
||||
|
||||
Object.entries(layerDurations).forEach(([name, duration]) => {
|
||||
const existing = this.layerStats.get(name);
|
||||
if (!existing) {
|
||||
this.layerStats.set(name, {
|
||||
avg: duration,
|
||||
max: duration,
|
||||
last: duration,
|
||||
total: duration,
|
||||
});
|
||||
} else {
|
||||
const avg = existing.avg + alpha * (duration - existing.avg);
|
||||
const max = Math.max(existing.max, duration);
|
||||
const total = existing.total + duration;
|
||||
this.layerStats.set(name, { avg, max, last: duration, total });
|
||||
}
|
||||
});
|
||||
|
||||
// Derive contributors sorted by total accumulated time spent
|
||||
const breakdown = Array.from(this.layerStats.entries())
|
||||
.map(([name, stats]) => ({
|
||||
name,
|
||||
avg: stats.avg,
|
||||
max: stats.max,
|
||||
total: stats.total,
|
||||
}))
|
||||
.sort((a, b) => b.total - a.total);
|
||||
|
||||
this.layerBreakdown = breakdown;
|
||||
}
|
||||
|
||||
updateTickMetrics(tickExecutionDuration?: number, tickDelay?: number) {
|
||||
if (!this.isVisible || !this.userSettings.performanceOverlay()) return;
|
||||
|
||||
// Update tick execution duration stats
|
||||
if (tickExecutionDuration !== undefined) {
|
||||
this.tickExecutionTimes.push(tickExecutionDuration);
|
||||
if (this.tickExecutionTimes.length > 60) {
|
||||
this.tickExecutionTimes.shift();
|
||||
}
|
||||
|
||||
if (this.tickExecutionTimes.length > 0) {
|
||||
const avg =
|
||||
this.tickExecutionTimes.reduce((a, b) => a + b, 0) /
|
||||
this.tickExecutionTimes.length;
|
||||
this.tickExecutionAvg = Math.round(avg * 100) / 100;
|
||||
this.tickExecutionMax = Math.round(
|
||||
Math.max(...this.tickExecutionTimes),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Update tick delay stats
|
||||
if (tickDelay !== undefined) {
|
||||
this.tickDelayTimes.push(tickDelay);
|
||||
if (this.tickDelayTimes.length > 60) {
|
||||
this.tickDelayTimes.shift();
|
||||
}
|
||||
|
||||
if (this.tickDelayTimes.length > 0) {
|
||||
const avg =
|
||||
this.tickDelayTimes.reduce((a, b) => a + b, 0) /
|
||||
this.tickDelayTimes.length;
|
||||
this.tickDelayAvg = Math.round(avg * 100) / 100;
|
||||
this.tickDelayMax = Math.round(Math.max(...this.tickDelayTimes));
|
||||
}
|
||||
}
|
||||
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
shouldTransform(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
private getPerformanceColor(fps: number): string {
|
||||
if (fps >= 55) return "performance-good";
|
||||
if (fps >= 30) return "performance-warning";
|
||||
return "performance-bad";
|
||||
}
|
||||
|
||||
private buildPerformanceSnapshot() {
|
||||
return {
|
||||
timestamp: new Date().toISOString(),
|
||||
fps: {
|
||||
current: this.currentFPS,
|
||||
average60s: this.averageFPS,
|
||||
frameTimeMs: this.frameTime,
|
||||
history: [...this.fpsHistory],
|
||||
},
|
||||
ticks: {
|
||||
executionAvgMs: this.tickExecutionAvg,
|
||||
executionMaxMs: this.tickExecutionMax,
|
||||
delayAvgMs: this.tickDelayAvg,
|
||||
delayMaxMs: this.tickDelayMax,
|
||||
executionSamples: [...this.tickExecutionTimes],
|
||||
delaySamples: [...this.tickDelayTimes],
|
||||
},
|
||||
layers: this.layerBreakdown.map((layer) => ({ ...layer })),
|
||||
};
|
||||
}
|
||||
|
||||
private clearCopyStatusTimeout() {
|
||||
if (this.copyStatusTimeoutId !== null) {
|
||||
clearTimeout(this.copyStatusTimeoutId);
|
||||
this.copyStatusTimeoutId = null;
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleCopyStatusReset() {
|
||||
this.clearCopyStatusTimeout();
|
||||
this.copyStatusTimeoutId = setTimeout(() => {
|
||||
this.copyStatus = "idle";
|
||||
this.copyStatusTimeoutId = null;
|
||||
this.requestUpdate();
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
private async handleCopyJson() {
|
||||
const snapshot = this.buildPerformanceSnapshot();
|
||||
const json = JSON.stringify(snapshot, null, 2);
|
||||
|
||||
try {
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
await navigator.clipboard.writeText(json);
|
||||
} else {
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = json;
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.left = "-9999px";
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
|
||||
this.copyStatus = "success";
|
||||
} catch (err) {
|
||||
console.warn("Failed to copy performance snapshot", err);
|
||||
this.copyStatus = "error";
|
||||
}
|
||||
|
||||
this.scheduleCopyStatusReset();
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.isVisible) {
|
||||
return html``;
|
||||
}
|
||||
|
||||
const style = `
|
||||
left: ${this.position.x}px;
|
||||
top: ${this.position.y}px;
|
||||
transform: none;
|
||||
`;
|
||||
|
||||
const copyLabel =
|
||||
this.copyStatus === "success"
|
||||
? translateText("performance_overlay.copied")
|
||||
: this.copyStatus === "error"
|
||||
? translateText("performance_overlay.failed_copy")
|
||||
: translateText("performance_overlay.copy_clipboard");
|
||||
|
||||
const maxLayerAvg =
|
||||
this.layerBreakdown.length > 0
|
||||
? Math.max(...this.layerBreakdown.map((l) => l.avg))
|
||||
: 1;
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="performance-overlay ${this.isDragging ? "dragging" : ""}"
|
||||
style="${style}"
|
||||
@mousedown="${this.handleMouseDown}"
|
||||
>
|
||||
<button class="reset-button" @click="${this.handleReset}">
|
||||
${translateText("performance_overlay.reset")}
|
||||
</button>
|
||||
<button
|
||||
class="copy-json-button"
|
||||
@click="${this.handleCopyJson}"
|
||||
title="${translateText("performance_overlay.copy_json_title")}"
|
||||
>
|
||||
${copyLabel}
|
||||
</button>
|
||||
<button class="close-button" @click="${this.handleClose}">×</button>
|
||||
<div class="performance-line">
|
||||
${translateText("performance_overlay.fps")}
|
||||
<span class="${this.getPerformanceColor(this.currentFPS)}"
|
||||
>${this.currentFPS}</span
|
||||
>
|
||||
</div>
|
||||
<div class="performance-line">
|
||||
${translateText("performance_overlay.avg_60s")}
|
||||
<span class="${this.getPerformanceColor(this.averageFPS)}"
|
||||
>${this.averageFPS}</span
|
||||
>
|
||||
</div>
|
||||
<div class="performance-line">
|
||||
${translateText("performance_overlay.frame")}
|
||||
<span class="${this.getPerformanceColor(1000 / this.frameTime)}"
|
||||
>${this.frameTime}ms</span
|
||||
>
|
||||
</div>
|
||||
<div class="performance-line">
|
||||
${translateText("performance_overlay.tick_exec")}
|
||||
<span>${this.tickExecutionAvg.toFixed(2)}ms</span>
|
||||
(max: <span>${this.tickExecutionMax}ms</span>)
|
||||
</div>
|
||||
<div class="performance-line">
|
||||
${translateText("performance_overlay.tick_delay")}
|
||||
<span>${this.tickDelayAvg.toFixed(2)}ms</span>
|
||||
(max: <span>${this.tickDelayMax}ms</span>)
|
||||
</div>
|
||||
${this.layerBreakdown.length
|
||||
? html`<div class="layers-section">
|
||||
<div class="performance-line">
|
||||
${translateText("performance_overlay.layers_header")}
|
||||
</div>
|
||||
${this.layerBreakdown.map((layer) => {
|
||||
const width = Math.min(
|
||||
100,
|
||||
(layer.avg / maxLayerAvg) * 100 || 0,
|
||||
);
|
||||
return html`<div class="layer-row">
|
||||
<span class="layer-name" title=${layer.name}
|
||||
>${layer.name}</span
|
||||
>
|
||||
<div class="layer-bar">
|
||||
<div class="layer-bar-fill" style="width: ${width}%;"></div>
|
||||
</div>
|
||||
<span class="layer-metrics">
|
||||
${layer.avg.toFixed(2)} / ${layer.max.toFixed(2)}ms
|
||||
</span>
|
||||
</div>`;
|
||||
})}
|
||||
</div>`
|
||||
: html``}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
renderTroops,
|
||||
translateText,
|
||||
} from "../../Utils";
|
||||
import { getFirstPlacePlayer, getPlayerIcons } from "../PlayerIcons";
|
||||
import { TransformHandler } from "../TransformHandler";
|
||||
import { Layer } from "./Layer";
|
||||
import { CloseRadialMenuEvent } from "./RadialMenu";
|
||||
@@ -221,6 +222,33 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
|
||||
return renderDuration(remainingSeconds);
|
||||
}
|
||||
|
||||
private renderPlayerNameIcons(player: PlayerView) {
|
||||
const firstPlace = getFirstPlacePlayer(this.game);
|
||||
const icons = getPlayerIcons({
|
||||
game: this.game,
|
||||
player,
|
||||
// Because we already show the alliance icon next to the alliance expiration timer, we don't need to show it a second time in this render
|
||||
includeAllianceIcon: false,
|
||||
firstPlace,
|
||||
});
|
||||
|
||||
if (icons.length === 0) {
|
||||
return html``;
|
||||
}
|
||||
|
||||
return html`<span class="flex items-center gap-1 ml-1 shrink-0">
|
||||
${icons.map((icon) =>
|
||||
icon.kind === "emoji" && icon.text
|
||||
? html`<span class="text-sm shrink-0" translate="no"
|
||||
>${icon.text}</span
|
||||
>`
|
||||
: icon.kind === "image" && icon.src
|
||||
? html`<img src=${icon.src} alt="" class="w-4 h-4 shrink-0" />`
|
||||
: html``,
|
||||
)}
|
||||
</span>`;
|
||||
}
|
||||
|
||||
private renderPlayerInfo(player: PlayerView) {
|
||||
const myPlayer = this.game.myPlayer();
|
||||
const isFriendly = myPlayer?.isFriendly(player);
|
||||
@@ -306,7 +334,8 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
|
||||
src=${"/flags/" + player.cosmetics.flag! + ".svg"}
|
||||
/>`
|
||||
: html``}
|
||||
${player.name()}
|
||||
<span>${player.name()}</span>
|
||||
${this.renderPlayerNameIcons(player)}
|
||||
</button>
|
||||
|
||||
<!-- Collapsible section -->
|
||||
|
||||
@@ -77,7 +77,6 @@ export class PlayerPanel extends LitElement implements Layer {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
init() {
|
||||
this.eventBus.on(MouseUpEvent, () => this.hide());
|
||||
|
||||
@@ -801,7 +800,7 @@ export class PlayerPanel extends LitElement implements Layer {
|
||||
</style>
|
||||
|
||||
<div
|
||||
class="fixed inset-0 z-[1001] flex items-center justify-center overflow-auto
|
||||
class="fixed inset-0 z-[10001] flex items-center justify-center overflow-auto
|
||||
bg-black/15 backdrop-brightness-110 pointer-events-auto"
|
||||
@contextmenu=${(e: MouseEvent) => e.preventDefault()}
|
||||
@wheel=${(e: MouseEvent) => e.stopPropagation()}
|
||||
@@ -816,70 +815,76 @@ export class PlayerPanel extends LitElement implements Layer {
|
||||
class="absolute inset-2 -z-10 rounded-2xl bg-black/25 backdrop-blur-[2px]"
|
||||
></div>
|
||||
<div
|
||||
class=${`relative w-full bg-zinc-900/95 p-6 rounded-2xl text-zinc-100 overflow-visible shadow-2xl shadow-black/50
|
||||
class=${`relative w-full bg-zinc-900/95 rounded-2xl text-zinc-100 shadow-2xl shadow-black/50
|
||||
${other.isTraitor() ? "traitor-ring" : "ring-1 ring-white/5"}`}
|
||||
>
|
||||
<!-- Close button -->
|
||||
<button
|
||||
@click=${this.handleClose}
|
||||
class="absolute -top-3 -right-3 flex h-7 w-7 items-center justify-center
|
||||
rounded-full bg-zinc-700 text-white shadow hover:bg-red-500 transition-colors"
|
||||
aria-label=${translateText("common.close") || "Close"}
|
||||
title=${translateText("common.close") || "Close"}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
<div style="overflow: visible;">
|
||||
<div
|
||||
style="max-height: calc(100vh - 120px - env(safe-area-inset-bottom)); overflow:auto; -webkit-overflow-scrolling: touch; resize: vertical;"
|
||||
>
|
||||
<div class="sticky top-0 z-20 flex justify-end p-2">
|
||||
<button
|
||||
@click=${this.handleClose}
|
||||
class="flex h-7 w-7 items-center justify-center rounded-full bg-zinc-700 text-white shadow hover:bg-red-500 transition-colors"
|
||||
aria-label=${translateText("common.close") || "Close"}
|
||||
title=${translateText("common.close") || "Close"}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex flex-col gap-2 font-sans antialiased text-[14.5px] leading-relaxed"
|
||||
>
|
||||
<!-- Identity (flag, name, type, traitor, relation) -->
|
||||
<div class="mb-1">${this.renderIdentityRow(other, my)}</div>
|
||||
<div
|
||||
class="p-6 flex flex-col gap-2 font-sans antialiased text-[14.5px] leading-relaxed"
|
||||
>
|
||||
<!-- Identity (flag, name, type, traitor, relation) -->
|
||||
<div class="mb-1">${this.renderIdentityRow(other, my)}</div>
|
||||
|
||||
${this.sendTarget
|
||||
? html`
|
||||
<send-resource-modal
|
||||
.open=${this.sendMode !== "none"}
|
||||
.mode=${this.sendMode}
|
||||
.total=${this.sendMode === "troops"
|
||||
? myTroopsNum
|
||||
: myGoldNum}
|
||||
.uiState=${this.uiState}
|
||||
.myPlayer=${my}
|
||||
.target=${this.sendTarget}
|
||||
.gameView=${this.g}
|
||||
.eventBus=${this.eventBus}
|
||||
.format=${this.sendMode === "troops"
|
||||
? renderTroops
|
||||
: renderNumber}
|
||||
@confirm=${this.confirmSend}
|
||||
@close=${this.closeSend}
|
||||
></send-resource-modal>
|
||||
`
|
||||
: ""}
|
||||
${this.sendTarget
|
||||
? html`
|
||||
<send-resource-modal
|
||||
.open=${this.sendMode !== "none"}
|
||||
.mode=${this.sendMode}
|
||||
.total=${this.sendMode === "troops"
|
||||
? myTroopsNum
|
||||
: myGoldNum}
|
||||
.uiState=${this.uiState}
|
||||
.myPlayer=${my}
|
||||
.target=${this.sendTarget}
|
||||
.gameView=${this.g}
|
||||
.eventBus=${this.eventBus}
|
||||
.format=${this.sendMode === "troops"
|
||||
? renderTroops
|
||||
: renderNumber}
|
||||
@confirm=${this.confirmSend}
|
||||
@close=${this.closeSend}
|
||||
></send-resource-modal>
|
||||
`
|
||||
: ""}
|
||||
|
||||
<ui-divider></ui-divider>
|
||||
<ui-divider></ui-divider>
|
||||
|
||||
<!-- Resources -->
|
||||
${this.renderResources(other)}
|
||||
<!-- Resources -->
|
||||
${this.renderResources(other)}
|
||||
|
||||
<ui-divider></ui-divider>
|
||||
<ui-divider></ui-divider>
|
||||
|
||||
<!-- Stats: betrayals / trading -->
|
||||
${this.renderStats(other, my)}
|
||||
<!-- Stats: betrayals / trading -->
|
||||
${this.renderStats(other, my)}
|
||||
|
||||
<ui-divider></ui-divider>
|
||||
<ui-divider></ui-divider>
|
||||
|
||||
<!-- Alliances list -->
|
||||
${this.renderAlliances(other)}
|
||||
<!-- Alliances list -->
|
||||
${this.renderAlliances(other)}
|
||||
|
||||
<!-- Alliance time remaining -->
|
||||
${this.renderAllianceExpiry()}
|
||||
<!-- Alliance time remaining -->
|
||||
${this.renderAllianceExpiry()}
|
||||
|
||||
<ui-divider class="mt-1"></ui-divider>
|
||||
<ui-divider class="mt-1"></ui-divider>
|
||||
|
||||
<!-- Actions -->
|
||||
${this.renderActions(my, other)}
|
||||
<!-- Actions -->
|
||||
${this.renderActions(my, other)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -452,7 +452,7 @@ export const deleteUnitElement: MenuElement = {
|
||||
.units()
|
||||
.filter(
|
||||
(unit) =>
|
||||
unit.constructionType() === undefined &&
|
||||
!unit.isUnderConstruction() &&
|
||||
unit.markedForDeletion() === false &&
|
||||
params.game.manhattanDist(unit.tile(), params.tile) <=
|
||||
DELETE_SELECTION_RADIUS,
|
||||
|
||||
@@ -70,9 +70,9 @@ export class ReplayPanel extends LitElement implements Layer {
|
||||
@contextmenu=${(e: Event) => e.preventDefault()}
|
||||
>
|
||||
<label class="block mb-1 text-white" translate="no">
|
||||
${this.isSingleplayer
|
||||
? translateText("replay_panel.game_speed")
|
||||
: translateText("replay_panel.replay_speed")}
|
||||
${this.game?.config()?.isReplay()
|
||||
? translateText("replay_panel.replay_speed")
|
||||
: translateText("replay_panel.game_speed")}
|
||||
</label>
|
||||
<div class="grid grid-cols-2 gap-1">
|
||||
${this.renderSpeedButton(ReplaySpeedMultiplier.slow, "×0.5")}
|
||||
|
||||
@@ -163,7 +163,7 @@ export class SAMRadiusLayer implements Layer {
|
||||
return {
|
||||
x: this.game.x(tile),
|
||||
y: this.game.y(tile),
|
||||
r: this.game.config().defaultSamRange(),
|
||||
r: this.game.config().samRange(sam.level()),
|
||||
owner: sam.owner().smallID(),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -143,19 +143,12 @@ export class SpriteFactory {
|
||||
const screenPos = this.transformHandler.worldToScreenCoordinates(worldPos);
|
||||
|
||||
const isMarkedForDeletion = unit.markedForDeletion() !== false;
|
||||
const isConstruction = unit.type() === UnitType.Construction;
|
||||
const constructionType = unit.constructionType();
|
||||
const structureType = isConstruction ? constructionType! : unit.type();
|
||||
const isConstruction = unit.isUnderConstruction();
|
||||
const structureType = unit.type();
|
||||
const { type, stage } = options;
|
||||
const { scale } = this.transformHandler;
|
||||
|
||||
if (type === "icon" || type === "dot") {
|
||||
if (isConstruction && constructionType === undefined) {
|
||||
console.warn(
|
||||
`Unit ${unit.id()} is a construction but has no construction type.`,
|
||||
);
|
||||
return parentContainer;
|
||||
}
|
||||
const texture = this.createTexture(
|
||||
structureType,
|
||||
unit.owner(),
|
||||
@@ -253,26 +246,13 @@ export class SpriteFactory {
|
||||
structureCanvas.height = Math.ceil(iconSize);
|
||||
const context = structureCanvas.getContext("2d")!;
|
||||
|
||||
const tc = owner.territoryColor();
|
||||
const bc = owner.borderColor();
|
||||
|
||||
// Potentially change logic here. Some TC/BC combinations do not provide good color contrast.
|
||||
const darker = bc.luminance() < tc.luminance() ? bc : tc;
|
||||
const lighter = bc.luminance() < tc.luminance() ? tc : bc;
|
||||
|
||||
let borderColor: string;
|
||||
if (isConstruction) {
|
||||
context.fillStyle = "rgb(198, 198, 198)";
|
||||
borderColor = "rgb(128, 127, 127)";
|
||||
} else {
|
||||
context.fillStyle = lighter
|
||||
.lighten(0.13)
|
||||
.alpha(renderIcon ? 0.65 : 1)
|
||||
.toRgbString();
|
||||
const darken = darker.isLight() ? 0.17 : 0.15;
|
||||
borderColor = darker.darken(darken).toRgbString();
|
||||
}
|
||||
context.strokeStyle = borderColor;
|
||||
// Use structureColors defined from the PlayerView.
|
||||
context.fillStyle = isConstruction
|
||||
? "rgb(198,198,198)"
|
||||
: owner.structureColors().light.toRgbString();
|
||||
context.strokeStyle = isConstruction
|
||||
? "rgb(127,127, 127)"
|
||||
: owner.structureColors().dark.toRgbString();
|
||||
context.lineWidth = 1;
|
||||
const halfIconSize = iconSize / 2;
|
||||
|
||||
@@ -400,7 +380,10 @@ export class SpriteFactory {
|
||||
};
|
||||
const [offsetX, offsetY] = SHAPE_OFFSETS[shape] || [0, 0];
|
||||
context.drawImage(
|
||||
this.getImageColored(structureInfo.image, borderColor),
|
||||
this.getImageColored(
|
||||
structureInfo.image,
|
||||
owner.structureColors().dark.toRgbString(),
|
||||
),
|
||||
offsetX,
|
||||
offsetY,
|
||||
);
|
||||
@@ -428,6 +411,7 @@ export class SpriteFactory {
|
||||
type: UnitType,
|
||||
stage: PIXI.Container,
|
||||
pos: { x: number; y: number },
|
||||
level?: number,
|
||||
): PIXI.Container | null {
|
||||
if (stage === undefined) throw new Error("Not initialized");
|
||||
const parentContainer = new PIXI.Container();
|
||||
@@ -435,7 +419,7 @@ export class SpriteFactory {
|
||||
let radius = 0;
|
||||
switch (type) {
|
||||
case UnitType.SAMLauncher:
|
||||
radius = this.game.config().defaultSamRange();
|
||||
radius = this.game.config().samRange(level ?? 1);
|
||||
break;
|
||||
case UnitType.Factory:
|
||||
radius = this.game.config().trainStationMaxRange();
|
||||
|
||||
@@ -17,6 +17,7 @@ import { TileRef } from "../../../core/game/GameMap";
|
||||
import { GameUpdateType } from "../../../core/game/GameUpdates";
|
||||
import { GameView, UnitView } from "../../../core/game/GameView";
|
||||
import {
|
||||
GhostStructureChangedEvent,
|
||||
MouseMoveEvent,
|
||||
MouseUpEvent,
|
||||
ToggleStructureEvent as ToggleStructuresEvent,
|
||||
@@ -59,6 +60,7 @@ export class StructureIconsLayer implements Layer {
|
||||
private ghostUnit: {
|
||||
container: PIXI.Container;
|
||||
range: PIXI.Container | null;
|
||||
rangeLevel?: number;
|
||||
buildableUnit: BuildableUnit;
|
||||
} | null = null;
|
||||
private pixicanvas: HTMLCanvasElement;
|
||||
@@ -278,6 +280,9 @@ export class StructureIconsLayer implements Layer {
|
||||
|
||||
this.ghostUnit.buildableUnit = unit;
|
||||
|
||||
const targetLevel = this.resolveGhostRangeLevel(unit);
|
||||
this.updateGhostRange(targetLevel);
|
||||
|
||||
if (unit.canUpgrade) {
|
||||
this.potentialUpgrade = this.renders.find(
|
||||
(r) =>
|
||||
@@ -370,12 +375,11 @@ export class StructureIconsLayer implements Layer {
|
||||
{ x: localX, y: localY },
|
||||
type,
|
||||
),
|
||||
range: this.factory.createRange(type, this.ghostStage, {
|
||||
x: localX,
|
||||
y: localY,
|
||||
}),
|
||||
range: null,
|
||||
buildableUnit: { type, canBuild: false, canUpgrade: false, cost: 0n },
|
||||
};
|
||||
const baseLevel = this.resolveGhostRangeLevel(this.ghostUnit.buildableUnit);
|
||||
this.updateGhostRange(baseLevel);
|
||||
}
|
||||
|
||||
private clearGhostStructure() {
|
||||
@@ -394,6 +398,50 @@ export class StructureIconsLayer implements Layer {
|
||||
private removeGhostStructure() {
|
||||
this.clearGhostStructure();
|
||||
this.uiState.ghostStructure = null;
|
||||
this.eventBus.emit(new GhostStructureChangedEvent(null));
|
||||
}
|
||||
|
||||
private resolveGhostRangeLevel(
|
||||
buildableUnit: BuildableUnit,
|
||||
): number | undefined {
|
||||
if (buildableUnit.type !== UnitType.SAMLauncher) {
|
||||
return undefined;
|
||||
}
|
||||
if (buildableUnit.canUpgrade !== false) {
|
||||
const existing = this.game.unit(buildableUnit.canUpgrade);
|
||||
if (existing) {
|
||||
return existing.level() + 1;
|
||||
} else {
|
||||
console.error("Failed to find existing SAMLauncher for upgrade");
|
||||
}
|
||||
}
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
private updateGhostRange(level?: number) {
|
||||
if (!this.ghostUnit) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.ghostUnit.range && this.ghostUnit.rangeLevel === level) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.ghostUnit.range?.destroy();
|
||||
this.ghostUnit.range = null;
|
||||
this.ghostUnit.rangeLevel = level;
|
||||
|
||||
const position = this.ghostUnit.container.position;
|
||||
const range = this.factory.createRange(
|
||||
this.ghostUnit.buildableUnit.type,
|
||||
this.ghostStage,
|
||||
{ x: position.x, y: position.y },
|
||||
level,
|
||||
);
|
||||
if (range) {
|
||||
this.ghostUnit.range = range;
|
||||
}
|
||||
}
|
||||
|
||||
private toggleStructures(toggleStructureType: UnitType[] | null): void {
|
||||
@@ -422,10 +470,7 @@ export class StructureIconsLayer implements Layer {
|
||||
this.checkForOwnershipChange(render, unitView);
|
||||
this.checkForLevelChange(render, unitView);
|
||||
}
|
||||
} else if (
|
||||
this.structures.has(unitView.type()) ||
|
||||
unitView.type() === UnitType.Construction
|
||||
) {
|
||||
} else if (this.structures.has(unitView.type())) {
|
||||
this.addNewStructure(unitView);
|
||||
}
|
||||
}
|
||||
@@ -438,10 +483,7 @@ export class StructureIconsLayer implements Layer {
|
||||
}
|
||||
|
||||
private modifyVisibility(render: StructureRenderInfo) {
|
||||
const structureType =
|
||||
render.unit.type() === UnitType.Construction
|
||||
? render.unit.constructionType()!
|
||||
: render.unit.type();
|
||||
const structureType = render.unit.type();
|
||||
const structureInfos = this.structures.get(structureType);
|
||||
|
||||
let focusStructure = false;
|
||||
@@ -482,10 +524,7 @@ export class StructureIconsLayer implements Layer {
|
||||
render: StructureRenderInfo,
|
||||
unit: UnitView,
|
||||
) {
|
||||
if (
|
||||
render.underConstruction &&
|
||||
render.unit.type() !== UnitType.Construction
|
||||
) {
|
||||
if (render.underConstruction && !unit.isUnderConstruction()) {
|
||||
render.underConstruction = false;
|
||||
render.iconContainer?.destroy();
|
||||
render.dotContainer?.destroy();
|
||||
@@ -533,10 +572,7 @@ export class StructureIconsLayer implements Layer {
|
||||
: screenPos.y,
|
||||
);
|
||||
|
||||
const type =
|
||||
render.unit.type() === UnitType.Construction
|
||||
? render.unit.constructionType()
|
||||
: render.unit.type();
|
||||
const type = render.unit.type();
|
||||
const margin =
|
||||
type !== undefined && STRUCTURE_SHAPES[type] !== undefined
|
||||
? ICON_SIZE[STRUCTURE_SHAPES[type]]
|
||||
@@ -590,7 +626,7 @@ export class StructureIconsLayer implements Layer {
|
||||
this.createLevelSprite(unitView),
|
||||
this.createDotSprite(unitView),
|
||||
unitView.level(),
|
||||
unitView.type() === UnitType.Construction,
|
||||
unitView.isUnderConstruction(),
|
||||
);
|
||||
this.renders.push(render);
|
||||
this.computeNewLocation(render);
|
||||
|
||||
@@ -190,7 +190,7 @@ export class StructureLayer implements Layer {
|
||||
)) {
|
||||
this.paintCell(
|
||||
new Cell(this.game.x(tile), this.game.y(tile)),
|
||||
unit.type() === UnitType.Construction
|
||||
unit.isUnderConstruction()
|
||||
? underConstructionColor
|
||||
: unit.owner().territoryColor(),
|
||||
130,
|
||||
@@ -199,7 +199,7 @@ export class StructureLayer implements Layer {
|
||||
}
|
||||
|
||||
private handleUnitRendering(unit: UnitView) {
|
||||
const unitType = unit.constructionType() ?? unit.type();
|
||||
const unitType = unit.type();
|
||||
const iconType = unitType;
|
||||
if (!this.isUnitTypeSupported(unitType)) return;
|
||||
|
||||
@@ -208,7 +208,7 @@ export class StructureLayer implements Layer {
|
||||
let borderColor = unit.owner().borderColor();
|
||||
|
||||
// Handle cooldown states and special icons
|
||||
if (unit.type() === UnitType.Construction) {
|
||||
if (unit.isUnderConstruction()) {
|
||||
icon = this.unitIcons.get(iconType);
|
||||
borderColor = underConstructionColor;
|
||||
} else {
|
||||
@@ -247,7 +247,7 @@ export class StructureLayer implements Layer {
|
||||
unit: UnitView,
|
||||
) {
|
||||
let color = unit.owner().borderColor();
|
||||
if (unit.type() === UnitType.Construction) {
|
||||
if (unit.isUnderConstruction()) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
color = underConstructionColor;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Layer } from "./Layer";
|
||||
|
||||
interface TeamEntry {
|
||||
teamName: string;
|
||||
isMyTeam: boolean;
|
||||
totalScoreStr: string;
|
||||
totalGold: string;
|
||||
totalTroops: string;
|
||||
@@ -28,6 +29,7 @@ export class TeamStats extends LitElement implements Layer {
|
||||
teams: TeamEntry[] = [];
|
||||
private _shownOnInit = false;
|
||||
private showUnits = false;
|
||||
private _myTeam: Team | null = null;
|
||||
|
||||
createRenderRoot() {
|
||||
return this; // use light DOM for Tailwind
|
||||
@@ -54,6 +56,11 @@ export class TeamStats extends LitElement implements Layer {
|
||||
const players = this.game.playerViews();
|
||||
const grouped: Record<Team, PlayerView[]> = {};
|
||||
|
||||
if (this._myTeam === null) {
|
||||
const myPlayer = this.game.myPlayer();
|
||||
this._myTeam = myPlayer?.team() ?? null;
|
||||
}
|
||||
|
||||
for (const player of players) {
|
||||
const team = player.team();
|
||||
if (team === null) continue;
|
||||
@@ -89,6 +96,7 @@ export class TeamStats extends LitElement implements Layer {
|
||||
|
||||
return {
|
||||
teamName: teamStr,
|
||||
isMyTeam: teamStr === this._myTeam,
|
||||
totalScoreStr: formatPercentage(totalScorePercent),
|
||||
totalScoreSort,
|
||||
totalGold: renderNumber(totalGold),
|
||||
@@ -131,27 +139,41 @@ export class TeamStats extends LitElement implements Layer {
|
||||
</div>
|
||||
${this.showUnits
|
||||
? html`
|
||||
<div class="py-1.5 text-center border-b border-slate-500">
|
||||
<div
|
||||
class="py-1.5 md:py-2.5 text-center border-b border-slate-500"
|
||||
>
|
||||
${translateText("leaderboard.launchers")}
|
||||
</div>
|
||||
<div class="py-1.5 text-center border-b border-slate-500">
|
||||
<div
|
||||
class="py-1.5 md:py-2.5 text-center border-b border-slate-500"
|
||||
>
|
||||
${translateText("leaderboard.sams")}
|
||||
</div>
|
||||
<div class="py-1.5 text-center border-b border-slate-500">
|
||||
<div
|
||||
class="py-1.5 md:py-2.5 text-center border-b border-slate-500"
|
||||
>
|
||||
${translateText("leaderboard.warships")}
|
||||
</div>
|
||||
<div class="py-1.5 text-center border-b border-slate-500">
|
||||
<div
|
||||
class="py-1.5 md:py-2.5 text-center border-b border-slate-500"
|
||||
>
|
||||
${translateText("leaderboard.cities")}
|
||||
</div>
|
||||
`
|
||||
: html`
|
||||
<div class="py-1.5 text-center border-b border-slate-500">
|
||||
<div
|
||||
class="py-1.5 md:py-2.5 text-center border-b border-slate-500"
|
||||
>
|
||||
${translateText("leaderboard.owned")}
|
||||
</div>
|
||||
<div class="py-1.5 text-center border-b border-slate-500">
|
||||
<div
|
||||
class="py-1.5 md:py-2.5 text-center border-b border-slate-500"
|
||||
>
|
||||
${translateText("leaderboard.gold")}
|
||||
</div>
|
||||
<div class="py-1.5 text-center border-b border-slate-500">
|
||||
<div
|
||||
class="py-1.5 md:py-2.5 text-center border-b border-slate-500"
|
||||
>
|
||||
${translateText("leaderboard.troops")}
|
||||
</div>
|
||||
`}
|
||||
@@ -162,7 +184,9 @@ export class TeamStats extends LitElement implements Layer {
|
||||
this.showUnits
|
||||
? html`
|
||||
<div
|
||||
class="contents hover:bg-slate-600/60 text-center cursor-pointer"
|
||||
class="contents hover:bg-slate-600/60 text-center cursor-pointer ${team.isMyTeam
|
||||
? "font-bold"
|
||||
: ""}"
|
||||
>
|
||||
<div class="py-1.5 border-b border-slate-500">
|
||||
${team.teamName}
|
||||
@@ -183,7 +207,9 @@ export class TeamStats extends LitElement implements Layer {
|
||||
`
|
||||
: html`
|
||||
<div
|
||||
class="contents hover:bg-slate-600/60 text-center cursor-pointer"
|
||||
class="contents hover:bg-slate-600/60 text-center cursor-pointer ${team.isMyTeam
|
||||
? "font-bold"
|
||||
: ""}"
|
||||
>
|
||||
<div class="py-1.5 border-b border-slate-500">
|
||||
${team.teamName}
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
DragEvent,
|
||||
MouseOverEvent,
|
||||
} from "../../InputHandler";
|
||||
import { FrameProfiler } from "../FrameProfiler";
|
||||
import { TransformHandler } from "../TransformHandler";
|
||||
import { Layer } from "./Layer";
|
||||
|
||||
@@ -89,6 +90,11 @@ export class TerritoryLayer implements Layer {
|
||||
const unitUpdates = updates !== null ? updates[GameUpdateType.Unit] : [];
|
||||
unitUpdates.forEach((update) => {
|
||||
if (update.unitType === UnitType.DefensePost) {
|
||||
// Only update borders if the defense post is not under construction
|
||||
if (update.underConstruction) {
|
||||
return; // Skip barrier creation while under construction
|
||||
}
|
||||
|
||||
const tile = update.pos;
|
||||
this.game
|
||||
.bfs(tile, euclDistFN(tile, this.game.config().defensePostRange()))
|
||||
@@ -399,7 +405,9 @@ export class TerritoryLayer implements Layer {
|
||||
now > this.lastRefresh + this.refreshRate
|
||||
) {
|
||||
this.lastRefresh = now;
|
||||
const renderTerritoryStart = FrameProfiler.start();
|
||||
this.renderTerritory();
|
||||
FrameProfiler.end("TerritoryLayer:renderTerritory", renderTerritoryStart);
|
||||
|
||||
const [topLeft, bottomRight] = this.transformHandler.screenBoundingRect();
|
||||
const vx0 = Math.max(0, topLeft.x);
|
||||
@@ -411,6 +419,7 @@ export class TerritoryLayer implements Layer {
|
||||
const h = vy1 - vy0 + 1;
|
||||
|
||||
if (w > 0 && h > 0) {
|
||||
const putImageStart = FrameProfiler.start();
|
||||
this.context.putImageData(
|
||||
this.alternativeView ? this.alternativeImageData : this.imageData,
|
||||
0,
|
||||
@@ -420,9 +429,11 @@ export class TerritoryLayer implements Layer {
|
||||
w,
|
||||
h,
|
||||
);
|
||||
FrameProfiler.end("TerritoryLayer:putImageData", putImageStart);
|
||||
}
|
||||
}
|
||||
|
||||
const drawCanvasStart = FrameProfiler.start();
|
||||
context.drawImage(
|
||||
this.canvas,
|
||||
-this.game.width() / 2,
|
||||
@@ -430,7 +441,9 @@ export class TerritoryLayer implements Layer {
|
||||
this.game.width(),
|
||||
this.game.height(),
|
||||
);
|
||||
FrameProfiler.end("TerritoryLayer:drawCanvas", drawCanvasStart);
|
||||
if (this.game.inSpawnPhase()) {
|
||||
const highlightDrawStart = FrameProfiler.start();
|
||||
context.drawImage(
|
||||
this.highlightCanvas,
|
||||
-this.game.width() / 2,
|
||||
@@ -438,6 +451,10 @@ export class TerritoryLayer implements Layer {
|
||||
this.game.width(),
|
||||
this.game.height(),
|
||||
);
|
||||
FrameProfiler.end(
|
||||
"TerritoryLayer:drawHighlightCanvas",
|
||||
highlightDrawStart,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -103,16 +103,12 @@ export class UILayer implements Layer {
|
||||
}
|
||||
|
||||
onUnitEvent(unit: UnitView) {
|
||||
const underConst = unit.isUnderConstruction();
|
||||
if (underConst) {
|
||||
this.createLoadingBar(unit);
|
||||
return;
|
||||
}
|
||||
switch (unit.type()) {
|
||||
case UnitType.Construction: {
|
||||
const constructionType = unit.constructionType();
|
||||
if (constructionType === undefined) {
|
||||
// Skip units without construction type
|
||||
return;
|
||||
}
|
||||
this.createLoadingBar(unit);
|
||||
break;
|
||||
}
|
||||
case UnitType.Warship: {
|
||||
this.drawHealthBar(unit);
|
||||
break;
|
||||
@@ -318,22 +314,20 @@ export class UILayer implements Layer {
|
||||
if (!unit.isActive()) {
|
||||
return 1;
|
||||
}
|
||||
switch (unit.type()) {
|
||||
case UnitType.Construction: {
|
||||
const constructionType = unit.constructionType();
|
||||
if (constructionType === undefined) {
|
||||
return 1;
|
||||
}
|
||||
const constDuration =
|
||||
this.game.unitInfo(constructionType).constructionDuration;
|
||||
if (constDuration === undefined) {
|
||||
throw new Error("unit does not have constructionTime");
|
||||
}
|
||||
return (
|
||||
(this.game.ticks() - unit.createdAt()) /
|
||||
(constDuration === 0 ? 1 : constDuration)
|
||||
);
|
||||
const underConst = unit.isUnderConstruction();
|
||||
if (underConst) {
|
||||
const constDuration = this.game.unitInfo(
|
||||
unit.type(),
|
||||
).constructionDuration;
|
||||
if (constDuration === undefined) {
|
||||
throw new Error("unit does not have constructionTime");
|
||||
}
|
||||
return (
|
||||
(this.game.ticks() - unit.createdAt()) /
|
||||
(constDuration === 0 ? 1 : constDuration)
|
||||
);
|
||||
}
|
||||
switch (unit.type()) {
|
||||
case UnitType.MissileSilo:
|
||||
case UnitType.SAMLauncher:
|
||||
return !unit.markedForDeletion()
|
||||
|
||||
@@ -18,7 +18,10 @@ import {
|
||||
UnitType,
|
||||
} from "../../../core/game/Game";
|
||||
import { GameView } from "../../../core/game/GameView";
|
||||
import { ToggleStructureEvent } from "../../InputHandler";
|
||||
import {
|
||||
GhostStructureChangedEvent,
|
||||
ToggleStructureEvent,
|
||||
} from "../../InputHandler";
|
||||
import { renderNumber, translateText } from "../../Utils";
|
||||
import { UIState } from "../UIState";
|
||||
import { Layer } from "./Layer";
|
||||
@@ -272,8 +275,10 @@ export class UnitDisplay extends LitElement implements Layer {
|
||||
@click=${() => {
|
||||
if (selected) {
|
||||
this.uiState.ghostStructure = null;
|
||||
this.eventBus?.emit(new GhostStructureChangedEvent(null));
|
||||
} else if (this.canBuild(unitType)) {
|
||||
this.uiState.ghostStructure = unitType;
|
||||
this.eventBus?.emit(new GhostStructureChangedEvent(unitType));
|
||||
}
|
||||
this.requestUpdate();
|
||||
}}
|
||||
|
||||
@@ -7,7 +7,9 @@ import { GameView, UnitView } from "../../../core/game/GameView";
|
||||
import { BezenhamLine } from "../../../core/utilities/Line";
|
||||
import {
|
||||
AlternateViewEvent,
|
||||
ContextMenuEvent,
|
||||
MouseUpEvent,
|
||||
TouchEvent,
|
||||
UnitSelectionEvent,
|
||||
} from "../../InputHandler";
|
||||
import { MoveWarshipIntentEvent } from "../../Transport";
|
||||
@@ -73,6 +75,7 @@ export class UnitLayer implements Layer {
|
||||
init() {
|
||||
this.eventBus.on(AlternateViewEvent, (e) => this.onAlternativeViewEvent(e));
|
||||
this.eventBus.on(MouseUpEvent, (e) => this.onMouseUp(e));
|
||||
this.eventBus.on(TouchEvent, (e) => this.onTouch(e));
|
||||
this.eventBus.on(UnitSelectionEvent, (e) => this.onUnitSelectionChange(e));
|
||||
this.redraw();
|
||||
|
||||
@@ -81,16 +84,10 @@ export class UnitLayer implements Layer {
|
||||
|
||||
/**
|
||||
* Find player-owned warships near the given cell within a configurable radius
|
||||
* @param cell The cell to check
|
||||
* @param clickRef The tile to check
|
||||
* @returns Array of player's warships in range, sorted by distance (closest first)
|
||||
*/
|
||||
private findWarshipsNearCell(cell: { x: number; y: number }): UnitView[] {
|
||||
if (!this.game.isValidCoord(cell.x, cell.y)) {
|
||||
// The cell coordinate were invalid (user probably clicked outside the map), therefore no warships can be found
|
||||
return [];
|
||||
}
|
||||
const clickRef = this.game.ref(cell.x, cell.y);
|
||||
|
||||
private findWarshipsNearCell(clickRef: TileRef): UnitView[] {
|
||||
// Only select warships owned by the player
|
||||
return this.game
|
||||
.units(UnitType.Warship)
|
||||
@@ -109,29 +106,75 @@ export class UnitLayer implements Layer {
|
||||
});
|
||||
}
|
||||
|
||||
private onMouseUp(event: MouseUpEvent) {
|
||||
// Convert screen coordinates to world coordinates
|
||||
private onMouseUp(
|
||||
event: MouseUpEvent,
|
||||
clickRef?: TileRef,
|
||||
nearbyWarships?: UnitView[],
|
||||
) {
|
||||
if (clickRef === undefined) {
|
||||
// Convert screen coordinates to world coordinates
|
||||
const cell = this.transformHandler.screenToWorldCoordinates(
|
||||
event.x,
|
||||
event.y,
|
||||
);
|
||||
if (!this.game.isValidCoord(cell.x, cell.y)) return;
|
||||
|
||||
clickRef = this.game.ref(cell.x, cell.y);
|
||||
}
|
||||
if (!this.game.isOcean(clickRef)) return;
|
||||
|
||||
if (this.selectedUnit) {
|
||||
this.eventBus.emit(
|
||||
new MoveWarshipIntentEvent(this.selectedUnit.id(), clickRef),
|
||||
);
|
||||
// Deselect
|
||||
this.eventBus.emit(new UnitSelectionEvent(this.selectedUnit, false));
|
||||
return;
|
||||
}
|
||||
|
||||
// Find warships near this tile, sorted by distance
|
||||
nearbyWarships ??= this.findWarshipsNearCell(clickRef);
|
||||
if (nearbyWarships.length > 0) {
|
||||
// Toggle selection of the closest warship
|
||||
this.eventBus.emit(new UnitSelectionEvent(nearbyWarships[0], true));
|
||||
}
|
||||
}
|
||||
|
||||
private onTouch(event: TouchEvent) {
|
||||
const cell = this.transformHandler.screenToWorldCoordinates(
|
||||
event.x,
|
||||
event.y,
|
||||
);
|
||||
|
||||
// Find warships near this cell, sorted by distance
|
||||
const nearbyWarships = this.findWarshipsNearCell(cell);
|
||||
const clickRef = this.game.ref(cell.x, cell.y);
|
||||
if (!this.game.isOcean(clickRef)) {
|
||||
// No isValidCoord/Ref check yet, that is done for ContextMenuEvent later
|
||||
// No warship to find because no Ocean tile, open Radial Menu
|
||||
this.eventBus.emit(new ContextMenuEvent(event.x, event.y));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.game.isValidRef(clickRef)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.selectedUnit) {
|
||||
const clickRef = this.game.ref(cell.x, cell.y);
|
||||
if (this.game.isOcean(clickRef)) {
|
||||
this.eventBus.emit(
|
||||
new MoveWarshipIntentEvent(this.selectedUnit.id(), clickRef),
|
||||
);
|
||||
}
|
||||
// Deselect
|
||||
this.eventBus.emit(new UnitSelectionEvent(this.selectedUnit, false));
|
||||
} else if (nearbyWarships.length > 0) {
|
||||
// Toggle selection of the closest warship
|
||||
const clickedUnit = nearbyWarships[0];
|
||||
this.eventBus.emit(new UnitSelectionEvent(clickedUnit, true));
|
||||
// Reuse the mouse logic, send clickRef to avoid fetching it again
|
||||
this.onMouseUp(new MouseUpEvent(event.x, event.y), clickRef);
|
||||
return;
|
||||
}
|
||||
|
||||
const nearbyWarships = this.findWarshipsNearCell(clickRef);
|
||||
|
||||
if (nearbyWarships.length > 0) {
|
||||
this.onMouseUp(
|
||||
new MouseUpEvent(event.x, event.y),
|
||||
clickRef,
|
||||
nearbyWarships,
|
||||
);
|
||||
} else {
|
||||
// No warships selected or nearby, open Radial Menu
|
||||
this.eventBus.emit(new ContextMenuEvent(event.x, event.y));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { LitElement, TemplateResult, html } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import ofmWintersLogo from "../../../../resources/images/OfmWintersLogo.png";
|
||||
import { isInIframe, translateText } from "../../../client/Utils";
|
||||
import { ColorPalette, Pattern } from "../../../core/CosmeticSchemas";
|
||||
import { EventBus } from "../../../core/EventBus";
|
||||
@@ -100,10 +101,19 @@ export class WinModal extends LitElement implements Layer {
|
||||
}
|
||||
|
||||
innerHtml() {
|
||||
if (isInIframe() || this.rand < 0.25) {
|
||||
if (isInIframe()) {
|
||||
return this.steamWishlist();
|
||||
}
|
||||
return this.renderPatternButton();
|
||||
|
||||
if (this.rand < 0.25) {
|
||||
return this.steamWishlist();
|
||||
} else if (this.rand < 0.5) {
|
||||
return this.ofmDisplay();
|
||||
} else if (this.rand < 0.75) {
|
||||
return this.discordDisplay();
|
||||
} else {
|
||||
return this.renderPatternButton();
|
||||
}
|
||||
}
|
||||
|
||||
renderPatternButton() {
|
||||
@@ -190,6 +200,55 @@ export class WinModal extends LitElement implements Layer {
|
||||
</p>`;
|
||||
}
|
||||
|
||||
ofmDisplay(): TemplateResult {
|
||||
return html`
|
||||
<div class="text-center mb-6 bg-black/30 p-2.5 rounded">
|
||||
<h3 class="text-xl font-semibold text-white mb-3">
|
||||
${translateText("win_modal.ofm_winter")}
|
||||
</h3>
|
||||
<div class="mb-3">
|
||||
<img
|
||||
src=${ofmWintersLogo}
|
||||
alt="OpenFront Masters Winter"
|
||||
class="mx-auto max-w-full h-auto max-h-[200px] rounded"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-white mb-3">
|
||||
${translateText("win_modal.ofm_winter_description")}
|
||||
</p>
|
||||
<a
|
||||
href="https://discord.gg/wXXJshB8Jt"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-block px-6 py-3 bg-green-600 text-white rounded font-semibold transition-all duration-200 hover:bg-green-700 hover:-translate-y-px no-underline"
|
||||
>
|
||||
${translateText("win_modal.join_tournament")}
|
||||
</a>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
discordDisplay(): TemplateResult {
|
||||
return html`
|
||||
<div class="text-center mb-6 bg-black/30 p-2.5 rounded">
|
||||
<h3 class="text-xl font-semibold text-white mb-3">
|
||||
${translateText("win_modal.join_discord")}
|
||||
</h3>
|
||||
<p class="text-white mb-3">
|
||||
${translateText("win_modal.discord_description")}
|
||||
</p>
|
||||
<a
|
||||
href="https://discord.com/invite/openfront"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-block px-6 py-3 bg-indigo-600 text-white rounded font-semibold transition-all duration-200 hover:bg-indigo-700 hover:-translate-y-px no-underline"
|
||||
>
|
||||
${translateText("win_modal.join_server")}
|
||||
</a>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
async show() {
|
||||
await this.loadPatternContent();
|
||||
this.isVisible = true;
|
||||
|
||||
+27
-28
@@ -56,7 +56,7 @@
|
||||
|
||||
.bg-image {
|
||||
content: "";
|
||||
background-image: url("/images/EuropeBackground.webp");
|
||||
background-image: url("/images/EuropeBackgroundBlurred.webp");
|
||||
background-position: center;
|
||||
background-attachment: fixed;
|
||||
background-size: cover;
|
||||
@@ -73,7 +73,6 @@
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
backdrop-filter: blur(5px);
|
||||
}
|
||||
|
||||
.dark .bg-image {
|
||||
@@ -220,9 +219,7 @@
|
||||
></button>
|
||||
</territory-patterns-modal>
|
||||
<username-input class="relative w-full"></username-input>
|
||||
<news-button
|
||||
class="w-[20%] md:w-[15%] component-hideable"
|
||||
></news-button>
|
||||
<news-button class="w-[20%] component-hideable"></news-button>
|
||||
</div>
|
||||
<div></div>
|
||||
<div>
|
||||
@@ -320,21 +317,19 @@
|
||||
</div>
|
||||
|
||||
<!-- Footer section -->
|
||||
<footer class="l-footer">
|
||||
<div class="l-footer__content">
|
||||
<div class="l-footer__col t-text-white">
|
||||
<footer
|
||||
class="flex justify-center px-3 py-3 md:px-6 md:py-3 bg-[var(--boxBackgroundColor)] backdrop-blur-sm"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col md:flex-row flex-nowrap justify-between items-center gap-4 md:gap-0 w-full max-w-[860px] flex-1"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col sm:flex-row gap-4 sm:gap-5 text-white/70 justify-center md:justify-start flex-shrink-0"
|
||||
>
|
||||
<a
|
||||
href="https://youtu.be/jvHEvbko3uw?si=znspkP84P76B1w5I"
|
||||
data-i18n="main.how_to_play"
|
||||
class="t-link"
|
||||
target="_blank"
|
||||
>
|
||||
How to Play
|
||||
</a>
|
||||
<a
|
||||
href="https://openfront.miraheze.org/wiki/Main_Page"
|
||||
href="https://openfront.wiki/Main_Page"
|
||||
data-i18n="main.wiki"
|
||||
class="t-link"
|
||||
class="text-white/70 hover:text-white transition-colors duration-300 ease-in-out whitespace-nowrap"
|
||||
target="_blank"
|
||||
>
|
||||
Wiki
|
||||
@@ -342,25 +337,25 @@
|
||||
<a
|
||||
target="_blank"
|
||||
href="https://www.reddit.com/r/Openfront/"
|
||||
class="t-link"
|
||||
class="text-white/70 hover:text-white transition-colors duration-300 ease-in-out whitespace-nowrap"
|
||||
>
|
||||
<span data-i18n="main.reddit"> Reddit </span>
|
||||
</a>
|
||||
<a
|
||||
target="_blank"
|
||||
href="https://discord.gg/jRpxXvG42t"
|
||||
class="t-link"
|
||||
class="text-white/70 hover:text-white transition-colors duration-300 ease-in-out whitespace-nowrap"
|
||||
>
|
||||
<span data-i18n="main.join_discord"> Join the Discord! </span>
|
||||
<span data-i18n="main.join_discord"> Discord </span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="l-footer__col t-text-white space-x-4">
|
||||
<div class="flex justify-center text-white/70 flex-shrink-0">
|
||||
<a
|
||||
href="https://github.com/openfrontio/OpenFrontIO"
|
||||
class="t-link inline-flex items-center space-x-2"
|
||||
class="text-white/70 hover:text-white transition-colors duration-300 ease-in-out inline-flex items-center gap-2 whitespace-nowrap"
|
||||
target="_blank"
|
||||
>
|
||||
©2025 OpenFront™
|
||||
© OpenFront™ and Contributors
|
||||
<img
|
||||
src="../../resources/icons/github-mark-white.svg"
|
||||
alt="GitHub"
|
||||
@@ -369,10 +364,14 @@
|
||||
class="ml-2"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-col sm:flex-row gap-4 sm:gap-4 text-white/70 justify-center md:justify-end flex-shrink-0"
|
||||
>
|
||||
<a
|
||||
href="/privacy-policy.html"
|
||||
data-i18n="main.privacy_policy"
|
||||
class="t-link"
|
||||
class="text-white/70 hover:text-white transition-colors duration-300 ease-in-out whitespace-nowrap"
|
||||
target="_blank"
|
||||
>
|
||||
Privacy Policy
|
||||
@@ -380,7 +379,7 @@
|
||||
<a
|
||||
href="/terms-of-service.html"
|
||||
data-i18n="main.terms_of_service"
|
||||
class="t-link"
|
||||
class="text-white/70 hover:text-white transition-colors duration-300 ease-in-out whitespace-nowrap"
|
||||
target="_blank"
|
||||
>
|
||||
Terms of Service
|
||||
@@ -408,14 +407,14 @@
|
||||
<spawn-timer></spawn-timer>
|
||||
<help-modal></help-modal>
|
||||
<dark-mode-button></dark-mode-button>
|
||||
<stats-button></stats-button>
|
||||
<alert-frame></alert-frame>
|
||||
<chat-modal></chat-modal>
|
||||
<user-setting></user-setting>
|
||||
<multi-tab-modal></multi-tab-modal>
|
||||
<news-modal></news-modal>
|
||||
<game-left-sidebar></game-left-sidebar>
|
||||
<flag-input-modal></flag-input-modal>
|
||||
<fps-display></fps-display>
|
||||
<performance-overlay></performance-overlay>
|
||||
<div
|
||||
id="language-modal"
|
||||
class="fixed inset-0 bg-black bg-opacity-50 z-50 hidden flex justify-center items-center"
|
||||
|
||||
+8
-29
@@ -32,26 +32,6 @@ export function getApiBase() {
|
||||
}
|
||||
|
||||
function getToken(): string | null {
|
||||
// Check window hash
|
||||
const { hash } = window.location;
|
||||
if (hash.startsWith("#")) {
|
||||
const params = new URLSearchParams(hash.slice(1));
|
||||
const token = params.get("token");
|
||||
if (token) {
|
||||
localStorage.setItem("token", token);
|
||||
params.delete("token");
|
||||
params.toString();
|
||||
}
|
||||
// Clean the URL
|
||||
history.replaceState(
|
||||
null,
|
||||
"",
|
||||
window.location.pathname +
|
||||
window.location.search +
|
||||
(params.size > 0 ? "#" + params.toString() : ""),
|
||||
);
|
||||
}
|
||||
|
||||
// Check cookie
|
||||
const cookie = document.cookie
|
||||
.split(";")
|
||||
@@ -83,21 +63,16 @@ export function discordLogin() {
|
||||
export async function tokenLogin(token: string): Promise<string | null> {
|
||||
const response = await fetch(
|
||||
`${getApiBase()}/login/token?login-token=${token}`,
|
||||
{
|
||||
credentials: "include",
|
||||
},
|
||||
);
|
||||
if (response.status !== 200) {
|
||||
console.error("Token login failed", response);
|
||||
return null;
|
||||
}
|
||||
const json = await response.json();
|
||||
const { jwt, email } = json;
|
||||
const payload = decodeJwt(jwt);
|
||||
const result = TokenPayloadSchema.safeParse(payload);
|
||||
if (!result.success) {
|
||||
console.error("Invalid token", result.error, result.error.message);
|
||||
return null;
|
||||
}
|
||||
clearToken();
|
||||
localStorage.setItem("token", jwt);
|
||||
const { email } = json;
|
||||
return email;
|
||||
}
|
||||
|
||||
@@ -225,6 +200,7 @@ export async function postRefresh(): Promise<boolean> {
|
||||
// Refresh the JWT
|
||||
const response = await fetch(getApiBase() + "/refresh", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
},
|
||||
@@ -242,6 +218,9 @@ export async function postRefresh(): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
localStorage.setItem("token", result.data.token);
|
||||
// Clear the cached logged in state
|
||||
// so that the next call to isLoggedIn() will refresh the token
|
||||
__isLoggedIn = undefined;
|
||||
return true;
|
||||
} catch (e) {
|
||||
__isLoggedIn = false;
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
@import url("./styles/core/variables.css");
|
||||
@import url("./styles/core/typography.css");
|
||||
@import url("./styles/layout/header.css");
|
||||
@import url("./styles/layout/footer.css");
|
||||
@import url("./styles/layout/container.css");
|
||||
@import url("./styles/components/button.css");
|
||||
@import url("./styles/components/modal.css");
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
.l-footer {
|
||||
background: var(--boxBackgroundColor);
|
||||
backdrop-filter: blur(var(--blur-md));
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 12px;
|
||||
|
||||
@media (min-width: 800px) {
|
||||
padding: 12px 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.l-footer__content {
|
||||
max-width: 860px;
|
||||
flex-wrap: wrap;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.l-footer__col {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
}
|
||||
@@ -90,3 +90,24 @@ export const PlayerProfileSchema = z.object({
|
||||
stats: PlayerStatsTreeSchema,
|
||||
});
|
||||
export type PlayerProfile = z.infer<typeof PlayerProfileSchema>;
|
||||
|
||||
export const ClanLeaderboardEntrySchema = z.object({
|
||||
clanTag: z.string(),
|
||||
games: z.number(),
|
||||
wins: z.number(),
|
||||
losses: z.number(),
|
||||
playerSessions: z.number(),
|
||||
weightedWins: z.number(),
|
||||
weightedLosses: z.number(),
|
||||
weightedWLRatio: z.number(),
|
||||
});
|
||||
export type ClanLeaderboardEntry = z.infer<typeof ClanLeaderboardEntrySchema>;
|
||||
|
||||
export const ClanLeaderboardResponseSchema = z.object({
|
||||
start: z.iso.datetime(),
|
||||
end: z.iso.datetime(),
|
||||
clans: ClanLeaderboardEntrySchema.array(),
|
||||
});
|
||||
export type ClanLeaderboardResponse = z.infer<
|
||||
typeof ClanLeaderboardResponseSchema
|
||||
>;
|
||||
|
||||
+26
-14
@@ -31,7 +31,7 @@ import { loadTerrainMap as loadGameMap } from "./game/TerrainMapLoader";
|
||||
import { PseudoRandom } from "./PseudoRandom";
|
||||
import { ClientID, GameStartInfo, Turn } from "./Schemas";
|
||||
import { sanitize, simpleHash } from "./Util";
|
||||
import { fixProfaneUsername } from "./validations/username";
|
||||
import { censorNameWithClanTag } from "./validations/username";
|
||||
|
||||
export async function createGameRunner(
|
||||
gameStart: GameStartInfo,
|
||||
@@ -47,17 +47,16 @@ export async function createGameRunner(
|
||||
);
|
||||
const random = new PseudoRandom(simpleHash(gameStart.gameID));
|
||||
|
||||
const humans = gameStart.players.map(
|
||||
(p) =>
|
||||
new PlayerInfo(
|
||||
p.clientID === clientID
|
||||
? sanitize(p.username)
|
||||
: fixProfaneUsername(sanitize(p.username)),
|
||||
PlayerType.Human,
|
||||
p.clientID,
|
||||
random.nextID(),
|
||||
),
|
||||
);
|
||||
const humans = gameStart.players.map((p) => {
|
||||
return new PlayerInfo(
|
||||
p.clientID === clientID
|
||||
? sanitize(p.username)
|
||||
: censorNameWithClanTag(p.username),
|
||||
PlayerType.Human,
|
||||
p.clientID,
|
||||
random.nextID(),
|
||||
);
|
||||
});
|
||||
|
||||
const nations = gameStart.config.disableNPCs
|
||||
? []
|
||||
@@ -65,8 +64,13 @@ export async function createGameRunner(
|
||||
(n) =>
|
||||
new Nation(
|
||||
new Cell(n.coordinates[0], n.coordinates[1]),
|
||||
n.strength,
|
||||
new PlayerInfo(n.name, PlayerType.FakeHuman, null, random.nextID()),
|
||||
new PlayerInfo(
|
||||
n.name,
|
||||
PlayerType.FakeHuman,
|
||||
null,
|
||||
random.nextID(),
|
||||
n.strength,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -101,6 +105,9 @@ export class GameRunner {
|
||||
) {}
|
||||
|
||||
init() {
|
||||
if (this.game.config().isRandomSpawn()) {
|
||||
this.game.addExecution(...this.execManager.spawnPlayers());
|
||||
}
|
||||
if (this.game.config().bots() > 0) {
|
||||
this.game.addExecution(
|
||||
...this.execManager.spawnBots(this.game.config().numBots()),
|
||||
@@ -131,9 +138,13 @@ export class GameRunner {
|
||||
this.currTurn++;
|
||||
|
||||
let updates: GameUpdates;
|
||||
let tickExecutionDuration: number = 0;
|
||||
|
||||
try {
|
||||
const startTime = performance.now();
|
||||
updates = this.game.executeNextTick();
|
||||
const endTime = performance.now();
|
||||
tickExecutionDuration = endTime - startTime;
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
console.error("Game tick error:", error.message);
|
||||
@@ -174,6 +185,7 @@ export class GameRunner {
|
||||
packedTileUpdates: new BigUint64Array(packedTileUpdates),
|
||||
updates: updates,
|
||||
playerNameViewData: this.playerViewData,
|
||||
tickExecutionDuration: tickExecutionDuration,
|
||||
});
|
||||
this.isExecuting = false;
|
||||
}
|
||||
|
||||
@@ -167,6 +167,7 @@ export const GameConfigSchema = z.object({
|
||||
infiniteGold: z.boolean(),
|
||||
infiniteTroops: z.boolean(),
|
||||
instantBuild: z.boolean(),
|
||||
randomSpawn: z.boolean(),
|
||||
maxPlayers: z.number().optional(),
|
||||
maxTimerValue: z.number().int().min(1).max(120).optional(),
|
||||
disabledUnits: z.enum(UnitType).array().optional(),
|
||||
@@ -427,6 +428,7 @@ export const PlayerSchema = z.object({
|
||||
|
||||
export const GameStartInfoSchema = z.object({
|
||||
gameID: ID,
|
||||
lobbyCreatedAt: z.number(),
|
||||
config: GameConfigSchema,
|
||||
players: PlayerSchema.array(),
|
||||
});
|
||||
@@ -463,6 +465,7 @@ export const ServerStartGameMessageSchema = z.object({
|
||||
// Turns the client missed if they are late to the game.
|
||||
turns: TurnSchema.array(),
|
||||
gameStartInfo: GameStartInfoSchema,
|
||||
lobbyCreatedAt: z.number(),
|
||||
});
|
||||
|
||||
export const ServerDesyncSchema = z.object({
|
||||
@@ -559,6 +562,7 @@ export const GameEndInfoSchema = GameStartInfoSchema.extend({
|
||||
duration: z.number().nonnegative(),
|
||||
num_turns: z.number(),
|
||||
winner: WinnerSchema,
|
||||
lobbyFillTime: z.number().nonnegative(),
|
||||
});
|
||||
export type GameEndInfo = z.infer<typeof GameEndInfoSchema>;
|
||||
|
||||
|
||||
+36
-10
@@ -148,9 +148,13 @@ export function calculateBoundingBoxCenter(
|
||||
borderTiles: ReadonlySet<TileRef>,
|
||||
): Cell {
|
||||
const { min, max } = calculateBoundingBox(gm, borderTiles);
|
||||
return boundingBoxCenter({ min, max });
|
||||
}
|
||||
|
||||
export function boundingBoxCenter(box: { min: Cell; max: Cell }): Cell {
|
||||
return new Cell(
|
||||
min.x + Math.floor((max.x - min.x) / 2),
|
||||
min.y + Math.floor((max.y - min.y) / 2),
|
||||
box.min.x + Math.floor((box.max.x - box.min.x) / 2),
|
||||
box.min.y + Math.floor((box.max.y - box.min.y) / 2),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -190,15 +194,27 @@ export function createPartialGameRecord(
|
||||
start: number,
|
||||
end: number,
|
||||
winner: Winner,
|
||||
// lobby creation time (ms). Defaults to start time for singleplayer.
|
||||
lobbyCreatedAt?: number,
|
||||
): PartialGameRecord {
|
||||
const duration = Math.floor((end - start) / 1000);
|
||||
const num_turns = allTurns.length;
|
||||
const turns = allTurns.filter(
|
||||
(t) => t.intents.length !== 0 || t.hash !== undefined,
|
||||
);
|
||||
|
||||
// Use start time as lobby creation time for singleplayer
|
||||
const actualLobbyCreatedAt = lobbyCreatedAt ?? start;
|
||||
const lobbyFillTime = Math.max(
|
||||
0,
|
||||
start - Math.min(actualLobbyCreatedAt, start),
|
||||
);
|
||||
|
||||
const record: PartialGameRecord = {
|
||||
info: {
|
||||
gameID,
|
||||
lobbyCreatedAt: actualLobbyCreatedAt,
|
||||
lobbyFillTime,
|
||||
config,
|
||||
players,
|
||||
start,
|
||||
@@ -289,17 +305,18 @@ export function createRandomName(
|
||||
}
|
||||
|
||||
export const emojiTable = [
|
||||
["😀", "😊", "😇", "😎", "😈"],
|
||||
["😀", "😊", "🥰", "😇", "😎"],
|
||||
["😞", "🥺", "😭", "😱", "😡"],
|
||||
["⏳", "🥱", "🤦♂️", "🖕", "🤡"],
|
||||
["👋", "👏", "👻", "💪", "🎃"],
|
||||
["👍", "👎", "❓", "🐔", "🐀"],
|
||||
["🆘", "🤝", "🕊️", "🏳️", "🛡️"],
|
||||
["😈", "🤡", "🥱", "🫡", "🖕"],
|
||||
["👋", "👏", "✋", "🙏", "💪"],
|
||||
["👍", "👎", "🫴", "🤌", "🤦♂️"],
|
||||
["🤝", "🆘", "🕊️", "🏳️", "⏳"],
|
||||
["🔥", "💥", "💀", "☢️", "⚠️"],
|
||||
["↖️", "⬆️", "↗️", "👑", "🥇"],
|
||||
["⬅️", "🎯", "➡️", "🥈", "🥉"],
|
||||
["↙️", "⬇️", "↘️", "❤️", "💔"],
|
||||
["💰", "🏭", "🚂", "⚓", "⛵"],
|
||||
["💰", "⚓", "⛵", "🏡", "🛡️"],
|
||||
["🏭", "🚂", "❓", "🐔", "🐀"],
|
||||
] as const;
|
||||
// 2d to 1d array
|
||||
export const flattenedEmojiTable = emojiTable.flat();
|
||||
@@ -323,9 +340,18 @@ export function sigmoid(
|
||||
|
||||
// Compute clan from name
|
||||
export function getClanTag(name: string): string | null {
|
||||
const clanTag = clanMatch(name);
|
||||
return clanTag ? clanTag[1].toUpperCase() : null;
|
||||
}
|
||||
|
||||
export function getClanTagOriginalCase(name: string): string | null {
|
||||
const clanTag = clanMatch(name);
|
||||
return clanTag ? clanTag[1] : null;
|
||||
}
|
||||
|
||||
function clanMatch(name: string): RegExpMatchArray | null {
|
||||
if (!name.includes("[") || !name.includes("]")) {
|
||||
return null;
|
||||
}
|
||||
const clanMatch = name.match(/\[([a-zA-Z0-9]{2,5})\]/);
|
||||
return clanMatch ? clanMatch[1].toUpperCase() : null;
|
||||
return name.match(/\[([a-zA-Z0-9]{2,5})\]/);
|
||||
}
|
||||
|
||||
+140
-188
@@ -42,222 +42,174 @@ function generateTeamColors(baseColor: Colord): Colord[] {
|
||||
}
|
||||
|
||||
export const nationColors: Colord[] = [
|
||||
colord("rgb(210,210,100)"), // Lime Yellow
|
||||
colord("rgb(180,210,120)"), // Light Green
|
||||
colord("rgb(170,190,100)"), // Yellow Green
|
||||
colord("rgb(80,200,120)"), // Emerald Green
|
||||
colord("rgb(130,200,130)"), // Light Sea Green
|
||||
colord("rgb(140,180,140)"), // Dark Sea Green
|
||||
colord("rgb(160,190,160)"), // Pale Green
|
||||
colord("rgb(160,180,140)"), // Dark Olive Green
|
||||
colord("rgb(100,160,80)"), // Olive Green
|
||||
colord("rgb(100,140,110)"), // Sea Green
|
||||
colord("rgb(100,180,160)"), // Aquamarine
|
||||
colord("rgb(130,180,170)"), // Medium Aquamarine
|
||||
colord("rgb(170,190,180)"), // Pale Blue Green
|
||||
colord("rgb(100,130,150)"), // Steel Blue
|
||||
colord("rgb(120,160,200)"), // Cornflower Blue
|
||||
colord("rgb(140,150,180)"), // Light Slate Gray
|
||||
colord("rgb(100,210,210)"), // Turquoise
|
||||
colord("rgb(140,180,220)"), // Light Blue
|
||||
colord("rgb(130,170,190)"), // Cadet Blue
|
||||
colord("rgb(100,180,230)"), // Sky Blue
|
||||
colord("rgb(80,130,190)"), // Navy Blue
|
||||
colord("rgb(120,120,190)"), // Periwinkle
|
||||
colord("rgb(150,110,190)"), // Lavender
|
||||
colord("rgb(160,120,160)"), // Purple Gray
|
||||
colord("rgb(170,140,190)"), // Medium Purple
|
||||
colord("rgb(180,130,180)"), // Plum
|
||||
colord("rgb(190,140,150)"), // Puce
|
||||
colord("rgb(180,100,230)"), // Purple
|
||||
colord("rgb(180,160,180)"), // Mauve
|
||||
colord("rgb(170,150,170)"), // Dusty Rose
|
||||
colord("rgb(150,130,150)"), // Thistle
|
||||
colord("rgb(230,180,180)"), // Light Pink
|
||||
colord("rgb(210,160,200)"), // Orchid
|
||||
colord("rgb(230,130,180)"), // Pink
|
||||
colord("rgb(210,100,160)"), // Hot Pink
|
||||
colord("rgb(190,100,130)"), // Maroon
|
||||
colord("rgb(220,120,120)"), // Coral
|
||||
colord("rgb(200,130,110)"), // Dark Salmon
|
||||
colord("rgb(230,140,140)"), // Salmon
|
||||
colord("rgb(230,100,100)"), // Bright Red
|
||||
colord("rgb(100,180,230)"), // Sky Blue
|
||||
colord("rgb(230,180,80)"), // Golden Yellow
|
||||
colord("rgb(180,100,230)"), // Purple
|
||||
colord("rgb(80,200,120)"), // Emerald Green
|
||||
colord("rgb(230,130,180)"), // Pink
|
||||
colord("rgb(100,160,80)"), // Olive Green
|
||||
colord("rgb(230,150,100)"), // Peach
|
||||
colord("rgb(80,130,190)"), // Navy Blue
|
||||
colord("rgb(210,210,100)"), // Lime Yellow
|
||||
colord("rgb(190,100,130)"), // Maroon
|
||||
colord("rgb(100,210,210)"), // Turquoise
|
||||
colord("rgb(210,140,80)"), // Light Orange
|
||||
colord("rgb(150,110,190)"), // Lavender
|
||||
colord("rgb(180,210,120)"), // Light Green
|
||||
colord("rgb(210,100,160)"), // Hot Pink
|
||||
colord("rgb(100,140,110)"), // Sea Green
|
||||
colord("rgb(230,180,180)"), // Light Pink
|
||||
colord("rgb(120,120,190)"), // Periwinkle
|
||||
colord("rgb(190,170,100)"), // Sand
|
||||
colord("rgb(100,180,160)"), // Aquamarine
|
||||
colord("rgb(210,160,200)"), // Orchid
|
||||
colord("rgb(170,190,100)"), // Yellow Green
|
||||
colord("rgb(100,130,150)"), // Steel Blue
|
||||
colord("rgb(230,140,140)"), // Salmon
|
||||
colord("rgb(140,180,220)"), // Light Blue
|
||||
colord("rgb(200,160,110)"), // Tan
|
||||
colord("rgb(180,130,180)"), // Plum
|
||||
colord("rgb(130,200,130)"), // Light Sea Green
|
||||
colord("rgb(220,120,120)"), // Coral
|
||||
colord("rgb(120,160,200)"), // Cornflower Blue
|
||||
colord("rgb(200,200,140)"), // Khaki
|
||||
colord("rgb(160,120,160)"), // Purple Gray
|
||||
colord("rgb(140,180,140)"), // Dark Sea Green
|
||||
colord("rgb(200,130,110)"), // Dark Salmon
|
||||
colord("rgb(130,170,190)"), // Cadet Blue
|
||||
colord("rgb(190,180,160)"), // Tan Gray
|
||||
colord("rgb(170,140,190)"), // Medium Purple
|
||||
colord("rgb(160,190,160)"), // Pale Green
|
||||
colord("rgb(190,150,130)"), // Rosy Brown
|
||||
colord("rgb(140,150,180)"), // Light Slate Gray
|
||||
colord("rgb(180,170,140)"), // Dark Khaki
|
||||
colord("rgb(150,130,150)"), // Thistle
|
||||
colord("rgb(170,190,180)"), // Pale Blue Green
|
||||
colord("rgb(190,140,150)"), // Puce
|
||||
colord("rgb(130,180,170)"), // Medium Aquamarine
|
||||
colord("rgb(180,160,180)"), // Mauve
|
||||
colord("rgb(160,180,140)"), // Dark Olive Green
|
||||
colord("rgb(170,150,170)"), // Dusty Rose
|
||||
colord("rgb(100,180,230)"), // Sky Blue
|
||||
colord("rgb(230,180,80)"), // Golden Yellow
|
||||
colord("rgb(180,100,230)"), // Purple
|
||||
colord("rgb(80,200,120)"), // Emerald Green
|
||||
colord("rgb(230,130,180)"), // Pink
|
||||
colord("rgb(100,160,80)"), // Olive Green
|
||||
colord("rgb(230,150,100)"), // Peach
|
||||
colord("rgb(80,130,190)"), // Navy Blue
|
||||
colord("rgb(210,210,100)"), // Lime Yellow
|
||||
colord("rgb(190,100,130)"), // Maroon
|
||||
colord("rgb(100,210,210)"), // Turquoise
|
||||
colord("rgb(210,140,80)"), // Light Orange
|
||||
colord("rgb(150,110,190)"), // Lavender
|
||||
colord("rgb(180,210,120)"), // Light Green
|
||||
colord("rgb(210,100,160)"), // Hot Pink
|
||||
colord("rgb(100,140,110)"), // Sea Green
|
||||
colord("rgb(230,180,180)"), // Light Pink
|
||||
colord("rgb(120,120,190)"), // Periwinkle
|
||||
colord("rgb(190,170,100)"), // Sand
|
||||
colord("rgb(100,180,160)"), // Aquamarine
|
||||
colord("rgb(210,160,200)"), // Orchid
|
||||
colord("rgb(170,190,100)"), // Yellow Green
|
||||
colord("rgb(100,130,150)"), // Steel Blue
|
||||
colord("rgb(230,140,140)"), // Salmon
|
||||
colord("rgb(140,180,220)"), // Light Blue
|
||||
colord("rgb(200,160,110)"), // Tan
|
||||
colord("rgb(180,130,180)"), // Plum
|
||||
colord("rgb(130,200,130)"), // Light Sea Green
|
||||
colord("rgb(220,120,120)"), // Coral
|
||||
colord("rgb(120,160,200)"), // Cornflower Blue
|
||||
colord("rgb(200,200,140)"), // Khaki
|
||||
colord("rgb(160,120,160)"), // Purple Gray
|
||||
colord("rgb(140,180,140)"), // Dark Sea Green
|
||||
colord("rgb(200,130,110)"), // Dark Salmon
|
||||
colord("rgb(130,170,190)"), // Cadet Blue
|
||||
colord("rgb(190,180,160)"), // Tan Gray
|
||||
colord("rgb(170,140,190)"), // Medium Purple
|
||||
colord("rgb(160,190,160)"), // Pale Green
|
||||
colord("rgb(190,150,130)"), // Rosy Brown
|
||||
colord("rgb(140,150,180)"), // Light Slate Gray
|
||||
colord("rgb(190,180,160)"), // Tan Gray
|
||||
colord("rgb(180,170,140)"), // Dark Khaki
|
||||
colord("rgb(150,130,150)"), // Thistle
|
||||
colord("rgb(170,190,180)"), // Pale Blue Green
|
||||
colord("rgb(190,140,150)"), // Puce
|
||||
colord("rgb(130,180,170)"), // Medium Aquamarine
|
||||
colord("rgb(180,160,180)"), // Mauve
|
||||
colord("rgb(160,180,140)"), // Dark Olive Green
|
||||
colord("rgb(170,150,170)"), // Dusty Rose
|
||||
colord("rgb(200,200,140)"), // Khaki
|
||||
colord("rgb(190,170,100)"), // Sand
|
||||
];
|
||||
|
||||
// Bright pastel theme with 64 colors
|
||||
export const humanColors: Colord[] = [
|
||||
colord("rgb(16,185,129)"), // Sea Green
|
||||
colord("rgb(34,197,94)"), // Emerald
|
||||
colord("rgb(45,212,191)"), // Turquoise
|
||||
colord("rgb(48,178,180)"), // Teal
|
||||
colord("rgb(52,211,153)"), // Spearmint
|
||||
colord("rgb(56,189,248)"), // Light Blue
|
||||
colord("rgb(59,130,246)"), // Royal Blue
|
||||
colord("rgb(67,190,84)"), // Fresh Green
|
||||
colord("rgb(74,222,128)"), // Mint
|
||||
colord("rgb(79,70,229)"), // Indigo
|
||||
colord("rgb(82,183,136)"), // Jade
|
||||
colord("rgb(96,165,250)"), // Sky Blue
|
||||
colord("rgb(99,202,253)"), // Azure
|
||||
colord("rgb(110,231,183)"), // Seafoam
|
||||
colord("rgb(124,58,237)"), // Royal Purple
|
||||
colord("rgb(125,211,252)"), // Crystal Blue
|
||||
colord("rgb(132,204,22)"), // Lime
|
||||
colord("rgb(133,77,14)"), // Chocolate
|
||||
colord("rgb(134,239,172)"), // Light Green
|
||||
colord("rgb(147,51,234)"), // Bright Purple
|
||||
colord("rgb(147,197,253)"), // Powder Blue
|
||||
colord("rgb(151,255,187)"), // Fresh Mint
|
||||
colord("rgb(163,230,53)"), // Yellow Green
|
||||
colord("rgb(167,139,250)"), // Periwinkle
|
||||
colord("rgb(168,85,247)"), // Vibrant Purple
|
||||
colord("rgb(179,136,255)"), // Light Purple
|
||||
colord("rgb(132,204,22)"), // Lime
|
||||
colord("rgb(16,185,129)"), // Sea Green
|
||||
colord("rgb(52,211,153)"), // Spearmint
|
||||
colord("rgb(45,212,191)"), // Turquoise
|
||||
colord("rgb(74,222,128)"), // Mint
|
||||
colord("rgb(110,231,183)"), // Seafoam
|
||||
colord("rgb(134,239,172)"), // Light Green
|
||||
colord("rgb(151,255,187)"), // Fresh Mint
|
||||
colord("rgb(186,255,201)"), // Pale Emerald
|
||||
colord("rgb(230,250,210)"), // Pastel Lime
|
||||
colord("rgb(34,197,94)"), // Emerald
|
||||
colord("rgb(67,190,84)"), // Fresh Green
|
||||
colord("rgb(82,183,136)"), // Jade
|
||||
colord("rgb(48,178,180)"), // Teal
|
||||
colord("rgb(230,255,250)"), // Mint Whisper
|
||||
colord("rgb(220,240,250)"), // Ice Blue
|
||||
colord("rgb(233,213,255)"), // Light Lilac
|
||||
colord("rgb(204,204,255)"), // Soft Lavender Blue
|
||||
colord("rgb(220,220,255)"), // Meringue Blue
|
||||
colord("rgb(202,225,255)"), // Baby Blue
|
||||
colord("rgb(147,197,253)"), // Powder Blue
|
||||
colord("rgb(125,211,252)"), // Crystal Blue
|
||||
colord("rgb(99,202,253)"), // Azure
|
||||
colord("rgb(56,189,248)"), // Light Blue
|
||||
colord("rgb(96,165,250)"), // Sky Blue
|
||||
colord("rgb(59,130,246)"), // Royal Blue
|
||||
colord("rgb(79,70,229)"), // Indigo
|
||||
colord("rgb(124,58,237)"), // Royal Purple
|
||||
colord("rgb(147,51,234)"), // Bright Purple
|
||||
colord("rgb(179,136,255)"), // Light Purple
|
||||
colord("rgb(167,139,250)"), // Periwinkle
|
||||
colord("rgb(217,70,239)"), // Fuchsia
|
||||
colord("rgb(168,85,247)"), // Vibrant Purple
|
||||
colord("rgb(190,92,251)"), // Amethyst
|
||||
colord("rgb(192,132,252)"), // Lavender
|
||||
colord("rgb(202,138,4)"), // Rich Gold
|
||||
colord("rgb(202,225,255)"), // Baby Blue
|
||||
colord("rgb(204,204,255)"), // Soft Lavender Blue
|
||||
colord("rgb(217,70,239)"), // Fuchsia
|
||||
colord("rgb(220,38,38)"), // Ruby
|
||||
colord("rgb(220,220,255)"), // Meringue Blue
|
||||
colord("rgb(220,240,250)"), // Ice Blue
|
||||
colord("rgb(230,250,210)"), // Pastel Lime
|
||||
colord("rgb(230,255,250)"), // Mint Whisper
|
||||
colord("rgb(233,213,255)"), // Light Lilac
|
||||
colord("rgb(234,88,12)"), // Burnt Orange
|
||||
colord("rgb(234,179,8)"), // Sunflower
|
||||
colord("rgb(235,75,75)"), // Bright Red
|
||||
colord("rgb(236,72,153)"), // Deep Pink
|
||||
colord("rgb(239,68,68)"), // Crimson
|
||||
colord("rgb(240,171,252)"), // Orchid
|
||||
colord("rgb(240,240,200)"), // Light Khaki
|
||||
colord("rgb(244,114,182)"), // Rose
|
||||
colord("rgb(236,72,153)"), // Deep Pink
|
||||
colord("rgb(220,38,38)"), // Ruby
|
||||
colord("rgb(239,68,68)"), // Crimson
|
||||
colord("rgb(235,75,75)"), // Bright Red
|
||||
colord("rgb(245,101,101)"), // Coral
|
||||
colord("rgb(245,158,11)"), // Amber
|
||||
colord("rgb(248,113,113)"), // Warm Red
|
||||
colord("rgb(249,115,22)"), // Tangerine
|
||||
colord("rgb(250,215,225)"), // Cotton Candy
|
||||
colord("rgb(250,250,210)"), // Pastel Lemon
|
||||
colord("rgb(251,113,133)"), // Watermelon
|
||||
colord("rgb(251,146,60)"), // Light Orange
|
||||
colord("rgb(251,191,36)"), // Marigold
|
||||
colord("rgb(251,235,245)"), // Rose Powder
|
||||
colord("rgb(252,165,165)"), // Peach
|
||||
colord("rgb(252,211,77)"), // Golden
|
||||
colord("rgb(253,164,175)"), // Salmon Pink
|
||||
colord("rgb(252,165,165)"), // Peach
|
||||
colord("rgb(255,204,229)"), // Blush Pink
|
||||
colord("rgb(255,223,186)"), // Apricot Cream
|
||||
colord("rgb(250,215,225)"), // Cotton Candy
|
||||
colord("rgb(251,235,245)"), // Rose Powder
|
||||
colord("rgb(240,240,200)"), // Light Khaki
|
||||
colord("rgb(250,250,210)"), // Pastel Lemon
|
||||
colord("rgb(255,240,200)"), // Vanilla
|
||||
colord("rgb(255,223,186)"), // Apricot Cream
|
||||
colord("rgb(252,211,77)"), // Golden
|
||||
colord("rgb(251,191,36)"), // Marigold
|
||||
colord("rgb(234,179,8)"), // Sunflower
|
||||
colord("rgb(202,138,4)"), // Rich Gold
|
||||
colord("rgb(245,158,11)"), // Amber
|
||||
colord("rgb(251,146,60)"), // Light Orange
|
||||
colord("rgb(249,115,22)"), // Tangerine
|
||||
colord("rgb(234,88,12)"), // Burnt Orange
|
||||
colord("rgb(133,77,14)"), // Chocolate
|
||||
];
|
||||
|
||||
export const botColors: Colord[] = [
|
||||
colord("rgb(190,120,120)"), // Muted Red
|
||||
colord("rgb(120,160,190)"), // Muted Sky Blue
|
||||
colord("rgb(190,160,100)"), // Muted Golden Yellow
|
||||
colord("rgb(160,120,190)"), // Muted Purple
|
||||
colord("rgb(100,170,130)"), // Muted Emerald Green
|
||||
colord("rgb(190,130,160)"), // Muted Pink
|
||||
colord("rgb(120,150,100)"), // Muted Olive Green
|
||||
colord("rgb(190,140,120)"), // Muted Peach
|
||||
colord("rgb(100,120,160)"), // Muted Navy Blue
|
||||
colord("rgb(170,170,120)"), // Muted Lime Yellow
|
||||
colord("rgb(160,120,130)"), // Muted Maroon
|
||||
colord("rgb(120,170,170)"), // Muted Turquoise
|
||||
colord("rgb(170,140,100)"), // Muted Light Orange
|
||||
colord("rgb(140,120,160)"), // Muted Lavender
|
||||
colord("rgb(150,170,130)"), // Muted Light Green
|
||||
colord("rgb(170,120,140)"), // Muted Hot Pink
|
||||
colord("rgb(120,140,120)"), // Muted Sea Green
|
||||
colord("rgb(180,160,160)"), // Muted Light Pink
|
||||
colord("rgb(130,130,160)"), // Muted Periwinkle
|
||||
colord("rgb(160,150,120)"), // Muted Sand
|
||||
colord("rgb(120,160,150)"), // Muted Aquamarine
|
||||
colord("rgb(170,150,170)"), // Muted Orchid
|
||||
colord("rgb(150,160,120)"), // Muted Yellow Green
|
||||
colord("rgb(120,130,140)"), // Muted Steel Blue
|
||||
colord("rgb(180,140,140)"), // Muted Salmon
|
||||
colord("rgb(140,160,170)"), // Muted Light Blue
|
||||
colord("rgb(170,150,130)"), // Muted Tan
|
||||
colord("rgb(160,130,160)"), // Muted Plum
|
||||
colord("rgb(130,170,130)"), // Muted Light Sea Green
|
||||
colord("rgb(170,130,130)"), // Muted Coral
|
||||
colord("rgb(130,150,170)"), // Muted Cornflower Blue
|
||||
colord("rgb(170,170,140)"), // Muted Khaki
|
||||
colord("rgb(150,130,150)"), // Muted Purple Gray
|
||||
colord("rgb(140,160,140)"), // Muted Dark Sea Green
|
||||
colord("rgb(170,130,120)"), // Muted Dark Salmon
|
||||
colord("rgb(130,150,160)"), // Muted Cadet Blue
|
||||
colord("rgb(160,160,150)"), // Muted Tan Gray
|
||||
colord("rgb(150,140,160)"), // Muted Medium Purple
|
||||
colord("rgb(150,170,150)"), // Muted Pale Green
|
||||
colord("rgb(160,140,130)"), // Muted Rosy Brown
|
||||
colord("rgb(140,150,160)"), // Muted Light Slate Gray
|
||||
colord("rgb(160,150,140)"), // Muted Dark Khaki
|
||||
colord("rgb(140,130,140)"), // Muted Thistle
|
||||
colord("rgb(150,160,160)"), // Muted Pale Blue Green
|
||||
colord("rgb(160,140,150)"), // Muted Puce
|
||||
colord("rgb(130,160,150)"), // Muted Medium Aquamarine
|
||||
colord("rgb(160,150,160)"), // Muted Mauve
|
||||
colord("rgb(150,160,140)"), // Muted Dark Olive Green
|
||||
colord("rgb(160,160,150)"), // Muted Tan Gray
|
||||
colord("rgb(170,170,140)"), // Muted Khaki
|
||||
colord("rgb(170,170,120)"), // Muted Lime Yellow
|
||||
colord("rgb(150,160,120)"), // Muted Yellow Green
|
||||
colord("rgb(150,170,130)"), // Muted Light Green
|
||||
colord("rgb(150,170,150)"), // Muted Pale Green
|
||||
colord("rgb(130,170,130)"), // Muted Light Sea Green
|
||||
colord("rgb(140,160,140)"), // Muted Dark Sea Green
|
||||
colord("rgb(120,150,100)"), // Muted Olive Green
|
||||
colord("rgb(120,140,120)"), // Muted Sea Green
|
||||
colord("rgb(100,170,130)"), // Muted Emerald Green
|
||||
colord("rgb(120,160,150)"), // Muted Aquamarine
|
||||
colord("rgb(130,160,150)"), // Muted Medium Aquamarine
|
||||
colord("rgb(120,170,170)"), // Muted Turquoise
|
||||
colord("rgb(120,160,190)"), // Muted Sky Blue
|
||||
colord("rgb(130,150,170)"), // Muted Cornflower Blue
|
||||
colord("rgb(130,150,160)"), // Muted Cadet Blue
|
||||
colord("rgb(140,150,160)"), // Muted Light Slate Gray
|
||||
colord("rgb(140,160,170)"), // Muted Light Blue
|
||||
colord("rgb(150,160,160)"), // Muted Pale Blue Green
|
||||
colord("rgb(100,120,160)"), // Muted Navy Blue
|
||||
colord("rgb(120,130,140)"), // Muted Steel Blue
|
||||
colord("rgb(130,130,160)"), // Muted Periwinkle
|
||||
colord("rgb(140,130,140)"), // Muted Thistle
|
||||
colord("rgb(140,120,160)"), // Muted Lavender
|
||||
colord("rgb(150,130,150)"), // Muted Purple Gray
|
||||
colord("rgb(150,140,160)"), // Muted Medium Purple
|
||||
colord("rgb(160,130,160)"), // Muted Plum
|
||||
colord("rgb(170,150,170)"), // Muted Orchid
|
||||
colord("rgb(160,120,190)"), // Muted Purple
|
||||
colord("rgb(160,120,130)"), // Muted Maroon
|
||||
colord("rgb(170,120,140)"), // Muted Hot Pink
|
||||
colord("rgb(170,130,120)"), // Muted Dark Salmon
|
||||
colord("rgb(170,130,130)"), // Muted Coral
|
||||
colord("rgb(180,140,140)"), // Muted Salmon
|
||||
colord("rgb(190,130,160)"), // Muted Pink
|
||||
colord("rgb(190,120,120)"), // Muted Red
|
||||
colord("rgb(190,140,120)"), // Muted Peach
|
||||
colord("rgb(190,160,100)"), // Muted Golden Yellow
|
||||
colord("rgb(170,140,100)"), // Muted Light Orange
|
||||
colord("rgb(160,140,130)"), // Muted Rosy Brown
|
||||
colord("rgb(170,150,130)"), // Muted Tan
|
||||
colord("rgb(160,150,120)"), // Muted Sand
|
||||
colord("rgb(160,150,140)"), // Muted Dark Khaki
|
||||
colord("rgb(160,140,150)"), // Muted Puce
|
||||
colord("rgb(160,150,160)"), // Muted Mauve
|
||||
colord("rgb(150,140,150)"), // Muted Dusty Rose
|
||||
colord("rgb(180,160,160)"), // Muted Light Pink
|
||||
];
|
||||
|
||||
// Fallback colors for when the color palette is exhausted.
|
||||
|
||||
@@ -88,10 +88,12 @@ export interface Config {
|
||||
infiniteTroops(): boolean;
|
||||
donateTroops(): boolean;
|
||||
instantBuild(): boolean;
|
||||
isRandomSpawn(): boolean;
|
||||
numSpawnPhaseTurns(): number;
|
||||
userSettings(): UserSettings;
|
||||
playerTeams(): TeamCountConfig;
|
||||
|
||||
useNationStrengthForStartManpower(): boolean;
|
||||
startManpower(playerInfo: PlayerInfo): number;
|
||||
troopIncreaseRate(player: Player | PlayerView): number;
|
||||
goldAdditionRate(player: Player | PlayerView): Gold;
|
||||
@@ -136,6 +138,7 @@ export interface Config {
|
||||
deleteUnitCooldown(): Tick;
|
||||
defaultDonationAmount(sender: Player): number;
|
||||
unitInfo(type: UnitType): UnitInfo;
|
||||
tradeShipShortRangeDebuff(): number;
|
||||
tradeShipGold(dist: number, numPorts: number): Gold;
|
||||
tradeShipSpawnRate(
|
||||
numTradeShips: number,
|
||||
@@ -170,6 +173,8 @@ export interface Config {
|
||||
defaultNukeTargetableRange(): number;
|
||||
defaultSamMissileSpeed(): number;
|
||||
defaultSamRange(): number;
|
||||
samRange(level: number): number;
|
||||
maxSamRange(): number;
|
||||
nukeDeathFactor(
|
||||
nukeType: NukeType,
|
||||
humans: number,
|
||||
@@ -186,6 +191,8 @@ export interface Theme {
|
||||
// Don't call directly, use PlayerView
|
||||
territoryColor(playerInfo: PlayerView): Colord;
|
||||
// Don't call directly, use PlayerView
|
||||
structureColors(territoryColor: Colord): { light: Colord; dark: Colord };
|
||||
// Don't call directly, use PlayerView
|
||||
borderColor(territoryColor: Colord): Colord;
|
||||
// Don't call directly, use PlayerView
|
||||
defendedBorderColors(territoryColor: Colord): { light: Colord; dark: Colord };
|
||||
|
||||
@@ -60,6 +60,7 @@ const numPlayersConfig = {
|
||||
[GameMapType.Europe]: [100, 70, 50],
|
||||
[GameMapType.EuropeClassic]: [50, 30, 30],
|
||||
[GameMapType.FalklandIslands]: [50, 30, 20],
|
||||
[GameMapType.FourIslands]: [20, 15, 10],
|
||||
[GameMapType.FaroeIslands]: [20, 15, 10],
|
||||
[GameMapType.GatewayToTheAtlantic]: [100, 70, 50],
|
||||
[GameMapType.GiantWorldMap]: [100, 70, 50],
|
||||
@@ -77,7 +78,6 @@ const numPlayersConfig = {
|
||||
[GameMapType.SouthAmerica]: [70, 50, 40],
|
||||
[GameMapType.StraitOfGibraltar]: [100, 70, 50],
|
||||
[GameMapType.World]: [50, 30, 20],
|
||||
[GameMapType.Yenisei]: [150, 100, 70],
|
||||
} as const satisfies Record<GameMapType, [number, number, number]>;
|
||||
|
||||
export abstract class DefaultServerConfig implements ServerConfig {
|
||||
@@ -337,6 +337,9 @@ export class DefaultConfig implements Config {
|
||||
instantBuild(): boolean {
|
||||
return this._gameConfig.instantBuild;
|
||||
}
|
||||
isRandomSpawn(): boolean {
|
||||
return this._gameConfig.randomSpawn;
|
||||
}
|
||||
infiniteGold(): boolean {
|
||||
return this._gameConfig.infiniteGold;
|
||||
}
|
||||
@@ -378,9 +381,10 @@ export class DefaultConfig implements Config {
|
||||
}
|
||||
|
||||
tradeShipGold(dist: number, numPorts: number): Gold {
|
||||
// Sigmoid: concave start, sharp S-curve middle, linear end - heavily punishes trades under 200
|
||||
// Sigmoid: concave start, sharp S-curve middle, linear end - heavily punishes trades under range debuff.
|
||||
const debuff = this.tradeShipShortRangeDebuff();
|
||||
const baseGold =
|
||||
100_000 / (1 + Math.exp(-0.03 * (dist - 200))) + 100 * dist;
|
||||
100_000 / (1 + Math.exp(-0.03 * (dist - debuff))) + 100 * dist;
|
||||
const numPortBonus = numPorts - 1;
|
||||
// Hyperbolic decay, midpoint at 5 ports, 3x bonus max.
|
||||
const bonus = 1 + 2 * (numPortBonus / (numPortBonus + 5));
|
||||
@@ -541,11 +545,6 @@ export class DefaultConfig implements Config {
|
||||
experimental: true,
|
||||
upgradable: true,
|
||||
};
|
||||
case UnitType.Construction:
|
||||
return {
|
||||
cost: () => 0n,
|
||||
territoryBound: true,
|
||||
};
|
||||
case UnitType.Train:
|
||||
return {
|
||||
cost: () => 0n,
|
||||
@@ -584,10 +583,11 @@ export class DefaultConfig implements Config {
|
||||
return 10 * 10;
|
||||
}
|
||||
deletionMarkDuration(): Tick {
|
||||
return 15 * 10;
|
||||
return 30 * 10;
|
||||
}
|
||||
|
||||
deleteUnitCooldown(): Tick {
|
||||
return 15 * 10;
|
||||
return 30 * 10;
|
||||
}
|
||||
emojiMessageDuration(): Tick {
|
||||
return 5 * 10;
|
||||
@@ -784,6 +784,10 @@ export class DefaultConfig implements Config {
|
||||
return 20;
|
||||
}
|
||||
|
||||
tradeShipShortRangeDebuff(): number {
|
||||
return 300;
|
||||
}
|
||||
|
||||
proximityBonusPortsNb(totalPorts: number) {
|
||||
return within(totalPorts / 3, 4, totalPorts);
|
||||
}
|
||||
@@ -796,20 +800,31 @@ export class DefaultConfig implements Config {
|
||||
}
|
||||
}
|
||||
|
||||
useNationStrengthForStartManpower(): boolean {
|
||||
// Currently disabled: FakeHumans became harder to play against due to AI improvements
|
||||
// nation strength multiplier was unintentionally disabled during those AI improvements (playerInfo.nation was undefined),
|
||||
// Re-enabling this without rebalancing FakeHuman difficulty elsewhere may make them overpowered
|
||||
return false;
|
||||
}
|
||||
|
||||
startManpower(playerInfo: PlayerInfo): number {
|
||||
if (playerInfo.playerType === PlayerType.Bot) {
|
||||
return 10_000;
|
||||
}
|
||||
if (playerInfo.playerType === PlayerType.FakeHuman) {
|
||||
const strength = this.useNationStrengthForStartManpower()
|
||||
? (playerInfo.nationStrength ?? 1)
|
||||
: 1;
|
||||
|
||||
switch (this._gameConfig.difficulty) {
|
||||
case Difficulty.Easy:
|
||||
return 2_500 * (playerInfo?.nation?.strength ?? 1);
|
||||
return 2_500 * strength;
|
||||
case Difficulty.Medium:
|
||||
return 5_000 * (playerInfo?.nation?.strength ?? 1);
|
||||
return 5_000 * strength;
|
||||
case Difficulty.Hard:
|
||||
return 20_000 * (playerInfo?.nation?.strength ?? 1);
|
||||
return 20_000 * strength;
|
||||
case Difficulty.Impossible:
|
||||
return 50_000 * (playerInfo?.nation?.strength ?? 1);
|
||||
return 50_000 * strength;
|
||||
}
|
||||
}
|
||||
return this.infiniteTroops() ? 1_000_000 : 25_000;
|
||||
@@ -913,6 +928,15 @@ export class DefaultConfig implements Config {
|
||||
return 70;
|
||||
}
|
||||
|
||||
samRange(level: number): number {
|
||||
// rational growth function (level 1 = 70, level 5 just above hydro range, asymptotically approaches 150)
|
||||
return this.maxSamRange() - 480 / (level + 5);
|
||||
}
|
||||
|
||||
maxSamRange(): number {
|
||||
return 150;
|
||||
}
|
||||
|
||||
defaultSamMissileSpeed(): number {
|
||||
return 12;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Colord, colord } from "colord";
|
||||
import { Colord, colord, LabaColor } from "colord";
|
||||
import { PseudoRandom } from "../PseudoRandom";
|
||||
import { PlayerType, Team, TerrainType } from "../game/Game";
|
||||
import { GameMap, TileRef } from "../game/GameMap";
|
||||
@@ -7,10 +7,7 @@ import { ColorAllocator } from "./ColorAllocator";
|
||||
import { botColors, fallbackColors, humanColors, nationColors } from "./Colors";
|
||||
import { Theme } from "./Config";
|
||||
|
||||
type ColorCache = Map<string, Colord>;
|
||||
|
||||
export class PastelTheme implements Theme {
|
||||
private borderColorCache: ColorCache = new Map<string, Colord>();
|
||||
private rand = new PseudoRandom(123);
|
||||
private humanColorAllocator = new ColorAllocator(humanColors, fallbackColors);
|
||||
private botColorAllocator = new ColorAllocator(botColors, botColors);
|
||||
@@ -65,6 +62,59 @@ export class PastelTheme implements Theme {
|
||||
return this.nationColorAllocator.assignColor(player.id());
|
||||
}
|
||||
|
||||
structureColors(territoryColor: Colord): { light: Colord; dark: Colord } {
|
||||
// Convert territory color to LAB color space. Territory color is rendered in game with alpha = 150/255, use that here.
|
||||
const lightLAB = territoryColor.alpha(150 / 255).toLab();
|
||||
// Get "border color" from territory color & convert to LAB color space
|
||||
const darkLAB = this.borderColor(territoryColor).toLab();
|
||||
// Calculate the contrast of the two provided colors
|
||||
let contrast = this.contrast(lightLAB, darkLAB);
|
||||
|
||||
// Don't want excessive contrast, so incrementally increase contrast within a loop.
|
||||
// Define target values, looping limits, and loop counter
|
||||
const loopLimit = 10; // Switch from darkening border to lightening fill if loopLimit is reached
|
||||
const maxIterations = 50; // maximum number of loops allowed, throw error above this limit
|
||||
const contrastTarget = 0.5;
|
||||
let loopCount = 0;
|
||||
|
||||
// Adjust luminance by 5 in each iteration. This is a balance between speed and not overdoing contrast changes.
|
||||
const luminanceChange = 5;
|
||||
|
||||
while (contrast < contrastTarget) {
|
||||
if (loopCount > maxIterations) {
|
||||
// Prevent runaway loops
|
||||
console.warn(`Infinite loop detected during structure color calculation.
|
||||
Light color: ${colord(lightLAB).toRgbString()},
|
||||
Dark color: ${colord(darkLAB).toRgbString()},
|
||||
Contrast: ${contrast}`);
|
||||
break;
|
||||
|
||||
// Increase the light color if the "loop limit" has been reach
|
||||
// (probably due to the dark color already being as dark as it can be)
|
||||
} else if (loopCount > loopLimit) {
|
||||
lightLAB.l = this.clamp(lightLAB.l + luminanceChange);
|
||||
|
||||
// Decrease the dark color first to keep the light color as close
|
||||
// to the territory color as possible
|
||||
} else {
|
||||
darkLAB.l = this.clamp(darkLAB.l - luminanceChange);
|
||||
}
|
||||
|
||||
// re-calculate contrast and increment loop counter
|
||||
contrast = this.contrast(lightLAB, darkLAB);
|
||||
loopCount++;
|
||||
}
|
||||
return { light: colord(lightLAB), dark: colord(darkLAB) };
|
||||
}
|
||||
|
||||
private contrast(first: LabaColor, second: LabaColor): number {
|
||||
return colord(first).delta(colord(second));
|
||||
}
|
||||
|
||||
private clamp(num: number, low: number = 0, high: number = 100): number {
|
||||
return Math.min(Math.max(low, num), high);
|
||||
}
|
||||
|
||||
// Don't call directly, use PlayerView
|
||||
borderColor(territoryColor: Colord): Colord {
|
||||
return territoryColor.darken(0.125);
|
||||
@@ -108,15 +158,15 @@ export class PastelTheme implements Theme {
|
||||
}
|
||||
case TerrainType.Plains:
|
||||
return colord({
|
||||
r: 216,
|
||||
g: 205 - 2 * mag,
|
||||
b: 127,
|
||||
r: 190,
|
||||
g: 220 - 2 * mag,
|
||||
b: 138,
|
||||
});
|
||||
case TerrainType.Highland:
|
||||
return colord({
|
||||
r: 223 + 2 * mag,
|
||||
g: 187 + 2 * mag,
|
||||
b: 132 + 2 * mag,
|
||||
r: 200 + 2 * mag,
|
||||
g: 183 + 2 * mag,
|
||||
b: 138 + 2 * mag,
|
||||
});
|
||||
case TerrainType.Mountain:
|
||||
return colord({
|
||||
|
||||
@@ -181,9 +181,10 @@ export class AttackExecution implements Execution {
|
||||
this._owner.id(),
|
||||
);
|
||||
}
|
||||
if (this.removeTroops === false) {
|
||||
if (this.removeTroops === false && this.sourceTile === null) {
|
||||
// startTroops are always added to attack troops at init but not always removed from owner troops
|
||||
// subtract startTroops from attack troops so we don't give back startTroops to owner that were never removed
|
||||
// boat attacks (sourceTile !== null) are the exception: troops were removed at departure and must be returned after attack still
|
||||
this.attack.setTroops(this.attack.troops() - (this.startTroops ?? 0));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,40 +1,26 @@
|
||||
import { Execution, Game, Player, Unit, UnitType } from "../game/Game";
|
||||
import { TileRef } from "../game/GameMap";
|
||||
import { Execution, Game, Unit, UnitType } from "../game/Game";
|
||||
import { TrainStationExecution } from "./TrainStationExecution";
|
||||
|
||||
export class CityExecution implements Execution {
|
||||
private mg: Game;
|
||||
private city: Unit | null = null;
|
||||
private active: boolean = true;
|
||||
private stationCreated = false;
|
||||
|
||||
constructor(
|
||||
private player: Player,
|
||||
private tile: TileRef,
|
||||
) {}
|
||||
constructor(private city: Unit) {}
|
||||
|
||||
init(mg: Game, ticks: number): void {
|
||||
this.mg = mg;
|
||||
}
|
||||
|
||||
tick(ticks: number): void {
|
||||
if (this.city === null) {
|
||||
const spawnTile = this.player.canBuild(UnitType.City, this.tile);
|
||||
if (spawnTile === false) {
|
||||
console.warn("cannot build city");
|
||||
this.active = false;
|
||||
return;
|
||||
}
|
||||
this.city = this.player.buildUnit(UnitType.City, spawnTile, {});
|
||||
if (!this.stationCreated) {
|
||||
this.createStation();
|
||||
this.stationCreated = true;
|
||||
}
|
||||
if (!this.city.isActive()) {
|
||||
this.active = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.player !== this.city.owner()) {
|
||||
this.player = this.city.owner();
|
||||
}
|
||||
}
|
||||
|
||||
isActive(): boolean {
|
||||
@@ -45,16 +31,14 @@ export class CityExecution implements Execution {
|
||||
return false;
|
||||
}
|
||||
|
||||
createStation(): void {
|
||||
if (this.city !== null) {
|
||||
const nearbyFactory = this.mg.hasUnitNearby(
|
||||
this.city.tile()!,
|
||||
this.mg.config().trainStationMaxRange(),
|
||||
UnitType.Factory,
|
||||
);
|
||||
if (nearbyFactory) {
|
||||
this.mg.addExecution(new TrainStationExecution(this.city));
|
||||
}
|
||||
private createStation(): void {
|
||||
const nearbyFactory = this.mg.hasUnitNearby(
|
||||
this.city.tile()!,
|
||||
this.mg.config().trainStationMaxRange(),
|
||||
UnitType.Factory,
|
||||
);
|
||||
if (nearbyFactory) {
|
||||
this.mg.addExecution(new TrainStationExecution(this.city));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,4 @@
|
||||
import {
|
||||
Execution,
|
||||
Game,
|
||||
Gold,
|
||||
Player,
|
||||
Tick,
|
||||
Unit,
|
||||
UnitType,
|
||||
} from "../game/Game";
|
||||
import { Execution, Game, Player, Tick, Unit, UnitType } from "../game/Game";
|
||||
import { TileRef } from "../game/GameMap";
|
||||
import { CityExecution } from "./CityExecution";
|
||||
import { DefensePostExecution } from "./DefensePostExecution";
|
||||
@@ -19,14 +11,12 @@ import { SAMLauncherExecution } from "./SAMLauncherExecution";
|
||||
import { WarshipExecution } from "./WarshipExecution";
|
||||
|
||||
export class ConstructionExecution implements Execution {
|
||||
private construction: Unit | null = null;
|
||||
private structure: Unit | null = null;
|
||||
private active: boolean = true;
|
||||
private mg: Game;
|
||||
|
||||
private ticksUntilComplete: Tick;
|
||||
|
||||
private cost: Gold;
|
||||
|
||||
constructor(
|
||||
private player: Player,
|
||||
private constructionType: UnitType,
|
||||
@@ -52,45 +42,52 @@ export class ConstructionExecution implements Execution {
|
||||
}
|
||||
|
||||
tick(ticks: number): void {
|
||||
if (this.construction === null) {
|
||||
if (this.structure === null) {
|
||||
const info = this.mg.unitInfo(this.constructionType);
|
||||
if (info.constructionDuration === undefined) {
|
||||
// For non-structure units (nukes/warship), charge once and delegate to specialized executions.
|
||||
const isStructure = this.isStructure(this.constructionType);
|
||||
if (!isStructure) {
|
||||
// Defer validation and gold deduction to the specific execution
|
||||
this.completeConstruction();
|
||||
this.active = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Structures: build real unit and mark under construction
|
||||
const spawnTile = this.player.canBuild(this.constructionType, this.tile);
|
||||
if (spawnTile === false) {
|
||||
console.warn(`cannot build ${this.constructionType}`);
|
||||
this.active = false;
|
||||
return;
|
||||
}
|
||||
this.construction = this.player.buildUnit(
|
||||
UnitType.Construction,
|
||||
this.structure = this.player.buildUnit(
|
||||
this.constructionType,
|
||||
spawnTile,
|
||||
{},
|
||||
);
|
||||
this.cost = this.mg.unitInfo(this.constructionType).cost(this.player);
|
||||
this.player.removeGold(this.cost);
|
||||
this.construction.setConstructionType(this.constructionType);
|
||||
this.ticksUntilComplete = info.constructionDuration;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.construction.isActive()) {
|
||||
const duration = info.constructionDuration ?? 0;
|
||||
if (duration > 0) {
|
||||
this.structure.setUnderConstruction(true);
|
||||
this.ticksUntilComplete = duration;
|
||||
return;
|
||||
}
|
||||
// No construction time
|
||||
this.completeConstruction();
|
||||
this.active = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.player !== this.construction.owner()) {
|
||||
this.player = this.construction.owner();
|
||||
if (!this.structure.isActive()) {
|
||||
this.active = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.player !== this.structure.owner()) {
|
||||
this.player = this.structure.owner();
|
||||
}
|
||||
|
||||
if (this.ticksUntilComplete === 0) {
|
||||
this.player = this.construction.owner();
|
||||
this.construction.delete(false);
|
||||
// refund the cost so player has the gold to build the unit
|
||||
this.player.addGold(this.cost);
|
||||
this.player = this.structure.owner();
|
||||
this.completeConstruction();
|
||||
this.active = false;
|
||||
return;
|
||||
@@ -99,6 +96,9 @@ export class ConstructionExecution implements Execution {
|
||||
}
|
||||
|
||||
private completeConstruction() {
|
||||
if (this.structure) {
|
||||
this.structure.setUnderConstruction(false);
|
||||
}
|
||||
const player = this.player;
|
||||
switch (this.constructionType) {
|
||||
case UnitType.AtomBomb:
|
||||
@@ -116,22 +116,24 @@ export class ConstructionExecution implements Execution {
|
||||
);
|
||||
break;
|
||||
case UnitType.Port:
|
||||
this.mg.addExecution(new PortExecution(player, this.tile));
|
||||
this.mg.addExecution(new PortExecution(this.structure!));
|
||||
break;
|
||||
case UnitType.MissileSilo:
|
||||
this.mg.addExecution(new MissileSiloExecution(player, this.tile));
|
||||
this.mg.addExecution(new MissileSiloExecution(this.structure!));
|
||||
break;
|
||||
case UnitType.DefensePost:
|
||||
this.mg.addExecution(new DefensePostExecution(player, this.tile));
|
||||
this.mg.addExecution(new DefensePostExecution(this.structure!));
|
||||
break;
|
||||
case UnitType.SAMLauncher:
|
||||
this.mg.addExecution(new SAMLauncherExecution(player, this.tile));
|
||||
this.mg.addExecution(
|
||||
new SAMLauncherExecution(player, null, this.structure!),
|
||||
);
|
||||
break;
|
||||
case UnitType.City:
|
||||
this.mg.addExecution(new CityExecution(player, this.tile));
|
||||
this.mg.addExecution(new CityExecution(this.structure!));
|
||||
break;
|
||||
case UnitType.Factory:
|
||||
this.mg.addExecution(new FactoryExecution(player, this.tile));
|
||||
this.mg.addExecution(new FactoryExecution(this.structure!));
|
||||
break;
|
||||
default:
|
||||
console.warn(
|
||||
@@ -141,6 +143,20 @@ export class ConstructionExecution implements Execution {
|
||||
}
|
||||
}
|
||||
|
||||
private isStructure(type: UnitType): boolean {
|
||||
switch (type) {
|
||||
case UnitType.Port:
|
||||
case UnitType.MissileSilo:
|
||||
case UnitType.DefensePost:
|
||||
case UnitType.SAMLauncher:
|
||||
case UnitType.City:
|
||||
case UnitType.Factory:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
isActive(): boolean {
|
||||
return this.active;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { Execution, Game, Player, Unit, UnitType } from "../game/Game";
|
||||
import { TileRef } from "../game/GameMap";
|
||||
import { Execution, Game, Unit } from "../game/Game";
|
||||
import { ShellExecution } from "./ShellExecution";
|
||||
|
||||
export class DefensePostExecution implements Execution {
|
||||
private mg: Game;
|
||||
private post: Unit | null = null;
|
||||
private active: boolean = true;
|
||||
|
||||
private target: Unit | null = null;
|
||||
@@ -12,17 +10,13 @@ export class DefensePostExecution implements Execution {
|
||||
|
||||
private alreadySentShell = new Set<Unit>();
|
||||
|
||||
constructor(
|
||||
private player: Player,
|
||||
private tile: TileRef,
|
||||
) {}
|
||||
constructor(private post: Unit) {}
|
||||
|
||||
init(mg: Game, ticks: number): void {
|
||||
this.mg = mg;
|
||||
}
|
||||
|
||||
private shoot() {
|
||||
if (this.post === null) return;
|
||||
if (this.target === null) return;
|
||||
const shellAttackRate = this.mg.config().defensePostShellAttackRate();
|
||||
if (this.mg.ticks() - this.lastShellAttack > shellAttackRate) {
|
||||
@@ -45,22 +39,14 @@ export class DefensePostExecution implements Execution {
|
||||
}
|
||||
|
||||
tick(ticks: number): void {
|
||||
if (this.post === null) {
|
||||
const spawnTile = this.player.canBuild(UnitType.DefensePost, this.tile);
|
||||
if (spawnTile === false) {
|
||||
console.warn("cannot build Defense Post");
|
||||
this.active = false;
|
||||
return;
|
||||
}
|
||||
this.post = this.player.buildUnit(UnitType.DefensePost, spawnTile, {});
|
||||
}
|
||||
if (!this.post.isActive()) {
|
||||
this.active = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.player !== this.post.owner()) {
|
||||
this.player = this.post.owner();
|
||||
// Do nothing while the structure is under construction
|
||||
if (this.post.isUnderConstruction()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.target !== null && !this.target.isActive()) {
|
||||
|
||||
@@ -26,6 +26,7 @@ import { SpawnExecution } from "./SpawnExecution";
|
||||
import { TargetPlayerExecution } from "./TargetPlayerExecution";
|
||||
import { TransportShipExecution } from "./TransportShipExecution";
|
||||
import { UpgradeStructureExecution } from "./UpgradeStructureExecution";
|
||||
import { PlayerSpawner } from "./utils/PlayerSpawner";
|
||||
|
||||
export class Executor {
|
||||
// private random = new PseudoRandom(999)
|
||||
@@ -131,6 +132,10 @@ export class Executor {
|
||||
return new BotSpawner(this.mg, this.gameID).spawnBots(numBots);
|
||||
}
|
||||
|
||||
spawnPlayers(): Execution[] {
|
||||
return new PlayerSpawner(this.mg, this.gameID).spawnPlayers();
|
||||
}
|
||||
|
||||
fakeHumanExecutions(): Execution[] {
|
||||
const execs: Execution[] = [];
|
||||
for (const nation of this.mg.nations()) {
|
||||
|
||||
@@ -1,39 +1,26 @@
|
||||
import { Execution, Game, Player, Unit, UnitType } from "../game/Game";
|
||||
import { TileRef } from "../game/GameMap";
|
||||
import { Execution, Game, Unit, UnitType } from "../game/Game";
|
||||
import { TrainStationExecution } from "./TrainStationExecution";
|
||||
|
||||
export class FactoryExecution implements Execution {
|
||||
private factory: Unit | null = null;
|
||||
private active: boolean = true;
|
||||
private game: Game;
|
||||
constructor(
|
||||
private player: Player,
|
||||
private tile: TileRef,
|
||||
) {}
|
||||
private stationCreated = false;
|
||||
|
||||
constructor(private factory: Unit) {}
|
||||
|
||||
init(mg: Game, ticks: number): void {
|
||||
this.game = mg;
|
||||
}
|
||||
|
||||
tick(ticks: number): void {
|
||||
if (!this.factory) {
|
||||
const spawnTile = this.player.canBuild(UnitType.Factory, this.tile);
|
||||
if (spawnTile === false) {
|
||||
console.warn("cannot build factory");
|
||||
this.active = false;
|
||||
return;
|
||||
}
|
||||
this.factory = this.player.buildUnit(UnitType.Factory, spawnTile, {});
|
||||
if (!this.stationCreated) {
|
||||
this.createStation();
|
||||
this.stationCreated = true;
|
||||
}
|
||||
if (!this.factory.isActive()) {
|
||||
this.active = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.player !== this.factory.owner()) {
|
||||
this.player = this.factory.owner();
|
||||
}
|
||||
}
|
||||
|
||||
isActive(): boolean {
|
||||
@@ -44,19 +31,17 @@ export class FactoryExecution implements Execution {
|
||||
return false;
|
||||
}
|
||||
|
||||
createStation(): void {
|
||||
if (this.factory !== null) {
|
||||
const structures = this.game.nearbyUnits(
|
||||
this.factory.tile()!,
|
||||
this.game.config().trainStationMaxRange(),
|
||||
[UnitType.City, UnitType.Port, UnitType.Factory],
|
||||
);
|
||||
private createStation(): void {
|
||||
const structures = this.game.nearbyUnits(
|
||||
this.factory.tile()!,
|
||||
this.game.config().trainStationMaxRange(),
|
||||
[UnitType.City, UnitType.Port, UnitType.Factory],
|
||||
);
|
||||
|
||||
this.game.addExecution(new TrainStationExecution(this.factory, true));
|
||||
for (const { unit } of structures) {
|
||||
if (!unit.hasTrainStation()) {
|
||||
this.game.addExecution(new TrainStationExecution(unit));
|
||||
}
|
||||
this.game.addExecution(new TrainStationExecution(this.factory, true));
|
||||
for (const { unit } of structures) {
|
||||
if (!unit.hasTrainStation()) {
|
||||
this.game.addExecution(new TrainStationExecution(unit));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
Cell,
|
||||
Difficulty,
|
||||
Execution,
|
||||
Game,
|
||||
Gold,
|
||||
@@ -46,6 +47,11 @@ export class FakeHumanExecution implements Execution {
|
||||
private readonly lastMIRVSent: [Tick, TileRef][] = [];
|
||||
private readonly embargoMalusApplied = new Set<PlayerID>();
|
||||
|
||||
// Track our transport ships we currently own
|
||||
private trackedTransportShips: Set<Unit> = new Set();
|
||||
// Track our trade ships we currently own
|
||||
private trackedTradeShips: Set<Unit> = new Set();
|
||||
|
||||
/** MIRV Strategy Constants */
|
||||
|
||||
/** Ticks until MIRV can be attempted again */
|
||||
@@ -133,6 +139,16 @@ export class FakeHumanExecution implements Execution {
|
||||
}
|
||||
|
||||
tick(ticks: number) {
|
||||
// Ship tracking
|
||||
if (
|
||||
this.player !== null &&
|
||||
this.player.isAlive() &&
|
||||
this.mg.config().gameConfig().difficulty !== Difficulty.Easy
|
||||
) {
|
||||
this.trackTransportShipsAndRetaliate();
|
||||
this.trackTradeShipsAndRetaliate();
|
||||
}
|
||||
|
||||
if (ticks % this.attackRate !== this.attackTick) {
|
||||
return;
|
||||
}
|
||||
@@ -190,50 +206,50 @@ export class FakeHumanExecution implements Execution {
|
||||
if (this.player === null || this.behavior === null) {
|
||||
throw new Error("not initialized");
|
||||
}
|
||||
|
||||
const enemyborder = Array.from(this.player.borderTiles())
|
||||
.flatMap((t) => this.mg.neighbors(t))
|
||||
.filter(
|
||||
(t) =>
|
||||
this.mg.isLand(t) && this.mg.ownerID(t) !== this.player?.smallID(),
|
||||
);
|
||||
|
||||
if (enemyborder.length === 0) {
|
||||
if (this.random.chance(10)) {
|
||||
this.sendBoatRandomly();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (this.random.chance(20)) {
|
||||
this.sendBoatRandomly();
|
||||
return;
|
||||
}
|
||||
|
||||
const borderPlayers = enemyborder.map((t) =>
|
||||
this.mg.playerBySmallID(this.mg.ownerID(t)),
|
||||
);
|
||||
if (borderPlayers.some((o) => !o.isPlayer())) {
|
||||
this.behavior.sendAttack(this.mg.terraNullius());
|
||||
return;
|
||||
}
|
||||
|
||||
const enemies = borderPlayers
|
||||
const borderingEnemies = borderPlayers
|
||||
.filter((o) => o.isPlayer())
|
||||
.sort((a, b) => a.troops() - b.troops());
|
||||
|
||||
// 5% chance to send a random alliance request
|
||||
if (this.random.chance(20)) {
|
||||
const toAlly = this.random.randElement(enemies);
|
||||
if (this.player.canSendAllianceRequest(toAlly)) {
|
||||
this.mg.addExecution(
|
||||
new AllianceRequestExecution(this.player, toAlly.id()),
|
||||
);
|
||||
if (enemyborder.length === 0) {
|
||||
if (this.random.chance(5)) {
|
||||
this.sendBoatRandomly(borderingEnemies);
|
||||
}
|
||||
} else {
|
||||
if (this.random.chance(10)) {
|
||||
this.sendBoatRandomly(borderingEnemies);
|
||||
return;
|
||||
}
|
||||
|
||||
if (borderPlayers.some((o) => !o.isPlayer())) {
|
||||
this.behavior.sendAttack(this.mg.terraNullius());
|
||||
return;
|
||||
}
|
||||
|
||||
// 5% chance to send a random alliance request
|
||||
if (this.random.chance(20)) {
|
||||
const toAlly = this.random.randElement(borderingEnemies);
|
||||
if (this.player.canSendAllianceRequest(toAlly)) {
|
||||
this.mg.addExecution(
|
||||
new AllianceRequestExecution(this.player, toAlly.id()),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.behavior.forgetOldEnemies();
|
||||
this.behavior.assistAllies();
|
||||
|
||||
const enemy = this.behavior.selectEnemy(enemies);
|
||||
const enemy = this.behavior.selectEnemy(borderingEnemies);
|
||||
if (!enemy) return;
|
||||
this.maybeSendEmoji(enemy);
|
||||
this.maybeSendNuke(enemy);
|
||||
@@ -581,7 +597,7 @@ export class FakeHumanExecution implements Execution {
|
||||
return this.mg.unitInfo(type).cost(this.player);
|
||||
}
|
||||
|
||||
sendBoatRandomly() {
|
||||
sendBoatRandomly(borderingEnemies: Player[]) {
|
||||
if (this.player === null) throw new Error("not initialized");
|
||||
const oceanShore = Array.from(this.player.borderTiles()).filter((t) =>
|
||||
this.mg.isOceanShore(t),
|
||||
@@ -592,9 +608,14 @@ export class FakeHumanExecution implements Execution {
|
||||
|
||||
const src = this.random.randElement(oceanShore);
|
||||
|
||||
const dst = this.randomBoatTarget(src, 150);
|
||||
// First look for high-interest targets (unowned or bot-owned). Mainly relevant for earlygame
|
||||
let dst = this.randomBoatTarget(src, borderingEnemies, true);
|
||||
if (dst === null) {
|
||||
return;
|
||||
// None found? Then look for players
|
||||
dst = this.randomBoatTarget(src, borderingEnemies, false);
|
||||
if (dst === null) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.mg.addExecution(
|
||||
@@ -634,13 +655,17 @@ export class FakeHumanExecution implements Execution {
|
||||
return null;
|
||||
}
|
||||
|
||||
private randomBoatTarget(tile: TileRef, dist: number): TileRef | null {
|
||||
private randomBoatTarget(
|
||||
tile: TileRef,
|
||||
borderingEnemies: Player[],
|
||||
highInterestOnly: boolean = false,
|
||||
): TileRef | null {
|
||||
if (this.player === null) throw new Error("not initialized");
|
||||
const x = this.mg.x(tile);
|
||||
const y = this.mg.y(tile);
|
||||
for (let i = 0; i < 500; i++) {
|
||||
const randX = this.random.nextInt(x - dist, x + dist);
|
||||
const randY = this.random.nextInt(y - dist, y + dist);
|
||||
const randX = this.random.nextInt(x - 150, x + 150);
|
||||
const randY = this.random.nextInt(y - 150, y + 150);
|
||||
if (!this.mg.isValidCoord(randX, randY)) {
|
||||
continue;
|
||||
}
|
||||
@@ -649,11 +674,27 @@ export class FakeHumanExecution implements Execution {
|
||||
continue;
|
||||
}
|
||||
const owner = this.mg.owner(randTile);
|
||||
if (!owner.isPlayer()) {
|
||||
return randTile;
|
||||
if (owner === this.player) {
|
||||
continue;
|
||||
}
|
||||
if (!owner.isFriendly(this.player)) {
|
||||
return randTile;
|
||||
// Don't send boats to players with which we share a border, that usually looks stupid
|
||||
if (owner.isPlayer() && borderingEnemies.includes(owner)) {
|
||||
continue;
|
||||
}
|
||||
// Don't spam boats into players that are more than twice as large as us
|
||||
if (owner.isPlayer() && owner.troops() > this.player.troops() * 2) {
|
||||
continue;
|
||||
}
|
||||
// High-interest targeting: prioritize unowned tiles or tiles owned by bots
|
||||
if (highInterestOnly) {
|
||||
if (!owner.isPlayer() || owner.type() === PlayerType.Bot) {
|
||||
return randTile;
|
||||
}
|
||||
} else {
|
||||
// Normal targeting: return unowned tiles or tiles owned by non-friendly players
|
||||
if (!owner.isPlayer() || !owner.isFriendly(this.player)) {
|
||||
return randTile;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
@@ -879,6 +920,70 @@ export class FakeHumanExecution implements Execution {
|
||||
}
|
||||
}
|
||||
|
||||
// Send out a warship if our transport ship got captured
|
||||
private trackTransportShipsAndRetaliate(): void {
|
||||
if (this.player === null) return;
|
||||
|
||||
// Add any currently owned transport ships to our tracking set
|
||||
this.player
|
||||
.units(UnitType.TransportShip)
|
||||
.forEach((u) => this.trackedTransportShips.add(u));
|
||||
|
||||
// Iterate tracked transport ships; if it got destroyed by an enemy: retaliate
|
||||
for (const ship of Array.from(this.trackedTransportShips)) {
|
||||
if (!ship.isActive()) {
|
||||
// Distinguish between arrival/retreat and enemy destruction
|
||||
if (ship.wasDestroyedByEnemy()) {
|
||||
this.maybeRetaliateWithWarship(ship.tile());
|
||||
}
|
||||
this.trackedTransportShips.delete(ship);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Send out a warship if our trade ship got captured
|
||||
private trackTradeShipsAndRetaliate(): void {
|
||||
if (this.player === null) return;
|
||||
|
||||
// Add any currently owned trade ships to our tracking map
|
||||
this.player
|
||||
.units(UnitType.TradeShip)
|
||||
.forEach((u) => this.trackedTradeShips.add(u));
|
||||
|
||||
// Iterate tracked trade ships; if we no longer own it, it was captured: retaliate
|
||||
for (const ship of Array.from(this.trackedTradeShips)) {
|
||||
if (!ship.isActive()) {
|
||||
this.trackedTradeShips.delete(ship);
|
||||
continue;
|
||||
}
|
||||
if (ship.owner().id() !== this.player.id()) {
|
||||
// Ship was ours and is now owned by someone else -> captured
|
||||
this.maybeRetaliateWithWarship(ship.tile());
|
||||
this.trackedTradeShips.delete(ship);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private maybeRetaliateWithWarship(tile: TileRef): void {
|
||||
if (this.player === null) return;
|
||||
|
||||
const { difficulty } = this.mg.config().gameConfig();
|
||||
// In Easy never retaliate. In Medium retaliate with 15% chance. Hard with 50%, Impossible with 80%.
|
||||
if (
|
||||
(difficulty === Difficulty.Medium && this.random.nextInt(0, 100) < 15) ||
|
||||
(difficulty === Difficulty.Hard && this.random.nextInt(0, 100) < 50) ||
|
||||
(difficulty === Difficulty.Impossible && this.random.nextInt(0, 100) < 80)
|
||||
) {
|
||||
const canBuild = this.player.canBuild(UnitType.Warship, tile);
|
||||
if (canBuild === false) {
|
||||
return;
|
||||
}
|
||||
this.mg.addExecution(
|
||||
new ConstructionExecution(this.player, UnitType.Warship, tile),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
isActive(): boolean {
|
||||
return this.active;
|
||||
}
|
||||
|
||||
@@ -1,35 +1,21 @@
|
||||
import { Execution, Game, Player, Unit, UnitType } from "../game/Game";
|
||||
import { TileRef } from "../game/GameMap";
|
||||
import { Execution, Game, Unit } from "../game/Game";
|
||||
|
||||
export class MissileSiloExecution implements Execution {
|
||||
private active = true;
|
||||
private mg: Game;
|
||||
private silo: Unit | null = null;
|
||||
private silo: Unit;
|
||||
|
||||
constructor(
|
||||
private player: Player,
|
||||
private tile: TileRef,
|
||||
) {}
|
||||
constructor(silo: Unit) {
|
||||
this.silo = silo;
|
||||
}
|
||||
|
||||
init(mg: Game, ticks: number): void {
|
||||
this.mg = mg;
|
||||
}
|
||||
|
||||
tick(ticks: number): void {
|
||||
if (this.silo === null) {
|
||||
const spawn = this.player.canBuild(UnitType.MissileSilo, this.tile);
|
||||
if (spawn === false) {
|
||||
console.warn(
|
||||
`player ${this.player} cannot build missile silo at ${this.tile}`,
|
||||
);
|
||||
this.active = false;
|
||||
return;
|
||||
}
|
||||
this.silo = this.player.buildUnit(UnitType.MissileSilo, spawn, {});
|
||||
|
||||
if (this.player !== this.silo.owner()) {
|
||||
this.player = this.silo.owner();
|
||||
}
|
||||
if (this.silo.isUnderConstruction()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// frontTime is the time the earliest missile fired.
|
||||
|
||||
@@ -103,7 +103,7 @@ export class NukeExecution implements Execution {
|
||||
|
||||
tick(ticks: number): void {
|
||||
if (this.nuke === null) {
|
||||
const spawn = this.src ?? this.player.canBuild(this.nukeType, this.dst);
|
||||
const spawn = this.player.canBuild(this.nukeType, this.dst);
|
||||
if (spawn === false) {
|
||||
console.warn(`cannot build Nuke`);
|
||||
this.active = false;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Execution, Game, Player, Unit, UnitType } from "../game/Game";
|
||||
import { TileRef } from "../game/GameMap";
|
||||
import { Execution, Game, Unit, UnitType } from "../game/Game";
|
||||
import { PseudoRandom } from "../PseudoRandom";
|
||||
import { TradeShipExecution } from "./TradeShipExecution";
|
||||
import { TrainStationExecution } from "./TrainStationExecution";
|
||||
@@ -7,14 +6,13 @@ import { TrainStationExecution } from "./TrainStationExecution";
|
||||
export class PortExecution implements Execution {
|
||||
private active = true;
|
||||
private mg: Game;
|
||||
private port: Unit | null = null;
|
||||
private port: Unit;
|
||||
private random: PseudoRandom;
|
||||
private checkOffset: number;
|
||||
|
||||
constructor(
|
||||
private player: Player,
|
||||
private tile: TileRef,
|
||||
) {}
|
||||
constructor(port: Unit) {
|
||||
this.port = port;
|
||||
}
|
||||
|
||||
init(mg: Game, ticks: number): void {
|
||||
this.mg = mg;
|
||||
@@ -26,27 +24,18 @@ export class PortExecution implements Execution {
|
||||
if (this.mg === null || this.random === null || this.checkOffset === null) {
|
||||
throw new Error("Not initialized");
|
||||
}
|
||||
if (this.port === null) {
|
||||
const tile = this.tile;
|
||||
const spawn = this.player.canBuild(UnitType.Port, tile);
|
||||
if (spawn === false) {
|
||||
console.warn(
|
||||
`player ${this.player.id()} cannot build port at ${this.tile}`,
|
||||
);
|
||||
this.active = false;
|
||||
return;
|
||||
}
|
||||
this.port = this.player.buildUnit(UnitType.Port, spawn, {});
|
||||
this.createStation();
|
||||
}
|
||||
|
||||
if (!this.port.isActive()) {
|
||||
this.active = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.player.id() !== this.port.owner().id()) {
|
||||
this.player = this.port.owner();
|
||||
if (this.port.isUnderConstruction()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.port.hasTrainStation()) {
|
||||
this.createStation();
|
||||
}
|
||||
|
||||
// Only check every 10 ticks for performance.
|
||||
@@ -58,14 +47,16 @@ export class PortExecution implements Execution {
|
||||
return;
|
||||
}
|
||||
|
||||
const ports = this.player.tradingPorts(this.port);
|
||||
const ports = this.tradingPorts();
|
||||
|
||||
if (ports.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const port = this.random.randElement(ports);
|
||||
this.mg.addExecution(new TradeShipExecution(this.player, this.port, port));
|
||||
this.mg.addExecution(
|
||||
new TradeShipExecution(this.port.owner(), this.port, port),
|
||||
);
|
||||
}
|
||||
|
||||
isActive(): boolean {
|
||||
@@ -78,8 +69,10 @@ export class PortExecution implements Execution {
|
||||
|
||||
shouldSpawnTradeShip(): boolean {
|
||||
const numTradeShips = this.mg.unitCount(UnitType.TradeShip);
|
||||
const numPlayerPorts = this.player.unitCount(UnitType.Port);
|
||||
const numPlayerTradeShips = this.player.unitCount(UnitType.TradeShip);
|
||||
const numPlayerPorts = this.port!.owner().unitCount(UnitType.Port);
|
||||
const numPlayerTradeShips = this.port!.owner().unitCount(
|
||||
UnitType.TradeShip,
|
||||
);
|
||||
const spawnRate = this.mg
|
||||
.config()
|
||||
.tradeShipSpawnRate(numTradeShips, numPlayerPorts, numPlayerTradeShips);
|
||||
@@ -92,15 +85,49 @@ export class PortExecution implements Execution {
|
||||
}
|
||||
|
||||
createStation(): void {
|
||||
if (this.port !== null) {
|
||||
const nearbyFactory = this.mg.hasUnitNearby(
|
||||
this.port.tile()!,
|
||||
this.mg.config().trainStationMaxRange(),
|
||||
UnitType.Factory,
|
||||
);
|
||||
if (nearbyFactory) {
|
||||
this.mg.addExecution(new TrainStationExecution(this.port));
|
||||
}
|
||||
const nearbyFactory = this.mg.hasUnitNearby(
|
||||
this.port.tile()!,
|
||||
this.mg.config().trainStationMaxRange(),
|
||||
UnitType.Factory,
|
||||
);
|
||||
if (nearbyFactory) {
|
||||
this.mg.addExecution(new TrainStationExecution(this.port));
|
||||
}
|
||||
}
|
||||
|
||||
// It's a probability list, so if an element appears twice it's because it's
|
||||
// twice more likely to be picked later.
|
||||
tradingPorts(): Unit[] {
|
||||
const ports = this.mg
|
||||
.players()
|
||||
.filter((p) => p !== this.port!.owner() && p.canTrade(this.port!.owner()))
|
||||
.flatMap((p) => p.units(UnitType.Port))
|
||||
.sort((p1, p2) => {
|
||||
return (
|
||||
this.mg.manhattanDist(this.port!.tile(), p1.tile()) -
|
||||
this.mg.manhattanDist(this.port!.tile(), p2.tile())
|
||||
);
|
||||
});
|
||||
|
||||
const weightedPorts: Unit[] = [];
|
||||
|
||||
for (const [i, otherPort] of ports.entries()) {
|
||||
const expanded = new Array(otherPort.level()).fill(otherPort);
|
||||
weightedPorts.push(...expanded);
|
||||
const tooClose =
|
||||
this.mg.manhattanDist(this.port!.tile(), otherPort.tile()) <
|
||||
this.mg.config().tradeShipShortRangeDebuff();
|
||||
const closeBonus =
|
||||
i < this.mg.config().proximityBonusPortsNb(ports.length);
|
||||
if (!tooClose && closeBonus) {
|
||||
// If the port is close, but not too close, add it again
|
||||
// to increase the chances of trading with it.
|
||||
weightedPorts.push(...expanded);
|
||||
}
|
||||
if (!tooClose && this.port!.owner().isFriendly(otherPort.owner())) {
|
||||
weightedPorts.push(...expanded);
|
||||
}
|
||||
}
|
||||
return weightedPorts;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ class SAMTargetingSystem {
|
||||
|
||||
private isInRange(tile: TileRef) {
|
||||
const samTile = this.sam.tile();
|
||||
const range = this.mg.config().defaultSamRange();
|
||||
const range = this.mg.config().samRange(this.sam.level());
|
||||
const rangeSquared = range * range;
|
||||
return this.mg.euclideanDistSquared(samTile, tile) <= rangeSquared;
|
||||
}
|
||||
@@ -82,7 +82,7 @@ class SAMTargetingSystem {
|
||||
|
||||
public getSingleTarget(ticks: number): Target | null {
|
||||
// Look beyond the SAM range so it can preshot nukes
|
||||
const detectionRange = this.mg.config().defaultSamRange() * 2;
|
||||
const detectionRange = this.mg.config().maxSamRange() * 2;
|
||||
const nukes = this.mg.nearbyUnits(
|
||||
this.sam.tile(),
|
||||
detectionRange,
|
||||
@@ -216,6 +216,10 @@ export class SAMLauncherExecution implements Execution {
|
||||
}
|
||||
this.targetingSystem ??= new SAMTargetingSystem(this.mg, this.sam);
|
||||
|
||||
if (this.sam.isUnderConstruction()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.sam.isInCooldown()) {
|
||||
const frontTime = this.sam.missileTimerQueue()[0];
|
||||
if (frontTime === undefined) {
|
||||
|
||||
@@ -148,6 +148,10 @@ export class TradeShipExecution implements Execution {
|
||||
this.tradeShip!.owner().id(),
|
||||
gold,
|
||||
);
|
||||
// Record stats
|
||||
this.mg
|
||||
.stats()
|
||||
.boatCapturedTrade(this.tradeShip!.owner(), this.origOwner, gold);
|
||||
} else {
|
||||
this.srcPort.owner().addGold(gold);
|
||||
this._dstPort.owner().addGold(gold, this._dstPort.tile());
|
||||
@@ -163,6 +167,10 @@ export class TradeShipExecution implements Execution {
|
||||
this.srcPort.owner().id(),
|
||||
gold,
|
||||
);
|
||||
// Record stats
|
||||
this.mg
|
||||
.stats()
|
||||
.boatArriveTrade(this.srcPort.owner(), this._dstPort.owner(), gold);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { renderTroops } from "../../client/Utils";
|
||||
import {
|
||||
Execution,
|
||||
Game,
|
||||
@@ -14,6 +15,7 @@ import { PathFindResultType } from "../pathfinding/AStar";
|
||||
import { PathFinder } from "../pathfinding/PathFinding";
|
||||
import { AttackExecution } from "./AttackExecution";
|
||||
|
||||
const malusForRetreat = 25;
|
||||
export class TransportShipExecution implements Execution {
|
||||
private lastMove: number;
|
||||
|
||||
@@ -179,17 +181,18 @@ export class TransportShipExecution implements Execution {
|
||||
|
||||
// Team mate can conquer disconnected player and get their ships
|
||||
// captureUnit has changed the owner of the unit, now update attacker
|
||||
const boatOwner = this.boat.owner();
|
||||
if (
|
||||
this.originalOwner.isDisconnected() &&
|
||||
this.boat.owner() !== this.originalOwner &&
|
||||
this.boat.owner().isOnSameTeam(this.originalOwner)
|
||||
boatOwner !== this.originalOwner &&
|
||||
boatOwner.isOnSameTeam(this.originalOwner)
|
||||
) {
|
||||
this.attacker = this.boat.owner();
|
||||
this.originalOwner = this.boat.owner(); // for when this owner disconnects too
|
||||
this.attacker = boatOwner;
|
||||
this.originalOwner = boatOwner; // for when this owner disconnects too
|
||||
}
|
||||
|
||||
if (this.boat.retreating()) {
|
||||
// Ensure retreat source is valid for the new owner
|
||||
// Ensure retreat source is still valid for (new) owner
|
||||
if (this.mg.owner(this.src!) !== this.attacker) {
|
||||
// Use bestTransportShipSpawn, not canBuild because of its max boats check etc
|
||||
const newSrc = this.attacker.bestTransportShipSpawn(this.dst);
|
||||
@@ -221,14 +224,23 @@ export class TransportShipExecution implements Execution {
|
||||
switch (result.type) {
|
||||
case PathFindResultType.Completed:
|
||||
if (this.mg.owner(this.dst) === this.attacker) {
|
||||
this.attacker.addTroops(this.boat.troops());
|
||||
const deaths = this.boat.troops() * (malusForRetreat / 100);
|
||||
const survivors = this.boat.troops() - deaths;
|
||||
this.attacker.addTroops(survivors);
|
||||
this.boat.delete(false);
|
||||
this.active = false;
|
||||
|
||||
// Record stats
|
||||
this.mg
|
||||
.stats()
|
||||
.boatArriveTroops(this.attacker, this.target, this.boat.troops());
|
||||
.boatArriveTroops(this.attacker, this.target, survivors);
|
||||
if (deaths) {
|
||||
this.mg.displayMessage(
|
||||
`Attack cancelled, ${renderTroops(deaths)} soldiers killed during retreat.`,
|
||||
MessageType.ATTACK_CANCELLED,
|
||||
this.attacker.id(),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.attacker.conquer(this.dst);
|
||||
|
||||
@@ -36,6 +36,9 @@ export class AllianceExtensionExecution implements Execution {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this is a new request (before adding it)
|
||||
const wasOnlyOneAgreed = alliance.onlyOneAgreedToExtend();
|
||||
|
||||
// Mark this player's intent to extend
|
||||
alliance.addExtensionRequest(this.from);
|
||||
|
||||
@@ -56,6 +59,16 @@ export class AllianceExtensionExecution implements Execution {
|
||||
undefined,
|
||||
{ name: this.from.displayName() },
|
||||
);
|
||||
} else if (alliance.onlyOneAgreedToExtend() && !wasOnlyOneAgreed) {
|
||||
// Send message to the other player that someone wants to renew
|
||||
// Only send if this is a new request (transition from "none" to "one")
|
||||
mg.displayMessage(
|
||||
"events_display.wants_to_renew_alliance",
|
||||
MessageType.RENEW_ALLIANCE,
|
||||
this.toID,
|
||||
undefined,
|
||||
{ name: this.from.displayName() },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,12 +19,7 @@ export class AllianceRequestReplyExecution implements Execution {
|
||||
return;
|
||||
}
|
||||
this.requestor = mg.player(this.requestorID);
|
||||
}
|
||||
|
||||
tick(ticks: number): void {
|
||||
if (this.requestor === null) {
|
||||
throw new Error("Not initialized");
|
||||
}
|
||||
if (this.requestor.isFriendly(this.recipient)) {
|
||||
console.warn("already allied");
|
||||
} else {
|
||||
@@ -46,6 +41,8 @@ export class AllianceRequestReplyExecution implements Execution {
|
||||
this.active = false;
|
||||
}
|
||||
|
||||
tick(ticks: number): void {}
|
||||
|
||||
isActive(): boolean {
|
||||
return this.active;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,11 @@ import {
|
||||
Tick,
|
||||
} from "../../game/Game";
|
||||
import { PseudoRandom } from "../../PseudoRandom";
|
||||
import { flattenedEmojiTable } from "../../Util";
|
||||
import {
|
||||
boundingBoxCenter,
|
||||
calculateBoundingBoxCenter,
|
||||
flattenedEmojiTable,
|
||||
} from "../../Util";
|
||||
import { AllianceExtensionExecution } from "../alliance/AllianceExtensionExecution";
|
||||
import { AttackExecution } from "../AttackExecution";
|
||||
import { EmojiExecution } from "../EmojiExecution";
|
||||
@@ -20,7 +24,7 @@ const EMOJI_ASSIST_ACCEPT = (["👍", "⛵", "🤝", "🎯"] as const).map(emoji
|
||||
const EMOJI_RELATION_TOO_LOW = (["🥱", "🤦♂️"] as const).map(emojiId);
|
||||
const EMOJI_TARGET_ME = (["🥺", "💀"] as const).map(emojiId);
|
||||
const EMOJI_TARGET_ALLY = (["🕊️", "👎"] as const).map(emojiId);
|
||||
export const EMOJI_HECKLE = (["👻", "🎃"] as const).map(emojiId);
|
||||
export const EMOJI_HECKLE = (["🤡", "😡"] as const).map(emojiId);
|
||||
|
||||
export class BotBehavior {
|
||||
private enemy: Player | null = null;
|
||||
@@ -198,7 +202,7 @@ export class BotBehavior {
|
||||
}
|
||||
}
|
||||
|
||||
selectEnemy(enemies: Player[]): Player | null {
|
||||
selectEnemy(borderingEnemies: Player[]): Player | null {
|
||||
if (this.enemy === null) {
|
||||
// Save up troops until we reach the reserve ratio
|
||||
if (!this.hasReserveRatioTroops()) return null;
|
||||
@@ -249,13 +253,18 @@ export class BotBehavior {
|
||||
}
|
||||
|
||||
// Select the weakest player
|
||||
if (this.enemy === null && enemies.length > 0) {
|
||||
this.setNewEnemy(enemies[0]);
|
||||
if (this.enemy === null && borderingEnemies.length > 0) {
|
||||
this.setNewEnemy(borderingEnemies[0]);
|
||||
}
|
||||
|
||||
// Select a random player
|
||||
if (this.enemy === null && enemies.length > 0) {
|
||||
this.setNewEnemy(this.random.randElement(enemies));
|
||||
if (this.enemy === null && borderingEnemies.length > 0) {
|
||||
this.setNewEnemy(this.random.randElement(borderingEnemies));
|
||||
}
|
||||
|
||||
// If we don't have bordering enemies, we are on an island. Attack someone on an island next to us
|
||||
if (this.enemy === null && borderingEnemies.length === 0) {
|
||||
this.selectNearestIslandEnemy();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -263,6 +272,64 @@ export class BotBehavior {
|
||||
return this.enemySanityCheck();
|
||||
}
|
||||
|
||||
getPlayerCenter(player: Player) {
|
||||
if (player.largestClusterBoundingBox) {
|
||||
return boundingBoxCenter(player.largestClusterBoundingBox);
|
||||
}
|
||||
return calculateBoundingBoxCenter(this.game, player.borderTiles());
|
||||
}
|
||||
|
||||
selectNearestIslandEnemy() {
|
||||
const myBorder = this.player.borderTiles();
|
||||
if (myBorder.size === 0) return;
|
||||
|
||||
const filteredPlayers = this.game.players().filter((p) => {
|
||||
if (p === this.player) return false;
|
||||
if (!p.isAlive()) return false;
|
||||
if (p.borderTiles().size === 0) return false;
|
||||
if (this.player.isFriendly(p)) return false;
|
||||
// Don't spam boats into players more than 2x our troops
|
||||
return p.troops() <= this.player.troops() * 2;
|
||||
});
|
||||
|
||||
if (filteredPlayers.length > 0) {
|
||||
const playerCenter = this.getPlayerCenter(this.player);
|
||||
|
||||
const sortedPlayers = filteredPlayers
|
||||
.map((filteredPlayer) => {
|
||||
const filteredPlayerCenter = this.getPlayerCenter(filteredPlayer);
|
||||
|
||||
const playerCenterTile = this.game.ref(
|
||||
playerCenter.x,
|
||||
playerCenter.y,
|
||||
);
|
||||
const filteredPlayerCenterTile = this.game.ref(
|
||||
filteredPlayerCenter.x,
|
||||
filteredPlayerCenter.y,
|
||||
);
|
||||
|
||||
const distance = this.game.manhattanDist(
|
||||
playerCenterTile,
|
||||
filteredPlayerCenterTile,
|
||||
);
|
||||
return { player: filteredPlayer, distance };
|
||||
})
|
||||
.sort((a, b) => a.distance - b.distance); // Sort by distance (ascending)
|
||||
|
||||
// Select the nearest or second-nearest enemy (So our boat doesn't always run into the same warship, if there is one)
|
||||
let selectedEnemy: Player | null;
|
||||
if (sortedPlayers.length > 1 && this.random.chance(2)) {
|
||||
selectedEnemy = sortedPlayers[1].player;
|
||||
} else {
|
||||
selectedEnemy = sortedPlayers[0].player;
|
||||
}
|
||||
|
||||
if (selectedEnemy !== null) {
|
||||
this.setNewEnemy(selectedEnemy);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
selectRandomEnemy(): Player | TerraNullius | null {
|
||||
if (this.enemy === null) {
|
||||
// Save up troops until we reach the trigger ratio
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Game, PlayerType } from "../../game/Game";
|
||||
import { TileRef } from "../../game/GameMap";
|
||||
import { PseudoRandom } from "../../PseudoRandom";
|
||||
import { GameID } from "../../Schemas";
|
||||
import { simpleHash } from "../../Util";
|
||||
import { SpawnExecution } from "../SpawnExecution";
|
||||
|
||||
export class PlayerSpawner {
|
||||
private random: PseudoRandom;
|
||||
private players: SpawnExecution[] = [];
|
||||
private static readonly MAX_SPAWN_TRIES = 10_000;
|
||||
private static readonly MIN_SPAWN_DISTANCE = 30;
|
||||
|
||||
constructor(
|
||||
private gm: Game,
|
||||
gameID: GameID,
|
||||
) {
|
||||
this.random = new PseudoRandom(simpleHash(gameID));
|
||||
}
|
||||
|
||||
private randTile(): TileRef {
|
||||
const x = this.random.nextInt(0, this.gm.width());
|
||||
const y = this.random.nextInt(0, this.gm.height());
|
||||
|
||||
return this.gm.ref(x, y);
|
||||
}
|
||||
|
||||
private randomSpawnLand(): TileRef | null {
|
||||
let tries = 0;
|
||||
|
||||
while (tries < PlayerSpawner.MAX_SPAWN_TRIES) {
|
||||
tries++;
|
||||
|
||||
const tile = this.randTile();
|
||||
|
||||
if (
|
||||
!this.gm.isLand(tile) ||
|
||||
this.gm.hasOwner(tile) ||
|
||||
this.gm.isBorder(tile)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let tooCloseToOtherPlayer = false;
|
||||
for (const spawn of this.players) {
|
||||
if (
|
||||
this.gm.manhattanDist(spawn.tile, tile) <
|
||||
PlayerSpawner.MIN_SPAWN_DISTANCE
|
||||
) {
|
||||
tooCloseToOtherPlayer = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (tooCloseToOtherPlayer) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return tile;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
spawnPlayers(): SpawnExecution[] {
|
||||
for (const player of this.gm.allPlayers()) {
|
||||
if (player.type() !== PlayerType.Human) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const spawnLand = this.randomSpawnLand();
|
||||
|
||||
if (spawnLand === null) {
|
||||
// TODO: this should normally not happen, additional logic may be needed, if this occurs
|
||||
continue;
|
||||
}
|
||||
|
||||
this.players.push(new SpawnExecution(player.info(), spawnLand));
|
||||
}
|
||||
|
||||
return this.players;
|
||||
}
|
||||
}
|
||||
+9
-12
@@ -96,11 +96,11 @@ export enum GameMapType {
|
||||
StraitOfGibraltar = "Strait of Gibraltar",
|
||||
Italia = "Italia",
|
||||
Japan = "Japan",
|
||||
Yenisei = "Yenisei",
|
||||
Pluto = "Pluto",
|
||||
Montreal = "Montreal",
|
||||
Achiran = "Achiran",
|
||||
BaikalNukeWars = "Baikal (Nuke Wars)",
|
||||
FourIslands = "Four Islands",
|
||||
}
|
||||
|
||||
export type GameMapName = keyof typeof GameMapType;
|
||||
@@ -133,7 +133,6 @@ export const mapCategories: Record<string, GameMapType[]> = {
|
||||
GameMapType.StraitOfGibraltar,
|
||||
GameMapType.Italia,
|
||||
GameMapType.Japan,
|
||||
GameMapType.Yenisei,
|
||||
GameMapType.Montreal,
|
||||
],
|
||||
fantasy: [
|
||||
@@ -143,6 +142,7 @@ export const mapCategories: Record<string, GameMapType[]> = {
|
||||
GameMapType.DeglaciatedAntarctica,
|
||||
GameMapType.Achiran,
|
||||
GameMapType.BaikalNukeWars,
|
||||
GameMapType.FourIslands,
|
||||
],
|
||||
};
|
||||
|
||||
@@ -193,7 +193,6 @@ export enum UnitType {
|
||||
City = "City",
|
||||
MIRV = "MIRV",
|
||||
MIRVWarhead = "MIRV Warhead",
|
||||
Construction = "Construction",
|
||||
Train = "Train",
|
||||
Factory = "Factory",
|
||||
}
|
||||
@@ -205,7 +204,6 @@ export enum TrainType {
|
||||
|
||||
const _structureTypes: ReadonlySet<UnitType> = new Set([
|
||||
UnitType.City,
|
||||
UnitType.Construction,
|
||||
UnitType.DefensePost,
|
||||
UnitType.SAMLauncher,
|
||||
UnitType.MissileSilo,
|
||||
@@ -279,8 +277,6 @@ export interface UnitParamsMap {
|
||||
[UnitType.MIRVWarhead]: {
|
||||
targetTile?: number;
|
||||
};
|
||||
|
||||
[UnitType.Construction]: Record<string, never>;
|
||||
}
|
||||
|
||||
// Type helper to get params type for a specific unit type
|
||||
@@ -305,7 +301,6 @@ export enum Relation {
|
||||
export class Nation {
|
||||
constructor(
|
||||
public readonly spawnCell: Cell,
|
||||
public readonly strength: number,
|
||||
public readonly playerInfo: PlayerInfo,
|
||||
) {}
|
||||
}
|
||||
@@ -413,7 +408,7 @@ export class PlayerInfo {
|
||||
public readonly clientID: ClientID | null,
|
||||
// TODO: make player id the small id
|
||||
public readonly id: PlayerID,
|
||||
public readonly nation?: Nation | null,
|
||||
public readonly nationStrength?: number,
|
||||
) {
|
||||
this.clan = getClanTag(name);
|
||||
}
|
||||
@@ -451,6 +446,7 @@ export interface Unit {
|
||||
toUpdate(): UnitUpdate;
|
||||
hasTrainStation(): boolean;
|
||||
setTrainStation(trainStation: boolean): void;
|
||||
wasDestroyedByEnemy(): boolean;
|
||||
|
||||
// Train
|
||||
trainType(): TrainType | undefined;
|
||||
@@ -495,9 +491,9 @@ export interface Unit {
|
||||
setSafeFromPirates(): void; // Only for trade ships
|
||||
isSafeFromPirates(): boolean; // Only for trade ships
|
||||
|
||||
// Construction
|
||||
constructionType(): UnitType | null;
|
||||
setConstructionType(type: UnitType): void;
|
||||
// Construction phase on structures
|
||||
isUnderConstruction(): boolean;
|
||||
setUnderConstruction(underConstruction: boolean): void;
|
||||
|
||||
// Upgradable Structures
|
||||
level(): number;
|
||||
@@ -659,7 +655,6 @@ export interface Player {
|
||||
// Misc
|
||||
toUpdate(): PlayerUpdate;
|
||||
playerProfile(): PlayerProfile;
|
||||
tradingPorts(port: Unit): Unit[];
|
||||
// WARNING: this operation is expensive.
|
||||
bestTransportShipSpawn(tile: TileRef): TileRef | false;
|
||||
}
|
||||
@@ -706,12 +701,14 @@ export interface Game extends GameMap {
|
||||
searchRange: number,
|
||||
type: UnitType,
|
||||
playerId?: PlayerID,
|
||||
includeUnderConstruction?: boolean,
|
||||
): boolean;
|
||||
nearbyUnits(
|
||||
tile: TileRef,
|
||||
searchRange: number,
|
||||
types: UnitType | UnitType[],
|
||||
predicate?: UnitPredicate,
|
||||
includeUnderConstruction?: boolean,
|
||||
): Array<{ unit: Unit; distSquared: number }>;
|
||||
|
||||
addExecution(...exec: Execution[]): void;
|
||||
|
||||
@@ -768,8 +768,15 @@ export class GameImpl implements Game {
|
||||
searchRange: number,
|
||||
type: UnitType,
|
||||
playerId?: PlayerID,
|
||||
includeUnderConstruction?: boolean,
|
||||
) {
|
||||
return this.unitGrid.hasUnitNearby(tile, searchRange, type, playerId);
|
||||
return this.unitGrid.hasUnitNearby(
|
||||
tile,
|
||||
searchRange,
|
||||
type,
|
||||
playerId,
|
||||
includeUnderConstruction,
|
||||
);
|
||||
}
|
||||
|
||||
nearbyUnits(
|
||||
@@ -777,12 +784,14 @@ export class GameImpl implements Game {
|
||||
searchRange: number,
|
||||
types: UnitType | UnitType[],
|
||||
predicate?: UnitPredicate,
|
||||
includeUnderConstruction?: boolean,
|
||||
): Array<{ unit: Unit; distSquared: number }> {
|
||||
return this.unitGrid.nearbyUnits(
|
||||
tile,
|
||||
searchRange,
|
||||
types,
|
||||
predicate,
|
||||
includeUnderConstruction,
|
||||
) as Array<{
|
||||
unit: Unit;
|
||||
distSquared: number;
|
||||
|
||||
@@ -19,6 +19,7 @@ export interface GameUpdateViewData {
|
||||
updates: GameUpdates;
|
||||
packedTileUpdates: BigUint64Array;
|
||||
playerNameViewData: Record<string, NameViewData>;
|
||||
tickExecutionDuration?: number;
|
||||
}
|
||||
|
||||
export interface ErrorUpdate {
|
||||
@@ -127,7 +128,7 @@ export interface UnitUpdate {
|
||||
targetUnitId?: number; // Only for trade ships
|
||||
targetTile?: TileRef; // Only for nukes
|
||||
health?: number;
|
||||
constructionType?: UnitType;
|
||||
underConstruction?: boolean;
|
||||
missileTimerQueue: number[];
|
||||
level: number;
|
||||
hasTrainStation: boolean;
|
||||
@@ -178,6 +179,7 @@ export interface AllianceView {
|
||||
other: PlayerID;
|
||||
createdAt: Tick;
|
||||
expiresAt: Tick;
|
||||
hasExtensionRequest: boolean;
|
||||
}
|
||||
|
||||
export interface AllianceRequestUpdate {
|
||||
|
||||
@@ -122,8 +122,8 @@ export class UnitView {
|
||||
health(): number {
|
||||
return this.data.health ?? 0;
|
||||
}
|
||||
constructionType(): UnitType | undefined {
|
||||
return this.data.constructionType;
|
||||
isUnderConstruction(): boolean {
|
||||
return this.data.underConstruction === true;
|
||||
}
|
||||
targetUnitId(): number | undefined {
|
||||
return this.data.targetUnitId;
|
||||
@@ -185,6 +185,8 @@ export class PlayerView {
|
||||
|
||||
private _territoryColor: Colord;
|
||||
private _borderColor: Colord;
|
||||
// Update here to include structure light and dark colors
|
||||
private _structureColors: { light: Colord; dark: Colord };
|
||||
private _defendedBorderColors: { light: Colord; dark: Colord };
|
||||
|
||||
constructor(
|
||||
@@ -230,6 +232,11 @@ export class PlayerView {
|
||||
this._territoryColor = defaultTerritoryColor;
|
||||
}
|
||||
|
||||
this._structureColors = this.game
|
||||
.config()
|
||||
.theme()
|
||||
.structureColors(this._territoryColor);
|
||||
|
||||
const maybeFocusedBorderColor =
|
||||
this.game.myClientID() === this.data.clientID
|
||||
? this.game.config().theme().focusedBorderColor()
|
||||
@@ -263,6 +270,10 @@ export class PlayerView {
|
||||
return isPrimary ? this._territoryColor : this._borderColor;
|
||||
}
|
||||
|
||||
structureColors(): { light: Colord; dark: Colord } {
|
||||
return this._structureColors;
|
||||
}
|
||||
|
||||
borderColor(tile?: TileRef, isDefended: boolean = false): Colord {
|
||||
if (tile === undefined || !isDefended) {
|
||||
return this._borderColor;
|
||||
|
||||
+24
-42
@@ -172,6 +172,10 @@ export class PlayerImpl implements Player {
|
||||
other: a.other(this).id(),
|
||||
createdAt: a.createdAt(),
|
||||
expiresAt: a.expiresAt(),
|
||||
hasExtensionRequest:
|
||||
a.expiresAt() <=
|
||||
this.mg.ticks() +
|
||||
this.mg.config().allianceExtensionPromptOffset(),
|
||||
}) satisfies AllianceView,
|
||||
),
|
||||
hasSpawned: this.hasSpawned(),
|
||||
@@ -229,8 +233,8 @@ export class PlayerImpl implements Player {
|
||||
const built = this.numUnitsConstructed[type] ?? 0;
|
||||
let constructing = 0;
|
||||
for (const unit of this._units) {
|
||||
if (unit.type() !== UnitType.Construction) continue;
|
||||
if (unit.constructionType() !== type) continue;
|
||||
if (unit.type() !== type) continue;
|
||||
if (!unit.isUnderConstruction()) continue;
|
||||
constructing++;
|
||||
}
|
||||
const total = constructing + built;
|
||||
@@ -253,12 +257,12 @@ export class PlayerImpl implements Player {
|
||||
let total = 0;
|
||||
for (const unit of this._units) {
|
||||
if (unit.type() === type) {
|
||||
total += unit.level();
|
||||
continue;
|
||||
if (unit.isUnderConstruction()) {
|
||||
total++;
|
||||
} else {
|
||||
total += unit.level();
|
||||
}
|
||||
}
|
||||
if (unit.type() !== UnitType.Construction) continue;
|
||||
if (unit.constructionType() !== type) continue;
|
||||
total++;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
@@ -891,7 +895,7 @@ export class PlayerImpl implements Player {
|
||||
|
||||
const range = this.mg.config().structureMinDist();
|
||||
const existing = this.mg
|
||||
.nearbyUnits(targetTile, range, type)
|
||||
.nearbyUnits(targetTile, range, type, undefined, true)
|
||||
.sort((a, b) => a.distSquared - b.distSquared);
|
||||
if (existing.length === 0) {
|
||||
return false;
|
||||
@@ -911,6 +915,9 @@ export class PlayerImpl implements Player {
|
||||
if (unit.isMarkedForDeletion()) {
|
||||
return false;
|
||||
}
|
||||
if (unit.isUnderConstruction()) {
|
||||
return false;
|
||||
}
|
||||
if (unit.owner() !== this) {
|
||||
return false;
|
||||
}
|
||||
@@ -1041,7 +1048,6 @@ export class PlayerImpl implements Player {
|
||||
case UnitType.SAMLauncher:
|
||||
case UnitType.City:
|
||||
case UnitType.Factory:
|
||||
case UnitType.Construction:
|
||||
return this.landBasedStructureSpawn(targetTile, validTiles);
|
||||
default:
|
||||
assertNever(unitType);
|
||||
@@ -1055,10 +1061,10 @@ export class PlayerImpl implements Player {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// only get missilesilos that are not on cooldown
|
||||
// only get missilesilos that are not on cooldown and not under construction
|
||||
const spawns = this.units(UnitType.MissileSilo)
|
||||
.filter((silo) => {
|
||||
return !silo.isInCooldown();
|
||||
return !silo.isInCooldown() && !silo.isUnderConstruction();
|
||||
})
|
||||
.sort(distSortUnit(this.mg, tile));
|
||||
if (spawns.length === 0) {
|
||||
@@ -1130,7 +1136,13 @@ export class PlayerImpl implements Player {
|
||||
return this.mg.config().unitInfo(unitTypeValue).territoryBound;
|
||||
});
|
||||
|
||||
const nearbyUnits = this.mg.nearbyUnits(tile, searchRadius * 2, types);
|
||||
const nearbyUnits = this.mg.nearbyUnits(
|
||||
tile,
|
||||
searchRadius * 2,
|
||||
types,
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
const nearbyTiles = this.mg.bfs(tile, (gm, t) => {
|
||||
return (
|
||||
this.mg.euclideanDistSquared(tile, t) < searchRadiusSquared &&
|
||||
@@ -1279,34 +1291,4 @@ export class PlayerImpl implements Player {
|
||||
bestTransportShipSpawn(targetTile: TileRef): TileRef | false {
|
||||
return bestShoreDeploymentSource(this.mg, this, targetTile);
|
||||
}
|
||||
|
||||
// It's a probability list, so if an element appears twice it's because it's
|
||||
// twice more likely to be picked later.
|
||||
tradingPorts(port: Unit): Unit[] {
|
||||
const ports = this.mg
|
||||
.players()
|
||||
.filter((p) => p !== port.owner() && p.canTrade(port.owner()))
|
||||
.flatMap((p) => p.units(UnitType.Port))
|
||||
.sort((p1, p2) => {
|
||||
return (
|
||||
this.mg.manhattanDist(port.tile(), p1.tile()) -
|
||||
this.mg.manhattanDist(port.tile(), p2.tile())
|
||||
);
|
||||
});
|
||||
|
||||
const weightedPorts: Unit[] = [];
|
||||
|
||||
for (const [i, otherPort] of ports.entries()) {
|
||||
const expanded = new Array(otherPort.level()).fill(otherPort);
|
||||
weightedPorts.push(...expanded);
|
||||
if (i < this.mg.config().proximityBonusPortsNb(ports.length)) {
|
||||
weightedPorts.push(...expanded);
|
||||
}
|
||||
if (port.owner().isFriendly(otherPort.owner())) {
|
||||
weightedPorts.push(...expanded);
|
||||
}
|
||||
}
|
||||
|
||||
return weightedPorts;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,8 +20,8 @@ export class Railroad {
|
||||
isActive: false,
|
||||
railTiles,
|
||||
});
|
||||
this.from.getRailroads().delete(this);
|
||||
this.to.getRailroads().delete(this);
|
||||
this.from.removeRailroad(this);
|
||||
this.to.removeRailroad(this);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,14 +29,11 @@ export function getOrientedRailroad(
|
||||
from: TrainStation,
|
||||
to: TrainStation,
|
||||
): OrientedRailroad | null {
|
||||
for (const railroad of from.getRailroads()) {
|
||||
if (railroad.from === to) {
|
||||
return new OrientedRailroad(railroad, false);
|
||||
} else if (railroad.to === to) {
|
||||
return new OrientedRailroad(railroad, true);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
const railroad = from.getRailroadTo(to);
|
||||
if (!railroad) return null;
|
||||
// If tiles are stored from -> to, we go forward when railroad.to === to
|
||||
const forward = railroad.to === to;
|
||||
return new OrientedRailroad(railroad, forward);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -23,6 +23,9 @@ export interface Stats {
|
||||
// Player betrays another player
|
||||
betray(player: Player): void;
|
||||
|
||||
// Time between lobby creation and game start (ms)
|
||||
lobbyFillTime(fillTimeMs: number): void;
|
||||
|
||||
// Player sends a trade ship to target
|
||||
boatSendTrade(player: Player, target: Player): void;
|
||||
|
||||
|
||||
@@ -264,4 +264,6 @@ export class StatsImpl implements Stats {
|
||||
playerKilled(player: Player, tick: number): void {
|
||||
this._addPlayerKilled(player, tick);
|
||||
}
|
||||
|
||||
lobbyFillTime(fillTimeMs: number): void {}
|
||||
}
|
||||
|
||||
@@ -76,6 +76,8 @@ export class TrainStation {
|
||||
{};
|
||||
private cluster: Cluster | null;
|
||||
private railroads: Set<Railroad> = new Set();
|
||||
// Quick lookup from neighboring station to connecting railroad
|
||||
private railroadByNeighbor: Map<TrainStation, Railroad> = new Map();
|
||||
|
||||
constructor(
|
||||
private mg: Game,
|
||||
@@ -91,10 +93,19 @@ export class TrainStation {
|
||||
|
||||
clearRailroads() {
|
||||
this.railroads.clear();
|
||||
this.railroadByNeighbor.clear();
|
||||
}
|
||||
|
||||
addRailroad(railRoad: Railroad) {
|
||||
this.railroads.add(railRoad);
|
||||
const neighbor = railRoad.from === this ? railRoad.to : railRoad.from;
|
||||
this.railroadByNeighbor.set(neighbor, railRoad);
|
||||
}
|
||||
|
||||
removeRailroad(railRoad: Railroad) {
|
||||
this.railroads.delete(railRoad);
|
||||
const neighbor = railRoad.from === this ? railRoad.to : railRoad.from;
|
||||
this.railroadByNeighbor.delete(neighbor);
|
||||
}
|
||||
|
||||
removeNeighboringRails(station: TrainStation) {
|
||||
@@ -111,7 +122,7 @@ export class TrainStation {
|
||||
isActive: false,
|
||||
railTiles,
|
||||
});
|
||||
this.railroads.delete(toRemove);
|
||||
this.removeRailroad(toRemove);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,6 +150,10 @@ export class TrainStation {
|
||||
return this.railroads;
|
||||
}
|
||||
|
||||
getRailroadTo(station: TrainStation): Railroad | null {
|
||||
return this.railroadByNeighbor.get(station) ?? null;
|
||||
}
|
||||
|
||||
setCluster(cluster: Cluster | null) {
|
||||
this.cluster = cluster;
|
||||
}
|
||||
|
||||
@@ -137,6 +137,7 @@ export class UnitGrid {
|
||||
searchRange: number,
|
||||
types: readonly UnitType[] | UnitType,
|
||||
predicate?: UnitPredicate,
|
||||
includeUnderConstruction: boolean = false,
|
||||
): Array<{ unit: Unit | UnitView; distSquared: number }> {
|
||||
const nearby: Array<{ unit: Unit | UnitView; distSquared: number }> = [];
|
||||
const { startGridX, endGridX, startGridY, endGridY } = this.getCellsInRange(
|
||||
@@ -152,6 +153,10 @@ export class UnitGrid {
|
||||
if (unitSet === undefined) continue;
|
||||
for (const unit of unitSet) {
|
||||
if (!unit.isActive()) continue;
|
||||
// Exclude units under construction by default (e.g., defense posts being built)
|
||||
// But include them for spacing checks
|
||||
if (!includeUnderConstruction && unit.isUnderConstruction())
|
||||
continue;
|
||||
const distSquared = this.squaredDistanceFromTile(unit, tile);
|
||||
if (distSquared > rangeSquared) continue;
|
||||
const value = { unit, distSquared };
|
||||
@@ -169,10 +174,16 @@ export class UnitGrid {
|
||||
tile: TileRef,
|
||||
rangeSquared: number,
|
||||
playerId?: PlayerID,
|
||||
includeUnderConstruction: boolean = false,
|
||||
): boolean {
|
||||
if (!unit.isActive()) {
|
||||
return false;
|
||||
}
|
||||
// Exclude units under construction by default (e.g., defense posts being built)
|
||||
// But include them for spacing checks
|
||||
if (!includeUnderConstruction && unit.isUnderConstruction()) {
|
||||
return false;
|
||||
}
|
||||
if (playerId !== undefined && unit.owner().id() !== playerId) {
|
||||
return false;
|
||||
}
|
||||
@@ -186,6 +197,7 @@ export class UnitGrid {
|
||||
searchRange: number,
|
||||
type: UnitType,
|
||||
playerId?: PlayerID,
|
||||
includeUnderConstruction: boolean = false,
|
||||
): boolean {
|
||||
const { startGridX, endGridX, startGridY, endGridY } = this.getCellsInRange(
|
||||
tile,
|
||||
@@ -197,7 +209,15 @@ export class UnitGrid {
|
||||
const unitSet = this.grid[cy][cx].get(type);
|
||||
if (unitSet === undefined) continue;
|
||||
for (const unit of unitSet) {
|
||||
if (this.unitIsInRange(unit, tile, rangeSquared, playerId)) {
|
||||
if (
|
||||
this.unitIsInRange(
|
||||
unit,
|
||||
tile,
|
||||
rangeSquared,
|
||||
playerId,
|
||||
includeUnderConstruction,
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
+17
-12
@@ -24,8 +24,9 @@ export class UnitImpl implements Unit {
|
||||
private _retreating: boolean = false;
|
||||
private _targetedBySAM = false;
|
||||
private _reachedTarget = false;
|
||||
private _wasDestroyedByEnemy: boolean = false;
|
||||
private _lastSetSafeFromPirates: number; // Only for trade ships
|
||||
private _constructionType: UnitType | undefined;
|
||||
private _underConstruction: boolean = false;
|
||||
private _lastOwner: PlayerImpl | null = null;
|
||||
private _troops: number;
|
||||
// Number of missiles in cooldown, if empty all missiles are ready.
|
||||
@@ -131,7 +132,7 @@ export class UnitImpl implements Unit {
|
||||
targetable: this._targetable,
|
||||
lastPos: this._lastTile,
|
||||
health: this.hasHealth() ? Number(this._health) : undefined,
|
||||
constructionType: this._constructionType,
|
||||
underConstruction: this._underConstruction,
|
||||
targetUnitId: this._targetUnit?.id() ?? undefined,
|
||||
targetTile: this.targetTile() ?? undefined,
|
||||
missileTimerQueue: this._missileTimerQueue,
|
||||
@@ -252,6 +253,10 @@ export class UnitImpl implements Unit {
|
||||
if (!this.isActive()) {
|
||||
throw new Error(`cannot delete ${this} not active`);
|
||||
}
|
||||
|
||||
// Record whether this unit was destroyed by an enemy (vs. arrived / retreated)
|
||||
this._wasDestroyedByEnemy = destroyer !== undefined;
|
||||
|
||||
this._owner._units = this._owner._units.filter((b) => b !== this);
|
||||
this._active = false;
|
||||
this.mg.addUpdate(this.toUpdate());
|
||||
@@ -291,6 +296,10 @@ export class UnitImpl implements Unit {
|
||||
return this._active;
|
||||
}
|
||||
|
||||
wasDestroyedByEnemy(): boolean {
|
||||
return this._wasDestroyedByEnemy;
|
||||
}
|
||||
|
||||
retreating(): boolean {
|
||||
return this._retreating;
|
||||
}
|
||||
@@ -302,19 +311,15 @@ export class UnitImpl implements Unit {
|
||||
this._retreating = true;
|
||||
}
|
||||
|
||||
constructionType(): UnitType | null {
|
||||
if (this.type() !== UnitType.Construction) {
|
||||
throw new Error(`Cannot get construction type on ${this.type()}`);
|
||||
}
|
||||
return this._constructionType ?? null;
|
||||
isUnderConstruction(): boolean {
|
||||
return this._underConstruction;
|
||||
}
|
||||
|
||||
setConstructionType(type: UnitType): void {
|
||||
if (this.type() !== UnitType.Construction) {
|
||||
throw new Error(`Cannot set construction type on ${this.type()}`);
|
||||
setUnderConstruction(underConstruction: boolean): void {
|
||||
if (this._underConstruction !== underConstruction) {
|
||||
this._underConstruction = underConstruction;
|
||||
this.mg.addUpdate(this.toUpdate());
|
||||
}
|
||||
this._constructionType = type;
|
||||
this.mg.addUpdate(this.toUpdate());
|
||||
}
|
||||
|
||||
hash(): number {
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
skipNonAlphabeticTransformer,
|
||||
} from "obscenity";
|
||||
import { translateText } from "../../client/Utils";
|
||||
import { simpleHash } from "../Util";
|
||||
import { getClanTagOriginalCase, sanitize, simpleHash } from "../Util";
|
||||
|
||||
const matcher = new RegExpMatcher({
|
||||
...englishDataset.build(),
|
||||
@@ -45,6 +45,55 @@ export function isProfaneUsername(username: string): boolean {
|
||||
return matcher.hasMatch(username);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes and censors profane usernames and clan tags.
|
||||
* Profane username is overwritten, profane clan tag is removed.
|
||||
*
|
||||
* Preserves non-profane clan tag:
|
||||
* prevents desync after clan team assignment because local player's own clan tag and name aren't overwritten
|
||||
*
|
||||
* Removing bad clan tags won't hurt existing clans nor cause desyncs:
|
||||
* - full name including clan tag was overwritten in the past, if any part of name was bad
|
||||
* - only each seperate local player name with a profane clan tag will remain, no clan team assignment
|
||||
*
|
||||
* Examples:
|
||||
* - "GoodName" -> "GoodName"
|
||||
* - "Good$Name" -> "GoodName"
|
||||
* - "BadName" -> "Censored"
|
||||
* - "[CLAN]GoodName" -> "[CLAN]GoodName"
|
||||
* - "[CLaN]BadName" -> "[CLaN] Censored"
|
||||
* - "[BAD]GoodName" -> "GoodName"
|
||||
* - "[BAD]BadName" -> "Censored"
|
||||
*/
|
||||
export function censorNameWithClanTag(username: string): string {
|
||||
const sanitizedUsername = sanitize(username);
|
||||
|
||||
// Don't use getClanTag because that returns upperCase and if original isn't, str replace `[{$clanTag}]` won't match
|
||||
const clanTag = getClanTagOriginalCase(sanitizedUsername);
|
||||
|
||||
const nameWithoutClan = clanTag
|
||||
? sanitizedUsername.replace(`[${clanTag}]`, "").trim()
|
||||
: sanitizedUsername;
|
||||
|
||||
const clanTagIsProfane = clanTag ? isProfaneUsername(clanTag) : false;
|
||||
const usernameIsProfane = isProfaneUsername(nameWithoutClan);
|
||||
|
||||
const censoredNameWithoutClan = usernameIsProfane
|
||||
? fixProfaneUsername(nameWithoutClan)
|
||||
: nameWithoutClan;
|
||||
|
||||
// Restore clan tag if it existed and is not profane
|
||||
if (clanTag && !clanTagIsProfane) {
|
||||
if (usernameIsProfane) {
|
||||
return `[${clanTag}] ${censoredNameWithoutClan}`;
|
||||
}
|
||||
return sanitizedUsername;
|
||||
}
|
||||
|
||||
// Don't restore profane or nonexistent clan tag
|
||||
return censoredNameWithoutClan;
|
||||
}
|
||||
|
||||
export function validateUsername(username: string): {
|
||||
isValid: boolean;
|
||||
error?: string;
|
||||
|
||||
@@ -56,6 +56,7 @@ export class GameManager {
|
||||
infiniteTroops: false,
|
||||
maxTimerValue: undefined,
|
||||
instantBuild: false,
|
||||
randomSpawn: false,
|
||||
gameMode: GameMode.FFA,
|
||||
bots: 400,
|
||||
disabledUnits: [],
|
||||
|
||||
+15
-17
@@ -40,7 +40,6 @@ export class GameServer {
|
||||
private turns: Turn[] = [];
|
||||
private intents: Intent[] = [];
|
||||
public activeClients: Client[] = [];
|
||||
private LobbyCreatorID: string | undefined;
|
||||
private allClients: Map<ClientID, Client> = new Map();
|
||||
private clientsDisconnectedStatus: Map<ClientID, boolean> = new Map();
|
||||
private _hasStarted = false;
|
||||
@@ -75,10 +74,9 @@ export class GameServer {
|
||||
public readonly createdAt: number,
|
||||
private config: ServerConfig,
|
||||
public gameConfig: GameConfig,
|
||||
lobbyCreatorID?: string,
|
||||
private lobbyCreatorID?: string,
|
||||
) {
|
||||
this.log = log_.child({ gameID: id });
|
||||
this.LobbyCreatorID = lobbyCreatorID ?? undefined;
|
||||
}
|
||||
|
||||
public updateGameConfig(gameConfig: Partial<GameConfig>): void {
|
||||
@@ -115,6 +113,9 @@ export class GameServer {
|
||||
if (gameConfig.instantBuild !== undefined) {
|
||||
this.gameConfig.instantBuild = gameConfig.instantBuild;
|
||||
}
|
||||
if (gameConfig.randomSpawn !== undefined) {
|
||||
this.gameConfig.randomSpawn = gameConfig.randomSpawn;
|
||||
}
|
||||
if (gameConfig.gameMode !== undefined) {
|
||||
this.gameConfig.gameMode = gameConfig.gameMode;
|
||||
}
|
||||
@@ -137,10 +138,10 @@ export class GameServer {
|
||||
return;
|
||||
}
|
||||
// Log when lobby creator joins private game
|
||||
if (client.clientID === this.LobbyCreatorID) {
|
||||
if (client.clientID === this.lobbyCreatorID) {
|
||||
this.log.info("Lobby creator joined", {
|
||||
gameID: this.id,
|
||||
creatorID: this.LobbyCreatorID,
|
||||
creatorID: this.lobbyCreatorID,
|
||||
});
|
||||
}
|
||||
this.log.info("client (re)joining game", {
|
||||
@@ -252,13 +253,11 @@ export class GameServer {
|
||||
|
||||
// Handle kick_player intent via WebSocket
|
||||
case "kick_player": {
|
||||
const authenticatedClientID = client.clientID;
|
||||
|
||||
// Check if the authenticated client is the lobby creator
|
||||
if (authenticatedClientID !== this.LobbyCreatorID) {
|
||||
if (client.clientID !== this.lobbyCreatorID) {
|
||||
this.log.warn(`Only lobby creator can kick players`, {
|
||||
clientID: authenticatedClientID,
|
||||
creatorID: this.LobbyCreatorID,
|
||||
clientID: client.clientID,
|
||||
creatorID: this.lobbyCreatorID,
|
||||
target: clientMsg.intent.target,
|
||||
gameID: this.id,
|
||||
});
|
||||
@@ -266,16 +265,16 @@ export class GameServer {
|
||||
}
|
||||
|
||||
// Don't allow lobby creator to kick themselves
|
||||
if (authenticatedClientID === clientMsg.intent.target) {
|
||||
if (client.clientID === clientMsg.intent.target) {
|
||||
this.log.warn(`Cannot kick yourself`, {
|
||||
clientID: authenticatedClientID,
|
||||
clientID: client.clientID,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Log and execute the kick
|
||||
this.log.info(`Lobby creator initiated kick of player`, {
|
||||
creatorID: authenticatedClientID,
|
||||
creatorID: client.clientID,
|
||||
target: clientMsg.intent.target,
|
||||
gameID: this.id,
|
||||
kickMethod: "websocket",
|
||||
@@ -398,6 +397,7 @@ export class GameServer {
|
||||
|
||||
const result = GameStartInfoSchema.safeParse({
|
||||
gameID: this.id,
|
||||
lobbyCreatedAt: this.createdAt,
|
||||
config: this.gameConfig,
|
||||
players: this.activeClients.map((c) => ({
|
||||
username: c.username,
|
||||
@@ -436,6 +436,7 @@ export class GameServer {
|
||||
type: "start",
|
||||
turns: this.turns.slice(lastTurn),
|
||||
gameStartInfo: this.gameStartInfo,
|
||||
lobbyCreatedAt: this.createdAt,
|
||||
} satisfies ServerStartGameMessage),
|
||||
);
|
||||
} catch (error) {
|
||||
@@ -520,10 +521,6 @@ export class GameServer {
|
||||
}
|
||||
}
|
||||
|
||||
public isPrivateLobbyCreator(clientID: string): boolean {
|
||||
return this.LobbyCreatorID === clientID;
|
||||
}
|
||||
|
||||
phase(): GamePhase {
|
||||
const now = Date.now();
|
||||
const alive: Client[] = [];
|
||||
@@ -703,6 +700,7 @@ export class GameServer {
|
||||
this._startTime ?? 0,
|
||||
Date.now(),
|
||||
this.winner?.winner,
|
||||
this.createdAt,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -25,7 +25,7 @@ const frequency: Partial<Record<GameMapName, number>> = {
|
||||
Africa: 7,
|
||||
Asia: 6,
|
||||
Australia: 4,
|
||||
Achiran: 14,
|
||||
Achiran: 5,
|
||||
Baikal: 5,
|
||||
BetweenTwoSeas: 5,
|
||||
BlackSea: 6,
|
||||
@@ -50,7 +50,6 @@ const frequency: Partial<Record<GameMapName, number>> = {
|
||||
SouthAmerica: 5,
|
||||
StraitOfGibraltar: 5,
|
||||
World: 8,
|
||||
Yenisei: 0,
|
||||
};
|
||||
|
||||
interface MapWithMode {
|
||||
@@ -95,6 +94,7 @@ export class MapPlaylist {
|
||||
infiniteTroops: false,
|
||||
maxTimerValue: undefined,
|
||||
instantBuild: false,
|
||||
randomSpawn: false,
|
||||
disableNPCs: mode === GameMode.Team && playerTeams !== HumansVsNations,
|
||||
gameMode: mode,
|
||||
playerTeams,
|
||||
|
||||
Reference in New Issue
Block a user