Merge branch 'main' into canbuildtransport-perf

This commit is contained in:
VariableVince
2025-11-29 11:12:46 +01:00
committed by GitHub
288 changed files with 9413 additions and 3666 deletions
+1 -1
View File
@@ -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"
+62 -1
View File
@@ -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;
+2 -2
View File
@@ -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"
+31 -21
View File
@@ -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
View File
@@ -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;
+4
View File
@@ -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,
+1 -1
View File
@@ -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"
+3 -2
View File
@@ -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
View File
@@ -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) => {
+48
View File
@@ -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;
}
}
+4 -2
View File
@@ -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,
})
+23
View File
@@ -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,
+237
View File
@@ -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()} &middot;
${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();
}
}
+32 -13
View File
@@ -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>
`;
}
+1 -1
View File
@@ -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");
}
+288
View File
@@ -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) ?? [],
}));
}
}
+1 -1
View File
@@ -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")
-63
View File
@@ -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;
}
}
+5
View File
@@ -979,6 +979,11 @@
"continent": "Asia",
"name": "Iran"
},
{
"code": "Pahlavi Iran",
"continent": "Asia",
"name": "Pahlavi Iran"
},
{
"code": "ie",
"continent": "Europe",
+40 -81
View File
@@ -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
+62
View File
@@ -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;
}
}
+20 -10
View File
@@ -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(
+223
View File
@@ -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)`;
}
+4 -4
View File
@@ -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,
+1
View File
@@ -26,6 +26,7 @@ export function conquestFxFactory(
x,
y,
FxType.Conquest,
2500,
);
const fadeAnimation = new FadeFx(swordAnimation, 0.1, 0.6);
conquestFx.push(fadeAnimation);
-4
View File
@@ -16,8 +16,4 @@ export enum FxType {
UnderConstruction = "UnderConstruction",
Dust = "Dust",
Conquest = "Conquest",
Tentacle = "Tentacle",
Shark = "Shark",
Bubble = "Bubble",
Tornado = "Tornado",
}
+2 -31
View File
@@ -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 -1
View File
@@ -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;
+113
View File
@@ -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) {
+16 -7
View File
@@ -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
+44 -37
View File
@@ -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>
`;
+95 -18
View File
@@ -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
-268
View File
@@ -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>
`;
}
}
+1 -77
View File
@@ -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: {
+3 -1
View File
@@ -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>
`;
}
+3 -1
View File
@@ -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,
};
});
+171 -266
View File
@@ -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 -->
+58 -53
View File
@@ -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,
+3 -3
View File
@@ -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")}
+1 -1
View File
@@ -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);
+4 -4
View File
@@ -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;
}
+35 -9
View File
@@ -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,
);
}
}
+18 -24
View File
@@ -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()
+6 -1
View File
@@ -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();
}}
+67 -24
View File
@@ -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));
}
}
+61 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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;
-1
View File
@@ -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");
-24
View File
@@ -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;
}
+21
View File
@@ -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
View File
@@ -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;
}
+4
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+7
View File
@@ -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 };
+38 -14
View File
@@ -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;
}
+60 -10
View File
@@ -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({
+2 -1
View File
@@ -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));
}
+13 -29
View File
@@ -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));
}
}
}
+52 -36
View File
@@ -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;
}
+5 -19
View File
@@ -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()) {
+5
View File
@@ -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()) {
+16 -31
View File
@@ -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));
}
}
}
+141 -36
View File
@@ -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;
}
+7 -21
View File
@@ -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.
+1 -1
View File
@@ -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;
+62 -35
View File
@@ -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;
}
}
+6 -2
View File
@@ -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) {
+8
View File
@@ -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;
}
+19 -7
View File
@@ -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;
}
+74 -7
View File
@@ -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
+83
View File
@@ -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
View File
@@ -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;
+10 -1
View File
@@ -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;
+3 -1
View File
@@ -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 {
+13 -2
View File
@@ -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
View File
@@ -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;
}
}
+7 -10
View File
@@ -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);
}
/**
+3
View File
@@ -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;
+2
View File
@@ -264,4 +264,6 @@ export class StatsImpl implements Stats {
playerKilled(player: Player, tick: number): void {
this._addPlayerKilled(player, tick);
}
lobbyFillTime(fillTimeMs: number): void {}
}
+16 -1
View File
@@ -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;
}
+21 -1
View File
@@ -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
View File
@@ -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 {
+50 -1
View File
@@ -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;
+1
View File
@@ -56,6 +56,7 @@ export class GameManager {
infiniteTroops: false,
maxTimerValue: undefined,
instantBuild: false,
randomSpawn: false,
gameMode: GameMode.FFA,
bots: 400,
disabledUnits: [],
+15 -17
View File
@@ -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,
),
),
);
+2 -2
View File
@@ -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,