Homepage update & add 3 public lobbies (#3191)

## Description:

Update UI 
check https://homepageupdate.openfront.dev/ 

Improved mobile UI (now fills whole screen for all modals) e.g.:
<img width="432" height="852" alt="image"
src="https://github.com/user-attachments/assets/56de40af-4137-4c57-96b7-3910c9a665b8"
/>

Converted PublicLobby to be "GameModeSelector" to get a nicer 4x4 grid
div, where <GameModeSelector> now handles all the username validation
now (removed redundant code from modals such as matchmaking) also fixed
a bug where someone could have "[XX] X" as thier username (when the
minimum should be 3 chars for their name)

Now visually displays the 3 lobbies ffa/team/special (which is a
continuation from the work done in: #3196 )
<img width="818" height="563" alt="image"
src="https://github.com/user-attachments/assets/a15cd31b-6061-4fb8-83ee-ffde6225cfa7"
/>

updated the background:
<img width="1919" height="807" alt="image"
src="https://github.com/user-attachments/assets/358a7434-51b8-4540-baf2-d1be05053c44"
/>



slightly updated the glassy-look to be less glassy:
<img width="825" height="729" alt="image"
src="https://github.com/user-attachments/assets/1801871b-bbf8-43db-ac53-489337ae80a5"
/>



## Please complete the following:

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

## Please put your Discord username so you can be contacted if a bug or
regression is found:

w.o.n
This commit is contained in:
Ryan
2026-02-18 23:11:01 -06:00
committed by GitHub
parent 2a7db43db3
commit 70f2abb181
48 changed files with 1016 additions and 725 deletions
+363
View File
@@ -0,0 +1,363 @@
import { html, LitElement, nothing, type TemplateResult } from "lit";
import { customElement, state } from "lit/decorators.js";
import {
Duos,
GameMapType,
GameMode,
HumansVsNations,
PublicGameModifiers,
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, 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;
private serverTimeOffset: 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();
}
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();
}
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="grid grid-cols-1 lg:grid-cols-2 gap-4 w-[70%] lg:w-full mx-auto"
>
${ffa ? this.renderLobbyCard(ffa, this.getLobbyTitle(ffa)) : nothing}
${teams
? this.renderLobbyCard(teams, this.getLobbyTitle(teams))
: nothing}
${special ? this.renderSpecialLobbyCard(special) : nothing}
${this.renderQuickActionsSection()}
</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="grid grid-cols-2 gap-2 h-40 lg:h-56">
${this.renderSmallActionCard(
translateText("main.solo"),
this.openSinglePlayerModal,
)}
${this.renderSmallActionCard(
translateText("mode_selector.ranked_title"),
this.openRankedMenu,
)}
${this.renderSmallActionCard(
translateText("main.create"),
this.openHostLobby,
)}
${this.renderSmallActionCard(
translateText("main.join"),
this.openJoinLobby,
)}
</div>
`;
}
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) {
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"
>
${title}
</button>
`;
}
private renderLobbyCard(
lobby: PublicGameInfo,
titleContent: string | TemplateResult,
) {
const mapType = lobby.gameConfig!.gameMap as GameMapType;
const mapImageSrc = terrainMapFileLoader.getMapData(mapType).webpPath;
const timeRemaining = Math.max(
0,
Math.floor((lobby.startsAt - this.serverTimeOffset - Date.now()) / 1000),
);
const timeDisplay = renderDuration(timeRemaining);
const mapName = getMapName(lobby.gameConfig?.gameMap);
const modifierLabels = this.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 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}"
>
<div class="relative flex-1 overflow-hidden ${CARD_BG}">
${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"
/>`
: 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">
${timeRemaining > 0
? html`<span
class="text-[10px] font-bold uppercase tracking-widest bg-blue-600 px-2 py-0.5 rounded"
>${timeDisplay}</span
>`
: html`<span
class="text-[10px] font-bold uppercase tracking-widest bg-green-600 px-2 py-0.5 rounded"
>${translateText("public_lobby.starting_game")}</span
>`}
</div>
</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>
<span
class="text-xs font-bold uppercase tracking-widest shrink-0 ml-2"
>
${lobby.numClients}/${lobby.gameConfig?.maxPlayers}
</span>
</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 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) {
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 teamCount
? translateText("public_lobby.teams_Duos", {
team_count: String(teamCount),
})
: formatTeamsOf(undefined, 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);
}
case Quads: {
const teamCount = totalPlayers
? Math.floor(totalPlayers / 4)
: undefined;
return teamCount
? translateText("public_lobby.teams_Quads", {
team_count: String(teamCount),
})
: formatTeamsOf(undefined, 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 "";
}
}