mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-29 03:44:40 +00:00
Merge branch 'main' into team-names
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
`,
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
})();
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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]">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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),
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
/>`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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)"
|
||||
>
|
||||
${this.getTranslatedPlayerTeamLabel()} ⦿
|
||||
${getTranslatedPlayerTeamLabel(this.playerTeam)}
|
||||
⦿
|
||||
</span>
|
||||
</div>
|
||||
`
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>`
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
) {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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(),
|
||||
|
||||
@@ -224,7 +224,7 @@ export class DefaultConfig implements Config {
|
||||
}
|
||||
|
||||
spawnNations(): boolean {
|
||||
return !this._gameConfig.disableNations;
|
||||
return this._gameConfig.nations !== "disabled";
|
||||
}
|
||||
|
||||
isUnitDisabled(unitType: UnitType): boolean {
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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()),
|
||||
);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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[][];
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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)];
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user