Merge branch 'main' into team-names

This commit is contained in:
Evan
2026-03-05 20:50:47 -08:00
committed by GitHub
127 changed files with 5869 additions and 1658 deletions
+20 -20
View File
@@ -13,7 +13,7 @@ import {
import { createPartialGameRecord, replacer } from "../core/Util";
import { ServerConfig } from "../core/configuration/Config";
import { getConfig } from "../core/configuration/ConfigLoader";
import { PlayerActions, UnitType } from "../core/game/Game";
import { BuildableUnit, Structures, UnitType } from "../core/game/Game";
import { TileRef } from "../core/game/GameMap";
import { GameMapLoader } from "../core/game/GameMapLoader";
import {
@@ -548,7 +548,7 @@ export class ClientGameRunner {
if (myPlayer === null) return;
this.myPlayer = myPlayer;
}
this.myPlayer.actions(tile).then((actions) => {
this.myPlayer.actions(tile, [UnitType.TransportShip]).then((actions) => {
if (actions.canAttack) {
this.eventBus.emit(
new SendAttackIntentEvent(
@@ -556,7 +556,7 @@ export class ClientGameRunner {
this.myPlayer!.troops() * this.renderer.uiState.attackRatio,
),
);
} else if (this.canAutoBoat(actions, tile)) {
} else if (this.canAutoBoat(actions.buildableUnits, tile)) {
this.sendBoatAttackIntent(tile);
}
});
@@ -591,7 +591,7 @@ export class ClientGameRunner {
}
private findAndUpgradeNearestBuilding(clickedTile: TileRef) {
this.myPlayer!.actions(clickedTile).then((actions) => {
this.myPlayer!.actions(clickedTile, Structures.types).then((actions) => {
const upgradeUnits: {
unitId: number;
unitType: UnitType;
@@ -644,15 +644,17 @@ export class ClientGameRunner {
this.myPlayer = myPlayer;
}
this.myPlayer.actions(tile).then((actions) => {
if (this.canBoatAttack(actions) === false) {
console.warn(
"Boat attack triggered but can't send Transport Ship to tile",
);
return;
}
this.sendBoatAttackIntent(tile);
});
this.myPlayer
.buildables(tile, [UnitType.TransportShip])
.then((buildables) => {
if (this.canBoatAttack(buildables) !== false) {
this.sendBoatAttackIntent(tile);
} else {
console.warn(
"Boat attack triggered but can't send Transport Ship to tile",
);
}
});
}
private doGroundAttackUnderCursor(): void {
@@ -667,7 +669,7 @@ export class ClientGameRunner {
this.myPlayer = myPlayer;
}
this.myPlayer.actions(tile).then((actions) => {
this.myPlayer.actions(tile, null).then((actions) => {
if (actions.canAttack) {
this.eventBus.emit(
new SendAttackIntentEvent(
@@ -696,10 +698,8 @@ export class ClientGameRunner {
return this.gameView.ref(cell.x, cell.y);
}
private canBoatAttack(actions: PlayerActions): false | TileRef {
const bu = actions.buildableUnits.find(
(bu) => bu.type === UnitType.TransportShip,
);
private canBoatAttack(buildables: BuildableUnit[]): false | TileRef {
const bu = buildables.find((bu) => bu.type === UnitType.TransportShip);
return bu?.canBuild ?? false;
}
@@ -714,10 +714,10 @@ export class ClientGameRunner {
);
}
private canAutoBoat(actions: PlayerActions, tile: TileRef): boolean {
private canAutoBoat(buildables: BuildableUnit[], tile: TileRef): boolean {
if (!this.gameView.isLand(tile)) return false;
const canBuild = this.canBoatAttack(actions);
const canBuild = this.canBoatAttack(buildables);
if (canBuild === false) return false;
// TODO: Global enable flag
+2 -1
View File
@@ -121,7 +121,8 @@ export class FlagInput extends LitElement {
} else {
const img = document.createElement("img");
img.src = this.flag ? `/flags/${this.flag}.svg` : `/flags/xx.svg`;
img.className = "w-full h-full object-cover drop-shadow";
img.className = "w-full h-full object-cover pointer-events-none";
img.draggable = false;
img.onerror = () => {
if (!img.src.endsWith("/flags/xx.svg")) {
img.src = "/flags/xx.svg";
+2 -1
View File
@@ -59,7 +59,8 @@ export class FlagInputModal extends BaseModal {
w-[100px] sm:w-[120px]"
>
<img
class="w-full h-auto rounded shadow-sm group-hover:scale-105 transition-transform duration-200"
class="w-full h-auto rounded group-hover:scale-105 transition-transform duration-200 pointer-events-none"
draggable="false"
src="/flags/${country.code}.svg"
loading="lazy"
@error=${(e: Event) => {
+2 -5
View File
@@ -1,7 +1,7 @@
import { html, LitElement } from "lit";
import { customElement, property, query, state } from "lit/decorators.js";
import { GameEndInfo } from "../core/Schemas";
import { GameMapType, hasUnusualThumbnailSize } from "../core/game/Game";
import { GameMapType } from "../core/game/Game";
import { fetchGameById } from "./Api";
import { terrainMapFileLoader } from "./TerrainMapFileLoader";
import { UsernameInput } from "./UsernameInput";
@@ -107,7 +107,6 @@ export class GameInfoModal extends LitElement {
if (!info) {
return html``;
}
const isUnusualThumbnailSize = hasUnusualThumbnailSize(info.config.gameMap);
return html`
<div
class="h-37.5 flex relative justify-between rounded-xl bg-black/20 items-center"
@@ -115,9 +114,7 @@ export class GameInfoModal extends LitElement {
${this.mapImage
? html`<img
src="${this.mapImage}"
class="absolute place-self-start col-span-full row-span-full h-full rounded-xl mask-[linear-gradient(to_left,transparent,#fff)] ${isUnusualThumbnailSize
? "object-cover object-center"
: ""}"
class="absolute place-self-start col-span-full row-span-full h-full rounded-xl mask-[linear-gradient(to_left,transparent,#fff)] object-cover object-center"
/>`
: html`<div
class="place-self-start col-span-full row-span-full h-full rounded-xl bg-gray-300"
+211 -117
View File
@@ -1,11 +1,11 @@
import { html, LitElement, nothing, type TemplateResult } from "lit";
import { customElement, state } from "lit/decorators.js";
import { getServerConfigFromClient } from "src/core/configuration/ConfigLoader";
import {
Duos,
GameMapType,
GameMode,
HumansVsNations,
PublicGameModifiers,
Quads,
Trios,
} from "../core/game/Game";
@@ -16,14 +16,21 @@ import { PublicLobbySocket } from "./LobbySocket";
import { JoinLobbyEvent } from "./Main";
import { SinglePlayerModal } from "./SinglePlayerModal";
import { terrainMapFileLoader } from "./TerrainMapFileLoader";
import { getMapName, renderDuration, translateText } from "./Utils";
import {
getMapName,
getModifierLabels,
renderDuration,
translateText,
} from "./Utils";
const CARD_BG = "bg-[color-mix(in_oklab,var(--frenchBlue)_70%,black)]";
@customElement("game-mode-selector")
export class GameModeSelector extends LitElement {
@state() private lobbies: PublicGames | null = null;
@state() private mapAspectRatios: Map<GameMapType, number> = new Map();
private serverTimeOffset: number = 0;
private defaultLobbyTime: number = 0;
private lobbySocket = new PublicLobbySocket((lobbies) =>
this.handleLobbiesUpdate(lobbies),
@@ -57,6 +64,9 @@ export class GameModeSelector extends LitElement {
connectedCallback() {
super.connectedCallback();
this.lobbySocket.start();
getServerConfigFromClient().then((config) => {
this.defaultLobbyTime = config.gameCreationRate() / 1000;
});
}
disconnectedCallback() {
@@ -77,6 +87,30 @@ export class GameModeSelector extends LitElement {
}),
);
this.requestUpdate();
const allGames = Object.values(lobbies.games ?? {}).flat();
for (const game of allGames) {
const mapType = game.gameConfig?.gameMap as GameMapType;
if (mapType && !this.mapAspectRatios.has(mapType)) {
// New Map reference triggers Lit reactivity; placeholder ratio 1 lets
// has() guard against duplicate in-flight fetches.
this.mapAspectRatios = new Map(this.mapAspectRatios).set(mapType, 1);
terrainMapFileLoader
.getMapData(mapType)
.manifest()
.then((m: any) => {
if (m?.map?.width && m?.map?.height) {
this.mapAspectRatios = new Map(this.mapAspectRatios).set(
mapType,
m.map.width / m.map.height,
);
}
})
.catch((e) =>
console.error(`Failed to load manifest for ${mapType}`, e),
);
}
}
}
render() {
@@ -85,58 +119,110 @@ export class GameModeSelector extends LitElement {
const special = this.lobbies?.games?.["special"]?.[0];
return html`
<div
class="grid grid-cols-1 lg:grid-cols-2 gap-4 w-[70%] lg:w-full mx-auto pb-4 lg:pb-0"
>
${ffa ? this.renderLobbyCard(ffa, this.getLobbyTitle(ffa)) : nothing}
${teams
? this.renderLobbyCard(teams, this.getLobbyTitle(teams))
: nothing}
${special ? this.renderSpecialLobbyCard(special) : nothing}
${this.renderQuickActionsSection()}
<div class="flex flex-col gap-4 w-[84%] sm:w-full mx-auto pb-4 sm:pb-0">
<!-- Solo: mobile only, top -->
<div class="sm:hidden h-14">
${this.renderSmallActionCard(
translateText("main.solo"),
this.openSinglePlayerModal,
"bg-sky-600",
)}
</div>
<!-- Create/ranked/join: mobile only, below solo -->
<div class="sm:hidden grid grid-cols-3 gap-4 h-14">
${this.renderSmallActionCard(
translateText("main.create"),
this.openHostLobby,
"bg-[color-mix(in_oklab,var(--frenchBlue)_75%,black)]",
)}
${this.renderSmallActionCard(
translateText("mode_selector.ranked_title"),
this.openRankedMenu,
"bg-[color-mix(in_oklab,var(--frenchBlue)_75%,black)]",
)}
${this.renderSmallActionCard(
translateText("main.join"),
this.openJoinLobby,
"bg-[color-mix(in_oklab,var(--frenchBlue)_75%,black)]",
)}
</div>
<!-- Game cards grid -->
<div
class="grid grid-cols-1 sm:grid-cols-[2fr_1fr] gap-4 sm:h-[min(24rem,40vh)]"
>
<!-- Left col: main card (desktop only) -->
${special
? html`<div class="hidden sm:block">
${this.renderSpecialLobbyCard(special)}
</div>`
: ffa
? html`<div class="hidden sm:block">
${this.renderLobbyCard(ffa, this.getLobbyTitle(ffa))}
</div>`
: nothing}
<!-- Right col: FFA + teams (desktop only) -->
<div class="hidden sm:flex sm:flex-col sm:gap-4">
${special && ffa
? html`<div class="flex-1 min-h-0">
${this.renderLobbyCard(ffa, this.getLobbyTitle(ffa))}
</div>`
: nothing}
${teams
? html`<div class="flex-1 min-h-0">
${this.renderLobbyCard(teams, this.getLobbyTitle(teams))}
</div>`
: nothing}
</div>
<!-- Mobile: special, ffa, teams inline -->
<div class="sm:hidden">
${special ? this.renderSpecialLobbyCard(special) : nothing}
</div>
<div class="sm:hidden">
${ffa
? this.renderLobbyCard(ffa, this.getLobbyTitle(ffa))
: nothing}
</div>
<div class="sm:hidden">
${teams
? this.renderLobbyCard(teams, this.getLobbyTitle(teams))
: nothing}
</div>
</div>
<!-- Solo: full width, desktop only -->
<div class="hidden sm:block h-14">
${this.renderSmallActionCard(
translateText("main.solo"),
this.openSinglePlayerModal,
"bg-sky-600",
)}
</div>
<!-- Bottom row: create + ranked + join (desktop only) -->
<div class="hidden sm:grid grid-cols-3 gap-4 h-14">
${this.renderSmallActionCard(
translateText("main.create"),
this.openHostLobby,
"bg-[color-mix(in_oklab,var(--frenchBlue)_75%,black)]",
)}
${this.renderSmallActionCard(
translateText("mode_selector.ranked_title"),
this.openRankedMenu,
"bg-[color-mix(in_oklab,var(--frenchBlue)_75%,black)]",
)}
${this.renderSmallActionCard(
translateText("main.join"),
this.openJoinLobby,
"bg-[color-mix(in_oklab,var(--frenchBlue)_75%,black)]",
)}
</div>
</div>
`;
}
private renderSpecialLobbyCard(lobby: PublicGameInfo) {
const subtitle = this.getLobbyTitle(lobby);
const mainTitle = translateText("mode_selector.special_title");
const titleContent = subtitle
? html`
<span class="block">${mainTitle}</span>
<span class="block text-[10px] leading-tight text-white/70">
${subtitle}
</span>
`
: mainTitle;
return this.renderLobbyCard(lobby, titleContent);
}
private renderQuickActionsSection() {
return html`
<div class="contents lg:flex lg:flex-col lg:gap-2 lg:h-56">
<div class="max-lg:order-first grid grid-cols-2 gap-2 h-20 lg:flex-1">
${this.renderSmallActionCard(
translateText("main.solo"),
this.openSinglePlayerModal,
)}
${this.renderSmallActionCard(
translateText("mode_selector.ranked_title"),
this.openRankedMenu,
)}
</div>
<div class="grid grid-cols-2 gap-2 h-20 lg:flex-1">
${this.renderSmallActionCard(
translateText("main.create"),
this.openHostLobby,
)}
${this.renderSmallActionCard(
translateText("main.join"),
this.openJoinLobby,
)}
</div>
</div>
`;
return this.renderLobbyCard(lobby, this.getLobbyTitle(lobby));
}
private openRankedMenu = () => {
@@ -161,11 +247,15 @@ export class GameModeSelector extends LitElement {
(document.querySelector("join-lobby-modal") as JoinLobbyModal)?.open();
};
private renderSmallActionCard(title: string, onClick: () => void) {
private renderSmallActionCard(
title: string,
onClick: () => void,
bgClass: string = CARD_BG,
) {
return html`
<button
@click=${onClick}
class="flex items-center justify-center w-full h-full rounded-xl ${CARD_BG} border-0 transition-transform hover:scale-[1.02] active:scale-[0.98] text-sm lg:text-base font-bold text-white uppercase tracking-wider text-center"
class="flex items-center justify-center w-full h-full rounded-xl ${bgClass} border-0 transition-transform hover:scale-[1.02] active:scale-[0.98] text-sm lg:text-base font-bold text-white uppercase tracking-wider text-center"
>
${title}
</button>
@@ -178,6 +268,11 @@ export class GameModeSelector extends LitElement {
) {
const mapType = lobby.gameConfig!.gameMap as GameMapType;
const mapImageSrc = terrainMapFileLoader.getMapData(mapType).webpPath;
const aspectRatio = this.mapAspectRatios.get(mapType);
// Use object-contain for extreme aspect ratios (e.g. Amazon River ~20:1) so
// the full map is visible instead of being cropped by object-cover.
const useContain =
aspectRatio !== undefined && (aspectRatio > 4 || aspectRatio < 0.25);
const timeRemaining = lobby.startsAt
? Math.max(
0,
@@ -188,17 +283,19 @@ export class GameModeSelector extends LitElement {
: undefined;
let timeDisplay: string = "";
let timeDisplayUppercase = false;
if (timeRemaining === undefined) {
timeDisplay = "-s";
timeDisplay = renderDuration(this.defaultLobbyTime);
} else if (timeRemaining > 0) {
timeDisplay = renderDuration(timeRemaining);
} else {
timeDisplay = translateText("public_lobby.starting_game");
timeDisplayUppercase = true;
}
const mapName = getMapName(lobby.gameConfig?.gameMap);
const modifierLabels = this.getModifierLabels(
const modifierLabels = getModifierLabels(
lobby.gameConfig?.publicGameModifiers,
);
// Sort by length for visual consistency (shorter labels first)
@@ -209,59 +306,78 @@ export class GameModeSelector extends LitElement {
return html`
<button
@click=${() => this.validateAndJoin(lobby)}
class="group flex flex-col w-full h-40 lg:h-56 text-white uppercase rounded-2xl overflow-hidden transition-transform duration-200 hover:scale-[1.02] active:scale-[0.98] ${CARD_BG}"
class="group relative w-full h-44 sm:h-full text-white uppercase rounded-2xl transition-transform duration-200 hover:scale-[1.02] active:scale-[0.98]"
style="background-color: color-mix(in oklab, var(--frenchBlue) 75%, black)"
>
<div class="relative flex-1 overflow-hidden ${CARD_BG}">
<!-- Image clipped separately so overflow-hidden doesn't block absolute children -->
<div
class="absolute inset-0 rounded-2xl overflow-hidden pointer-events-none"
>
${mapImageSrc
? html`<img
src="${mapImageSrc}"
alt="${mapName ?? lobby.gameConfig?.gameMap ?? "map"}"
draggable="false"
class="absolute inset-0 w-full h-full object-contain object-center scale-[1.05] pointer-events-none"
class="absolute inset-0 w-full h-full ${useContain
? "object-contain"
: "object-cover object-center scale-[1.05]"} [image-rendering:auto]"
/>`
: null}
<div
class="absolute inset-x-2 bottom-2 flex items-end justify-between gap-2"
>
${modifierLabels.length > 0
? html`<div class="flex flex-col items-start gap-1">
${modifierLabels.map(
(label) =>
html`<span
class="px-2 py-0.5 rounded text-[10px] font-medium uppercase tracking-wide bg-teal-600 text-white shadow-[0_0_6px_rgba(13,148,136,0.35)]"
>${label}</span
>`,
)}
</div>`
: html`<div></div>`}
<div class="shrink-0">
<span
class="text-[10px] font-bold uppercase tracking-widest bg-blue-600 px-2 py-0.5 rounded"
>${timeDisplay}</span
>
</div>
</div>
<!-- Top row: modifiers + timer -->
<div
class="absolute inset-x-2 top-2 flex items-start justify-between gap-2"
>
${modifierLabels.length > 0
? html`<div class="flex flex-col items-start gap-1">
${modifierLabels.map(
(label) =>
html`<span
class="px-2 py-0.5 rounded text-xs font-bold uppercase tracking-widest bg-teal-600 text-white shadow-[0_0_6px_rgba(13,148,136,0.35)]"
>${label}</span
>`,
)}
</div>`
: html`<div></div>`}
<div class="shrink-0">
<span
class="text-xs font-bold tracking-widest ${timeDisplayUppercase
? "uppercase"
: "normal-case"} bg-sky-600 px-2.5 py-1 rounded"
>${timeDisplay}</span
>
</div>
</div>
<div class="flex items-center justify-between px-3 py-2">
<div class="flex flex-col gap-0.5 min-w-0">
<h3
class="text-sm lg:text-base font-bold uppercase tracking-wider text-left leading-tight"
>
${titleContent}
</h3>
${mapName
? html`<p
class="text-[10px] text-white/70 uppercase tracking-wider text-left"
>
${mapName}
</p>`
: ""}
</div>
<!-- Bottom bar: map name + mode, with player count floating above -->
<div
class="absolute bottom-0 left-0 right-0 flex flex-col px-3 py-2 bg-black/55 backdrop-blur-sm rounded-b-2xl"
style="overflow: visible;"
>
<span
class="text-xs font-bold uppercase tracking-widest shrink-0 ml-2"
class="absolute bottom-full right-2 mb-1 flex items-center gap-1 text-xs font-bold tracking-widest bg-black/70 backdrop-blur-sm px-2 py-0.5 rounded"
>
${lobby.numClients}/${lobby.gameConfig?.maxPlayers}
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4 inline-block"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
d="M13 6a3 3 0 11-6 0 3 3 0 016 0zM18 8a2 2 0 11-4 0 2 2 0 014 0zM14 15a4 4 0 00-8 0v3h8v-3zM6 8a2 2 0 11-4 0 2 2 0 014 0zM16 18v-3a5.972 5.972 0 00-.75-2.906A3.005 3.005 0 0119 15v3h-3zM4.75 12.094A5.973 5.973 0 004 15v3H1v-3a3 3 0 013.75-2.906z"
></path>
</svg>
</span>
${mapName
? html`<p
class="text-sm sm:text-base font-bold uppercase tracking-wider text-left leading-tight"
>
${mapName}
</p>`
: ""}
<h3 class="text-xs text-white/70 uppercase tracking-wider text-left">
${titleContent}
</h3>
</div>
</button>
`;
@@ -283,16 +399,6 @@ export class GameModeSelector extends LitElement {
);
}
private getModifierLabels(mods: PublicGameModifiers | undefined): string[] {
if (!mods) return [];
return [
mods.isRandomSpawn && translateText("public_game_modifier.random_spawn"),
mods.isCompact && translateText("public_game_modifier.compact_map"),
mods.isCrowded && translateText("public_game_modifier.crowded"),
mods.startingGold && translateText("public_game_modifier.starting_gold"),
].filter((x): x is string => !!x);
}
private getLobbyTitle(lobby: PublicGameInfo): string {
const config = lobby.gameConfig!;
if (config.gameMode === GameMode.FFA) {
@@ -324,31 +430,19 @@ export class GameModeSelector extends LitElement {
const teamCount = totalPlayers
? Math.floor(totalPlayers / 2)
: undefined;
return teamCount
? translateText("public_lobby.teams_Duos", {
team_count: String(teamCount),
})
: formatTeamsOf(undefined, 2);
return formatTeamsOf(teamCount, 2);
}
case Trios: {
const teamCount = totalPlayers
? Math.floor(totalPlayers / 3)
: undefined;
return teamCount
? translateText("public_lobby.teams_Trios", {
team_count: String(teamCount),
})
: formatTeamsOf(undefined, 3);
return formatTeamsOf(teamCount, 3);
}
case Quads: {
const teamCount = totalPlayers
? Math.floor(totalPlayers / 4)
: undefined;
return teamCount
? translateText("public_lobby.teams_Quads", {
team_count: String(teamCount),
})
: formatTeamsOf(undefined, 4);
return formatTeamsOf(teamCount, 4);
}
case HumansVsNations: {
const humanSlots = config.maxPlayers ?? lobby.numClients;
+6 -34
View File
@@ -1,5 +1,6 @@
import { LitElement, html } from "lit";
import { customElement, property } from "lit/decorators.js";
import { Platform } from "./Platform";
declare global {
interface Window {
@@ -28,7 +29,7 @@ export class GoogleAdElement extends LitElement {
}
render() {
if (isElectron()) {
if (Platform.isElectron) {
return html``;
}
return html`
@@ -48,6 +49,10 @@ export class GoogleAdElement extends LitElement {
connectedCallback() {
super.connectedCallback();
if (Platform.isElectron) {
return;
}
// Wait for the component to be fully rendered
setTimeout(() => {
try {
@@ -61,37 +66,4 @@ export class GoogleAdElement extends LitElement {
}
}
// Check if running in Electron
const isElectron = () => {
// Renderer process
if (
window !== undefined &&
typeof window.process === "object" &&
// @ts-expect-error hidden
window.process.type === "renderer"
) {
return true;
}
// Main process
if (
process !== undefined &&
typeof process.versions === "object" &&
!!process.versions.electron
) {
return true;
}
// Detect the user agent when the `nodeIntegration` option is set to false
if (
typeof navigator === "object" &&
typeof navigator.userAgent === "string" &&
navigator.userAgent.indexOf("Electron") >= 0
) {
return true;
}
return false;
};
export default GoogleAdElement;
+4 -4
View File
@@ -120,8 +120,8 @@ export class GutterAds extends LitElement {
return html`
<!-- Left Gutter Ad -->
<div
class="hidden xl:flex fixed transform -translate-y-1/2 w-[160px] min-h-[600px] z-[100] pointer-events-auto items-center justify-center"
style="left: calc(50% - 10cm - 230px); top: calc(50% + 10px);"
class="hidden xl:flex fixed transform -translate-y-1/2 w-[160px] min-h-[600px] z-40 pointer-events-auto items-center justify-center"
style="left: calc(50% - 10.5cm - 208px); top: calc(50% + 10px);"
>
<div
id="${this.leftContainerId}"
@@ -131,8 +131,8 @@ export class GutterAds extends LitElement {
<!-- Right Gutter Ad -->
<div
class="hidden xl:flex fixed transform -translate-y-1/2 w-[160px] min-h-[600px] z-[100] pointer-events-auto items-center justify-center"
style="left: calc(50% + 10cm + 70px); top: calc(50% + 10px);"
class="hidden xl:flex fixed transform -translate-y-1/2 w-[160px] min-h-[600px] z-40 pointer-events-auto items-center justify-center"
style="left: calc(50% + 10.5cm + 48px); top: calc(50% + 10px);"
>
<div
id="${this.rightContainerId}"
+2 -1
View File
@@ -4,6 +4,7 @@ import { translateText, TUTORIAL_VIDEO_URL } from "../client/Utils";
import { BaseModal } from "./components/BaseModal";
import "./components/Difficulties";
import { modalHeader } from "./components/ui/ModalHeader";
import { Platform } from "./Platform";
import { TroubleshootingModal } from "./TroubleshootingModal";
@customElement("help-modal")
@@ -39,7 +40,7 @@ export class HelpModal extends BaseModal {
console.warn("Invalid keybinds JSON:", e);
}
const isMac = /Mac/.test(navigator.userAgent);
const isMac = Platform.isMac;
return {
toggleView: "Space",
coordinateGrid: "KeyM",
+72 -48
View File
@@ -8,7 +8,6 @@ import {
GameMapSize,
GameMapType,
GameMode,
HumansVsNations,
UnitType,
} from "../core/game/Game";
import {
@@ -33,11 +32,13 @@ import { JoinLobbyEvent } from "./Main";
import { terrainMapFileLoader } from "./TerrainMapFileLoader";
import {
getBotsForCompactMap,
getNationsForCompactMap,
getRandomMapType,
getUpdatedDisabledUnits,
parseBoundedFloatFromInput,
parseBoundedIntegerFromInput,
preventDisallowedKeys,
sliderToNationsConfig,
toOptionalNumber,
} from "./utilities/GameConfigHelpers";
@@ -45,7 +46,8 @@ import {
export class HostLobbyModal extends BaseModal {
@state() private selectedMap: GameMapType = GameMapType.World;
@state() private selectedDifficulty: Difficulty = Difficulty.Easy;
@state() private disableNations = false;
@state() private nations: number = 0;
@state() private defaultNationCount: number = 0;
@state() private gameMode: GameMode = GameMode.FFA;
@state() private teamCount: TeamCountConfig = 2;
@@ -75,11 +77,11 @@ export class HostLobbyModal extends BaseModal {
@state() private useRandomMap: boolean = false;
@state() private disabledUnits: UnitType[] = [];
@state() private lobbyCreatorClientID: string = "";
@state() private nationCount: number = 0;
@property({ attribute: false }) eventBus: EventBus | null = null;
// Add a new timer for debouncing bot changes
// Timers for debouncing slider changes
private botsUpdateTimer: number | null = null;
private nationsUpdateTimer: number | null = null;
private mapLoader = terrainMapFileLoader;
private leaveLobbyOnClose = true;
@@ -193,18 +195,18 @@ export class HostLobbyModal extends BaseModal {
.labelKey=${"single_modal.starting_gold"}
.checked=${this.startingGold}
.inputId=${"starting-gold-value"}
.inputMin=${0}
.inputMax=${1000000000}
.inputStep=${100000}
.inputMin=${0.1}
.inputMax=${1000}
.inputStep=${"any"}
.inputValue=${this.startingGoldValue}
.inputAriaLabel=${translateText("single_modal.starting_gold")}
.inputPlaceholder=${translateText(
"single_modal.starting_gold_placeholder",
)}
.defaultInputValue=${5000000}
.minValidOnEnable=${0}
.defaultInputValue=${5}
.minValidOnEnable=${0.1}
.onToggle=${this.handleStartingGoldToggle}
.onInput=${this.handleStartingGoldValueChanges}
.onChange=${this.handleStartingGoldValueChanges}
.onKeyDown=${this.handleStartingGoldValueKeyDown}
></toggle-input-card>`,
];
@@ -242,7 +244,7 @@ export class HostLobbyModal extends BaseModal {
},
difficulty: {
selected: this.selectedDifficulty,
disabled: this.disableNations,
disabled: this.nations === 0,
},
gameMode: {
selected: this.gameMode,
@@ -257,14 +259,13 @@ export class HostLobbyModal extends BaseModal {
labelKey: "host_modal.bots",
disabledKey: "host_modal.bots_disabled",
},
nations: {
value: this.nations,
defaultValue: this.defaultNationCount,
labelKey: "host_modal.nations",
disabledKey: "host_modal.nations_disabled",
},
toggles: [
{
labelKey: "host_modal.disable_nations",
checked: this.disableNations,
hidden:
this.gameMode === GameMode.Team &&
this.teamCount === HumansVsNations,
},
{
labelKey: "host_modal.instant_build",
checked: this.instantBuild,
@@ -307,6 +308,7 @@ export class HostLobbyModal extends BaseModal {
@game-mode-selected=${this.handleConfigGameModeSelected}
@team-count-selected=${this.handleConfigTeamCountSelected}
@bots-changed=${this.handleBotsChange}
@nations-changed=${this.handleNationsChange}
@option-toggle-changed=${this.handleConfigOptionToggleChanged}
@unit-toggle-changed=${this.handleConfigUnitToggleChanged}
></game-config-settings>
@@ -318,9 +320,7 @@ export class HostLobbyModal extends BaseModal {
.lobbyCreatorClientID=${this.lobbyCreatorClientID}
.currentClientID=${this.lobbyCreatorClientID}
.teamCount=${this.teamCount}
.nationCount=${this.nationCount}
.disableNations=${this.disableNations}
.isCompactMap=${this.compactMap}
.nationCount=${this.nations}
.onKickPlayer=${(clientID: string) => this.kickPlayer(clientID)}
></lobby-player-view>
</div>
@@ -406,6 +406,10 @@ export class HostLobbyModal extends BaseModal {
);
}
public confirmBeforeClose(): boolean {
return confirm(translateText("host_modal.leave_confirmation"));
}
protected onClose(): void {
console.log("Closing host lobby modal");
this.stopLobbyUpdates();
@@ -420,11 +424,16 @@ export class HostLobbyModal extends BaseModal {
clearTimeout(this.botsUpdateTimer);
this.botsUpdateTimer = null;
}
if (this.nationsUpdateTimer !== null) {
clearTimeout(this.nationsUpdateTimer);
this.nationsUpdateTimer = null;
}
// Reset all transient form state to ensure clean slate
this.selectedMap = GameMapType.World;
this.selectedDifficulty = Difficulty.Easy;
this.disableNations = false;
this.nations = 0;
this.defaultNationCount = 0;
this.gameMode = GameMode.FFA;
this.teamCount = 2;
this.bots = 400;
@@ -444,7 +453,6 @@ export class HostLobbyModal extends BaseModal {
this.lobbyId = "";
this.clients = [];
this.lobbyCreatorClientID = "";
this.nationCount = 0;
this.goldMultiplier = false;
this.goldMultiplierValue = undefined;
this.startingGold = false;
@@ -504,9 +512,6 @@ export class HostLobbyModal extends BaseModal {
const { labelKey, checked } = customEvent.detail;
switch (labelKey) {
case "host_modal.disable_nations":
void this.handleDisableNationsChange(checked);
break;
case "host_modal.instant_build":
this.handleInstantBuildChange(checked);
break;
@@ -645,12 +650,17 @@ export class HostLobbyModal extends BaseModal {
private handleStartingGoldValueChanges = (e: Event) => {
const input = e.target as HTMLInputElement;
const value = parseBoundedIntegerFromInput(input, {
min: 0,
max: 1000000000,
const value = parseBoundedFloatFromInput(input, {
min: 0.1,
max: 1000,
});
this.startingGoldValue = value;
if (value === undefined) {
this.startingGoldValue = undefined;
input.value = "";
} else {
this.startingGoldValue = value;
}
this.putGameConfig();
};
@@ -677,6 +687,11 @@ export class HostLobbyModal extends BaseModal {
private handleCompactMapChange = (val: boolean) => {
this.compactMap = val;
this.bots = getBotsForCompactMap(this.bots, val);
this.nations = getNationsForCompactMap(
this.nations,
this.defaultNationCount,
val,
);
this.putGameConfig();
};
@@ -704,10 +719,21 @@ export class HostLobbyModal extends BaseModal {
this.putGameConfig();
};
private handleDisableNationsChange = async (val: boolean) => {
this.disableNations = val;
console.log(`updating disable nations to ${this.disableNations}`);
this.putGameConfig();
private handleNationsChange = (e: Event) => {
const customEvent = e as CustomEvent<{ value: number }>;
const value = customEvent.detail.value;
if (isNaN(value) || value < 0 || value > 400) {
return;
}
this.nations = value;
if (this.nationsUpdateTimer !== null) {
clearTimeout(this.nationsUpdateTimer);
}
this.nationsUpdateTimer = window.setTimeout(() => {
this.putGameConfig();
this.nationsUpdateTimer = null;
}, 300);
};
private async handleGameModeSelection(value: GameMode) {
@@ -755,14 +781,10 @@ export class HostLobbyModal extends BaseModal {
? spawnImmunityTicks
: undefined,
playerTeams: this.teamCount,
...(this.gameMode === GameMode.Team &&
this.teamCount === HumansVsNations
? {
disableNations: false,
}
: {
disableNations: this.disableNations,
}),
nations: sliderToNationsConfig(
this.nations,
this.defaultNationCount,
),
maxTimerValue:
this.maxTimer === true ? this.maxTimerValue : undefined,
goldMultiplier:
@@ -770,7 +792,9 @@ export class HostLobbyModal extends BaseModal {
? this.goldMultiplierValue
: undefined,
startingGold:
this.startingGold === true ? this.startingGoldValue : undefined,
this.startingGold === true && this.startingGoldValue !== undefined
? Math.round(this.startingGoldValue * 1_000_000)
: undefined,
} satisfies Partial<GameConfig>,
},
bubbles: true,
@@ -823,14 +847,14 @@ export class HostLobbyModal extends BaseModal {
const manifest = await mapData.manifest();
// Only update if the map hasn't changed
if (this.selectedMap === currentMap) {
this.nationCount = manifest.nations.length;
this.defaultNationCount = manifest.nations.length;
this.nations = this.compactMap
? Math.max(0, Math.floor(manifest.nations.length * 0.25))
: manifest.nations.length;
}
} catch (error) {
console.warn("Failed to load nation count", error);
// Only update if the map hasn't changed
if (this.selectedMap === currentMap) {
this.nationCount = 0;
}
// Leave existing values unchanged so the UI stays consistent
}
}
}
+8 -5
View File
@@ -1,8 +1,9 @@
import { EventBus, GameEvent } from "../core/EventBus";
import { UnitType } from "../core/game/Game";
import { PlayerBuildableUnitType, UnitType } from "../core/game/Game";
import { UnitView } from "../core/game/GameView";
import { UserSettings } from "../core/game/UserSettings";
import { UIState } from "./graphics/UIState";
import { Platform } from "./Platform";
import { ReplaySpeedMultiplier } from "./utilities/ReplaySpeedMultiplier";
export class MouseUpEvent implements GameEvent {
@@ -82,11 +83,13 @@ export class RefreshGraphicsEvent implements GameEvent {}
export class TogglePerformanceOverlayEvent implements GameEvent {}
export class ToggleStructureEvent implements GameEvent {
constructor(public readonly structureTypes: UnitType[] | null) {}
constructor(
public readonly structureTypes: PlayerBuildableUnitType[] | null,
) {}
}
export class GhostStructureChangedEvent implements GameEvent {
constructor(public readonly ghostStructure: UnitType | null) {}
constructor(public readonly ghostStructure: PlayerBuildableUnitType | null) {}
}
export class SwapRocketDirectionEvent implements GameEvent {
@@ -202,7 +205,7 @@ export class InputHandler {
}
// Mac users might have different keybinds
const isMac = /Mac/.test(navigator.userAgent);
const isMac = Platform.isMac;
this.keybinds = {
toggleView: "Space",
@@ -608,7 +611,7 @@ export class InputHandler {
this.eventBus.emit(new ContextMenuEvent(event.clientX, event.clientY));
}
private setGhostStructure(ghostStructure: UnitType | null) {
private setGhostStructure(ghostStructure: PlayerBuildableUnitType | null) {
this.uiState.ghostStructure = ghostStructure;
this.eventBus.emit(new GhostStructureChangedEvent(ghostStructure));
}
+9 -13
View File
@@ -19,12 +19,7 @@ import {
PublicGameInfo,
} from "../core/Schemas";
import { getServerConfigFromClient } from "../core/configuration/ConfigLoader";
import {
GameMapSize,
GameMode,
GameType,
HumansVsNations,
} from "../core/game/Game";
import { GameMode, GameType, HumansVsNations } from "../core/game/Game";
import { getApiBase } from "./Api";
import { crazyGamesSDK } from "./CrazyGamesSDK";
import { JoinLobbyEvent } from "./Main";
@@ -34,6 +29,7 @@ import "./components/CopyButton";
import "./components/LobbyConfigItem";
import "./components/LobbyPlayerView";
import { modalHeader } from "./components/ui/ModalHeader";
import { nationsConfigToSlider } from "./utilities/GameConfigHelpers";
@customElement("join-lobby-modal")
export class JoinLobbyModal extends BaseModal {
@@ -134,11 +130,10 @@ export class JoinLobbyModal extends BaseModal {
.lobbyCreatorClientID=${hostClientID}
.currentClientID=${this.currentClientID}
.teamCount=${this.gameConfig?.playerTeams ?? 2}
.nationCount=${this.nationCount}
.disableNations=${this.gameConfig?.disableNations ??
false}
.isCompactMap=${this.gameConfig?.gameMapSize ===
GameMapSize.Compact}
.nationCount=${nationsConfigToSlider(
this.gameConfig?.nations ?? "default",
this.nationCount,
)}
></lobby-player-view>
`
: ""}
@@ -437,9 +432,10 @@ export class JoinLobbyModal extends BaseModal {
(m) => html`
<lobby-config-item
.label=${translateText(m.labelKey)}
.value=${m.value !== undefined
.value=${m.formattedValue ??
(m.value !== undefined
? renderNumber(m.value)
: translateText("common.enabled")}
: translateText("common.enabled"))}
></lobby-config-item>
`,
)}
+3 -2
View File
@@ -349,15 +349,16 @@ export class LangSelector extends LitElement {
id="lang-selector"
title="Change Language"
@click=${this.openModal}
class="border-none bg-none cursor-pointer p-0 flex items-center justify-center"
class="border-none bg-none cursor-pointer p-0 flex items-center justify-center transition-transform duration-200 hover:scale-[1.1] active:scale-[0.9]"
style="width: 28px; height: 28px;"
>
<img
id="lang-flag"
class="object-contain hover:scale-110 transition-transform duration-200"
class="object-contain pointer-events-none"
style="width: 28px; height: 28px;"
src="/flags/${currentLang.svg}.svg"
alt="flag"
draggable="false"
/>
</button>
`;
+5 -3
View File
@@ -1,3 +1,5 @@
import { Platform } from "./Platform";
export function initLayout() {
// Wait for play-page component to render before setting up hamburger menu
customElements.whenDefined("play-page").then(() => {
@@ -6,7 +8,7 @@ export function initLayout() {
const backdrop = document.getElementById("mobile-menu-backdrop");
// Force sidebar visibility style to ensure it's not hidden by other CSS
if (sidebar && window.innerWidth < 768) {
if (sidebar && Platform.isMobileWidth) {
sidebar.style.display = "flex";
}
@@ -59,7 +61,7 @@ export function initLayout() {
// Close menu when clicking a menu link or button (Mobile only)
sidebar.addEventListener("click", (e) => {
// On desktop, we want the menu to stay open unless explicitly toggled
if (window.innerWidth >= 768) return;
if (!Platform.isMobileWidth) return;
// If the click happened on or inside an anchor/button/menu item, close the menu
const clickedElement = (e.target as Element).closest
@@ -75,7 +77,7 @@ export function initLayout() {
// Close on Escape (Mobile only)
document.addEventListener("keydown", (e) => {
if (window.innerWidth >= 768) return;
if (!Platform.isMobileWidth) return;
if (e.key === "Escape" && sidebar.classList.contains("open")) {
closeMenu();
}
+8 -2
View File
@@ -832,10 +832,13 @@ class Client {
if (window.location.hash === "" || window.location.hash === "#") {
history.replaceState(null, "", window.location.origin + "#refresh");
}
const lobbyIdHidden = !this.userSettings.lobbyIdVisibility();
history.pushState(
null,
"",
`/${config.workerPath(lobby.gameID)}/game/${lobby.gameID}?live`,
lobbyIdHidden
? "/streamer-mode"
: `/${config.workerPath(lobby.gameID)}/game/${lobby.gameID}?live`,
);
// Store current URL for popstate confirmation
@@ -848,7 +851,10 @@ class Client {
lobbyId: string,
config: Awaited<ReturnType<typeof getServerConfigFromClient>>,
) {
const targetUrl = `/${config.workerPath(lobbyId)}/game/${lobbyId}`;
const lobbyIdHidden = !this.userSettings.lobbyIdVisibility();
const targetUrl = lobbyIdHidden
? "/streamer-mode"
: `/${config.workerPath(lobbyId)}/game/${lobbyId}`;
const currentUrl = window.location.pathname;
if (currentUrl !== targetUrl) {
+7
View File
@@ -106,6 +106,13 @@ export function initNavigation() {
) as any;
if (openModal && typeof openModal.close === "function") {
// Check confirmation guard before closing
if (
typeof openModal.confirmBeforeClose === "function" &&
!openModal.confirmBeforeClose()
) {
return;
}
// Call leaveLobby or closeAndLeave first if it exists (for lobby modals)
if (typeof openModal.leaveLobby === "function") {
openModal.leaveLobby();
+1 -1
View File
@@ -130,7 +130,7 @@ export class PatternInput extends LitElement {
<span
class=${showSelect
? "hidden"
: "w-full h-full overflow-hidden flex items-center justify-center [&>img]:object-cover [&>img]:w-full [&>img]:h-full"}
: "w-full h-full overflow-hidden flex items-center justify-center [&>img]:object-cover [&>img]:w-full [&>img]:h-full [&>img]:pointer-events-none"}
>
${!showSelect ? previewContent : null}
</span>
+112
View File
@@ -0,0 +1,112 @@
export const Platform = (() => {
const isBrowser =
typeof window !== "undefined" && typeof navigator !== "undefined";
const normalizePlatform = (platform: string): string => {
const normalized = platform.toLowerCase();
if (normalized.includes("windows")) return "Windows";
if (
normalized.includes("iphone") ||
normalized.includes("ipad") ||
normalized.includes("ipod") ||
normalized.includes("ios")
) {
return "iOS";
}
if (
normalized.includes("mac") ||
normalized.includes("macintosh") ||
normalized.includes("macos")
) {
return "macOS";
}
if (normalized.includes("android")) return "Android";
if (normalized.includes("chrome os")) return "Linux";
if (normalized.includes("linux")) return "Linux";
return "Unknown";
};
// OS Extraction
const extractOS = (): string => {
if (!isBrowser) return "Unknown";
const uaData = (navigator as any).userAgentData;
if (uaData?.platform) {
return normalizePlatform(uaData.platform);
}
const ua = navigator.userAgent;
if (/windows nt/i.test(ua)) return "Windows";
if (/iphone|ipad|ipod/i.test(ua)) return "iOS";
if (
/mac os x/i.test(ua) &&
((navigator.maxTouchPoints ?? 0) > 1 || /ipad/i.test(ua))
) {
return "iOS";
}
if (/mac os x/i.test(ua)) return "macOS";
if (/android/i.test(ua)) return "Android";
if (/linux/i.test(ua)) return "Linux";
return "Unknown";
};
const currentOS = extractOS();
// Environment Extraction
const performElectronCheck = (): boolean => {
// Renderer process
if (
typeof window !== "undefined" &&
typeof (window as any).process === "object" &&
(window as any).process.type === "renderer"
) {
return true;
}
// Main process
if (
typeof process !== "undefined" &&
typeof process.versions === "object" &&
!!process.versions.electron
) {
return true;
}
// Detect the user agent when the `nodeIntegration` option is set to false
if (
isBrowser &&
typeof navigator.userAgent === "string" &&
navigator.userAgent.indexOf("Electron") >= 0
) {
return true;
}
return false;
};
const isMac = currentOS === "macOS";
return {
os: currentOS,
isMac,
isWindows: currentOS === "Windows",
isIOS: currentOS === "iOS",
isAndroid: currentOS === "Android",
isLinux: currentOS === "Linux",
isElectron: performElectronCheck(),
get isMobileWidth(): boolean {
return isBrowser ? window.innerWidth < 768 : false;
},
get isTabletWidth(): boolean {
return isBrowser
? window.innerWidth >= 768 && window.innerWidth < 1024
: false;
},
get isDesktopWidth(): boolean {
return isBrowser ? window.innerWidth >= 1024 : false;
},
};
})();
+83 -40
View File
@@ -8,7 +8,6 @@ import {
GameMapType,
GameMode,
GameType,
HumansVsNations,
UnitType,
} from "../core/game/Game";
import { TeamCountConfig } from "../core/Schemas";
@@ -26,18 +25,21 @@ import { JoinLobbyEvent } from "./Main";
import { UsernameInput } from "./UsernameInput";
import {
getBotsForCompactMap,
getNationsForCompactMap,
getRandomMapType,
getUpdatedDisabledUnits,
parseBoundedFloatFromInput,
parseBoundedIntegerFromInput,
preventDisallowedKeys,
sliderToNationsConfig,
toOptionalNumber,
} from "./utilities/GameConfigHelpers";
import { terrainMapFileLoader } from "./TerrainMapFileLoader";
const DEFAULT_OPTIONS = {
selectedMap: GameMapType.World,
selectedDifficulty: Difficulty.Easy,
disableNations: false,
bots: 400,
infiniteGold: false,
infiniteTroops: false,
@@ -61,7 +63,8 @@ export class SinglePlayerModal extends BaseModal {
@state() private selectedMap: GameMapType = DEFAULT_OPTIONS.selectedMap;
@state() private selectedDifficulty: Difficulty =
DEFAULT_OPTIONS.selectedDifficulty;
@state() private disableNations: boolean = DEFAULT_OPTIONS.disableNations;
@state() private nations: number = 0;
@state() private defaultNationCount: number = 0;
@state() private bots: number = DEFAULT_OPTIONS.bots;
@state() private infiniteGold: boolean = DEFAULT_OPTIONS.infiniteGold;
@state() private infiniteTroops: boolean = DEFAULT_OPTIONS.infiniteTroops;
@@ -88,12 +91,15 @@ export class SinglePlayerModal extends BaseModal {
...DEFAULT_OPTIONS.disabledUnits,
];
private mapLoader = terrainMapFileLoader;
connectedCallback() {
super.connectedCallback();
document.addEventListener(
"userMeResponse",
this.handleUserMeResponse as EventListener,
);
void this.loadNationCount();
}
disconnectedCallback() {
@@ -204,18 +210,18 @@ export class SinglePlayerModal extends BaseModal {
.labelKey=${"single_modal.starting_gold"}
.checked=${this.startingGold}
.inputId=${"starting-gold-value"}
.inputMin=${0}
.inputMax=${1000000000}
.inputStep=${100000}
.inputMin=${0.1}
.inputMax=${1000}
.inputStep=${"any"}
.inputValue=${this.startingGoldValue}
.inputAriaLabel=${translateText("single_modal.starting_gold")}
.inputPlaceholder=${translateText(
"single_modal.starting_gold_placeholder",
)}
.defaultInputValue=${5000000}
.minValidOnEnable=${0}
.defaultInputValue=${5}
.minValidOnEnable=${0.1}
.onToggle=${this.handleStartingGoldToggle}
.onInput=${this.handleStartingGoldValueChanges}
.onChange=${this.handleStartingGoldValueChanges}
.onKeyDown=${this.handleStartingGoldValueKeyDown}
></toggle-input-card>`,
];
@@ -265,7 +271,7 @@ export class SinglePlayerModal extends BaseModal {
},
difficulty: {
selected: this.selectedDifficulty,
disabled: this.disableNations,
disabled: this.nations === 0,
},
gameMode: {
selected: this.gameMode,
@@ -280,14 +286,13 @@ export class SinglePlayerModal extends BaseModal {
labelKey: "single_modal.bots",
disabledKey: "single_modal.bots_disabled",
},
nations: {
value: this.nations,
defaultValue: this.defaultNationCount,
labelKey: "single_modal.nations",
disabledKey: "single_modal.nations_disabled",
},
toggles: [
{
labelKey: "single_modal.disable_nations",
checked: this.disableNations,
hidden:
this.gameMode === GameMode.Team &&
this.teamCount === HumansVsNations,
},
{
labelKey: "single_modal.instant_build",
checked: this.instantBuild,
@@ -322,6 +327,7 @@ export class SinglePlayerModal extends BaseModal {
@game-mode-selected=${this.handleConfigGameModeSelected}
@team-count-selected=${this.handleConfigTeamCountSelected}
@bots-changed=${this.handleBotsChange}
@nations-changed=${this.handleNationsChange}
@option-toggle-changed=${this.handleConfigOptionToggleChanged}
@unit-toggle-changed=${this.handleConfigUnitToggleChanged}
></game-config-settings>
@@ -366,7 +372,7 @@ export class SinglePlayerModal extends BaseModal {
// Check if any options other than map and difficulty have been changed from defaults
private hasOptionsChanged(): boolean {
return (
this.disableNations !== DEFAULT_OPTIONS.disableNations ||
this.nations !== this.defaultNationCount ||
this.bots !== DEFAULT_OPTIONS.bots ||
this.infiniteGold !== DEFAULT_OPTIONS.infiniteGold ||
this.infiniteTroops !== DEFAULT_OPTIONS.infiniteTroops ||
@@ -387,8 +393,9 @@ export class SinglePlayerModal extends BaseModal {
this.selectedDifficulty = DEFAULT_OPTIONS.selectedDifficulty;
this.gameMode = DEFAULT_OPTIONS.gameMode;
this.useRandomMap = DEFAULT_OPTIONS.useRandomMap;
this.disableNations = DEFAULT_OPTIONS.disableNations;
this.bots = DEFAULT_OPTIONS.bots;
this.nations = 0;
this.defaultNationCount = 0;
this.infiniteGold = DEFAULT_OPTIONS.infiniteGold;
this.infiniteTroops = DEFAULT_OPTIONS.infiniteTroops;
this.compactMap = DEFAULT_OPTIONS.compactMap;
@@ -404,8 +411,14 @@ export class SinglePlayerModal extends BaseModal {
this.startingGoldValue = DEFAULT_OPTIONS.startingGoldValue;
}
protected onOpen(): void {
void this.loadNationCount();
}
private handleSelectRandomMap() {
this.useRandomMap = true;
this.selectedMap = getRandomMapType();
void this.loadNationCount();
}
private handleConfigRandomMapSelected = () => {
@@ -415,6 +428,7 @@ export class SinglePlayerModal extends BaseModal {
private handleMapSelection(value: GameMapType) {
this.selectedMap = value;
this.useRandomMap = false;
void this.loadNationCount();
}
private handleConfigMapSelected = (e: Event) => {
@@ -444,6 +458,11 @@ export class SinglePlayerModal extends BaseModal {
private handleCompactMapChange(val: boolean) {
this.compactMap = val;
this.bots = getBotsForCompactMap(this.bots, val);
this.nations = getNationsForCompactMap(
this.nations,
this.defaultNationCount,
val,
);
}
private handleConfigOptionToggleChanged = (e: Event) => {
@@ -454,9 +473,6 @@ export class SinglePlayerModal extends BaseModal {
const { labelKey, checked } = customEvent.detail;
switch (labelKey) {
case "single_modal.disable_nations":
this.disableNations = checked;
break;
case "single_modal.instant_build":
this.instantBuild = checked;
break;
@@ -496,6 +512,15 @@ export class SinglePlayerModal extends BaseModal {
this.bots = value;
};
private handleNationsChange = (e: Event) => {
const customEvent = e as CustomEvent<{ value: number }>;
const value = customEvent.detail.value;
if (isNaN(value) || value < 0 || value > 400) {
return;
}
this.nations = value;
};
private handleMaxTimerToggle = (
checked: boolean,
value: number | string | undefined,
@@ -566,12 +591,17 @@ export class SinglePlayerModal extends BaseModal {
private handleStartingGoldValueChanges = (e: Event) => {
const input = e.target as HTMLInputElement;
const value = parseBoundedIntegerFromInput(input, {
min: 0,
max: 1000000000,
const value = parseBoundedFloatFromInput(input, {
min: 0.1,
max: 1000,
});
this.startingGoldValue = value;
if (value === undefined) {
this.startingGoldValue = undefined;
input.value = "";
} else {
this.startingGoldValue = value;
}
};
private handleGameModeSelection(value: GameMode) {
@@ -604,11 +634,6 @@ export class SinglePlayerModal extends BaseModal {
finalMaxTimerValue = Math.max(1, Math.min(120, this.maxTimerValue));
}
// If random map is selected, choose a random map now
if (this.useRandomMap) {
this.selectedMap = getRandomMapType();
}
console.log(
`Starting single player game with map: ${GameMapType[this.selectedMap as keyof typeof GameMapType]}${this.useRandomMap ? " (Randomly selected)" : ""}`,
);
@@ -657,19 +682,19 @@ export class SinglePlayerModal extends BaseModal {
disabledUnits: this.disabledUnits
.map((u) => Object.values(UnitType).find((ut) => ut === u))
.filter((ut): ut is UnitType => ut !== undefined),
...(this.gameMode === GameMode.Team &&
this.teamCount === HumansVsNations
? {
disableNations: false,
}
: {
disableNations: this.disableNations,
}),
nations: sliderToNationsConfig(
this.nations,
this.defaultNationCount,
),
...(this.goldMultiplier && this.goldMultiplierValue
? { goldMultiplier: this.goldMultiplierValue }
: {}),
...(this.startingGold && this.startingGoldValue !== undefined
? { startingGold: this.startingGoldValue }
? {
startingGold: Math.round(
this.startingGoldValue * 1_000_000,
),
}
: {}),
},
lobbyCreatedAt: Date.now(), // ms; server should be authoritative in MP
@@ -682,4 +707,22 @@ export class SinglePlayerModal extends BaseModal {
);
this.close();
}
private async loadNationCount() {
const currentMap = this.selectedMap;
try {
const mapData = this.mapLoader.getMapData(currentMap);
const manifest = await mapData.manifest();
// Only update if the map hasn't changed
if (this.selectedMap === currentMap) {
this.defaultNationCount = manifest.nations.length;
this.nations = this.compactMap
? Math.max(0, Math.floor(manifest.nations.length * 0.25))
: manifest.nations.length;
}
} catch (error) {
console.warn("Failed to load nation count", error);
// Leave existing values unchanged so the UI stays consistent
}
}
}
+2 -2
View File
@@ -11,14 +11,14 @@ import "./components/baseComponents/setting/SettingToggle";
import { BaseModal } from "./components/BaseModal";
import { modalHeader } from "./components/ui/ModalHeader";
import "./FlagInputModal";
import { Platform } from "./Platform";
interface FlagInputModalElement extends HTMLElement {
open(): void;
returnTo?: string;
}
const isMac =
typeof navigator !== "undefined" && /Mac/.test(navigator.userAgent);
const isMac = Platform.isMac;
const DefaultKeybinds: Record<string, string> = {
toggleView: "Space",
+31 -13
View File
@@ -6,10 +6,12 @@ import {
MessageType,
PublicGameModifiers,
Quads,
Team,
Trios,
} from "../core/game/Game";
import { GameConfig } from "../core/Schemas";
import type { LangSelector } from "./LangSelector";
import { Platform } from "./Platform";
export const TUTORIAL_VIDEO_URL = "https://www.youtube.com/embed/EN2oOog3pSs";
@@ -109,8 +111,12 @@ export interface ModifierInfo {
labelKey: string;
/** Translation key for badge/short label (e.g. "public_game_modifier.random_spawn") */
badgeKey: string;
/** Parameters to pass to translateText for the badge key */
badgeParams?: Record<string, string | number>;
/** The raw value if applicable (e.g. startingGold amount) */
value?: number;
/** Pre-formatted display string (used instead of renderNumber when provided) */
formattedValue?: string;
}
/**
@@ -139,11 +145,24 @@ export function getActiveModifiers(
badgeKey: "public_game_modifier.crowded",
});
}
if (modifiers.isHardNations) {
result.push({
labelKey: "host_modal.hard_nations",
badgeKey: "public_game_modifier.hard_nations",
});
}
if (modifiers.startingGold) {
const millions = parseFloat(
(modifiers.startingGold / 1_000_000).toPrecision(12),
);
result.push({
labelKey: "host_modal.starting_gold",
badgeKey: "public_game_modifier.starting_gold",
badgeParams: {
amount: millions,
},
value: modifiers.startingGold,
formattedValue: `${millions}M`,
});
}
return result;
@@ -155,7 +174,9 @@ export function getActiveModifiers(
export function getModifierLabels(
modifiers: PublicGameModifiers | undefined,
): string[] {
return getActiveModifiers(modifiers).map((m) => translateText(m.badgeKey));
return getActiveModifiers(modifiers).map((m) =>
translateText(m.badgeKey, m.badgeParams),
);
}
export function renderDuration(totalSeconds: number): string {
@@ -394,6 +415,13 @@ export const translateText = (
}
};
export function getTranslatedPlayerTeamLabel(team: Team | null): string {
if (!team) return "";
const translationKey = `team_colors.${team.toLowerCase()}`;
const translated = translateText(translationKey);
return translated === translationKey ? team : translated;
}
/**
* Severity colors mapping for message types
*/
@@ -450,21 +478,11 @@ export function getMessageTypeClasses(type: MessageType): string {
}
export function getModifierKey(): string {
const isMac = /Mac/.test(navigator.userAgent);
if (isMac) {
return "⌘"; // Command key
} else {
return "Ctrl";
}
return Platform.isMac ? "⌘" : "Ctrl";
}
export function getAltKey(): string {
const isMac = /Mac/.test(navigator.userAgent);
if (isMac) {
return "⌥"; // Option key
} else {
return "Alt";
}
return Platform.isMac ? "⌥" : "Alt";
}
export function getGamesPlayed(): number {
+17
View File
@@ -39,6 +39,11 @@ export abstract class BaseModal extends LitElement {
if (this.modalEl) {
this.modalEl.onClose = () => {
if (this.isModalOpen) {
if (!this.confirmBeforeClose()) {
// Re-open the underlying o-modal since it already closed itself
this.modalEl?.open();
return;
}
this.close();
}
};
@@ -57,6 +62,9 @@ export abstract class BaseModal extends LitElement {
private handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape" && this.isModalOpen) {
e.preventDefault();
if (!this.confirmBeforeClose()) {
return;
}
this.close();
}
};
@@ -93,6 +101,15 @@ export abstract class BaseModal extends LitElement {
// Default implementation does nothing
}
/**
* Guard called before closing via Escape key or click-outside.
* Override in subclasses to show a confirmation dialog.
* Return false to prevent the modal from closing.
*/
public confirmBeforeClose(): boolean {
return true;
}
/**
* Open the modal. Handles both inline and modal element modes.
* Subclasses can override onOpen() for custom behavior.
+1 -1
View File
@@ -49,7 +49,7 @@ export class DesktopNavBar extends LitElement {
return html`
<nav
class="hidden lg:flex w-full bg-slate-900 items-center justify-center gap-8 py-4 shrink-0 z-50 relative"
class="hidden lg:flex w-full bg-zinc-900/90 backdrop-blur-md items-center justify-center gap-8 py-4 shrink-0 z-50 relative"
>
<div class="flex flex-col items-center justify-center">
<div class="h-8 text-[#2563eb]">
+10 -1
View File
@@ -14,6 +14,8 @@ export class FluentSlider extends LitElement {
@property({ type: Number }) step = 1;
@property({ type: String }) labelKey = "";
@property({ type: String }) disabledKey = "";
@property({ type: Number }) defaultValue: number | undefined = undefined;
@property({ type: String }) defaultLabelKey = "";
@state() private isEditing = false;
@@ -131,7 +133,14 @@ export class FluentSlider extends LitElement {
>
${this.value === 0 && this.disabledKey
? translateText(this.disabledKey)
: this.value}
: this.defaultValue !== undefined &&
this.value === this.defaultValue &&
this.defaultLabelKey
? html`${this.value}
<span class="text-white/40 uppercase"
>(${translateText(this.defaultLabelKey)})</span
>`
: this.value}
</span>`}
</div>
</div>
+11 -7
View File
@@ -10,9 +10,9 @@ export class Footer extends LitElement {
render() {
return html`
<footer
class="[.in-game_&]:hidden bg-slate-950/70 backdrop-blur-md flex flex-col items-center justify-center gap-2 pt-[2px] pb-2 text-white/50 w-full border-t border-white/10 shrink-0 mt-auto"
class="[.in-game_&]:hidden bg-zinc-900/90 backdrop-blur-md flex flex-col items-center justify-center gap-1 pt-1 pb-3 text-white/50 w-full border-t border-white/10 shrink-0 mt-auto relative z-50"
>
<div class="flex items-center justify-center gap-6 pt-2">
<div class="flex items-center justify-center gap-4 lg:gap-6 pt-2">
<a
href="https://github.com/openfrontio/OpenFrontIO"
target="_blank"
@@ -22,7 +22,8 @@ export class Footer extends LitElement {
<img
src="/icons/github-mark-white.svg"
data-i18n-alt="news.github_link"
class="h-7 w-7 object-contain"
class="h-6 w-6 lg:h-7 lg:w-7 object-contain pointer-events-none"
draggable="false"
/>
</a>
<a
@@ -32,7 +33,7 @@ export class Footer extends LitElement {
class="opacity-60 hover:opacity-100 hover:scale-110 transition-all"
>
<svg
class="h-7 w-7 object-contain"
class="h-6 w-6 lg:h-7 lg:w-7 object-contain pointer-events-none"
viewBox="0 0 24 24"
fill="white"
xmlns="http://www.w3.org/2000/svg"
@@ -49,7 +50,7 @@ export class Footer extends LitElement {
class="opacity-60 hover:opacity-100 hover:scale-110 transition-all"
>
<svg
class="h-7 w-7 object-contain"
class="h-6 w-6 lg:h-7 lg:w-7 object-contain pointer-events-none"
viewBox="0 0 24 24"
fill="white"
>
@@ -67,11 +68,14 @@ export class Footer extends LitElement {
<img
src="/icons/wiki-logo.svg"
data-i18n-alt="main.wiki"
class="h-7 w-7 object-contain"
class="h-6 w-6 lg:h-7 lg:w-7 object-contain pointer-events-none"
draggable="false"
/>
</a>
</div>
<div class="text-xs mt-2 flex items-center justify-center gap-4">
<div
class="text-xs mt-1 lg:mt-2 flex items-center justify-center gap-4 px-4"
>
<a
href="/terms-of-service.html"
data-i18n="main.terms_of_service"
@@ -186,6 +186,13 @@ export interface GameConfigSettingsData {
labelKey: string;
disabledKey: string;
};
nations?: {
value: number;
defaultValue?: number;
labelKey: string;
disabledKey: string;
hidden?: boolean;
};
toggles: ToggleOptionConfig[];
inputCards: TemplateResult[];
};
@@ -246,6 +253,11 @@ export class GameConfigSettings extends LitElement {
this.emit("bots-changed", customEvent.detail);
};
private handleNationsChanged = (event: Event) => {
const customEvent = event as CustomEvent<{ value: number }>;
this.emit("nations-changed", customEvent.detail);
};
private handleUnitToggle = (unit: UnitType, checked: boolean) => {
this.emit("unit-toggle-changed", { unit, checked });
};
@@ -423,6 +435,26 @@ export class GameConfigSettings extends LitElement {
></fluent-slider>
</div>
${settings.options.nations && !settings.options.nations.hidden
? html`<div
class="col-span-2 rounded-xl p-4 flex flex-col justify-center border transition-all duration-200 ${settings
.options.nations.value > 0
? ACTIVE_CARD
: INACTIVE_CARD}"
>
<fluent-slider
min="0"
max="400"
step="1"
.value=${settings.options.nations.value}
.defaultValue=${settings.options.nations.defaultValue}
defaultLabelKey="common.map_default"
labelKey=${settings.options.nations.labelKey}
disabledKey=${settings.options.nations.disabledKey}
@value-changed=${this.handleNationsChanged}
></fluent-slider>
</div>`
: nothing}
${settings.options.toggles.map((toggle) =>
this.renderOptionToggle(toggle),
)}
+12 -34
View File
@@ -13,12 +13,11 @@ import {
Team,
Trios,
} from "../../core/game/Game";
import { getCompactMapNationCount } from "../../core/game/NationCreation";
import { assignTeamsLobbyPreview } from "../../core/game/TeamAssignment";
import { UserSettings } from "../../core/game/UserSettings";
import { ClientInfo, TeamCountConfig } from "../../core/Schemas";
import { createRandomName } from "../../core/Util";
import { translateText } from "../Utils";
import { getTranslatedPlayerTeamLabel, translateText } from "../Utils";
export interface TeamPreviewData {
team: Team;
@@ -36,8 +35,6 @@ export class LobbyTeamView extends LitElement {
@property({ attribute: "team-count" }) teamCount: TeamCountConfig = 2;
@property({ type: Function }) onKickPlayer?: (clientID: string) => void;
@property({ type: Number }) nationCount: number = 0;
@property({ type: Boolean }) disableNations: boolean = false;
@property({ type: Boolean }) isCompactMap: boolean = false;
private theme: PastelTheme = new PastelTheme();
@state() private showTeamColors: boolean = false;
@@ -50,9 +47,7 @@ export class LobbyTeamView extends LitElement {
changedProperties.has("gameMode") ||
changedProperties.has("clients") ||
changedProperties.has("teamCount") ||
changedProperties.has("nationCount") ||
changedProperties.has("disableNations") ||
changedProperties.has("isCompactMap")
changedProperties.has("nationCount")
) {
const teamsList = this.getTeamList();
this.computeTeamPreview(teamsList);
@@ -72,8 +67,8 @@ export class LobbyTeamView extends LitElement {
? translateText("host_modal.player")
: translateText("host_modal.players")}
<span style="margin: 0 8px;">•</span>
${this.getEffectiveNationCount()}
${this.getEffectiveNationCount() === 1
${this.nationCount}
${this.nationCount === 1
? translateText("host_modal.nation_player")
: translateText("host_modal.nation_players")}
</div>
@@ -182,17 +177,18 @@ export class LobbyTeamView extends LitElement {
}
private renderTeamCard(preview: TeamPreviewData, isEmpty: boolean = false) {
const effectiveNationCount = this.getEffectiveNationCount();
const displayCount =
preview.team === ColoredTeams.Nations
? effectiveNationCount
? this.nationCount
: preview.players.length;
const maxTeamSize =
preview.team === ColoredTeams.Nations
? effectiveNationCount
? this.nationCount
: this.teamMaxSize;
const teamLabel = getTranslatedPlayerTeamLabel(preview.team);
return html`
<div class="bg-gray-800 border border-gray-700 rounded-xl flex flex-col">
<div
@@ -204,7 +200,7 @@ export class LobbyTeamView extends LitElement {
style="--bg:${this.teamHeaderColor(preview.team)};"
></span>`
: null}
<span class="truncate">${preview.team}</span>
<span class="truncate">${teamLabel}</span>
<span class="text-white/90">${displayCount}/${maxTeamSize}</span>
</div>
<div class="p-2 ${isEmpty ? "" : "flex flex-col gap-1.5"}">
@@ -249,7 +245,7 @@ export class LobbyTeamView extends LitElement {
private getTeamList(): Team[] {
if (this.gameMode !== GameMode.Team) return [];
const playerCount = this.clients.length + this.getEffectiveNationCount();
const playerCount = this.clients.length + this.nationCount;
const config = this.teamCount;
if (config === HumansVsNations) {
@@ -313,7 +309,7 @@ export class LobbyTeamView extends LitElement {
const assignment = assignTeamsLobbyPreview(
players,
teams,
this.getEffectiveNationCount(),
this.nationCount,
);
const buckets = new Map<Team, ClientInfo[]>();
for (const t of teams) buckets.set(t, []);
@@ -337,9 +333,7 @@ export class LobbyTeamView extends LitElement {
// Fallback: divide players across teams; guard against 0 and empty lobbies
this.teamMaxSize = Math.max(
1,
Math.ceil(
(this.clients.length + this.getEffectiveNationCount()) / teams.length,
),
Math.ceil((this.clients.length + this.nationCount) / teams.length),
);
}
this.teamPreview = teams.map((t) => ({
@@ -348,22 +342,6 @@ export class LobbyTeamView extends LitElement {
}));
}
/**
* Returns the effective nation count for display purposes.
* In HumansVsNations mode, this equals the number of human players.
* For compact maps, only 25% of nations are used.
* Otherwise, it uses the manifest nation count (or 0 if nations are disabled).
*/
private getEffectiveNationCount(): number {
if (this.disableNations) {
return 0;
}
if (this.gameMode === GameMode.Team && this.teamCount === HumansVsNations) {
return this.clients.length;
}
return getCompactMapNationCount(this.nationCount, this.isCompactMap);
}
private displayUsername(client: ClientInfo): string {
if (!this.userSettings.anonymousNames()) {
return client.username;
+2 -1
View File
@@ -169,7 +169,8 @@ export function renderPatternPreview(
return html`<img
src="${generatePreviewDataUrl(pattern, width, height)}"
alt="Pattern preview"
class="w-full h-full object-contain [image-rendering:pixelated]"
class="w-full h-full object-contain [image-rendering:pixelated] pointer-events-none"
draggable="false"
/>`;
}
+10 -6
View File
@@ -11,7 +11,7 @@ export class PlayPage extends LitElement {
return html`
<div
id="page-play"
class="flex flex-col gap-2 w-full lg:max-w-6xl mx-auto px-0 lg:px-4 lg:my-auto min-h-0"
class="flex flex-col gap-2 w-full px-0 lg:px-4 lg:my-auto min-h-0"
>
<token-login class="absolute"></token-login>
@@ -100,13 +100,16 @@ export class PlayPage extends LitElement {
</div>
<div
class="w-full pb-4 lg:pb-0 flex flex-col gap-0 lg:grid lg:grid-cols-12 lg:gap-2"
class="w-full pb-4 lg:pb-0 flex flex-col gap-4 lg:grid lg:grid-cols-[2fr_1fr] lg:gap-4"
>
<!-- Mobile: spacer for fixed top bar -->
<div class="lg:hidden h-[calc(env(safe-area-inset-top)+56px)]"></div>
<div
class="px-2 py-2 bg-[color-mix(in_oklab,var(--frenchBlue)_75%,black)] border-y border-white/10 overflow-visible lg:col-span-9 lg:flex lg:items-center lg:gap-x-2 lg:h-[60px] lg:p-3 lg:relative lg:z-20 lg:border-y-0 lg:rounded-xl"
class="lg:hidden h-[calc(env(safe-area-inset-top)+56px)] lg:col-span-2 -mb-4"
></div>
<!-- Username: left col -->
<div
class="px-2 py-2 bg-[color-mix(in_oklab,var(--frenchBlue)_75%,black)] border-y border-white/10 overflow-visible lg:flex lg:items-center lg:gap-x-2 lg:h-[60px] lg:p-3 lg:relative lg:z-20 lg:border-y-0 lg:rounded-xl"
>
<div class="flex items-center gap-2 min-w-0 w-full">
<username-input
@@ -121,7 +124,8 @@ export class PlayPage extends LitElement {
</div>
</div>
<div class="hidden lg:flex lg:col-span-3 h-[60px] gap-2">
<!-- Skin + flag: right col -->
<div class="hidden lg:flex h-[60px] gap-2">
<pattern-input
id="pattern-input-desktop"
show-select-label
+1 -1
View File
@@ -93,7 +93,7 @@ export class MapPicker extends LitElement {
private renderFeaturedMaps() {
let featuredMapList = featuredMaps;
if (!featuredMapList.includes(this.selectedMap)) {
if (!this.useRandomMap && !featuredMapList.includes(this.selectedMap)) {
featuredMapList = [this.selectedMap, ...featuredMaps];
}
return html`<div class="w-full">
+2 -2
View File
@@ -1,4 +1,4 @@
import { AllPlayers, nukeTypes } from "../../core/game/Game";
import { AllPlayers, Nukes } from "../../core/game/Game";
import { GameView, PlayerView } from "../../core/game/GameView";
import allianceIcon from "/images/AllianceIcon.svg?url";
import allianceIconFaded from "/images/AllianceIconFaded.svg?url";
@@ -134,7 +134,7 @@ export function getPlayerIcons(
}
// Nuke icon (different color depending on whether the local player is the target)
const nukesSentByOtherPlayer = game.units(...nukeTypes).filter((unit) => {
const nukesSentByOtherPlayer = game.units(...Nukes.types).filter((unit) => {
const isSendingNuke = player.id() === unit.owner().id();
const notMyPlayer = !myPlayer || unit.owner().id() !== myPlayer.id();
return isSendingNuke && notMyPlayer && unit.isActive();
+2 -2
View File
@@ -1,9 +1,9 @@
import { UnitType } from "../../core/game/Game";
import { PlayerBuildableUnitType } from "../../core/game/Game";
import { TileRef } from "../../core/game/GameMap";
export interface UIState {
attackRatio: number;
ghostStructure: UnitType | null;
ghostStructure: PlayerBuildableUnitType | null;
overlappingRailroads: number[];
ghostRailPaths: TileRef[][];
rocketDirectionUp: boolean;
+13 -17
View File
@@ -1,11 +1,12 @@
import { LitElement, css, html } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, state } from "lit/decorators.js";
import { translateText } from "../../../client/Utils";
import { EventBus } from "../../../core/EventBus";
import {
BuildableUnit,
BuildMenus,
Gold,
PlayerActions,
PlayerBuildableUnitType,
UnitType,
} from "../../../core/game/Game";
import { TileRef } from "../../../core/game/GameMap";
@@ -37,7 +38,7 @@ import samlauncherIcon from "/images/SamLauncherIconWhite.svg?url";
import shieldIcon from "/images/ShieldIconWhite.svg?url";
export interface BuildItemDisplay {
unitType: UnitType;
unitType: PlayerBuildableUnitType;
icon: string;
description?: string;
key?: string;
@@ -127,7 +128,7 @@ export class BuildMenu extends LitElement implements Layer {
public eventBus: EventBus;
public uiState: UIState;
private clickedTile: TileRef;
public playerActions: PlayerActions | null = null;
public playerBuildables: BuildableUnit[] | null = null;
private filteredBuildTable: BuildItemDisplay[][] = buildTable;
public transformHandler: TransformHandler;
@@ -358,20 +359,15 @@ export class BuildMenu extends LitElement implements Layer {
private _hidden = true;
public canBuildOrUpgrade(item: BuildItemDisplay): boolean {
if (this.game?.myPlayer() === null || this.playerActions === null) {
if (this.game?.myPlayer() === null || this.playerBuildables === null) {
return false;
}
const unit = this.playerActions.buildableUnits.filter(
(u) => u.type === item.unitType,
);
if (unit.length === 0) {
return false;
}
return unit[0].canBuild !== false || unit[0].canUpgrade !== false;
const unit = this.playerBuildables.find((u) => u.type === item.unitType);
return unit ? unit.canBuild !== false || unit.canUpgrade !== false : false;
}
public cost(item: BuildItemDisplay): Gold {
for (const bu of this.playerActions?.buildableUnits ?? []) {
for (const bu of this.playerBuildables ?? []) {
if (bu.type === item.unitType) {
return bu.cost;
}
@@ -419,7 +415,7 @@ export class BuildMenu extends LitElement implements Layer {
(row) => html`
<div class="build-row">
${row.map((item) => {
const buildableUnit = this.playerActions?.buildableUnits.find(
const buildableUnit = this.playerBuildables?.find(
(bu) => bu.type === item.unitType,
);
if (buildableUnit === undefined) {
@@ -492,9 +488,9 @@ export class BuildMenu extends LitElement implements Layer {
private refresh() {
this.game
.myPlayer()
?.actions(this.clickedTile)
.then((actions) => {
this.playerActions = actions;
?.buildables(this.clickedTile, BuildMenus.types)
.then((buildables) => {
this.playerBuildables = buildables;
this.requestUpdate();
});
+7 -12
View File
@@ -2,9 +2,10 @@ import { Colord } from "colord";
import { html, LitElement } from "lit";
import { customElement, state } from "lit/decorators.js";
import { EventBus } from "../../../core/EventBus";
import { GameMode } from "../../../core/game/Game";
import { GameMode, Team } from "../../../core/game/Game";
import { GameView } from "../../../core/game/GameView";
import { translateText } from "../../Utils";
import { Platform } from "../../Platform";
import { getTranslatedPlayerTeamLabel, translateText } from "../../Utils";
import { ImmunityBarVisibleEvent } from "./ImmunityTimer";
import { Layer } from "./Layer";
import { SpawnBarVisibleEvent } from "./SpawnTimer";
@@ -24,7 +25,7 @@ export class GameLeftSidebar extends LitElement implements Layer {
@state()
private isPlayerTeamLabelVisible = false;
@state()
private playerTeam: string | null = null;
private playerTeam: Team | null = null;
@state()
private spawnBarVisible = false;
@state()
@@ -51,7 +52,7 @@ export class GameLeftSidebar extends LitElement implements Layer {
this.isPlayerTeamLabelVisible = true;
}
// Make it visible by default on large screens
if (window.innerWidth >= 1024) {
if (Platform.isDesktopWidth) {
// lg breakpoint
this._shownOnInit = true;
}
@@ -98,13 +99,6 @@ export class GameLeftSidebar extends LitElement implements Layer {
return this.game?.config().gameConfig().gameMode === GameMode.Team;
}
private getTranslatedPlayerTeamLabel(): string {
if (!this.playerTeam) return "";
const translationKey = `team_colors.${this.playerTeam.toLowerCase()}`;
const translated = translateText(translationKey);
return translated === translationKey ? this.playerTeam : translated;
}
render() {
return html`
<aside
@@ -179,7 +173,8 @@ export class GameLeftSidebar extends LitElement implements Layer {
style="--color: ${this.playerColor.toRgbString()}"
class="text-(--color)"
>
&nbsp;${this.getTranslatedPlayerTeamLabel()} &#10687;
&nbsp;${getTranslatedPlayerTeamLabel(this.playerTeam)}
&#10687;
</span>
</div>
`
+1 -1
View File
@@ -112,7 +112,7 @@ export class MainRadialMenu extends LitElement implements Layer {
screenX: number | null = null,
screenY: number | null = null,
) {
this.buildMenu.playerActions = actions;
this.buildMenu.playerBuildables = actions.buildableUnits;
const tileOwner = this.game.owner(tile);
const recipient = tileOwner.isPlayer() ? (tileOwner as PlayerView) : null;
@@ -26,7 +26,7 @@ export class NukeTrajectoryPreviewLayer implements Layer {
private lastTrajectoryUpdate: number = 0;
private lastTargetTile: TileRef | null = null;
private currentGhostStructure: UnitType | null = null;
// Cache spawn tile to avoid expensive player.actions() calls
// Cache spawn tile to avoid expensive player.buildables() calls
private cachedSpawnTile: TileRef | null = null;
constructor(
@@ -75,7 +75,7 @@ export class NukeTrajectoryPreviewLayer implements Layer {
}
/**
* Update trajectory preview - called from tick() to cache spawn tile via expensive player.actions() call
* Update trajectory preview - called from tick() to cache spawn tile via expensive player.buildables() call
* This only runs when target tile changes, minimizing worker thread communication
*/
private updateTrajectoryPreview() {
@@ -138,14 +138,14 @@ export class NukeTrajectoryPreviewLayer implements Layer {
// Get buildable units to find spawn tile (expensive call - only on tick when tile changes)
player
.actions(targetTile, [ghostStructure])
.then((actions) => {
.buildables(targetTile, [ghostStructure])
.then((buildables) => {
// Ignore stale results if target changed
if (this.lastTargetTile !== targetTile) {
return;
}
const buildableUnit = actions.buildableUnits.find(
const buildableUnit = buildables.find(
(bu) => bu.type === ghostStructure,
);
@@ -171,7 +171,7 @@ export class NukeTrajectoryPreviewLayer implements Layer {
/**
* Update trajectory path - called from renderLayer() each frame for smooth visual feedback
* Uses cached spawn tile to avoid expensive player.actions() calls
* Uses cached spawn tile to avoid expensive player.buildables() calls
*/
private updateTrajectoryPath() {
const ghostStructure = this.currentGhostStructure;
+181 -111
View File
@@ -6,6 +6,7 @@ import {
TickMetricsEvent,
TogglePerformanceOverlayEvent,
} from "../../InputHandler";
import type { LangSelector } from "../../LangSelector";
import { translateText } from "../../Utils";
import { FrameProfiler } from "../FrameProfiler";
import { Layer } from "./Layer";
@@ -72,11 +73,15 @@ export class PerformanceOverlay extends LitElement implements Layer {
private frameCount: number = 0;
private lastTime: number = 0;
private frameTimes: number[] = [];
private frameTimesSum: number = 0;
private fpsHistory: number[] = [];
private fpsHistorySum: number = 0;
private lastSecondTime: number = 0;
private framesThisSecond: number = 0;
private tickExecutionTimes: number[] = [];
private tickExecutionTimesSum: number = 0;
private tickDelayTimes: number[] = [];
private tickDelayTimesSum: number = 0;
private tickTimestamps: number[] = [];
private tickHead1s: number = 0;
private tickHead60s: number = 0;
@@ -101,28 +106,12 @@ export class PerformanceOverlay extends LitElement implements Layer {
{ avg: number; max: number; last: number; total: number }
> = new Map();
@state()
private layerBreakdown: {
name: string;
avg: number;
max: number;
total: number;
}[] = [];
// Smoothed per-layer tick timings (EMA over recent ticks)
private tickLayerStats: Map<
string,
{ avg: number; max: number; last: number; total: number }
> = new Map();
@state()
private tickLayerBreakdown: {
name: string;
avg: number;
max: number;
total: number;
}[] = [];
@state()
private tickLayerLastCount: number = 0;
@@ -147,6 +136,113 @@ export class PerformanceOverlay extends LitElement implements Layer {
{ avg: number; max: number; last: number; total: number }
> = new Map();
private langSelector: LangSelector | null = null;
private uiTextLang: string | null = null;
private uiTextTranslationsRef: Record<string, string> | undefined = undefined;
private uiTextDefaultTranslationsRef: Record<string, string> | undefined =
undefined;
private uiText: {
copied: string;
failedCopy: string;
copyClipboard: string;
reset: string;
copyJsonTitle: string;
fps: string;
avg60s: string;
frame: string;
tps: string;
tpsAvg60s: string;
tickExec: string;
maxLabel: string;
tickDelay: string;
layersHeader: string;
tickLayersHeader: string;
collapse: string;
expand: string;
renderLayersTableHeader: string;
tickLayersTableHeader: string;
} = {
copied: "performance_overlay.copied",
failedCopy: "performance_overlay.failed_copy",
copyClipboard: "performance_overlay.copy_clipboard",
reset: "performance_overlay.reset",
copyJsonTitle: "performance_overlay.copy_json_title",
fps: "performance_overlay.fps",
avg60s: "performance_overlay.avg_60s",
frame: "performance_overlay.frame",
tps: "performance_overlay.tps",
tpsAvg60s: "performance_overlay.tps_avg_60s",
tickExec: "performance_overlay.tick_exec",
maxLabel: "performance_overlay.max_label",
tickDelay: "performance_overlay.tick_delay",
layersHeader: "performance_overlay.layers_header",
tickLayersHeader: "performance_overlay.tick_layers_header",
collapse: "performance_overlay.collapse",
expand: "performance_overlay.expand",
renderLayersTableHeader: "performance_overlay.render_layers_table_header",
tickLayersTableHeader: "performance_overlay.tick_layers_table_header",
};
private ensureUiText() {
const selector =
this.langSelector && this.langSelector.isConnected
? this.langSelector
: (document.querySelector("lang-selector") as LangSelector | null);
this.langSelector = selector;
const lang = selector?.currentLang ?? null;
const translationsRef = selector?.translations;
const defaultTranslationsRef = selector?.defaultTranslations;
if (
lang === this.uiTextLang &&
translationsRef === this.uiTextTranslationsRef &&
defaultTranslationsRef === this.uiTextDefaultTranslationsRef
) {
return;
}
this.uiTextLang = lang;
this.uiTextTranslationsRef = translationsRef;
this.uiTextDefaultTranslationsRef = defaultTranslationsRef;
this.uiText = {
copied: translateText("performance_overlay.copied"),
failedCopy: translateText("performance_overlay.failed_copy"),
copyClipboard: translateText("performance_overlay.copy_clipboard"),
reset: translateText("performance_overlay.reset"),
copyJsonTitle: translateText("performance_overlay.copy_json_title"),
fps: translateText("performance_overlay.fps"),
avg60s: translateText("performance_overlay.avg_60s"),
frame: translateText("performance_overlay.frame"),
tps: translateText("performance_overlay.tps"),
tpsAvg60s: translateText("performance_overlay.tps_avg_60s"),
tickExec: translateText("performance_overlay.tick_exec"),
maxLabel: translateText("performance_overlay.max_label"),
tickDelay: translateText("performance_overlay.tick_delay"),
layersHeader: translateText("performance_overlay.layers_header"),
tickLayersHeader: translateText("performance_overlay.tick_layers_header"),
collapse: translateText("performance_overlay.collapse"),
expand: translateText("performance_overlay.expand"),
renderLayersTableHeader: translateText(
"performance_overlay.render_layers_table_header",
),
tickLayersTableHeader: translateText(
"performance_overlay.tick_layers_table_header",
),
};
}
private static computeLayerBreakdown(
stats: Map<
string,
{ avg: number; max: number; last: number; total: number }
>,
): { name: string; avg: number; max: number; total: number }[] {
return Array.from(stats.entries())
.map(([name, s]) => ({ name, avg: s.avg, max: s.max, total: s.total }))
.sort((a, b) => b.total - a.total);
}
static styles = css`
.performance-overlay {
position: fixed;
@@ -481,8 +577,6 @@ export class PerformanceOverlay extends LitElement implements Layer {
this.dragState = null;
this.isDragging = false;
}
this.requestUpdate();
}
private handleClose() {
@@ -513,8 +607,6 @@ export class PerformanceOverlay extends LitElement implements Layer {
),
y: Math.max(margin, Math.min(viewportHeight - 100, newY)),
};
this.requestUpdate();
};
private onDragPointerUp = (e: PointerEvent) => {
@@ -526,7 +618,6 @@ export class PerformanceOverlay extends LitElement implements Layer {
this.dragState = null;
this.isDragging = false;
this.requestUpdate();
};
private handleDragPointerDown = (e: PointerEvent) => {
@@ -576,7 +667,6 @@ export class PerformanceOverlay extends LitElement implements Layer {
this.overlayWidthPx = this.resizeState.pendingWidthPx;
this.resizeState = null;
this.requestUpdate();
};
private handleResizePointerDown = (e: PointerEvent) => {
@@ -605,7 +695,9 @@ export class PerformanceOverlay extends LitElement implements Layer {
this.frameCount = 0;
this.lastTime = 0;
this.frameTimes = [];
this.frameTimesSum = 0;
this.fpsHistory = [];
this.fpsHistorySum = 0;
this.lastSecondTime = 0;
this.framesThisSecond = 0;
this.currentFPS = 0;
@@ -615,6 +707,8 @@ export class PerformanceOverlay extends LitElement implements Layer {
// reset tick metrics
this.tickExecutionTimes = [];
this.tickDelayTimes = [];
this.tickExecutionTimesSum = 0;
this.tickDelayTimesSum = 0;
this.tickExecutionAvg = 0;
this.tickExecutionMax = 0;
this.tickDelayAvg = 0;
@@ -627,11 +721,9 @@ export class PerformanceOverlay extends LitElement implements Layer {
// reset layer breakdown
this.layerStats.clear();
this.layerBreakdown = [];
// reset tick layer breakdown
this.tickLayerStats.clear();
this.tickLayerBreakdown = [];
this.tickLayerLastCount = 0;
this.tickLayerLastTotalMs = 0;
this.tickLayerLastDurations = {};
@@ -641,8 +733,6 @@ export class PerformanceOverlay extends LitElement implements Layer {
this.renderPerTickLayerStats.clear();
this.renderLayersExpanded = false;
this.tickLayersExpanded = false;
this.requestUpdate();
};
private toggleRenderLayersExpanded = (e: Event) => {
@@ -676,14 +766,15 @@ export class PerformanceOverlay extends LitElement implements Layer {
// Track frame times for current FPS calculation (last 60 frames)
this.frameTimes.push(deltaTime);
this.frameTimesSum += deltaTime;
if (this.frameTimes.length > 60) {
this.frameTimes.shift();
const removed = this.frameTimes.shift();
if (removed !== undefined) this.frameTimesSum -= removed;
}
// 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;
const avgFrameTime = this.frameTimesSum / this.frameTimes.length;
this.currentFPS = Math.round(1000 / avgFrameTime);
this.frameTime = Math.round(avgFrameTime);
}
@@ -694,14 +785,16 @@ export class PerformanceOverlay extends LitElement implements Layer {
// Update every second
if (now - this.lastSecondTime >= 1000) {
this.fpsHistory.push(this.framesThisSecond);
this.fpsHistorySum += this.framesThisSecond;
if (this.fpsHistory.length > 60) {
this.fpsHistory.shift();
const removed = this.fpsHistory.shift();
if (removed !== undefined) this.fpsHistorySum -= removed;
}
// 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.fpsHistorySum / this.fpsHistory.length,
);
}
@@ -715,8 +808,6 @@ export class PerformanceOverlay extends LitElement implements Layer {
if (layerDurations) {
this.updateLayerStats(layerDurations);
}
this.requestUpdate();
}
private updateLayerStats(layerDurations: Record<string, number>) {
@@ -738,18 +829,6 @@ export class PerformanceOverlay extends LitElement implements Layer {
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;
}
updateRenderPerTickMetrics(
@@ -761,7 +840,7 @@ export class PerformanceOverlay extends LitElement implements Layer {
const alpha = 0.2; // smoothing factor for EMA
this.renderLastTickFrameCount = frameCount;
this.renderLastTickLayerDurations = { ...layerDurations };
this.renderLastTickLayerDurations = layerDurations;
this.renderLastTickLayerTotalMs = Object.values(layerDurations).reduce(
(acc, ms) => acc + ms,
0,
@@ -798,7 +877,7 @@ export class PerformanceOverlay extends LitElement implements Layer {
const entries = Object.entries(tickLayerDurations);
this.tickLayerLastCount = entries.length;
this.tickLayerLastDurations = { ...tickLayerDurations };
this.tickLayerLastDurations = tickLayerDurations;
this.tickLayerLastTotalMs = entries.reduce((acc, [, duration]) => {
return acc + duration;
}, 0);
@@ -819,17 +898,6 @@ export class PerformanceOverlay extends LitElement implements Layer {
this.tickLayerStats.set(name, { avg, max, last: duration, total });
}
});
const breakdown = Array.from(this.tickLayerStats.entries())
.map(([name, stats]) => ({
name,
avg: stats.avg,
max: stats.max,
total: stats.total,
}))
.sort((a, b) => b.total - a.total);
this.tickLayerBreakdown = breakdown;
}
updateTickMetrics(tickExecutionDuration?: number, tickDelay?: number) {
@@ -869,38 +937,38 @@ export class PerformanceOverlay extends LitElement implements Layer {
// Update tick execution duration stats
if (tickExecutionDuration !== undefined) {
this.tickExecutionTimes.push(tickExecutionDuration);
this.tickExecutionTimesSum += tickExecutionDuration;
if (this.tickExecutionTimes.length > 60) {
this.tickExecutionTimes.shift();
const removed = this.tickExecutionTimes.shift();
if (removed !== undefined) this.tickExecutionTimesSum -= removed;
}
if (this.tickExecutionTimes.length > 0) {
const avg =
this.tickExecutionTimes.reduce((a, b) => a + b, 0) /
this.tickExecutionTimes.length;
const avg = this.tickExecutionTimesSum / this.tickExecutionTimes.length;
this.tickExecutionAvg = Math.round(avg * 100) / 100;
this.tickExecutionMax = Math.round(
Math.max(...this.tickExecutionTimes),
);
let max = 0;
for (const v of this.tickExecutionTimes) max = Math.max(max, v);
this.tickExecutionMax = Math.round(max);
}
}
// Update tick delay stats
if (tickDelay !== undefined) {
this.tickDelayTimes.push(tickDelay);
this.tickDelayTimesSum += tickDelay;
if (this.tickDelayTimes.length > 60) {
this.tickDelayTimes.shift();
const removed = this.tickDelayTimes.shift();
if (removed !== undefined) this.tickDelayTimesSum -= removed;
}
if (this.tickDelayTimes.length > 0) {
const avg =
this.tickDelayTimes.reduce((a, b) => a + b, 0) /
this.tickDelayTimes.length;
const avg = this.tickDelayTimesSum / this.tickDelayTimes.length;
this.tickDelayAvg = Math.round(avg * 100) / 100;
this.tickDelayMax = Math.round(Math.max(...this.tickDelayTimes));
let max = 0;
for (const v of this.tickDelayTimes) max = Math.max(max, v);
this.tickDelayMax = Math.round(max);
}
}
this.requestUpdate();
}
shouldTransform(): boolean {
@@ -945,8 +1013,12 @@ export class PerformanceOverlay extends LitElement implements Layer {
layerTotalMs: this.renderLastTickLayerTotalMs,
layers: { ...this.renderLastTickLayerDurations },
},
layers: this.layerBreakdown.map((layer) => ({ ...layer })),
tickLayers: this.tickLayerBreakdown.map((layer) => ({ ...layer })),
layers: PerformanceOverlay.computeLayerBreakdown(this.layerStats).map(
(layer) => ({ ...layer }),
),
tickLayers: PerformanceOverlay.computeLayerBreakdown(
this.tickLayerStats,
).map((layer) => ({ ...layer })),
};
}
@@ -962,7 +1034,6 @@ export class PerformanceOverlay extends LitElement implements Layer {
this.copyStatusTimeoutId = setTimeout(() => {
this.copyStatus = "idle";
this.copyStatusTimeoutId = null;
this.requestUpdate();
}, 2000);
}
@@ -998,6 +1069,8 @@ export class PerformanceOverlay extends LitElement implements Layer {
return html``;
}
this.ensureUiText();
const margin = 8;
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
@@ -1015,13 +1088,20 @@ export class PerformanceOverlay extends LitElement implements Layer {
const copyLabel =
this.copyStatus === "success"
? translateText("performance_overlay.copied")
? this.uiText.copied
: this.copyStatus === "error"
? translateText("performance_overlay.failed_copy")
: translateText("performance_overlay.copy_clipboard");
? this.uiText.failedCopy
: this.uiText.copyClipboard;
const renderLayersToShow = this.layerBreakdown.slice(0, 10);
const tickLayersToShow = this.tickLayerBreakdown.slice(0, 10);
const renderLayerBreakdown = this.renderLayersExpanded
? PerformanceOverlay.computeLayerBreakdown(this.layerStats)
: [];
const tickLayerBreakdown = this.tickLayersExpanded
? PerformanceOverlay.computeLayerBreakdown(this.tickLayerStats)
: [];
const renderLayersToShow = renderLayerBreakdown.slice(0, 10);
const tickLayersToShow = tickLayerBreakdown.slice(0, 10);
const maxLayerAvg =
renderLayersToShow.length > 0
@@ -1048,12 +1128,12 @@ export class PerformanceOverlay extends LitElement implements Layer {
@pointerdown=${this.handleDragPointerDown}
></div>
<button class="reset-button" @click="${this.handleReset}">
${translateText("performance_overlay.reset")}
${this.uiText.reset}
</button>
<button
class="copy-json-button"
@click="${this.handleCopyJson}"
title="${translateText("performance_overlay.copy_json_title")}"
title="${this.uiText.copyJsonTitle}"
>
${copyLabel}
</button>
@@ -1064,53 +1144,51 @@ export class PerformanceOverlay extends LitElement implements Layer {
></div>
<div class="overlay-scroll">
<div class="performance-line">
${translateText("performance_overlay.fps")}
${this.uiText.fps}
<span class="${this.getPerformanceColor(this.currentFPS)}"
>${this.currentFPS}</span
>
</div>
<div class="performance-line">
${translateText("performance_overlay.avg_60s")}
${this.uiText.avg60s}
<span class="${this.getPerformanceColor(this.averageFPS)}"
>${this.averageFPS}</span
>
</div>
<div class="performance-line">
${translateText("performance_overlay.frame")}
${this.uiText.frame}
<span class="${this.getPerformanceColor(1000 / this.frameTime)}"
>${this.frameTime}ms</span
>
</div>
<div class="performance-line">
${translateText("performance_overlay.tps")}
${this.uiText.tps}
<span class="${this.getTPSColor(this.currentTPS)}"
>${this.currentTPS}</span
>
(${translateText("performance_overlay.tps_avg_60s")}
(${this.uiText.tpsAvg60s}
<span>${this.averageTPS}</span>)
</div>
<div class="performance-line">
${translateText("performance_overlay.tick_exec")}
${this.uiText.tickExec}
<span>${this.tickExecutionAvg.toFixed(2)}ms</span>
(max: <span>${this.tickExecutionMax}ms</span>)
(${this.uiText.maxLabel} <span>${this.tickExecutionMax}ms</span>)
</div>
<div class="performance-line">
${translateText("performance_overlay.tick_delay")}
${this.uiText.tickDelay}
<span>${this.tickDelayAvg.toFixed(2)}ms</span>
(max: <span>${this.tickDelayMax}ms</span>)
(${this.uiText.maxLabel} <span>${this.tickDelayMax}ms</span>)
</div>
${this.layerBreakdown.length
${this.layerStats.size
? html`<div class="layers-section">
<div class="performance-line section-header">
<span
>${translateText("performance_overlay.layers_header")}</span
>
<span>${this.uiText.layersHeader}</span>
<button
class="collapse-button"
@click=${this.toggleRenderLayersExpanded}
title=${this.renderLayersExpanded
? translateText("performance_overlay.collapse")
: translateText("performance_overlay.expand")}
? this.uiText.collapse
: this.uiText.expand}
>
${this.renderLayersExpanded ? "▾" : "▸"}
</button>
@@ -1125,9 +1203,7 @@ export class PerformanceOverlay extends LitElement implements Layer {
? html`<div class="layer-row table-header" style="--pct: 0%;">
<span class="layer-name"></span>
<span class="layer-metrics">
${translateText(
"performance_overlay.render_layers_table_header",
)}
${this.uiText.renderLayersTableHeader}
</span>
</div>
${renderLayersToShow.map((layer) => {
@@ -1161,20 +1237,16 @@ export class PerformanceOverlay extends LitElement implements Layer {
: html``}
</div>`
: html``}
${this.tickLayerBreakdown.length
${this.tickLayerStats.size
? html`<div class="layers-section">
<div class="performance-line section-header">
<span
>${translateText(
"performance_overlay.tick_layers_header",
)}</span
>
<span>${this.uiText.tickLayersHeader}</span>
<button
class="collapse-button"
@click=${this.toggleTickLayersExpanded}
title=${this.tickLayersExpanded
? translateText("performance_overlay.collapse")
: translateText("performance_overlay.expand")}
? this.uiText.collapse
: this.uiText.expand}
>
${this.tickLayersExpanded ? "▾" : "▸"}
</button>
@@ -1189,9 +1261,7 @@ export class PerformanceOverlay extends LitElement implements Layer {
? html`<div class="layer-row table-header" style="--pct: 0%;">
<span class="layer-name"></span>
<span class="layer-metrics">
${translateText(
"performance_overlay.tick_layers_table_header",
)}
${this.uiText.tickLayersTableHeader}
</span>
</div>
${tickLayersToShow.map((layer) => {
@@ -19,6 +19,7 @@ import {
TouchEvent,
} from "../../InputHandler";
import {
getTranslatedPlayerTeamLabel,
renderDuration,
renderNumber,
renderTroops,
@@ -314,6 +315,7 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
playerType = translateText("player_type.player");
break;
}
const playerTeam = getTranslatedPlayerTeamLabel(player.team());
return html`
<div class="flex items-start gap-2 lg:gap-3 p-1.5 lg:p-2">
@@ -357,7 +359,7 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
/>`
: html``}
<span>${player.name()}</span>
${player.team() !== null && player.type() !== PlayerType.Bot
${playerTeam !== "" && player.type() !== PlayerType.Bot
? html`<div class="flex flex-col leading-tight">
<span class="text-gray-400 text-xs font-normal"
>${playerType}</span
@@ -369,7 +371,7 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
.theme()
.teamColor(player.team()!)
.toHex()}"
>${player.team()}</span
>${playerTeam}</span
>]</span
>
</div>`
+3 -6
View File
@@ -121,7 +121,7 @@ export class PlayerPanel extends LitElement implements Layer {
// Refresh actions & alliance expiry
const myPlayer = this.g.myPlayer();
if (myPlayer !== null && myPlayer.isAlive()) {
this.actions = await myPlayer.actions(this.tile);
this.actions = await myPlayer.actions(this.tile, null);
if (this.actions?.interaction?.allianceInfo?.expiresAt !== undefined) {
const expiresAt = this.actions.interaction.allianceInfo.expiresAt;
const remainingTicks = expiresAt - this.g.ticks();
@@ -340,22 +340,19 @@ export class PlayerPanel extends LitElement implements Layer {
case PlayerType.Nation:
return {
labelKey: "player_type.nation",
aria: "Nation player",
classes: "border-indigo-400/25 bg-indigo-500/10 text-indigo-200",
icon: "🏛️",
};
case PlayerType.Bot:
return {
labelKey: "player_type.bot",
aria: "Bot",
classes: "border-purple-400/25 bg-purple-500/10 text-purple-200",
icon: "🤖",
icon: "⚔️",
};
case PlayerType.Human:
default:
return {
labelKey: "player_type.player",
aria: "Human player",
classes: "border-zinc-400/20 bg-zinc-500/5 text-zinc-300",
icon: "👤",
};
@@ -517,7 +514,7 @@ export class PlayerPanel extends LitElement implements Layer {
? html`<span
class=${`inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-xs font-semibold ${chip.classes}`}
role="status"
aria-label=${chip.aria}
aria-label=${translateText(chip.labelKey)}
title=${translateText(chip.labelKey)}
>
<span aria-hidden="true" class="leading-none">${chip.icon}</span>
@@ -1,8 +1,10 @@
import { Config } from "../../../core/configuration/Config";
import {
AllPlayers,
BuildableAttacks,
PlayerActions,
StructureTypes,
PlayerBuildableUnitType,
Structures,
UnitType,
} from "../../../core/game/Game";
import { TileRef } from "../../../core/game/GameMap";
@@ -376,40 +378,34 @@ export const infoMenuElement: MenuElement = {
},
};
function getAllEnabledUnits(myPlayer: boolean, config: Config): Set<UnitType> {
const units: Set<UnitType> = new Set<UnitType>();
function getAllEnabledUnits(
myPlayer: boolean,
config: Config,
): Set<PlayerBuildableUnitType> {
const units: Set<PlayerBuildableUnitType> =
new Set<PlayerBuildableUnitType>();
const addIfEnabled = (unitType: UnitType) => {
const addIfEnabled = (unitType: PlayerBuildableUnitType) => {
if (!config.isUnitDisabled(unitType)) {
units.add(unitType);
}
};
if (myPlayer) {
StructureTypes.forEach(addIfEnabled);
Structures.types.forEach(addIfEnabled);
} else {
addIfEnabled(UnitType.Warship);
addIfEnabled(UnitType.HydrogenBomb);
addIfEnabled(UnitType.MIRV);
addIfEnabled(UnitType.AtomBomb);
BuildableAttacks.types.forEach(addIfEnabled);
}
return units;
}
const ATTACK_UNIT_TYPES: UnitType[] = [
UnitType.AtomBomb,
UnitType.MIRV,
UnitType.HydrogenBomb,
UnitType.Warship,
];
function createMenuElements(
params: MenuElementParams,
filterType: "attack" | "build",
elementIdPrefix: string,
): MenuElement[] {
const unitTypes: Set<UnitType> = getAllEnabledUnits(
const unitTypes: Set<PlayerBuildableUnitType> = getAllEnabledUnits(
params.selected === params.myPlayer,
params.game.config(),
);
@@ -419,8 +415,8 @@ function createMenuElements(
(item) =>
unitTypes.has(item.unitType) &&
(filterType === "attack"
? ATTACK_UNIT_TYPES.includes(item.unitType)
: !ATTACK_UNIT_TYPES.includes(item.unitType)),
? BuildableAttacks.has(item.unitType)
: !BuildableAttacks.has(item.unitType)),
)
.map((item: BuildItemDisplay) => {
const canBuildOrUpgrade = params.buildMenu.canBuildOrUpgrade(item);
@@ -1,6 +1,7 @@
import { LitElement, html } from "lit";
import { customElement, state } from "lit/decorators.js";
import { crazyGamesSDK } from "src/client/CrazyGamesSDK";
import { Platform } from "src/client/Platform";
import { getGamesPlayed } from "src/client/Utils";
import { GameType } from "src/core/game/Game";
import { GameView } from "../../../core/game/GameView";
@@ -21,7 +22,7 @@ export class SpawnVideoAd extends LitElement implements Layer {
init() {
if (
!window.adsEnabled ||
window.innerWidth < 768 ||
Platform.isMobileWidth ||
crazyGamesSDK.isOnCrazyGames() ||
this.game.config().gameConfig().gameType === GameType.Singleplayer ||
getGamesPlayed() < 3 // Don't show to new players
@@ -1,6 +1,10 @@
import * as PIXI from "pixi.js";
import { Theme } from "../../../core/configuration/Config";
import { Cell, UnitType } from "../../../core/game/Game";
import {
Cell,
PlayerBuildableUnitType,
UnitType,
} from "../../../core/game/Game";
import { GameView, PlayerView, UnitView } from "../../../core/game/GameView";
import { TransformHandler } from "../TransformHandler";
import anchorIcon from "/images/AnchorIcon.png?url";
@@ -108,7 +112,7 @@ export class SpriteFactory {
player: PlayerView,
ghostStage: PIXI.Container,
pos: { x: number; y: number },
structureType: UnitType,
structureType: PlayerBuildableUnitType,
): {
container: PIXI.Container;
priceText: PIXI.BitmapText;
@@ -8,7 +8,9 @@ import { wouldNukeBreakAlliance } from "../../../core/execution/Util";
import {
BuildableUnit,
Cell,
PlayerBuildableUnitType,
PlayerID,
Structures,
UnitType,
} from "../../../core/game/Game";
import { TileRef } from "../../../core/game/GameMap";
@@ -77,20 +79,20 @@ export class StructureIconsLayer implements Layer {
private readonly theme: Theme;
private renderer: PIXI.Renderer | null = null;
private rendererInitialized: boolean = false;
private renders: StructureRenderInfo[] = [];
private readonly seenUnits: Set<UnitView> = new Set();
private readonly rendersByUnitId: Map<number, StructureRenderInfo> =
new Map();
private readonly seenUnitIds: Set<number> = new Set();
private readonly connectedAllySmallIds: Set<number> = new Set();
private readonly mousePos = { x: 0, y: 0 };
private renderSprites = true;
private factory: SpriteFactory;
private readonly structures: Map<UnitType, { visible: boolean }> = new Map([
[UnitType.City, { visible: true }],
[UnitType.Factory, { visible: true }],
[UnitType.DefensePost, { visible: true }],
[UnitType.Port, { visible: true }],
[UnitType.MissileSilo, { visible: true }],
[UnitType.SAMLauncher, { visible: true }],
]);
private readonly structures: Map<
PlayerBuildableUnitType,
{ visible: boolean }
> = new Map(Structures.types.map((type) => [type, { visible: true }]));
private lastGhostQueryAt: number;
private visibilityStateDirty = true;
private hasHiddenStructure = false;
potentialUpgrade: StructureRenderInfo | undefined;
constructor(
@@ -185,18 +187,22 @@ export class StructureIconsLayer implements Layer {
}
tick() {
this.game
.updatesSinceLastTick()
?.[GameUpdateType.Unit]?.map((unit) => this.game.unit(unit.id))
?.forEach((unitView) => {
if (unitView === undefined) return;
const unitUpdates = this.game.updatesSinceLastTick()?.[GameUpdateType.Unit];
if (unitUpdates) {
for (let i = 0, len = unitUpdates.length; i < len; i++) {
const unitView = this.game.unit(unitUpdates[i].id);
if (unitView === undefined) {
continue;
}
const unitId = unitView.id();
if (unitView.isActive()) {
this.handleActiveUnit(unitView);
} else if (this.seenUnits.has(unitView)) {
} else if (this.seenUnitIds.has(unitId)) {
this.handleInactiveUnit(unitView);
}
});
}
}
this.renderSprites =
this.game.config().userSettings()?.structureSprites() ?? true;
}
@@ -224,7 +230,7 @@ export class StructureIconsLayer implements Layer {
this.renderGhost();
if (this.transformHandler.hasChanged()) {
for (const render of this.renders) {
for (const render of this.rendersByUnitId.values()) {
this.computeNewLocation(render);
}
}
@@ -269,13 +275,21 @@ export class StructureIconsLayer implements Layer {
(nukeType === UnitType.AtomBomb || nukeType === UnitType.HydrogenBomb)
) {
// Only check connected allies - nuking disconnected allies doesn't cause a traitor debuff
const allies = myPlayer.allies().filter((a) => !a.isDisconnected());
if (allies.length > 0) {
this.connectedAllySmallIds.clear();
const allies = myPlayer.allies();
for (let i = 0; i < allies.length; i++) {
const ally = allies[i];
if (!ally.isDisconnected()) {
this.connectedAllySmallIds.add(ally.smallID());
}
}
if (this.connectedAllySmallIds.size > 0) {
targetingAlly = wouldNukeBreakAlliance({
game: this.game,
targetTile: tileRef,
magnitude: this.game.config().nukeMagnitudes(nukeType),
allySmallIds: new Set(allies.map((a) => a.smallID())),
allySmallIds: this.connectedAllySmallIds,
threshold: this.game.config().nukeAllianceBreakThreshold(),
});
}
@@ -283,8 +297,8 @@ export class StructureIconsLayer implements Layer {
this.game
?.myPlayer()
?.actions(tileRef, [this.ghostUnit?.buildableUnit.type])
.then((actions) => {
?.buildables(tileRef, [this.ghostUnit?.buildableUnit.type])
.then((buildables) => {
if (this.potentialUpgrade) {
this.potentialUpgrade.iconContainer.filters = [];
this.potentialUpgrade.dotContainer.filters = [];
@@ -295,7 +309,7 @@ export class StructureIconsLayer implements Layer {
if (!this.ghostUnit) return;
const unit = actions.buildableUnits.find(
const unit = buildables.find(
(u) => u.type === this.ghostUnit!.buildableUnit.type,
);
const showPrice = this.game.config().userSettings().cursorCostLabel();
@@ -318,11 +332,14 @@ export class StructureIconsLayer implements Layer {
this.updateGhostRange(targetLevel, targetingAlly);
if (unit.canUpgrade) {
this.potentialUpgrade = this.renders.find(
(r) =>
r.unit.id() === unit.canUpgrade &&
r.unit.owner().id() === this.game.myPlayer()?.id(),
);
this.potentialUpgrade = this.rendersByUnitId.get(unit.canUpgrade);
if (
this.potentialUpgrade &&
this.potentialUpgrade.unit.owner().id() !==
this.game.myPlayer()?.id()
) {
this.potentialUpgrade = undefined;
}
if (this.potentialUpgrade) {
this.potentialUpgrade.iconContainer.filters = [
new OutlineFilter({ thickness: 2, color: "rgba(0, 255, 0, 1)" }),
@@ -434,7 +451,7 @@ export class StructureIconsLayer implements Layer {
this.ghostUnit.range?.position.set(localX, localY);
}
private createGhostStructure(type: UnitType | null) {
private createGhostStructure(type: PlayerBuildableUnitType | null) {
const player = this.game.myPlayer();
if (!player) return;
if (type === null) {
@@ -540,25 +557,44 @@ export class StructureIconsLayer implements Layer {
}
}
private toggleStructures(toggleStructureType: UnitType[] | null): void {
private toggleStructures(
toggleStructureType: PlayerBuildableUnitType[] | null,
): void {
for (const [structureType, infos] of this.structures) {
infos.visible =
toggleStructureType?.indexOf(structureType) !== -1 ||
toggleStructureType === null;
}
for (const render of this.renders) {
this.visibilityStateDirty = true;
for (const render of this.rendersByUnitId.values()) {
this.modifyVisibility(render);
}
}
private refreshVisibilityStateCache() {
if (!this.visibilityStateDirty) {
return;
}
this.hasHiddenStructure = false;
for (const infos of this.structures.values()) {
if (infos.visible === false) {
this.hasHiddenStructure = true;
break;
}
}
this.visibilityStateDirty = false;
}
private findRenderByUnit(
unitView: UnitView,
): StructureRenderInfo | undefined {
return this.renders.find((render) => render.unit.id() === unitView.id());
return this.rendersByUnitId.get(unitView.id());
}
private handleActiveUnit(unitView: UnitView) {
if (this.seenUnits.has(unitView)) {
if (this.seenUnitIds.has(unitView.id())) {
const render = this.findRenderByUnit(unitView);
if (render) {
this.checkForConstructionState(render, unitView);
@@ -566,12 +602,18 @@ export class StructureIconsLayer implements Layer {
this.checkForOwnershipChange(render, unitView);
this.checkForLevelChange(render, unitView);
}
} else if (this.structures.has(unitView.type())) {
} else if (
this.structures.has(unitView.type() as PlayerBuildableUnitType)
) {
this.addNewStructure(unitView);
}
}
private handleInactiveUnit(unitView: UnitView) {
if (!this.seenUnitIds.has(unitView.id())) {
return;
}
const render = this.findRenderByUnit(unitView);
if (render) {
this.deleteStructure(render);
@@ -579,20 +621,15 @@ export class StructureIconsLayer implements Layer {
}
private modifyVisibility(render: StructureRenderInfo) {
const structureType = render.unit.type();
this.refreshVisibilityStateCache();
const structureType = render.unit.type() as PlayerBuildableUnitType;
const structureInfos = this.structures.get(structureType);
let focusStructure = false;
for (const infos of this.structures.values()) {
if (infos.visible === false) {
focusStructure = true;
break;
}
}
if (structureInfos) {
render.iconContainer.alpha = structureInfos.visible ? 1 : 0.3;
render.dotContainer.alpha = structureInfos.visible ? 1 : 0.3;
if (structureInfos.visible && focusStructure) {
if (structureInfos.visible && this.hasHiddenStructure) {
render.iconContainer.filters = [
new OutlineFilter({ thickness: 2, color: "rgb(255, 255, 255)" }),
];
@@ -714,7 +751,7 @@ export class StructureIconsLayer implements Layer {
}
private addNewStructure(unitView: UnitView) {
this.seenUnits.add(unitView);
this.seenUnitIds.add(unitView.id());
const render = new StructureRenderInfo(
unitView,
unitView.owner().id(),
@@ -724,7 +761,7 @@ export class StructureIconsLayer implements Layer {
unitView.level(),
unitView.isUnderConstruction(),
);
this.renders.push(render);
this.rendersByUnitId.set(unitView.id(), render);
this.computeNewLocation(render);
this.modifyVisibility(render);
}
@@ -754,7 +791,11 @@ export class StructureIconsLayer implements Layer {
render.iconContainer?.destroy();
render.levelContainer?.destroy();
render.dotContainer?.destroy();
this.renders = this.renders.filter((r) => r.unit !== render.unit);
this.seenUnits.delete(render.unit);
const unitId = render.unit.id();
this.rendersByUnitId.delete(unitId);
this.seenUnitIds.delete(unitId);
if (this.potentialUpgrade?.unit.id() === unitId) {
this.potentialUpgrade = undefined;
}
}
}
+11 -7
View File
@@ -69,14 +69,18 @@ export class TeamStats extends LitElement implements Layer {
}
for (const player of players) {
const team = player.team();
if (team === null) continue;
grouped[team] ??= [];
grouped[team].push(player);
const rawTeam = player.team();
if (rawTeam === null) continue;
grouped[rawTeam] ??= [];
grouped[rawTeam].push(player);
}
this.teams = Object.entries(grouped)
.map(([teamStr, teamPlayers]) => {
.map(([rawTeam, teamPlayers]) => {
const key = `team_colors.${rawTeam.toLowerCase()}`;
const translated = translateText(key);
const teamName = translated !== key ? translated : rawTeam;
let totalGold = 0n;
let totalMaxTroops = 0;
let totalScoreSort = 0;
@@ -102,8 +106,8 @@ export class TeamStats extends LitElement implements Layer {
const totalScorePercent = totalScoreSort / numTilesWithoutFallout;
return {
teamName: teamStr,
isMyTeam: teamStr === this._myTeam,
teamName,
isMyTeam: rawTeam === this._myTeam,
totalScoreStr: formatPercentage(totalScorePercent),
totalScoreSort,
totalGold: renderNumber(totalGold),
+16 -23
View File
@@ -1,7 +1,13 @@
import { LitElement, html } from "lit";
import { html, LitElement } from "lit";
import { customElement } from "lit/decorators.js";
import { EventBus } from "../../../core/EventBus";
import { Gold, PlayerActions, UnitType } from "../../../core/game/Game";
import {
BuildableUnit,
BuildMenus,
Gold,
PlayerBuildableUnitType,
UnitType,
} from "../../../core/game/Game";
import { GameView } from "../../../core/game/GameView";
import {
GhostStructureChangedEvent,
@@ -22,25 +28,12 @@ import portIcon from "/images/PortIcon.svg?url";
import samLauncherIcon from "/images/SamLauncherIconWhite.svg?url";
import defensePostIcon from "/images/ShieldIconWhite.svg?url";
const BUILDABLE_UNITS: UnitType[] = [
UnitType.City,
UnitType.Factory,
UnitType.Port,
UnitType.DefensePost,
UnitType.MissileSilo,
UnitType.SAMLauncher,
UnitType.Warship,
UnitType.AtomBomb,
UnitType.HydrogenBomb,
UnitType.MIRV,
];
@customElement("unit-display")
export class UnitDisplay extends LitElement implements Layer {
public game: GameView;
public eventBus: EventBus;
public uiState: UIState;
private playerActions: PlayerActions | null = null;
private playerBuildables: BuildableUnit[] | null = null;
private keybinds: Record<string, { value: string; key: string }> = {};
private _cities = 0;
private _warships = 0;
@@ -50,7 +43,7 @@ export class UnitDisplay extends LitElement implements Layer {
private _defensePost = 0;
private _samLauncher = 0;
private allDisabled = false;
private _hoveredUnit: UnitType | null = null;
private _hoveredUnit: PlayerBuildableUnitType | null = null;
createRenderRoot() {
return this;
@@ -68,12 +61,12 @@ export class UnitDisplay extends LitElement implements Layer {
}
}
this.allDisabled = BUILDABLE_UNITS.every((u) => config.isUnitDisabled(u));
this.allDisabled = BuildMenus.types.every((u) => config.isUnitDisabled(u));
this.requestUpdate();
}
private cost(item: UnitType): Gold {
for (const bu of this.playerActions?.buildableUnits ?? []) {
for (const bu of this.playerBuildables ?? []) {
if (bu.type === item) {
return bu.cost;
}
@@ -104,10 +97,10 @@ export class UnitDisplay extends LitElement implements Layer {
tick() {
const player = this.game?.myPlayer();
player?.actions(undefined, BUILDABLE_UNITS).then((actions) => {
this.playerActions = actions;
});
if (!player) return;
player.buildables(undefined, BuildMenus.types).then((buildables) => {
this.playerBuildables = buildables;
});
this._cities = player.totalUnitLevels(UnitType.City);
this._missileSilo = player.totalUnitLevels(UnitType.MissileSilo);
this._port = player.totalUnitLevels(UnitType.Port);
@@ -221,7 +214,7 @@ export class UnitDisplay extends LitElement implements Layer {
private renderUnitItem(
icon: string,
number: number | null,
unitType: UnitType,
unitType: PlayerBuildableUnitType,
structureKey: string,
hotkey: string,
) {
+2 -2
View File
@@ -19,6 +19,7 @@ import {
patternRelationship,
} from "../../Cosmetics";
import { crazyGamesSDK } from "../../CrazyGamesSDK";
import { Platform } from "../../Platform";
import { SendWinnerEvent } from "../../Transport";
import { Layer } from "./Layer";
@@ -186,8 +187,7 @@ export class WinModal extends LitElement implements Layer {
// Shuffle the array and take patterns based on screen size
const shuffled = [...purchasablePatterns].sort(() => Math.random() - 0.5);
const isMobile = window.innerWidth < 768; // md breakpoint
const maxPatterns = isMobile ? 1 : 3;
const maxPatterns = Platform.isMobileWidth ? 1 : 3;
const selectedPatterns = shuffled.slice(
0,
Math.min(maxPatterns, shuffled.length),
-4
View File
@@ -32,8 +32,4 @@
.l-header__highlightText {
color: #2563eb;
font-weight: 700;
filter: drop-shadow(1px 1px 0px rgb(255, 255, 255))
drop-shadow(-1px -1px 0px rgb(255, 255, 255))
drop-shadow(1px -1px 0px rgb(255, 255, 255))
drop-shadow(-1px 1px 0px rgb(255, 255, 255));
}
+3 -10
View File
@@ -1,3 +1,5 @@
import { Platform } from "../Platform";
export type RendererType = "Canvas2D" | "WebGL1" | "WebGL2";
export interface BrowserInfo {
@@ -48,7 +50,7 @@ export async function collectGraphicsDiagnostics(
const uaData = (navigator as any).userAgentData;
const os = uaData?.platform ?? detectOS(navigator.userAgent);
const os = Platform.os;
const browser: BrowserInfo = {
engine: uaData?.brands
@@ -130,12 +132,3 @@ export async function collectGraphicsDiagnostics(
power,
};
}
function detectOS(ua: string): string {
if (/windows nt/i.test(ua)) return "Windows";
if (/mac os x/i.test(ua)) return "macOS";
if (/android/i.test(ua)) return "Android";
if (/iphone|ipad|ipod/i.test(ua)) return "iOS";
if (/linux/i.test(ua)) return "Linux";
return "Unknown";
}
+47
View File
@@ -1,4 +1,31 @@
import { GameMapType, UnitType } from "../../core/game/Game";
import { GameConfig } from "../../core/Schemas";
/**
* Maps a slider value (0-400) to the nations config value.
* 0 "disabled", value === defaultNationCount "default", otherwise number.
*/
export function sliderToNationsConfig(
sliderValue: number,
defaultNationCount: number,
): GameConfig["nations"] {
if (sliderValue === 0) return "disabled";
if (sliderValue === defaultNationCount) return "default";
return sliderValue;
}
/**
* Maps a nations config value to a slider-friendly number.
* "disabled" 0, "default" defaultNationCount, number number.
*/
export function nationsConfigToSlider(
nations: GameConfig["nations"],
defaultNationCount: number,
): number {
if (nations === "disabled") return 0;
if (nations === "default") return defaultNationCount;
return nations;
}
export function toOptionalNumber(
value: number | string | undefined,
@@ -76,6 +103,26 @@ export function getBotsForCompactMap(
return bots;
}
export function getNationsForCompactMap(
nations: number,
defaultNationCount: number,
compactMapEnabled: boolean,
): number {
const compactCount = Math.max(0, Math.floor(defaultNationCount * 0.25));
if (compactMapEnabled) {
// Only reduce if at the full default
if (nations === defaultNationCount) {
return compactCount;
}
return nations;
}
// Restoring from compact: if at the compact default, go back to full default
if (nations === compactCount) {
return defaultNationCount;
}
return nations;
}
export function getRandomMapType(): GameMapType {
const maps = Object.values(GameMapType);
const randIdx = Math.floor(Math.random() * maps.length);
+3 -9
View File
@@ -1,13 +1,7 @@
import { z } from "zod";
import { base64urlToUuid } from "./Base64";
import { BigIntStringSchema, PlayerStatsSchema } from "./StatsSchemas";
import {
Difficulty,
GameMapType,
GameMode,
GameType,
RankedType,
} from "./game/Game";
import { Difficulty, GameMode, GameType, RankedType } from "./game/Game";
export const RefreshResponseSchema = z.object({
token: z.string(),
@@ -49,7 +43,7 @@ export const DiscordUserSchema = z.object({
export type DiscordUser = z.infer<typeof DiscordUserSchema>;
const SingleplayerMapAchievementSchema = z.object({
mapName: z.enum(GameMapType),
mapName: z.string(),
difficulty: z.enum(Difficulty),
});
@@ -105,7 +99,7 @@ export const PlayerGameSchema = z.object({
start: z.iso.datetime(),
mode: z.enum(GameMode),
type: z.enum(GameType),
map: z.enum(GameMapType),
map: z.string(),
difficulty: z.enum(Difficulty),
clientId: z.string().optional(),
});
+17 -3
View File
@@ -6,6 +6,7 @@ import { WinCheckExecution } from "./execution/WinCheckExecution";
import {
AllPlayers,
Attack,
BuildableUnit,
Cell,
Game,
GameUpdates,
@@ -13,6 +14,7 @@ import {
Player,
PlayerActions,
PlayerBorderTiles,
PlayerBuildableUnitType,
PlayerID,
PlayerInfo,
PlayerProfile,
@@ -189,18 +191,30 @@ export class GameRunner {
return Math.max(0, this.turns.length - this.currTurn);
}
public playerBuildables(
playerID: PlayerID,
x?: number,
y?: number,
units?: readonly PlayerBuildableUnitType[],
): BuildableUnit[] {
const player = this.game.player(playerID);
const tile =
x !== undefined && y !== undefined ? this.game.ref(x, y) : null;
return player.buildableUnits(tile, units);
}
public playerActions(
playerID: PlayerID,
x?: number,
y?: number,
units?: UnitType[],
units?: readonly PlayerBuildableUnitType[] | null,
): PlayerActions {
const player = this.game.player(playerID);
const tile =
x !== undefined && y !== undefined ? this.game.ref(x, y) : null;
const actions = {
canAttack: tile !== null && units === undefined && player.canAttack(tile),
buildableUnits: player.buildableUnits(tile, units),
canAttack: tile !== null && player.canAttack(tile),
buildableUnits: units === null ? [] : player.buildableUnits(tile, units),
canSendEmojiAllPlayers: player.canSendEmoji(AllPlayers),
canEmbargoAll: player.canEmbargoAll(),
} as PlayerActions;
+7 -1
View File
@@ -215,10 +215,16 @@ export const GameConfigSchema = z.object({
isCompact: z.boolean(),
isRandomSpawn: z.boolean(),
isCrowded: z.boolean(),
isHardNations: z.boolean(),
startingGold: z.number().int().min(0).optional(),
})
.optional(),
disableNations: z.boolean(),
nations: z
.number()
.int()
.min(1)
.max(400)
.or(z.enum(["default", "disabled"])),
bots: z.number().int().min(0).max(400),
infiniteGold: z.boolean(),
infiniteTroops: z.boolean(),
+1 -1
View File
@@ -224,7 +224,7 @@ export class DefaultConfig implements Config {
}
spawnNations(): boolean {
return !this._gameConfig.disableNations;
return this._gameConfig.nations !== "disabled";
}
isUnitDisabled(unitType: UnitType): boolean {
+2 -2
View File
@@ -1,4 +1,4 @@
import { Execution, Game, isStructureType, Player } from "../game/Game";
import { Execution, Game, Player, Structures } from "../game/Game";
import { PseudoRandom } from "../PseudoRandom";
import { simpleHash } from "../Util";
import { AllianceExtensionExecution } from "./alliance/AllianceExtensionExecution";
@@ -85,7 +85,7 @@ export class BotExecution implements Execution {
private deleteAllStructures() {
for (const unit of this.bot.units()) {
if (isStructureType(unit.type()) && this.bot.canDeleteUnit()) {
if (Structures.has(unit.type()) && this.bot.canDeleteUnit()) {
this.mg.addExecution(new DeleteUnitExecution(this.bot, unit.id()));
}
}
+3 -1
View File
@@ -13,7 +13,9 @@ export class BotSpawner {
private gs: Game,
private gameID: GameID,
) {
this.random = new PseudoRandom(simpleHash(gameID));
// Use a different seed than createGameRunner (which uses simpleHash(gameID))
// to avoid bot IDs colliding with nation/human IDs from the same PRNG sequence.
this.random = new PseudoRandom(simpleHash(gameID) + 2);
}
spawnBots(numBots: number): SpawnExecution[] {
+2 -2
View File
@@ -1,9 +1,9 @@
import {
Execution,
Game,
isStructureType,
MessageType,
Player,
Structures,
TerraNullius,
TrajectoryTile,
Unit,
@@ -346,7 +346,7 @@ export class NukeExecution implements Execution {
private redrawBuildings(range: number) {
const rangeSquared = range * range;
for (const unit of this.mg.units()) {
if (isStructureType(unit.type())) {
if (Structures.has(unit.type())) {
if (
this.mg.euclideanDistSquared(this.dst, unit.tile()) < rangeSquared
) {
+2 -2
View File
@@ -3,8 +3,8 @@ import {
Cell,
Execution,
Game,
isStructureType,
Player,
Structures,
UnitType,
} from "../game/Game";
import { TileRef } from "../game/GameMap";
@@ -42,7 +42,7 @@ export class PlayerExecution implements Execution {
tick(ticks: number) {
this.player.decayRelations();
for (const u of this.player.units()) {
if (!isStructureType(u.type())) {
if (!Structures.has(u.type())) {
continue;
}
+3 -3
View File
@@ -1,5 +1,5 @@
import { NukeMagnitude } from "../configuration/Config";
import { Game, Player, StructureTypes } from "../game/Game";
import { Game, Player, Structures } from "../game/Game";
import { euclDistFN, GameMap, TileRef } from "../game/GameMap";
import { GameView } from "../game/GameView";
@@ -60,7 +60,7 @@ export function wouldNukeBreakAlliance(
const wouldDestroyAlliedStructure = game.anyUnitNearby(
targetTile,
magnitude.outer,
StructureTypes,
Structures.types,
(unit) =>
unit.owner().isPlayer() && allySmallIds.has(unit.owner().smallID()),
);
@@ -119,7 +119,7 @@ export function listNukeBreakAlliance(
// Also check if any allied structures would be destroyed
game
.nearbyUnits(targetTile, magnitude.outer, StructureTypes)
.nearbyUnits(targetTile, magnitude.outer, Structures.types)
.forEach(({ unit }) =>
playersToBreakAllianceWith.add(unit.owner().smallID()),
);
+8 -2
View File
@@ -19,6 +19,10 @@ export class WinCheckExecution implements Execution {
private mg: Game | null = null;
// Hard time limit (in seconds) to force a winner before the server's
// maxGameDuration hard kill. 170mins (10 mins before 3hrs)
private static readonly HARD_TIME_LIMIT_SECONDS = 170 * 60;
constructor() {}
init(mg: Game, ticks: number) {
@@ -68,7 +72,8 @@ export class WinCheckExecution implements Execution {
(max.numTilesOwned() / numTilesWithoutFallout) * 100 >
this.mg.config().percentageTilesOwnedToWin() ||
(this.mg.config().gameConfig().maxTimerValue !== undefined &&
timeElapsed - this.mg.config().gameConfig().maxTimerValue! * 60 >= 0)
timeElapsed - this.mg.config().gameConfig().maxTimerValue! * 60 >= 0) ||
timeElapsed >= WinCheckExecution.HARD_TIME_LIMIT_SECONDS
) {
this.mg.setWinner(max, this.mg.stats().stats());
console.log(`${max.name()} has won the game`);
@@ -103,7 +108,8 @@ export class WinCheckExecution implements Execution {
if (
percentage > this.mg.config().percentageTilesOwnedToWin() ||
(this.mg.config().gameConfig().maxTimerValue !== undefined &&
timeElapsed - this.mg.config().gameConfig().maxTimerValue! * 60 >= 0)
timeElapsed - this.mg.config().gameConfig().maxTimerValue! * 60 >= 0) ||
timeElapsed >= WinCheckExecution.HARD_TIME_LIMIT_SECONDS
) {
if (max[0] === ColoredTeams.Bot) return;
this.mg.setWinner(max[0], this.mg.stats().stats());
@@ -28,6 +28,13 @@ export class NationAllianceBehavior {
handleAllianceRequests() {
for (const req of this.player.incomingAllianceRequests()) {
// Alliance Request intents created during the spawn phase are executed on
// the first tick post-spawn phase. With the following condition we reject
// all requests created during the spawn phase.
if (req.createdAt() <= this.game.config().numSpawnPhaseTurns() + 1) {
req.reject();
continue;
}
if (this.getAllianceDecision(req.requestor(), true)) {
req.accept();
} else {
@@ -76,11 +83,6 @@ export class NationAllianceBehavior {
otherPlayer: Player,
isResponse: boolean,
): boolean {
// Reject alliance requests during the spawn phase
if (this.game.inSpawnPhase()) {
return false;
}
// Easy (dumb) nations sometimes get confused and accept/reject randomly (Just like dumb humans do)
if (this.isConfused()) {
return this.random.chance(2);
@@ -5,7 +5,7 @@ import {
Player,
PlayerType,
Relation,
StructureTypes,
Structures,
Unit,
UnitType,
} from "../../game/Game";
@@ -356,7 +356,7 @@ export class NationStructureBehavior {
private getTotalStructureDensity(): number {
const tilesOwned = this.player.numTilesOwned();
return tilesOwned > 0
? this.player.units(...StructureTypes).length / tilesOwned
? this.player.units(...Structures.types).length / tilesOwned
: 0; //ignoring levels for structures
}
+4 -4
View File
@@ -3,11 +3,11 @@ import {
Game,
GameMode,
HumansVsNations,
isStructureType,
Player,
PlayerID,
PlayerType,
Relation,
Structures,
TerraNullius,
UnitType,
} from "../../game/Game";
@@ -361,7 +361,7 @@ export class AiAttackBehavior {
n.isPlayer() &&
n.type() === PlayerType.Bot &&
!this.player.isFriendly(n) &&
n.units().some((u) => isStructureType(u.type())),
n.units().some((u) => Structures.has(u.type())),
);
}
@@ -419,7 +419,7 @@ export class AiAttackBehavior {
const density = (p: Player) => p.troops() / p.numTilesOwned();
const ownsStructures = (p: Player) =>
p.units().some((u) => isStructureType(u.type()));
p.units().some((u) => Structures.has(u.type()));
const sortedBots = bots.slice().sort((a, b) => {
const aHasStructures = ownsStructures(a);
const bHasStructures = ownsStructures(b);
@@ -743,7 +743,7 @@ export class AiAttackBehavior {
const botWithStructures =
target.isPlayer() &&
target.type() === PlayerType.Bot &&
target.units().some((u) => isStructureType(u.type()));
target.units().some((u) => Structures.has(u.type()));
// Use the expand ratio when attacking a bot that owns structures — we need to
// recapture those structures ASAP, even before reaching the normal reserve.
const useReserve = target.isPlayer() && !botWithStructures;
+52 -21
View File
@@ -120,6 +120,7 @@ export enum GameMapType {
Lisbon = "Lisbon",
Manicouagan = "Manicouagan",
Lemnos = "Lemnos",
Passage = "Passage",
Sierpinski = "Sierpinski",
TheBox = "The Box",
TwoLakes = "Two Lakes",
@@ -134,15 +135,12 @@ export enum GameMapType {
TradersDream = "Traders Dream",
Hawaii = "Hawaii",
Alps = "Alps",
NileDelta = "Nile Delta",
Arctic = "Arctic",
}
export type GameMapName = keyof typeof GameMapType;
/** Maps that have unusual thumbnail dimensions requiring object-fit: cover */
export function hasUnusualThumbnailSize(map: GameMapType): boolean {
return map === GameMapType.AmazonRiver;
}
export const mapCategories: Record<string, GameMapType[]> = {
continental: [
GameMapType.World,
@@ -186,6 +184,8 @@ export const mapCategories: Record<string, GameMapType[]> = {
GameMapType.Yenisei,
GameMapType.Hawaii,
GameMapType.Alps,
GameMapType.NileDelta,
GameMapType.Arctic,
],
fantasy: [
GameMapType.Pangaea,
@@ -198,6 +198,7 @@ export const mapCategories: Record<string, GameMapType[]> = {
GameMapType.Svalmel,
GameMapType.Surrounded,
GameMapType.TradersDream,
GameMapType.Passage,
],
arcade: [
GameMapType.TheBox,
@@ -236,6 +237,7 @@ export interface PublicGameModifiers {
isCompact: boolean;
isRandomSpawn: boolean;
isCrowded: boolean;
isHardNations: boolean;
startingGold?: number;
}
@@ -247,6 +249,15 @@ export interface UnitInfo {
upgradable?: boolean;
}
function unitTypeGroup<T extends readonly UnitType[]>(types: T) {
return {
types,
has(type: UnitType): type is T[number] {
return (types as readonly UnitType[]).includes(type);
},
};
}
export enum UnitType {
TransportShip = "Transport",
Warship = "Warship",
@@ -272,20 +283,40 @@ export enum TrainType {
Carriage = "Carriage",
}
const _structureTypes: ReadonlySet<UnitType> = new Set([
export const Nukes = unitTypeGroup([
UnitType.AtomBomb,
UnitType.HydrogenBomb,
UnitType.MIRVWarhead,
UnitType.MIRV,
] as const);
export const BuildableAttacks = unitTypeGroup([
UnitType.AtomBomb,
UnitType.HydrogenBomb,
UnitType.MIRV,
UnitType.Warship,
] as const);
export const Structures = unitTypeGroup([
UnitType.City,
UnitType.DefensePost,
UnitType.SAMLauncher,
UnitType.MissileSilo,
UnitType.Port,
UnitType.Factory,
]);
] as const);
export const StructureTypes: readonly UnitType[] = [..._structureTypes];
export const BuildMenus = unitTypeGroup([
...Structures.types,
...BuildableAttacks.types,
] as const);
export function isStructureType(type: UnitType): boolean {
return _structureTypes.has(type);
}
export const PlayerBuildable = unitTypeGroup([
...BuildMenus.types,
UnitType.TransportShip,
] as const);
export type PlayerBuildableUnitType = (typeof PlayerBuildable.types)[number];
export interface OwnerComp {
owner: Player;
@@ -356,13 +387,6 @@ export type UnitParams<T extends UnitType> = UnitParamsMap[T];
export type AllUnitParams = UnitParamsMap[keyof UnitParamsMap];
export const nukeTypes = [
UnitType.AtomBomb,
UnitType.HydrogenBomb,
UnitType.MIRVWarhead,
UnitType.MIRV,
] as UnitType[];
export enum Relation {
Hostile = 0,
Distrustful = 1,
@@ -641,8 +665,15 @@ export interface Player {
unitCount(type: UnitType): number;
unitsConstructed(type: UnitType): number;
unitsOwned(type: UnitType): number;
buildableUnits(tile: TileRef | null, units?: UnitType[]): BuildableUnit[];
canBuild(type: UnitType, targetTile: TileRef): TileRef | false;
buildableUnits(
tile: TileRef | null,
units?: readonly PlayerBuildableUnitType[],
): BuildableUnit[];
canBuild(
type: UnitType,
targetTile: TileRef,
validTiles?: TileRef[] | null,
): TileRef | false;
buildUnit<T extends UnitType>(
type: T,
spawnTile: TileRef,
@@ -868,7 +899,7 @@ export interface BuildableUnit {
canBuild: TileRef | false;
// unit id of the existing unit that can be upgraded, or false if it cannot be upgraded.
canUpgrade: number | false;
type: UnitType;
type: PlayerBuildableUnitType;
cost: Gold;
overlappingRailroads: number[];
ghostRailPaths: TileRef[][];
+18 -1
View File
@@ -7,6 +7,7 @@ import { ClientID, GameID, Player, PlayerCosmetics } from "../Schemas";
import { createRandomName } from "../Util";
import { WorkerClient } from "../worker/WorkerClient";
import {
BuildableUnit,
Cell,
EmojiMessage,
GameUpdates,
@@ -14,6 +15,7 @@ import {
NameViewData,
PlayerActions,
PlayerBorderTiles,
PlayerBuildableUnitType,
PlayerID,
PlayerProfile,
PlayerType,
@@ -415,7 +417,10 @@ export class PlayerView {
return { hasEmbargo, hasFriendly };
}
async actions(tile?: TileRef, units?: UnitType[]): Promise<PlayerActions> {
async actions(
tile?: TileRef,
units?: readonly PlayerBuildableUnitType[] | null,
): Promise<PlayerActions> {
return this.game.worker.playerInteraction(
this.id(),
tile && this.game.x(tile),
@@ -424,6 +429,18 @@ export class PlayerView {
);
}
async buildables(
tile?: TileRef,
units?: readonly PlayerBuildableUnitType[],
): Promise<BuildableUnit[]> {
return this.game.worker.playerBuildables(
this.id(),
tile && this.game.x(tile),
tile && this.game.y(tile),
units,
);
}
async borderTiles(): Promise<PlayerBorderTiles> {
return this.game.worker.playerBorderTiles(this.id());
}
+56 -35
View File
@@ -13,9 +13,14 @@ import {
import { Nation as ManifestNation } from "./TerrainMapLoader";
/**
* Creates the nations array for a game, handling HumansVsNations mode specially.
* In HumansVsNations mode, the number of nations matches the number of human players to ensure fair gameplay.
* For compact maps, only 25% of the nations are used.
* Creates the nations array for a game.
* If config.nations is a number (custom count), uses that exact count,
* generating additional nations with random names if needed.
* If config.nations is "disabled", returns no nations.
* If config.nations is "default":
* - Public HumansVsNations: matches nation count to human player count
* - Public compact maps: uses 25% of manifest nations
* - Otherwise: uses all manifest nations
*/
export function createNationsForGame(
gameStart: GameStartInfo,
@@ -23,10 +28,6 @@ export function createNationsForGame(
numHumans: number,
random: PseudoRandom,
): Nation[] {
if (gameStart.config.disableNations) {
return [];
}
const toNation = (n: ManifestNation): Nation =>
new Nation(
new Cell(n.coordinates[0], n.coordinates[1]),
@@ -39,38 +40,59 @@ export function createNationsForGame(
gameStart.config.gameMode === GameMode.Team &&
gameStart.config.playerTeams === HumansVsNations;
// For compact maps, use only 25% of nations (minimum 1)
let effectiveNations = manifestNations;
if (isCompactMap && !isHumansVsNations) {
const targetCount = getCompactMapNationCount(manifestNations.length, true);
const shuffled = random.shuffleArray(manifestNations);
effectiveNations = shuffled.slice(0, targetCount);
}
// For non-HumansVsNations modes, simply use the effective nations
if (!isHumansVsNations) {
return effectiveNations.map(toNation);
}
// HumansVsNations mode: balance nation count to match human count
const isSingleplayer = gameStart.config.gameType === GameType.Singleplayer;
const targetNationCount = isSingleplayer ? 1 : numHumans;
if (targetNationCount === 0) {
const configNations = gameStart.config.nations;
if (configNations === "disabled") {
return [];
}
// If we have enough manifest nations, use a subset
if (manifestNations.length >= targetNationCount) {
// Shuffle manifest nations to add variety
const shuffled = random.shuffleArray(manifestNations);
return shuffled.slice(0, targetNationCount).map(toNation);
// If nations count is explicitly set, use that exact count
if (typeof configNations === "number") {
return createRandomNations(
configNations,
manifestNations,
toNation,
random,
);
}
// If we need more nations than defined in manifest, create additional ones
const nations: Nation[] = manifestNations.map(toNation);
if (gameStart.config.gameType === GameType.Public) {
// For HvN, balance nation count to match human count
if (isHumansVsNations) {
return createRandomNations(numHumans, manifestNations, toNation, random);
}
// For compact maps, use only 25% of nations (minimum 1)
if (isCompactMap) {
const targetCount = getCompactMapNationCount(
manifestNations.length,
true,
);
const shuffled = random.shuffleArray(manifestNations);
const slicedNations = shuffled.slice(0, targetCount);
return slicedNations.map(toNation);
}
}
return manifestNations.map(toNation);
}
/**
* Creates the requested number of nations from manifest data.
* If more nations are needed than available in the manifest, generates additional ones with random names.
*/
function createRandomNations(
targetCount: number,
manifestNations: ManifestNation[],
toNation: (n: ManifestNation) => Nation,
random: PseudoRandom,
): Nation[] {
const shuffled = random.shuffleArray(manifestNations);
if (targetCount <= manifestNations.length) {
return shuffled.slice(0, targetCount).map(toNation);
}
// Need more nations than defined in manifest, create additional ones
const nations: Nation[] = shuffled.map(toNation);
const usedNames = new Set(nations.map((n) => n.playerInfo.name));
const additionalCount = targetNationCount - manifestNations.length;
const additionalCount = targetCount - manifestNations.length;
for (let i = 0; i < additionalCount; i++) {
const name = generateUniqueNationName(random, usedNames);
usedNames.add(name);
@@ -81,7 +103,6 @@ export function createNationsForGame(
),
);
}
return nations;
}
+105 -57
View File
@@ -23,16 +23,17 @@ import {
EmojiMessage,
GameMode,
Gold,
isStructureType,
MessageType,
MutableAlliance,
Player,
PlayerBuildable,
PlayerBuildableUnitType,
PlayerID,
PlayerInfo,
PlayerProfile,
PlayerType,
Relation,
StructureTypes,
Structures,
Team,
TerraNullius,
Tick,
@@ -988,6 +989,17 @@ export class PlayerImpl implements Player {
}
public findUnitToUpgrade(type: UnitType, targetTile: TileRef): Unit | false {
const unit = this.findExistingUnitToUpgrade(type, targetTile);
if (unit === false || !this.canUpgradeUnit(unit)) {
return false;
}
return unit;
}
private findExistingUnitToUpgrade(
type: UnitType,
targetTile: TileRef,
): Unit | false {
const range = this.mg.config().structureMinDist();
const existing = this.mg
.nearbyUnits(targetTile, range, type, undefined, true)
@@ -995,29 +1007,35 @@ export class PlayerImpl implements Player {
if (existing.length === 0) {
return false;
}
const unit = existing[0].unit;
if (!this.canUpgradeUnit(unit)) {
return false;
}
return unit;
return existing[0].unit;
}
public canUpgradeUnit(unit: Unit): boolean {
if (unit.isMarkedForDeletion()) {
private canBuildUnitType(
unitType: UnitType,
knownCost: Gold | null = null,
): boolean {
if (this.mg.config().isUnitDisabled(unitType)) {
return false;
}
const cost = knownCost ?? this.mg.unitInfo(unitType).cost(this.mg, this);
if (this._gold < cost) {
return false;
}
if (unitType !== UnitType.MIRVWarhead && !this.isAlive()) {
return false;
}
return true;
}
private canUpgradeUnitType(unitType: UnitType): boolean {
return Boolean(this.mg.config().unitInfo(unitType).upgradable);
}
private isUnitValidToUpgrade(unit: Unit): boolean {
if (unit.isUnderConstruction()) {
return false;
}
if (!this.mg.config().unitInfo(unit.type()).upgradable) {
return false;
}
if (this.mg.config().isUnitDisabled(unit.type())) {
return false;
}
if (
this._gold < this.mg.config().unitInfo(unit.type()).cost(this.mg, this)
) {
if (unit.isMarkedForDeletion()) {
return false;
}
if (unit.owner() !== this) {
@@ -1026,6 +1044,19 @@ export class PlayerImpl implements Player {
return true;
}
public canUpgradeUnit(unit: Unit): boolean {
if (!this.canUpgradeUnitType(unit.type())) {
return false;
}
if (!this.canBuildUnitType(unit.type())) {
return false;
}
if (!this.isUnitValidToUpgrade(unit)) {
return false;
}
return true;
}
upgradeUnit(unit: Unit) {
const cost = this.mg.unitInfo(unit.type()).cost(this.mg, this);
this.removeGold(cost);
@@ -1035,42 +1066,58 @@ export class PlayerImpl implements Player {
public buildableUnits(
tile: TileRef | null,
units?: UnitType[],
units: readonly PlayerBuildableUnitType[] = PlayerBuildable.types,
): BuildableUnit[] {
const mg = this.mg;
const config = mg.config();
const rail = mg.railNetwork();
const inSpawnPhase = mg.inSpawnPhase();
const validTiles =
tile !== null &&
(units === undefined || units.some((u) => isStructureType(u)))
tile !== null && units.some((u) => Structures.has(u))
? this.validStructureSpawnTiles(tile)
: [];
return Object.values(UnitType)
.filter((u) => units === undefined || units.includes(u))
.map((u) => {
let canUpgrade: number | false = false;
let canBuild: TileRef | false = false;
if (!this.mg.inSpawnPhase()) {
const existingUnit = tile !== null && this.findUnitToUpgrade(u, tile);
if (existingUnit !== false) {
const len = units.length;
const result = new Array<BuildableUnit>(len);
for (let i = 0; i < len; i++) {
const u = units[i];
const cost = config.unitInfo(u).cost(mg, this);
let canUpgrade: number | false = false;
let canBuild: TileRef | false = false;
if (tile !== null && this.canBuildUnitType(u, cost) && !inSpawnPhase) {
if (this.canUpgradeUnitType(u)) {
const existingUnit = this.findExistingUnitToUpgrade(u, tile);
if (
existingUnit !== false &&
this.isUnitValidToUpgrade(existingUnit)
) {
canUpgrade = existingUnit.id();
}
if (tile !== null) {
canBuild = this.canBuild(u, tile, validTiles);
}
}
return {
type: u,
canBuild,
canUpgrade,
cost: this.mg.config().unitInfo(u).cost(this.mg, this),
overlappingRailroads:
canBuild !== false
? this.mg.railNetwork().overlappingRailroads(canBuild)
: [],
ghostRailPaths:
canBuild !== false
? this.mg.railNetwork().computeGhostRailPaths(u, canBuild)
: [],
};
});
canBuild = this.canSpawnUnitType(u, tile, validTiles);
}
const buildNew = canBuild !== false && canUpgrade === false;
result[i] = {
type: u,
canBuild,
canUpgrade,
cost,
overlappingRailroads: buildNew
? rail.overlappingRailroads(canBuild as TileRef)
: [],
ghostRailPaths: buildNew
? rail.computeGhostRailPaths(u, canBuild as TileRef)
: [],
};
}
return result;
}
canBuild(
@@ -1078,17 +1125,18 @@ export class PlayerImpl implements Player {
targetTile: TileRef,
validTiles: TileRef[] | null = null,
): TileRef | false {
if (this.mg.config().isUnitDisabled(unitType)) {
if (!this.canBuildUnitType(unitType)) {
return false;
}
const cost = this.mg.unitInfo(unitType).cost(this.mg, this);
if (
unitType !== UnitType.MIRVWarhead &&
(!this.isAlive() || this.gold() < cost)
) {
return false;
}
return this.canSpawnUnitType(unitType, targetTile, validTiles);
}
private canSpawnUnitType(
unitType: UnitType,
targetTile: TileRef,
validTiles: TileRef[] | null,
): TileRef | false {
switch (unitType) {
case UnitType.MIRV:
if (!this.mg.hasOwner(targetTile)) {
@@ -1144,7 +1192,7 @@ export class PlayerImpl implements Player {
const wouldHitTeammate = this.mg.anyUnitNearby(
tile,
magnitude.outer,
StructureTypes,
Structures.types,
(unit) => unit.owner().isPlayer() && this.isOnSameTeam(unit.owner()),
);
if (wouldHitTeammate) {
@@ -1227,7 +1275,7 @@ export class PlayerImpl implements Player {
const nearbyUnits = this.mg.nearbyUnits(
tile,
searchRadius * 2,
StructureTypes,
Structures.types,
undefined,
true,
);
+2 -4
View File
@@ -149,9 +149,7 @@ export class UnitGrid {
);
const rangeSquared = searchRange * searchRange;
// `Array.isArray` does not reliably narrow `readonly T[]` in TS, so use a
// cheap runtime check that narrows correctly for our string-backed UnitType.
if (typeof types !== "string") {
if (Array.isArray(types)) {
for (let cy = startGridY; cy <= endGridY; cy++) {
for (let cx = startGridX; cx <= endGridX; cx++) {
const cell = this.grid[cy][cx];
@@ -182,7 +180,7 @@ export class UnitGrid {
const type = types;
for (let cy = startGridY; cy <= endGridY; cy++) {
for (let cx = startGridX; cx <= endGridX; cx++) {
const unitSet = this.grid[cy][cx].get(type);
const unitSet = this.grid[cy][cx].get(type as UnitType);
if (unitSet === undefined) continue;
for (const unit of unitSet) {
if (!unit.isActive()) continue;
+23
View File
@@ -8,6 +8,7 @@ import {
MainThreadMessage,
PlayerActionsResultMessage,
PlayerBorderTilesResultMessage,
PlayerBuildablesResultMessage,
PlayerProfileResultMessage,
TransportShipSpawnResultMessage,
WorkerMessage,
@@ -184,6 +185,28 @@ ctx.addEventListener("message", async (e: MessageEvent<MainThreadMessage>) => {
throw error;
}
break;
case "player_buildables":
if (!gameRunner) {
throw new Error("Game runner not initialized");
}
try {
const buildables = (await gameRunner).playerBuildables(
message.playerID,
message.x,
message.y,
message.units,
);
sendMessage({
type: "player_buildables_result",
id: message.id,
result: buildables,
} as PlayerBuildablesResultMessage);
} catch (error) {
console.error("Failed to get buildables:", error);
throw error;
}
break;
case "player_profile":
if (!gameRunner) {
throw new Error("Game runner not initialized");
+37 -2
View File
@@ -1,10 +1,11 @@
import {
BuildableUnit,
Cell,
PlayerActions,
PlayerBorderTiles,
PlayerBuildableUnitType,
PlayerID,
PlayerProfile,
UnitType,
} from "../game/Game";
import { TileRef } from "../game/GameMap";
import { ErrorUpdate, GameUpdateViewData } from "../game/GameUpdates";
@@ -166,7 +167,7 @@ export class WorkerClient {
playerID: PlayerID,
x?: number,
y?: number,
units?: UnitType[],
units?: readonly PlayerBuildableUnitType[] | null,
): Promise<PlayerActions> {
return new Promise((resolve, reject) => {
if (!this.isInitialized) {
@@ -196,6 +197,40 @@ export class WorkerClient {
});
}
playerBuildables(
playerID: PlayerID,
x?: number,
y?: number,
units?: readonly PlayerBuildableUnitType[],
): Promise<BuildableUnit[]> {
return new Promise((resolve, reject) => {
if (!this.isInitialized) {
reject(new Error("Worker not initialized"));
return;
}
const messageId = generateID();
this.messageHandlers.set(messageId, (message) => {
if (
message.type === "player_buildables_result" &&
message.result !== undefined
) {
resolve(message.result);
}
});
this.worker.postMessage({
type: "player_buildables",
id: messageId,
playerID,
x,
y,
units,
});
});
}
attackAveragePosition(
playerID: number,
attackID: string,
+20 -2
View File
@@ -1,9 +1,10 @@
import {
BuildableUnit,
PlayerActions,
PlayerBorderTiles,
PlayerBuildableUnitType,
PlayerID,
PlayerProfile,
UnitType,
} from "../game/Game";
import { TileRef } from "../game/GameMap";
import { GameUpdateViewData } from "../game/GameUpdates";
@@ -17,6 +18,8 @@ export type WorkerMessageType =
| "game_update_batch"
| "player_actions"
| "player_actions_result"
| "player_buildables"
| "player_buildables_result"
| "player_profile"
| "player_profile_result"
| "player_border_tiles"
@@ -64,7 +67,7 @@ export interface PlayerActionsMessage extends BaseWorkerMessage {
playerID: PlayerID;
x?: number;
y?: number;
units?: UnitType[];
units?: readonly PlayerBuildableUnitType[] | null;
}
export interface PlayerActionsResultMessage extends BaseWorkerMessage {
@@ -72,6 +75,19 @@ export interface PlayerActionsResultMessage extends BaseWorkerMessage {
result: PlayerActions;
}
export interface PlayerBuildablesMessage extends BaseWorkerMessage {
type: "player_buildables";
playerID: PlayerID;
x?: number;
y?: number;
units?: readonly PlayerBuildableUnitType[];
}
export interface PlayerBuildablesResultMessage extends BaseWorkerMessage {
type: "player_buildables_result";
result: BuildableUnit[];
}
export interface PlayerProfileMessage extends BaseWorkerMessage {
type: "player_profile";
playerID: number;
@@ -120,6 +136,7 @@ export type MainThreadMessage =
| InitMessage
| TurnMessage
| PlayerActionsMessage
| PlayerBuildablesMessage
| PlayerProfileMessage
| PlayerBorderTilesMessage
| AttackAveragePositionMessage
@@ -131,6 +148,7 @@ export type WorkerMessage =
| GameUpdateMessage
| GameUpdateBatchMessage
| PlayerActionsResultMessage
| PlayerBuildablesResultMessage
| PlayerProfileResultMessage
| PlayerBorderTilesResultMessage
| AttackAveragePositionResultMessage
+1 -1
View File
@@ -72,7 +72,7 @@ export class GameManager {
gameType: GameType.Private,
gameMapSize: GameMapSize.Normal,
difficulty: Difficulty.Medium,
disableNations: false,
nations: "default",
infiniteGold: false,
infiniteTroops: false,
maxTimerValue: undefined,
+1 -1
View File
@@ -240,7 +240,7 @@ export function buildPreview(
if (gc?.infiniteTroops) gameOptions.push("Infinite Troops");
if (gc?.instantBuild) gameOptions.push("Instant Build");
if (gc?.randomSpawn) gameOptions.push("Random Spawn");
if (gc?.disableNations) gameOptions.push("Nations Disabled");
if (gc?.nations === "disabled") gameOptions.push("Nations Disabled");
if (gc?.donateTroops) gameOptions.push("Troop Donations Enabled");
if (gameOptions.length > 0) {
+2 -2
View File
@@ -112,8 +112,8 @@ export class GameServer {
if (gameConfig.difficulty !== undefined) {
this.gameConfig.difficulty = gameConfig.difficulty;
}
if (gameConfig.disableNations !== undefined) {
this.gameConfig.disableNations = gameConfig.disableNations;
if (gameConfig.nations !== undefined) {
this.gameConfig.nations = gameConfig.nations;
}
if (gameConfig.bots !== undefined) {
this.gameConfig.bots = gameConfig.bots;
+233 -57
View File
@@ -21,6 +21,9 @@ import { getMapLandTiles } from "./MapLandTiles";
const log = logger.child({});
const ARCADE_MAPS = new Set(mapCategories.arcade);
// Hard cap on player count for performance. Applied after compact-map reduction.
const MAX_PLAYER_COUNT = 125;
// How many times each map should appear in the playlist.
// Note: The Partial should eventually be removed for better type safety.
const frequency: Partial<Record<GameMapName, number>> = {
@@ -60,6 +63,7 @@ const frequency: Partial<Record<GameMapName, number>> = {
Svalmel: 8,
World: 8,
Lemnos: 3,
Passage: 4,
TwoLakes: 6,
StraitOfHormuz: 4,
Surrounded: 4,
@@ -74,6 +78,8 @@ const frequency: Partial<Record<GameMapName, number>> = {
TradersDream: 4,
Hawaii: 4,
Alps: 4,
NileDelta: 4,
Arctic: 6,
};
const TEAM_WEIGHTS: { config: TeamCountConfig; weight: number }[] = [
@@ -89,16 +95,33 @@ const TEAM_WEIGHTS: { config: TeamCountConfig; weight: number }[] = [
{ config: HumansVsNations, weight: 20 },
];
type ModifierKey = "isRandomSpawn" | "isCompact" | "isCrowded" | "startingGold";
type ModifierKey =
| "isRandomSpawn"
| "isCompact"
| "isCrowded"
| "isHardNations"
| "startingGold"
| "startingGoldHigh";
// Each entry represents one "ticket" in the pool. More tickets = higher chance of selection.
const SPECIAL_MODIFIER_POOL: ModifierKey[] = [
...Array<ModifierKey>(4).fill("isRandomSpawn"),
...Array<ModifierKey>(7).fill("isCompact"),
...Array<ModifierKey>(8).fill("isCompact"),
...Array<ModifierKey>(1).fill("isCrowded"),
...Array<ModifierKey>(6).fill("startingGold"),
...Array<ModifierKey>(1).fill("isHardNations"),
...Array<ModifierKey>(8).fill("startingGold"),
...Array<ModifierKey>(1).fill("startingGoldHigh"),
];
// Modifiers that cannot be active at the same time.
const MUTUALLY_EXCLUSIVE_MODIFIERS: [ModifierKey, ModifierKey][] = [
["startingGold", "startingGoldHigh"],
["isHardNations", "startingGoldHigh"],
];
// Probability of hard nations modifier in HumansVsNations games.
const HARD_NATIONS_HVN_PROBABILITY = 0.2; // 20%
export class MapPlaylist {
private playlists: Record<PublicGameType, GameMapType[]> = {
ffa: [],
@@ -111,17 +134,15 @@ export class MapPlaylist {
return this.getSpecialConfig();
}
// TODO: consider moving modifier to special lobby.
const mode = type === "ffa" ? GameMode.FFA : GameMode.Team;
const map = this.getNextMap(type);
const playerTeams =
mode === GameMode.Team ? this.getTeamCount() : undefined;
mode === GameMode.Team ? this.getTeamCount(map) : undefined;
const modifiers = this.getRandomPublicGameModifiers();
const modifiers = this.getRandomPublicGameModifiers(playerTeams);
const { startingGold } = modifiers;
let { isCompact, isRandomSpawn, isCrowded } = modifiers;
let { isCompact, isRandomSpawn, isCrowded, isHardNations } = modifiers;
// Duos, Trios, and Quads should not get random spawn (as it defeats the purpose)
if (
@@ -132,17 +153,22 @@ export class MapPlaylist {
isRandomSpawn = false;
}
// Maps with smallest player count (third number of calculateMapPlayerCounts) < 50 don't support compact map in team games
// (not enough players after 75% player reduction for compact maps)
// Hard nations modifier only applies when nations are present
if (mode === GameMode.Team && playerTeams !== HumansVsNations) {
isHardNations = false;
}
// Check if compact map would leave every team with at least 2 players
if (
isCompact &&
mode === GameMode.Team &&
!(await this.supportsCompactMapForTeams(map))
!(await this.supportsCompactMapForTeams(map, playerTeams!))
) {
isCompact = false;
}
// Crowded modifier: if the map's biggest player count (first number of calculateMapPlayerCounts) is 60 or lower (small maps),
// set player count to 125 (or 60 if compact map is also enabled)
// set player count to MAX_PLAYER_COUNT (or 60 if compact map is also enabled)
let crowdedMaxPlayers: number | undefined;
if (isCrowded) {
crowdedMaxPlayers = await this.getCrowdedMaxPlayers(map, isCompact);
@@ -167,20 +193,27 @@ export class MapPlaylist {
isCompact,
isRandomSpawn,
isCrowded,
isHardNations,
startingGold,
},
startingGold,
difficulty: Difficulty.Medium,
difficulty: isHardNations ? Difficulty.Hard : Difficulty.Medium,
infiniteGold: false,
infiniteTroops: false,
maxTimerValue: undefined,
instantBuild: false,
randomSpawn: isRandomSpawn,
disableNations: mode === GameMode.Team && playerTeams !== HumansVsNations,
nations:
mode === GameMode.Team && playerTeams !== HumansVsNations
? "disabled"
: "default",
gameMode: mode,
playerTeams,
bots: isCompact ? 100 : 400,
spawnImmunityDuration: startingGold ? 30 * 10 : 5 * 10,
spawnImmunityDuration: this.getSpawnImmunityDuration(
playerTeams,
startingGold,
),
disabledUnits: [],
} satisfies GameConfig;
}
@@ -189,13 +222,17 @@ export class MapPlaylist {
const mode = Math.random() < 0.5 ? GameMode.FFA : GameMode.Team;
const map = this.getNextMap("special");
const playerTeams =
mode === GameMode.Team ? this.getTeamCount() : undefined;
const supportsCompact =
mode !== GameMode.Team || (await this.supportsCompactMapForTeams(map));
mode === GameMode.Team ? this.getTeamCount(map) : undefined;
const excludedModifiers: ModifierKey[] = [];
const supportsCompact =
mode !== GameMode.Team ||
(await this.supportsCompactMapForTeams(map, playerTeams!));
if (!supportsCompact) {
excludedModifiers.push("isCompact");
}
if (
playerTeams === Duos ||
playerTeams === Trios ||
@@ -204,8 +241,29 @@ export class MapPlaylist {
excludedModifiers.push("isRandomSpawn");
}
let { isCrowded, startingGold, isCompact, isRandomSpawn } =
this.getRandomSpecialGameModifiers(excludedModifiers);
// Hard nations: excluded for non-HvN team modes (no nations present).
// For HumansVsNations: rolled independently (not via pool).
// For FFA: stays in the pool for normal ticket-based selection.
let hardNationsFromIndependentRoll: boolean | undefined;
let poolCountReduction = 0;
if (mode === GameMode.Team && playerTeams !== HumansVsNations) {
excludedModifiers.push("isHardNations");
} else if (playerTeams === HumansVsNations) {
excludedModifiers.push("isHardNations");
excludedModifiers.push("startingGoldHigh"); // Nations are disabled if that modifier is active
hardNationsFromIndependentRoll =
Math.random() < HARD_NATIONS_HVN_PROBABILITY;
poolCountReduction = hardNationsFromIndependentRoll ? 1 : 0;
}
const poolResult = this.getRandomSpecialGameModifiers(
excludedModifiers,
undefined,
poolCountReduction,
);
let { isCrowded, startingGold, isCompact, isRandomSpawn } = poolResult;
let isHardNations =
hardNationsFromIndependentRoll ?? poolResult.isHardNations;
let crowdedMaxPlayers: number | undefined;
if (isCrowded) {
@@ -216,10 +274,21 @@ export class MapPlaylist {
// Map doesn't support crowded. Drop it and pick one replacement only
// if it was the sole modifier, so the lobby always has at least one.
isCrowded = false;
if (!isRandomSpawn && !isCompact && startingGold === undefined) {
if (
!isRandomSpawn &&
!isCompact &&
!isHardNations &&
startingGold === undefined
) {
excludedModifiers.push("isCrowded");
({ isRandomSpawn, isCompact, startingGold } =
this.getRandomSpecialGameModifiers(excludedModifiers, 1));
const fallback = this.getRandomSpecialGameModifiers(
excludedModifiers,
1,
poolCountReduction,
);
({ isRandomSpawn, isCompact, startingGold } = fallback);
isHardNations =
hardNationsFromIndependentRoll ?? fallback.isHardNations;
}
}
}
@@ -230,6 +299,13 @@ export class MapPlaylist {
(await this.lobbyMaxPlayers(map, mode, playerTeams, isCompact)),
);
const nations: GameConfig["nations"] =
(mode === GameMode.Team && playerTeams !== HumansVsNations) ||
// Nations don't have PVP immunity, so 25M starting gold wouldn't work well with them
(startingGold !== undefined && startingGold >= 25_000_000)
? "disabled"
: "default";
return {
donateGold: mode === GameMode.Team,
donateTroops: mode === GameMode.Team,
@@ -241,20 +317,24 @@ export class MapPlaylist {
isCompact,
isRandomSpawn,
isCrowded,
isHardNations,
startingGold,
},
startingGold,
difficulty: Difficulty.Medium,
difficulty: isHardNations ? Difficulty.Hard : Difficulty.Medium,
infiniteGold: false,
infiniteTroops: false,
maxTimerValue: undefined,
instantBuild: false,
randomSpawn: isRandomSpawn,
disableNations: mode === GameMode.Team && playerTeams !== HumansVsNations,
nations,
gameMode: mode,
playerTeams,
bots: isCompact ? 100 : 400,
spawnImmunityDuration: startingGold ? 30 * 10 : 5 * 10,
spawnImmunityDuration: this.getSpawnImmunityDuration(
playerTeams,
startingGold,
),
disabledUnits: [],
} satisfies GameConfig;
}
@@ -282,7 +362,7 @@ export class MapPlaylist {
maxTimerValue: isCompact ? 10 : 15,
instantBuild: false,
randomSpawn: false,
disableNations: true,
nations: "disabled",
gameMode: GameMode.FFA,
bots: isCompact ? 100 : 400,
spawnImmunityDuration: 30 * 10,
@@ -354,14 +434,27 @@ export class MapPlaylist {
if (type !== "special" && ARCADE_MAPS.has(map)) {
return;
}
for (let i = 0; i < (frequency[key] ?? 0); i++) {
let freq = frequency[key] ?? 0;
// Double frequency for Baikal and FourIslands in team games
if (type === "team" && (key === "Baikal" || key === "FourIslands")) {
freq *= 2;
}
for (let i = 0; i < freq; i++) {
maps.push(map);
}
});
return maps;
}
private getTeamCount(): TeamCountConfig {
private getTeamCount(map: GameMapType): TeamCountConfig {
// Override team count for specific maps (75% chance)
if (map === GameMapType.Baikal && Math.random() < 0.75) {
return 2;
}
if (map === GameMapType.FourIslands && Math.random() < 0.75) {
return 4;
}
const totalWeight = TEAM_WEIGHTS.reduce((sum, w) => sum + w.weight, 0);
const roll = Math.random() * totalWeight;
@@ -375,30 +468,39 @@ export class MapPlaylist {
return TEAM_WEIGHTS[0].config;
}
private getRandomPublicGameModifiers(): PublicGameModifiers {
private getRandomPublicGameModifiers(
playerTeams?: TeamCountConfig,
): PublicGameModifiers {
return {
isRandomSpawn: Math.random() < 0.1, // 10% chance
isRandomSpawn: Math.random() < 0.05, // 5% chance
isCompact: Math.random() < 0.05, // 5% chance
isCrowded: Math.random() < 0.05, // 5% chance
startingGold: Math.random() < 0.05 ? 5_000_000 : undefined, // 5% chance
isHardNations:
playerTeams === HumansVsNations
? Math.random() < HARD_NATIONS_HVN_PROBABILITY
: Math.random() < 0.025, // 2.5% chance
};
}
private getRandomSpecialGameModifiers(
excludedModifiers: ModifierKey[] = [],
count?: number,
countReduction: number = 0,
): PublicGameModifiers {
// Roll how many modifiers to pick: 30% → 1, 40% → 2, 20% → 3, 10% → 4
const modifierCountRoll = Math.floor(Math.random() * 10) + 1;
const k =
count ??
(modifierCountRoll <= 3
? 1
: modifierCountRoll <= 7
? 2
: modifierCountRoll <= 9
? 3
: 4);
const k = Math.max(
0,
(count ??
(modifierCountRoll <= 3
? 1
: modifierCountRoll <= 7
? 2
: modifierCountRoll <= 9
? 3
: 4)) - countReduction,
);
// Shuffle the pool, then pick the first k unique modifier keys.
const pool = SPECIAL_MODIFIER_POOL.filter(
@@ -408,23 +510,100 @@ export class MapPlaylist {
const selected = new Set<ModifierKey>();
for (const key of pool) {
if (selected.size >= k) break;
selected.add(key);
// Skip if a mutually exclusive modifier is already selected
const blocked = MUTUALLY_EXCLUSIVE_MODIFIERS.some(
([a, b]) =>
(key === a && selected.has(b)) || (key === b && selected.has(a)),
);
if (!blocked) selected.add(key);
}
return {
isRandomSpawn: selected.has("isRandomSpawn"),
isCompact: selected.has("isCompact"),
isCrowded: selected.has("isCrowded"),
startingGold: selected.has("startingGold") ? 5_000_000 : undefined,
isHardNations: selected.has("isHardNations"),
startingGold: selected.has("startingGoldHigh")
? 25_000_000
: selected.has("startingGold")
? 5_000_000
: undefined,
};
}
// Maps with smallest player count (third number of calculateMapPlayerCounts) < 50 don't support compact map in team games
// (not enough players after 75% player reduction for compact maps)
private async supportsCompactMapForTeams(map: GameMapType): Promise<boolean> {
// Check whether a compact map still gives every team at least 2 players,
// using the worst-case player tier (smallest) from lobbyMaxPlayers.
private async supportsCompactMapForTeams(
map: GameMapType,
playerTeams: TeamCountConfig,
): Promise<boolean> {
const landTiles = await getMapLandTiles(map);
const [, , smallest] = this.calculateMapPlayerCounts(landTiles);
return smallest >= 50;
const [l, , s] = this.calculateMapPlayerCounts(landTiles);
// Worst case: smallest tier with team mode 1.5x multiplier, capped at l
let p = Math.min(Math.ceil(s * 1.5), l);
// Apply compact 75% player reduction, then cap for performance
p = Math.min(Math.max(3, Math.floor(p * 0.25)), MAX_PLAYER_COUNT);
// Apply team adjustment
p = this.adjustForTeams(p, playerTeams);
// Check at least 2 players per team AND at least 2 teams
return (
this.playersPerTeam(p, playerTeams) >= 2 &&
this.numberOfTeams(p, playerTeams) >= 2
);
}
private playersPerTeam(
adjustedPlayerCount: number,
playerTeams: TeamCountConfig,
): number {
switch (playerTeams) {
case Duos:
return Math.min(2, adjustedPlayerCount);
case Trios:
return Math.min(3, adjustedPlayerCount);
case Quads:
return Math.min(4, adjustedPlayerCount);
case HumansVsNations:
return adjustedPlayerCount; // adjustedPlayerCount is the human count
default:
return Math.floor(adjustedPlayerCount / playerTeams);
}
}
private numberOfTeams(
adjustedPlayerCount: number,
playerTeams: TeamCountConfig,
): number {
switch (playerTeams) {
case Duos:
return Math.floor(adjustedPlayerCount / 2);
case Trios:
return Math.floor(adjustedPlayerCount / 3);
case Quads:
return Math.floor(adjustedPlayerCount / 4);
case HumansVsNations:
return 2; // always 2 teams
default:
return playerTeams; // numeric value IS the team count
}
}
/**
* Centralised spawn-immunity duration logic.
* - HumansVsNations: always 5s (nations can't benefit from longer PVP immunity)
* - 25M starting gold: 2:30 (extra time to compensate for high gold)
* - 5M starting gold: 30s
* - Default: 5s
*/
private getSpawnImmunityDuration(
playerTeams?: TeamCountConfig,
startingGold?: number,
): number {
if (playerTeams === HumansVsNations) return 5 * 10;
if (startingGold !== undefined && startingGold >= 25_000_000)
return 150 * 10;
if (startingGold) return 30 * 10;
return 5 * 10;
}
private async getCrowdedMaxPlayers(
@@ -432,9 +611,10 @@ export class MapPlaylist {
isCompact: boolean,
): Promise<number | undefined> {
const landTiles = await getMapLandTiles(map);
const [firstPlayerCount] = this.calculateMapPlayerCounts(landTiles);
const [rawFirstPlayerCount] = this.calculateMapPlayerCounts(landTiles);
const firstPlayerCount = Math.min(rawFirstPlayerCount, MAX_PLAYER_COUNT);
if (firstPlayerCount <= 60) {
return isCompact ? 60 : 125;
return isCompact ? 60 : MAX_PLAYER_COUNT;
}
return undefined;
}
@@ -454,6 +634,8 @@ export class MapPlaylist {
if (isCompactMap) {
p = Math.max(3, Math.floor(p * 0.25));
}
// Cap for performance
p = Math.min(p, MAX_PLAYER_COUNT);
return this.adjustForTeams(p, numPlayerTeams);
}
@@ -487,7 +669,6 @@ export class MapPlaylist {
/**
* Calculate player counts from land tiles
* For every 1,000,000 land tiles, take 50 players
* Limit to max 125 players for performance
* Second value is 75% of calculated value, third is 50%
* All values are rounded to the nearest 5
*/
@@ -496,12 +677,7 @@ export class MapPlaylist {
): [number, number, number] {
const roundToNearest5 = (n: number) => Math.round(n / 5) * 5;
const base = roundToNearest5((landTiles / 1_000_000) * 50);
const limitedBase = Math.min(Math.max(base, 5), 125);
return [
limitedBase,
roundToNearest5(limitedBase * 0.75),
roundToNearest5(limitedBase * 0.5),
];
const base = Math.max(roundToNearest5((landTiles / 1_000_000) * 50), 5);
return [base, roundToNearest5(base * 0.75), roundToNearest5(base * 0.5)];
}
}