mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-22 12:26:32 +00:00
417fa0fe09
## Description: Adds two new public game modifiers for variety and improves compact map eligibility for team games. ### New Modifiers **Hard Nations (`isHardNations`)** - We need this modifier for HvN, because medium nations are easier now (will result in a much higher human winrate) - In a discord discussion we concluded that HvN should generally be easier (higher winrate than 50%, so players are less frustated) - Thats why only 20% of HvN games have the hard nations modifier (for now) - For PvPvE enjoyers, the modifier is also active in FFA games => (Only 2.5% chance, and 1 ticket in `SPECIAL_MODIFIER_POOL`) **25M Starting Gold (`startingGoldHigh`)** - Some people in the main discord wanted this modifier, and it will result in crazy games - Rare special-only modifier (1 ticket in pool); mutually exclusive with 5M starting gold via `MUTUALLY_EXCLUSIVE_MODIFIERS` - Disables nations (they lack PVP immunity, so 25M gold doesn't work well with them) - Excluded from HumansVsNations games (since it disables nations) - Spawn immunity set to **2 minutes 30 seconds** (vs 30s for 5M gold), so people can spend the gold and prepare ### Other Changes - **Improved `supportsCompactMapForTeams`**: Replaced the hard `smallest >= 50` land-tile cutoff with a per-team-config calculation that simulates worst-case compact player count and checks every team gets at least 2 players. - **HvN spawn immunity**: Always 5 seconds in both regular and special lobbies (to get rid of a confusing PVP immunity HeadsUpMessage in 5M starting gold games) - **Regular public lobby random spawn modifier probabilty**: Reduced from 10% to 5% (Because of the new modifier, so there aren't too many modifiers in non-special-lobbies, should only occur sometimes there) - Rebalanced `SPECIAL_MODIFIER_POOL` a bit ## 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: FloPinguin
370 lines
11 KiB
TypeScript
370 lines
11 KiB
TypeScript
import { html, LitElement, nothing, type TemplateResult } from "lit";
|
|
import { customElement, state } from "lit/decorators.js";
|
|
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-[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 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>
|
|
`;
|
|
}
|
|
|
|
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>
|
|
`;
|
|
}
|
|
|
|
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 = lobby.startsAt
|
|
? Math.max(
|
|
0,
|
|
Math.floor(
|
|
(lobby.startsAt - this.serverTimeOffset - Date.now()) / 1000,
|
|
),
|
|
)
|
|
: undefined;
|
|
|
|
let timeDisplay: string = "";
|
|
if (timeRemaining === undefined) {
|
|
timeDisplay = "-s";
|
|
} else if (timeRemaining > 0) {
|
|
timeDisplay = renderDuration(timeRemaining);
|
|
} else {
|
|
timeDisplay = translateText("public_lobby.starting_game");
|
|
}
|
|
|
|
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 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">
|
|
<span
|
|
class="text-[10px] font-bold uppercase tracking-widest bg-blue-600 px-2 py-0.5 rounded"
|
|
>${timeDisplay}</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 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 "";
|
|
}
|
|
}
|