mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 16:56:36 +00:00
0b9d43cb46
## Description: I hope we can get this into v30? The nation count is configurable now, just like the bot count. Replaced the "Disable Nations" toggle with a nations slider (0–400) in SinglePlayer and Host Lobby modals. <img width="710" height="121" alt="Screenshot 2026-03-03 021952" src="https://github.com/user-attachments/assets/c8d0f0c3-db51-4303-95fa-dbc770460ec2" /> Public games are staying exactly the same, this is just for singleplayer and private lobby fun. Youtubers could play HvN against 400 nations, for example. Singleplayer enjoyers no longer have to play against 1 nation in HvN, they can freely choose. `GameConfig.disableNations: boolean` got replaced by `nations: number (0-400, optional)` `undefined` = map default, `0` = disabled, number = custom count Nations slider defaults to the map's nation count, shows "(MAP DEFAULT)" label when unchanged Compact map toggle reduces nations to 25% when at default, restores when toggled off (just like we already do with bots) The nation count for HvN no longer automatically matches the human count in singleplayer and private games, only in public games. **What if there aren't enough nations configured for the map?** We just use the HvN logic (Generate random nations) ### Warning **This infra PR also needs to get merged: https://github.com/openfrontio/infra/pull/263 Otherwise players can set 0 nations and get achievements.** ## 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
481 lines
16 KiB
TypeScript
481 lines
16 KiB
TypeScript
import {
|
|
LitElement,
|
|
SVGTemplateResult,
|
|
TemplateResult,
|
|
html,
|
|
nothing,
|
|
svg,
|
|
} from "lit";
|
|
import { customElement, property } from "lit/decorators.js";
|
|
import {
|
|
Difficulty,
|
|
Duos,
|
|
GameMapType,
|
|
GameMode,
|
|
HumansVsNations,
|
|
Quads,
|
|
Trios,
|
|
UnitType,
|
|
} from "../../core/game/Game";
|
|
import { TeamCountConfig } from "../../core/Schemas";
|
|
import { translateText } from "../Utils";
|
|
import "./Difficulties";
|
|
import "./FluentSlider";
|
|
import "./map/MapPicker";
|
|
|
|
const ACTIVE_CARD =
|
|
"bg-blue-500/20 border-blue-500/50 shadow-[0_0_15px_rgba(59,130,246,0.2)]";
|
|
const INACTIVE_CARD =
|
|
"bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20";
|
|
|
|
const DISABLED_CARD =
|
|
"w-full rounded-xl border transition-all duration-200 opacity-30 grayscale cursor-not-allowed bg-white/5 border-white/5";
|
|
|
|
function cardClass(active: boolean, extra = ""): string {
|
|
return `w-full rounded-xl border cursor-pointer transition-all duration-200 active:scale-95 ${extra} ${active ? ACTIVE_CARD : INACTIVE_CARD}`;
|
|
}
|
|
|
|
const CARD_LABEL_CLASS =
|
|
"text-xs uppercase font-bold tracking-wider leading-tight break-words hyphens-auto";
|
|
|
|
const DIFFICULTY_OPTIONS = Object.entries(Difficulty).filter(([key]) =>
|
|
isNaN(Number(key)),
|
|
) as Array<[string, Difficulty]>;
|
|
const TEAM_COUNT_OPTIONS: TeamCountConfig[] = [
|
|
2,
|
|
3,
|
|
4,
|
|
5,
|
|
6,
|
|
7,
|
|
Quads,
|
|
Trios,
|
|
Duos,
|
|
HumansVsNations,
|
|
];
|
|
|
|
function stateTextClass(active: boolean): string {
|
|
return active ? "text-white" : "text-white/60";
|
|
}
|
|
|
|
function renderTextCardButton(
|
|
label: string,
|
|
active: boolean,
|
|
onClick: () => void,
|
|
cardExtraClass: string,
|
|
): TemplateResult {
|
|
return html`
|
|
<button class="${cardClass(active, cardExtraClass)}" @click=${onClick}>
|
|
<span class="${CARD_LABEL_CLASS} ${stateTextClass(active)}">
|
|
${label}
|
|
</span>
|
|
</button>
|
|
`;
|
|
}
|
|
|
|
function renderSection(
|
|
iconSvg: SVGTemplateResult,
|
|
colorClass: string,
|
|
bgClass: string,
|
|
titleKey: string,
|
|
content: TemplateResult | TemplateResult[],
|
|
sectionClass = "space-y-6",
|
|
): TemplateResult {
|
|
return html`
|
|
<section class=${sectionClass}>
|
|
${renderSectionHeader(iconSvg, colorClass, bgClass, titleKey)} ${content}
|
|
</section>
|
|
`;
|
|
}
|
|
|
|
const unitOptions: { type: UnitType; translationKey: string }[] = [
|
|
{ type: UnitType.City, translationKey: "unit_type.city" },
|
|
{ type: UnitType.DefensePost, translationKey: "unit_type.defense_post" },
|
|
{ type: UnitType.Port, translationKey: "unit_type.port" },
|
|
{ type: UnitType.Warship, translationKey: "unit_type.warship" },
|
|
{ type: UnitType.TransportShip, translationKey: "unit_type.boat" },
|
|
{ type: UnitType.MissileSilo, translationKey: "unit_type.missile_silo" },
|
|
{ type: UnitType.SAMLauncher, translationKey: "unit_type.sam_launcher" },
|
|
{ type: UnitType.AtomBomb, translationKey: "unit_type.atom_bomb" },
|
|
{ type: UnitType.HydrogenBomb, translationKey: "unit_type.hydrogen_bomb" },
|
|
{ type: UnitType.MIRV, translationKey: "unit_type.mirv" },
|
|
{ type: UnitType.Factory, translationKey: "unit_type.factory" },
|
|
];
|
|
|
|
const MAP_ICON = svg`<path
|
|
d="M21.731 2.269a2.625 2.625 0 00-3.712 0l-1.157 1.157 3.712 3.712 1.157-1.157a2.625 2.625 0 000-3.712zM19.513 8.199l-3.712-3.712-12.15 12.15a5.25 5.25 0 00-1.32 2.214l-.8 2.685a.75.75 0 00.933.933l2.685-.8a5.25 5.25 0 002.214-1.32L19.513 8.2z"
|
|
/>`;
|
|
|
|
const DIFFICULTY_ICON = svg`<path
|
|
fill-rule="evenodd"
|
|
d="M12.97 3.97a.75.75 0 011.06 0l7.5 7.5a.75.75 0 010 1.06l-7.5 7.5a.75.75 0 11-1.06-1.06l6.22-6.22H3a.75.75 0 010-1.5h16.19l-6.22-6.22a.75.75 0 010-1.06z"
|
|
clip-rule="evenodd"
|
|
/>`;
|
|
|
|
const MODE_ICON = svg`<path
|
|
d="M11.25 4.533A9.707 9.707 0 006 3a9.735 9.735 0 00-3.25.555.75.75 0 00-.5.707v14.25a.75.75 0 001 .707A8.237 8.237 0 016 18.75c1.995 0 3.823.707 5.25 1.886V4.533zM12.75 20.636A8.214 8.214 0 0118 18.75c.966 0 1.89.166 2.75.47a.75.75 0 001-.708V4.262a.75.75 0 00-.5-.707A9.735 9.735 0 0018 3a9.707 9.707 0 00-5.25 1.533v16.103z"
|
|
/>`;
|
|
|
|
const OPTIONS_ICON = svg`<path
|
|
fill-rule="evenodd"
|
|
d="M11.078 2.25c-.917 0-1.699.663-1.85 1.567L9.05 4.889c-.02.12-.115.26-.297.348a7.493 7.493 0 00-.986.57c-.166.115-.334.126-.45.083L6.3 5.508a1.875 1.875 0 00-2.282.819l-.922 1.597a1.875 1.875 0 00.432 2.385l.84.692c.095.078.17.229.154.43a7.598 7.598 0 000 1.139c.015.2-.059.352-.153.43l-.841.692a1.875 1.875 0 00-.432 2.385l.922 1.597a1.875 1.875 0 002.282.818l1.019-.382c.115-.043.283-.031.45.082.312.214.641.405.985.57.182.088.277.228.297.35l.178 1.071c.151.904.933 1.567 1.85 1.567h1.844c.916 0 1.699-.663 1.85-1.567l.178-1.072c.02-.12.114-.26.297-.349.344-.165.673-.356.985-.57.167-.114.335-.125.45-.082l1.02.382a1.875 1.875 0 002.28-.819l.922-1.597a1.875 1.875 0 00-.432-2.385l-.84-.692c-.095-.078-.17-.229-.154-.43a7.614 7.614 0 000-1.139c-.016-.2.059-.352.153-.43l.84-.692c.708-.582.891-1.59.433-2.385l-.922-1.597a1.875 1.875 0 00-2.282-.818l-1.02.382c-.114.043-.282.031-.449-.083a7.49 7.49 0 00-.985-.57c-.183-.087-.277-.227-.297-.348l-.179-1.072a1.875 1.875 0 00-1.85-1.567h-1.843zM12 15.75a3.75 3.75 0 100-7.5 3.75 3.75 0 000 7.5z"
|
|
clip-rule="evenodd"
|
|
/>`;
|
|
|
|
const ENABLES_ICON = svg`<path
|
|
fill-rule="evenodd"
|
|
d="M12 2.25c-5.385 0-9.75 4.365-9.75 9.75s4.365 9.75 9.75 9.75 9.75-4.365 9.75-9.75S17.385 2.25 12 2.25zm0 8.625a1.125 1.125 0 100 2.25 1.125 1.125 0 000-2.25zM15.375 12a1.125 1.125 0 112.25 0 1.125 1.125 0 01-2.25 0zM7.5 10.875a1.125 1.125 0 100 2.25 1.125 1.125 0 000-2.25z"
|
|
clip-rule="evenodd"
|
|
/>`;
|
|
|
|
function renderSectionHeader(
|
|
iconSvg: SVGTemplateResult,
|
|
colorClass: string,
|
|
bgClass: string,
|
|
titleKey: string,
|
|
): TemplateResult {
|
|
return html`
|
|
<div class="flex items-center gap-4 pb-2 border-b border-white/10">
|
|
<div
|
|
class="w-8 h-8 rounded-lg flex items-center justify-center ${bgClass} ${colorClass}"
|
|
>
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
viewBox="0 0 24 24"
|
|
fill="currentColor"
|
|
class="w-5 h-5"
|
|
>
|
|
${iconSvg}
|
|
</svg>
|
|
</div>
|
|
<h3 class="text-lg font-bold text-white uppercase tracking-wider">
|
|
${translateText(titleKey)}
|
|
</h3>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
export interface ToggleOptionConfig {
|
|
labelKey: string;
|
|
checked: boolean;
|
|
hidden?: boolean;
|
|
}
|
|
|
|
export interface GameConfigSettingsData {
|
|
map: {
|
|
selected: GameMapType;
|
|
useRandom: boolean;
|
|
randomMapDivider?: boolean;
|
|
showMedals?: boolean;
|
|
mapWins?: Map<GameMapType, Set<Difficulty>>;
|
|
};
|
|
difficulty: {
|
|
selected: Difficulty;
|
|
disabled: boolean;
|
|
};
|
|
gameMode: {
|
|
selected: GameMode;
|
|
};
|
|
teamCount: {
|
|
selected: TeamCountConfig;
|
|
};
|
|
options: {
|
|
titleKey: string;
|
|
bots: {
|
|
value: number;
|
|
labelKey: string;
|
|
disabledKey: string;
|
|
};
|
|
nations?: {
|
|
value: number;
|
|
defaultValue?: number;
|
|
labelKey: string;
|
|
disabledKey: string;
|
|
hidden?: boolean;
|
|
};
|
|
toggles: ToggleOptionConfig[];
|
|
inputCards: TemplateResult[];
|
|
};
|
|
unitTypes: {
|
|
titleKey: string;
|
|
disabledUnits: UnitType[];
|
|
};
|
|
}
|
|
|
|
@customElement("game-config-settings")
|
|
export class GameConfigSettings extends LitElement {
|
|
@property({ attribute: false }) settings?: GameConfigSettingsData;
|
|
@property({ attribute: false }) sectionGapClass = "space-y-6";
|
|
|
|
createRenderRoot() {
|
|
return this;
|
|
}
|
|
|
|
private emit<T>(name: string, detail: T) {
|
|
this.dispatchEvent(
|
|
new CustomEvent(name, {
|
|
detail,
|
|
bubbles: true,
|
|
composed: true,
|
|
}),
|
|
);
|
|
}
|
|
|
|
private handleSelectMap = (map: GameMapType) => {
|
|
this.emit("map-selected", { map });
|
|
};
|
|
|
|
private handleSelectRandom = () => {
|
|
this.emit("random-map-selected", {});
|
|
};
|
|
|
|
private handleDifficultySelect = (difficulty: Difficulty) => {
|
|
this.emit("difficulty-selected", { difficulty });
|
|
};
|
|
|
|
private handleGameModeSelect = (mode: GameMode) => {
|
|
this.emit("game-mode-selected", { mode });
|
|
};
|
|
|
|
private handleTeamCountSelect = (count: TeamCountConfig) => {
|
|
this.emit("team-count-selected", { count });
|
|
};
|
|
|
|
private handleOptionToggle = (toggle: ToggleOptionConfig) => {
|
|
this.emit("option-toggle-changed", {
|
|
labelKey: toggle.labelKey,
|
|
checked: !toggle.checked,
|
|
});
|
|
};
|
|
|
|
private handleBotsChanged = (event: Event) => {
|
|
const customEvent = event as CustomEvent<{ value: number }>;
|
|
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 });
|
|
};
|
|
|
|
private renderOptionToggle(toggle: ToggleOptionConfig): TemplateResult {
|
|
if (toggle.hidden) return html``;
|
|
|
|
return renderTextCardButton(
|
|
translateText(toggle.labelKey),
|
|
toggle.checked,
|
|
() => this.handleOptionToggle(toggle),
|
|
"p-4 text-center",
|
|
);
|
|
}
|
|
|
|
private renderUnitTypeOptions(disabledUnits: UnitType[]): TemplateResult[] {
|
|
return unitOptions.map(({ type, translationKey }) => {
|
|
const isEnabled = !disabledUnits.includes(type);
|
|
return html`
|
|
<button
|
|
class="${cardClass(isEnabled, "p-4 text-center")}"
|
|
aria-pressed=${isEnabled}
|
|
@click=${() => this.handleUnitToggle(type, isEnabled)}
|
|
>
|
|
<span class="${CARD_LABEL_CLASS} ${stateTextClass(isEnabled)}">
|
|
${translateText(translationKey)}
|
|
</span>
|
|
</button>
|
|
`;
|
|
});
|
|
}
|
|
|
|
render() {
|
|
if (!this.settings) return nothing;
|
|
const settings = this.settings;
|
|
|
|
return html`
|
|
<div class=${this.sectionGapClass}>
|
|
${renderSection(
|
|
MAP_ICON,
|
|
"text-blue-400",
|
|
"bg-blue-500/20",
|
|
"map.map",
|
|
html`<map-picker
|
|
.selectedMap=${settings.map.selected}
|
|
.useRandomMap=${settings.map.useRandom}
|
|
.randomMapDivider=${settings.map.randomMapDivider ?? false}
|
|
.showMedals=${settings.map.showMedals ?? false}
|
|
.mapWins=${settings.map.mapWins ?? new Map()}
|
|
.onSelectMap=${this.handleSelectMap}
|
|
.onSelectRandom=${this.handleSelectRandom}
|
|
></map-picker>`,
|
|
)}
|
|
${renderSection(
|
|
DIFFICULTY_ICON,
|
|
"text-green-400",
|
|
"bg-green-500/20",
|
|
"difficulty.difficulty",
|
|
html`
|
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
${DIFFICULTY_OPTIONS.map(([key, value]) => {
|
|
const isSelected = settings.difficulty.selected === value;
|
|
const isDisabled = settings.difficulty.disabled;
|
|
return html`
|
|
<button
|
|
?disabled=${isDisabled}
|
|
@click=${() =>
|
|
!isDisabled &&
|
|
this.handleDifficultySelect(value as Difficulty)}
|
|
class="${isDisabled
|
|
? `${DISABLED_CARD} flex flex-col items-center p-4 gap-3`
|
|
: cardClass(
|
|
isSelected,
|
|
"flex flex-col items-center p-4 gap-3",
|
|
)}"
|
|
>
|
|
<difficulty-display
|
|
.difficultyKey=${key}
|
|
class="transform scale-125 origin-center ${isDisabled
|
|
? "pointer-events-none"
|
|
: ""}"
|
|
></difficulty-display>
|
|
<span
|
|
class="${CARD_LABEL_CLASS} text-center mt-1 text-white"
|
|
>
|
|
${translateText(`difficulty.${key.toLowerCase()}`)}
|
|
</span>
|
|
</button>
|
|
`;
|
|
})}
|
|
</div>
|
|
`,
|
|
)}
|
|
${renderSection(
|
|
MODE_ICON,
|
|
"text-purple-400",
|
|
"bg-purple-500/20",
|
|
"host_modal.mode",
|
|
html`
|
|
<div class="grid grid-cols-2 gap-4">
|
|
${[GameMode.FFA, GameMode.Team].map((mode) => {
|
|
const isSelected = settings.gameMode.selected === mode;
|
|
return html`
|
|
<button
|
|
class="${cardClass(isSelected, "py-6 text-center")}"
|
|
@click=${() => this.handleGameModeSelect(mode)}
|
|
>
|
|
<span
|
|
class="text-sm font-bold text-white uppercase tracking-widest"
|
|
>
|
|
${mode === GameMode.FFA
|
|
? translateText("game_mode.ffa")
|
|
: translateText("game_mode.teams")}
|
|
</span>
|
|
</button>
|
|
`;
|
|
})}
|
|
</div>
|
|
`,
|
|
)}
|
|
${settings.gameMode.selected === GameMode.FFA
|
|
? nothing
|
|
: html`
|
|
<section class="space-y-6">
|
|
<div
|
|
class="text-xs font-bold text-white/40 uppercase tracking-widest mb-4 pl-2"
|
|
>
|
|
${translateText("host_modal.team_count")}
|
|
</div>
|
|
<div class="grid grid-cols-2 md:grid-cols-5 gap-3">
|
|
${TEAM_COUNT_OPTIONS.map((o) => {
|
|
const isSelected = settings.teamCount.selected === o;
|
|
return html`
|
|
<button
|
|
class="${cardClass(
|
|
isSelected,
|
|
"px-4 py-3 text-center",
|
|
)}"
|
|
@click=${() => this.handleTeamCountSelect(o)}
|
|
>
|
|
<span class="${CARD_LABEL_CLASS} text-white">
|
|
${typeof o === "string"
|
|
? o === HumansVsNations
|
|
? translateText("public_lobby.teams_hvn")
|
|
: translateText(`host_modal.teams_${o}`)
|
|
: translateText("public_lobby.teams", { num: o })}
|
|
</span>
|
|
</button>
|
|
`;
|
|
})}
|
|
</div>
|
|
</section>
|
|
`}
|
|
${renderSection(
|
|
OPTIONS_ICON,
|
|
"text-orange-400",
|
|
"bg-orange-500/20",
|
|
settings.options.titleKey,
|
|
html`
|
|
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
|
<div
|
|
class="col-span-2 rounded-xl p-4 flex flex-col justify-center border transition-all duration-200 ${settings
|
|
.options.bots.value > 0
|
|
? ACTIVE_CARD
|
|
: INACTIVE_CARD}"
|
|
>
|
|
<fluent-slider
|
|
min="0"
|
|
max="400"
|
|
step="1"
|
|
.value=${settings.options.bots.value}
|
|
labelKey=${settings.options.bots.labelKey}
|
|
disabledKey=${settings.options.bots.disabledKey}
|
|
@value-changed=${this.handleBotsChanged}
|
|
></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),
|
|
)}
|
|
${settings.options.inputCards}
|
|
</div>
|
|
`,
|
|
)}
|
|
${renderSection(
|
|
ENABLES_ICON,
|
|
"text-teal-400",
|
|
"bg-teal-500/20",
|
|
settings.unitTypes.titleKey,
|
|
html`
|
|
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4">
|
|
${this.renderUnitTypeOptions(settings.unitTypes.disabledUnits)}
|
|
</div>
|
|
`,
|
|
"space-y-6 pb-6",
|
|
)}
|
|
</div>
|
|
`;
|
|
}
|
|
}
|