Files
OpenFrontIO/src/client/GameModeSelector.ts
T
FloPinguin 417fa0fe09 For v30: Add new modifiers (Hard nations and 25M Starting Gold) 🙂 (#3316)
## 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
2026-03-01 20:12:38 -08:00

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 "";
}
}