mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 21:14:14 +00:00
469 lines
16 KiB
TypeScript
469 lines
16 KiB
TypeScript
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,
|
|
Quads,
|
|
Trios,
|
|
} from "../core/game/Game";
|
|
import { PublicGameInfo, PublicGames } from "../core/Schemas";
|
|
import { HostLobbyModal } from "./HostLobbyModal";
|
|
import { JoinLobbyModal } from "./JoinLobbyModal";
|
|
import { PublicLobbySocket } from "./LobbySocket";
|
|
import { JoinLobbyEvent } from "./Main";
|
|
import { SinglePlayerModal } from "./SinglePlayerModal";
|
|
import { terrainMapFileLoader } from "./TerrainMapFileLoader";
|
|
import {
|
|
getMapName,
|
|
getModifierLabels,
|
|
renderDuration,
|
|
translateText,
|
|
} from "./Utils";
|
|
|
|
const CARD_BG = "bg-sky-950";
|
|
|
|
@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),
|
|
);
|
|
|
|
createRenderRoot() {
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Validates username input and shows error message if invalid.
|
|
* Returns true if valid, false otherwise.
|
|
*/
|
|
private validateUsername(): boolean {
|
|
const usernameInput = document.querySelector("username-input") as any;
|
|
if (usernameInput?.isValid?.() === false) {
|
|
window.dispatchEvent(
|
|
new CustomEvent("show-message", {
|
|
detail: {
|
|
message: usernameInput.validationError,
|
|
color: "red",
|
|
duration: 3000,
|
|
},
|
|
}),
|
|
);
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
connectedCallback() {
|
|
super.connectedCallback();
|
|
this.lobbySocket.start();
|
|
getServerConfigFromClient().then((config) => {
|
|
this.defaultLobbyTime = config.gameCreationRate() / 1000;
|
|
});
|
|
}
|
|
|
|
disconnectedCallback() {
|
|
this.stop();
|
|
super.disconnectedCallback();
|
|
}
|
|
|
|
public stop() {
|
|
this.lobbySocket.stop();
|
|
}
|
|
|
|
private handleLobbiesUpdate(lobbies: PublicGames) {
|
|
this.lobbies = lobbies;
|
|
this.serverTimeOffset = lobbies.serverTime - Date.now();
|
|
document.dispatchEvent(
|
|
new CustomEvent("public-lobbies-update", {
|
|
detail: { payload: lobbies },
|
|
}),
|
|
);
|
|
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() {
|
|
const ffa = this.lobbies?.games?.["ffa"]?.[0];
|
|
const teams = this.lobbies?.games?.["team"]?.[0];
|
|
const special = this.lobbies?.games?.["special"]?.[0];
|
|
|
|
return html`
|
|
<div class="flex flex-col gap-4 w-full px-4 sm:px-0 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 hover:bg-sky-500 active:bg-sky-700",
|
|
)}
|
|
</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-slate-700 hover:bg-slate-600 active:bg-slate-800",
|
|
)}
|
|
${this.renderSmallActionCard(
|
|
translateText("mode_selector.ranked_title"),
|
|
this.openRankedMenu,
|
|
"bg-slate-700 hover:bg-slate-600 active:bg-slate-800",
|
|
)}
|
|
${this.renderSmallActionCard(
|
|
translateText("main.join"),
|
|
this.openJoinLobby,
|
|
"bg-slate-700 hover:bg-slate-600 active:bg-slate-800",
|
|
)}
|
|
</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 hover:bg-sky-500 active:bg-sky-700",
|
|
)}
|
|
</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-slate-700 hover:bg-slate-600 active:bg-slate-800",
|
|
)}
|
|
${this.renderSmallActionCard(
|
|
translateText("mode_selector.ranked_title"),
|
|
this.openRankedMenu,
|
|
"bg-slate-700 hover:bg-slate-600 active:bg-slate-800",
|
|
)}
|
|
${this.renderSmallActionCard(
|
|
translateText("main.join"),
|
|
this.openJoinLobby,
|
|
"bg-slate-700 hover:bg-slate-600 active:bg-slate-800",
|
|
)}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
private renderSpecialLobbyCard(lobby: PublicGameInfo) {
|
|
return this.renderLobbyCard(lobby, this.getLobbyTitle(lobby));
|
|
}
|
|
|
|
private openRankedMenu = () => {
|
|
if (!this.validateUsername()) return;
|
|
window.showPage?.("page-ranked");
|
|
};
|
|
|
|
private openSinglePlayerModal = () => {
|
|
if (!this.validateUsername()) return;
|
|
(
|
|
document.querySelector("single-player-modal") as SinglePlayerModal
|
|
)?.open();
|
|
};
|
|
|
|
private openHostLobby = () => {
|
|
if (!this.validateUsername()) return;
|
|
(document.querySelector("host-lobby-modal") as HostLobbyModal)?.open();
|
|
};
|
|
|
|
private openJoinLobby = () => {
|
|
if (!this.validateUsername()) return;
|
|
(document.querySelector("join-lobby-modal") as JoinLobbyModal)?.open();
|
|
};
|
|
|
|
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-lg ${bgClass} transition-colors text-sm lg:text-base font-medium text-white uppercase tracking-wider text-center"
|
|
>
|
|
${title}
|
|
</button>
|
|
`;
|
|
}
|
|
|
|
private renderLobbyCard(
|
|
lobby: PublicGameInfo,
|
|
titleContent: string | TemplateResult,
|
|
) {
|
|
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,
|
|
Math.floor(
|
|
(lobby.startsAt - this.serverTimeOffset - Date.now()) / 1000,
|
|
),
|
|
)
|
|
: undefined;
|
|
|
|
let timeDisplay: string = "";
|
|
let timeDisplayUppercase = false;
|
|
if (timeRemaining === undefined) {
|
|
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 = getModifierLabels(
|
|
lobby.gameConfig?.publicGameModifiers,
|
|
);
|
|
// Sort by length for visual consistency (shorter labels first)
|
|
if (modifierLabels.length > 1) {
|
|
modifierLabels.sort((a, b) => a.length - b.length);
|
|
}
|
|
|
|
return html`
|
|
<button
|
|
@click=${() => this.validateAndJoin(lobby)}
|
|
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] bg-sky-950"
|
|
>
|
|
<!-- 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 ${useContain
|
|
? "object-contain"
|
|
: "object-cover object-center scale-[1.05]"} [image-rendering:auto]"
|
|
/>`
|
|
: null}
|
|
</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 mt-[2px]">
|
|
${modifierLabels.map(
|
|
(label) =>
|
|
html`<span
|
|
class="px-2 py-1 rounded text-xs font-bold uppercase tracking-widest bg-sky-600 text-white shadow-[0_0_6px_rgba(14,165,233,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 text-white px-2 py-1 rounded"
|
|
>${timeDisplay}</span
|
|
>
|
|
</div>
|
|
</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="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>
|
|
`;
|
|
}
|
|
|
|
private validateAndJoin(lobby: PublicGameInfo) {
|
|
if (!this.validateUsername()) return;
|
|
|
|
this.dispatchEvent(
|
|
new CustomEvent("join-lobby", {
|
|
detail: {
|
|
gameID: lobby.gameID,
|
|
source: "public",
|
|
publicLobbyInfo: lobby,
|
|
} as JoinLobbyEvent,
|
|
bubbles: true,
|
|
composed: true,
|
|
}),
|
|
);
|
|
}
|
|
|
|
private getLobbyTitle(lobby: PublicGameInfo): string {
|
|
const config = lobby.gameConfig!;
|
|
if (config.gameMode === GameMode.FFA) {
|
|
return translateText("game_mode.ffa");
|
|
}
|
|
|
|
if (config?.gameMode === GameMode.Team) {
|
|
const totalPlayers = config.maxPlayers ?? lobby.numClients ?? undefined;
|
|
const formatTeamsOf = (
|
|
teamCount: number | undefined,
|
|
playersPerTeam: number | undefined,
|
|
label?: string,
|
|
) => {
|
|
if (!teamCount)
|
|
return label ?? translateText("mode_selector.teams_title");
|
|
const baseTitle = playersPerTeam
|
|
? translateText("mode_selector.teams_of", {
|
|
teamCount: String(teamCount),
|
|
playersPerTeam: String(playersPerTeam),
|
|
})
|
|
: translateText("mode_selector.teams_count", {
|
|
teamCount: String(teamCount),
|
|
});
|
|
return `${baseTitle}${label ? ` (${label})` : ""}`;
|
|
};
|
|
|
|
switch (config.playerTeams) {
|
|
case Duos: {
|
|
const teamCount = totalPlayers
|
|
? Math.floor(totalPlayers / 2)
|
|
: undefined;
|
|
return formatTeamsOf(teamCount, 2);
|
|
}
|
|
case Trios: {
|
|
const teamCount = totalPlayers
|
|
? Math.floor(totalPlayers / 3)
|
|
: undefined;
|
|
return formatTeamsOf(teamCount, 3);
|
|
}
|
|
case Quads: {
|
|
const teamCount = totalPlayers
|
|
? Math.floor(totalPlayers / 4)
|
|
: undefined;
|
|
return formatTeamsOf(teamCount, 4);
|
|
}
|
|
case HumansVsNations: {
|
|
const humanSlots = config.maxPlayers ?? lobby.numClients;
|
|
return humanSlots
|
|
? translateText("public_lobby.teams_hvn_detailed", {
|
|
num: String(humanSlots),
|
|
})
|
|
: translateText("public_lobby.teams_hvn");
|
|
}
|
|
default:
|
|
if (typeof config.playerTeams === "number") {
|
|
const teamCount = config.playerTeams;
|
|
const playersPerTeam =
|
|
totalPlayers && teamCount > 0
|
|
? Math.floor(totalPlayers / teamCount)
|
|
: undefined;
|
|
return formatTeamsOf(teamCount, playersPerTeam);
|
|
}
|
|
}
|
|
}
|
|
|
|
return "";
|
|
}
|
|
}
|