UI Extraction Host/Solo Modal (#3181)

## Description:

UI Extraction Host/Solo Modal
- Made all buttons do the same "press" feel (options/settings/random
map) are now the same as the map button.
- Also fixed a bug where you could "drag" the map image off the button.

## 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-12 18:41:14 +00:00
committed by GitHub
parent bd96bcca0d
commit f8c14398c8
9 changed files with 1322 additions and 1334 deletions
File diff suppressed because it is too large Load Diff
+260 -531
View File
@@ -4,14 +4,11 @@ import { translateText } from "../client/Utils";
import { UserMeResponse } from "../core/ApiSchemas";
import {
Difficulty,
Duos,
GameMapSize,
GameMapType,
GameMode,
GameType,
HumansVsNations,
Quads,
Trios,
UnitType,
} from "../core/game/Game";
import { UserSettings } from "../core/game/UserSettings";
@@ -21,9 +18,8 @@ import { hasLinkedAccount } from "./Api";
import "./components/baseComponents/Button";
import "./components/baseComponents/Modal";
import { BaseModal } from "./components/BaseModal";
import "./components/Difficulties";
import "./components/FluentSlider";
import "./components/map/MapPicker";
import "./components/GameConfigSettings";
import "./components/ToggleInputCard";
import { modalHeader } from "./components/ui/ModalHeader";
import { fetchCosmetics } from "./Cosmetics";
import { crazyGamesSDK } from "./CrazyGamesSDK";
@@ -31,10 +27,14 @@ import { FlagInput } from "./FlagInput";
import { JoinLobbyEvent } from "./Main";
import { UsernameInput } from "./UsernameInput";
import {
renderToggleInputCard,
renderToggleInputCardInput,
} from "./utilities/RenderToggleInputCard";
import { renderUnitTypeOptions } from "./utilities/RenderUnitTypeOptions";
getBotsForCompactMap,
getRandomMapType,
getUpdatedDisabledUnits,
parseBoundedFloatFromInput,
parseBoundedIntegerFromInput,
preventDisallowedKeys,
toOptionalNumber,
} from "./utilities/GameConfigHelpers";
const DEFAULT_OPTIONS = {
selectedMap: GameMapType.World,
@@ -166,6 +166,60 @@ export class SinglePlayerModal extends BaseModal {
}
render() {
const inputCards = [
html`<toggle-input-card
.labelKey=${"single_modal.max_timer"}
.checked=${this.maxTimer}
.inputId=${"end-timer-value"}
.inputMin=${1}
.inputMax=${120}
.inputValue=${this.maxTimerValue}
.inputAriaLabel=${translateText("single_modal.max_timer")}
.inputPlaceholder=${translateText("single_modal.max_timer_placeholder")}
.defaultInputValue=${30}
.minValidOnEnable=${1}
.onToggle=${this.handleMaxTimerToggle}
.onInput=${this.handleMaxTimerValueChanges}
.onKeyDown=${this.handleMaxTimerValueKeyDown}
></toggle-input-card>`,
html`<toggle-input-card
.labelKey=${"single_modal.gold_multiplier"}
.checked=${this.goldMultiplier}
.inputId=${"gold-multiplier-value"}
.inputMin=${0.1}
.inputMax=${1000}
.inputStep=${"any"}
.inputValue=${this.goldMultiplierValue}
.inputAriaLabel=${translateText("single_modal.gold_multiplier")}
.inputPlaceholder=${translateText(
"single_modal.gold_multiplier_placeholder",
)}
.defaultInputValue=${2}
.minValidOnEnable=${0.1}
.onToggle=${this.handleGoldMultiplierToggle}
.onChange=${this.handleGoldMultiplierValueChanges}
.onKeyDown=${this.handleGoldMultiplierValueKeyDown}
></toggle-input-card>`,
html`<toggle-input-card
.labelKey=${"single_modal.starting_gold"}
.checked=${this.startingGold}
.inputId=${"starting-gold-value"}
.inputMin=${0}
.inputMax=${1000000000}
.inputStep=${100000}
.inputValue=${this.startingGoldValue}
.inputAriaLabel=${translateText("single_modal.starting_gold")}
.inputPlaceholder=${translateText(
"single_modal.starting_gold_placeholder",
)}
.defaultInputValue=${5000000}
.minValidOnEnable=${0}
.onToggle=${this.handleStartingGoldToggle}
.onInput=${this.handleStartingGoldValueChanges}
.onKeyDown=${this.handleStartingGoldValueKeyDown}
></toggle-input-card>`,
];
const content = html`
<div
class="h-full flex flex-col bg-black/60 backdrop-blur-md rounded-2xl border border-white/10 overflow-hidden"
@@ -198,450 +252,81 @@ export class SinglePlayerModal extends BaseModal {
: this.renderNotLoggedInBanner(),
})}
<!-- Scrollable Content -->
<div class="flex-1 overflow-y-auto custom-scrollbar px-6 pb-6 mr-1">
<div class="max-w-5xl mx-auto space-y-6 pt-4">
<!-- Map Selection -->
<div class="space-y-6">
<div
class="flex items-center gap-4 pb-2 border-b border-white/10"
>
<div
class="w-8 h-8 rounded-lg bg-blue-500/20 flex items-center justify-center text-blue-400"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class="w-5 h-5"
>
<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"
/>
</svg>
</div>
<h3
class="text-lg font-bold text-white uppercase tracking-wider"
>
${translateText("map.map")}
</h3>
</div>
<map-picker
.selectedMap=${this.selectedMap}
.useRandomMap=${this.useRandomMap}
.showMedals=${this.showAchievements}
.mapWins=${this.mapWins}
.onSelectMap=${(mapValue: GameMapType) =>
this.handleMapSelection(mapValue)}
.onSelectRandom=${() => this.handleSelectRandomMap()}
></map-picker>
</div>
<!-- Difficulty Selection -->
<div class="space-y-6">
<div
class="flex items-center gap-4 pb-2 border-b border-white/10"
>
<div
class="w-8 h-8 rounded-lg bg-green-500/20 flex items-center justify-center text-green-400"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class="w-5 h-5"
>
<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"
/>
</svg>
</div>
<h3
class="text-lg font-bold text-white uppercase tracking-wider"
>
${translateText("difficulty.difficulty")}
</h3>
</div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
${Object.entries(Difficulty)
.filter(([key]) => isNaN(Number(key)))
.map(
([key, value]) => html`
<button
class="relative group rounded-xl border transition-all duration-200 w-full overflow-hidden flex flex-col items-center p-4 gap-3 ${this
.selectedDifficulty === value
? "bg-blue-500/20 border-blue-500/50 shadow-[0_0_15px_rgba(59,130,246,0.2)]"
: "bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20"} ${this
.disableNations
? "opacity-30 cursor-not-allowed grayscale"
: ""}"
@click=${() =>
!this.disableNations &&
this.handleDifficultySelection(value)}
>
<difficulty-display
class="${this.disableNations
? "pointer-events-none"
: ""} transform scale-125"
.difficultyKey=${key}
></difficulty-display>
<div
class="text-xs font-bold text-white uppercase tracking-wider text-center w-full mt-1 break-words hyphens-auto"
>
${translateText(`difficulty.${key.toLowerCase()}`)}
</div>
</button>
`,
)}
</div>
</div>
<!-- Game Mode Selection -->
<div class="space-y-6">
<div
class="flex items-center gap-4 pb-2 border-b border-white/10"
>
<div
class="w-8 h-8 rounded-lg bg-purple-500/20 flex items-center justify-center text-purple-400"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class="w-5 h-5"
>
<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"
/>
</svg>
</div>
<h3
class="text-lg font-bold text-white uppercase tracking-wider"
>
${translateText("host_modal.mode")}
</h3>
</div>
<div class="grid grid-cols-2 gap-4">
${[GameMode.FFA, GameMode.Team].map((mode) => {
const isSelected = this.gameMode === mode;
const label =
mode === GameMode.FFA
? translateText("game_mode.ffa")
: translateText("game_mode.teams");
return html`
<button
class="w-full py-6 rounded-xl border transition-all duration-200 flex flex-col items-center justify-center gap-3 ${isSelected
? "bg-blue-500/20 border-blue-500/50 shadow-[0_0_15px_rgba(59,130,246,0.2)]"
: "bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20"}"
@click=${() => this.handleGameModeSelection(mode)}
>
<div
class="text-sm font-bold text-white uppercase tracking-widest break-words hyphens-auto"
>
${label}
</div>
</button>
`;
})}
</div>
</div>
${this.gameMode === GameMode.FFA
? ""
: html`
<!-- Team Count Selection -->
<div 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">
${[
2,
3,
4,
5,
6,
7,
Quads,
Trios,
Duos,
HumansVsNations,
].map(
(o) => html`
<button
class="w-full px-4 py-3 rounded-xl border transition-all duration-200 flex items-center justify-center ${this
.teamCount === o
? "bg-blue-500/20 border-blue-500/50 shadow-[0_0_15px_rgba(59,130,246,0.2)]"
: "bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20"}"
@click=${() => this.handleTeamCountSelection(o)}
>
<div
class="text-xs font-bold text-white uppercase tracking-wider text-center break-words hyphens-auto"
>
${typeof o === "string"
? o === HumansVsNations
? translateText("public_lobby.teams_hvn")
: translateText(`host_modal.teams_${o}`)
: translateText(`public_lobby.teams`, {
num: o,
})}
</div>
</button>
`,
)}
</div>
</div>
`}
<!-- Game Options -->
<div class="space-y-6">
<div
class="flex items-center gap-4 pb-2 border-b border-white/10"
>
<div
class="w-8 h-8 rounded-lg bg-orange-500/20 flex items-center justify-center text-orange-400"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class="w-5 h-5"
>
<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"
/>
</svg>
</div>
<h3
class="text-lg font-bold text-white uppercase tracking-wider"
>
${translateText("single_modal.options_title")}
</h3>
</div>
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
<!-- Bot Slider Card -->
<div
class="col-span-2 rounded-xl p-4 flex flex-col justify-center min-h-[100px] border transition-all duration-200 ${this
.bots > 0
? "bg-blue-500/20 border-blue-500/50 shadow-[0_0_15px_rgba(59,130,246,0.2)]"
: "bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20 opacity-80"}"
>
<fluent-slider
min="0"
max="400"
step="1"
.value=${this.bots}
labelKey="single_modal.bots"
disabledKey="single_modal.bots_disabled"
@value-changed=${this.handleBotsChange}
></fluent-slider>
</div>
${this.renderOptionToggle(
"single_modal.disable_nations",
this.disableNations,
(val) => (this.disableNations = val),
this.gameMode === GameMode.Team &&
this.teamCount === HumansVsNations,
)}
${this.renderOptionToggle(
"single_modal.instant_build",
this.instantBuild,
(val) => (this.instantBuild = val),
)}
${this.renderOptionToggle(
"single_modal.random_spawn",
this.randomSpawn,
(val) => (this.randomSpawn = val),
)}
${this.renderOptionToggle(
"single_modal.infinite_gold",
this.infiniteGold,
(val) => (this.infiniteGold = val),
)}
${this.renderOptionToggle(
"single_modal.infinite_troops",
this.infiniteTroops,
(val) => (this.infiniteTroops = val),
)}
${this.renderOptionToggle(
"single_modal.compact_map",
this.compactMap,
(val) => {
this.compactMap = val;
if (val && this.bots === 400) {
this.bots = 100;
} else if (!val && this.bots === 100) {
this.bots = 400;
}
<div
class="flex-1 overflow-y-auto custom-scrollbar px-6 pt-4 pb-6 mr-1 mx-auto w-full max-w-5xl"
>
<game-config-settings
class="block"
.sectionGapClass=${"space-y-6"}
.settings=${{
map: {
selected: this.selectedMap,
useRandom: this.useRandomMap,
showMedals: this.showAchievements,
mapWins: this.mapWins,
},
difficulty: {
selected: this.selectedDifficulty,
disabled: this.disableNations,
},
gameMode: {
selected: this.gameMode,
},
teamCount: {
selected: this.teamCount,
},
options: {
titleKey: "single_modal.options_title",
bots: {
value: this.bots,
labelKey: "single_modal.bots",
disabledKey: "single_modal.bots_disabled",
},
toggles: [
{
labelKey: "single_modal.disable_nations",
checked: this.disableNations,
hidden:
this.gameMode === GameMode.Team &&
this.teamCount === HumansVsNations,
},
)}
${renderToggleInputCard({
labelKey: "single_modal.max_timer",
checked: this.maxTimer,
onClick: () => {
this.maxTimer = !this.maxTimer;
if (!this.maxTimer) {
this.maxTimerValue = undefined;
} else {
// Set default value when enabling if not already set or invalid
if (!this.maxTimerValue || this.maxTimerValue <= 0) {
this.maxTimerValue = 30;
}
// Focus the input after render
setTimeout(() => {
const input = this.getEndTimerInput();
if (input) {
input.focus();
input.select();
}
}, 0);
}
{
labelKey: "single_modal.instant_build",
checked: this.instantBuild,
},
input: renderToggleInputCardInput({
id: "end-timer-value",
min: 1,
max: 120,
value: this.maxTimerValue ?? "",
ariaLabel: translateText("single_modal.max_timer"),
placeholder: translateText(
"single_modal.max_timer_placeholder",
),
onInput: this.handleMaxTimerValueChanges,
onKeyDown: this.handleMaxTimerValueKeyDown,
}),
})}
<!-- Gold Multiplier -->
${renderToggleInputCard({
labelKey: "single_modal.gold_multiplier",
checked: this.goldMultiplier,
onClick: () => {
this.goldMultiplier = !this.goldMultiplier;
if (!this.goldMultiplier) {
this.goldMultiplierValue = undefined;
} else {
if (
!this.goldMultiplierValue ||
this.goldMultiplierValue <= 0
) {
this.goldMultiplierValue = 2;
}
setTimeout(() => {
const input = this.renderRoot.querySelector(
"#gold-multiplier-value",
) as HTMLInputElement;
if (input) {
input.focus();
input.select();
}
}, 0);
}
{
labelKey: "single_modal.random_spawn",
checked: this.randomSpawn,
},
input: renderToggleInputCardInput({
id: "gold-multiplier-value",
min: 0.1,
max: 1000,
step: "any",
value: this.goldMultiplierValue ?? "",
ariaLabel: translateText("single_modal.gold_multiplier"),
placeholder: translateText(
"single_modal.gold_multiplier_placeholder",
),
onChange: this.handleGoldMultiplierValueChanges,
onKeyDown: this.handleGoldMultiplierValueKeyDown,
}),
})}
<!-- Starting Gold -->
${renderToggleInputCard({
labelKey: "single_modal.starting_gold",
checked: this.startingGold,
onClick: () => {
this.startingGold = !this.startingGold;
if (!this.startingGold) {
this.startingGoldValue = undefined;
} else {
if (
!this.startingGoldValue ||
this.startingGoldValue < 0
) {
this.startingGoldValue = 5000000;
}
setTimeout(() => {
const input = this.renderRoot.querySelector(
"#starting-gold-value",
) as HTMLInputElement;
if (input) {
input.focus();
input.select();
}
}, 0);
}
{
labelKey: "single_modal.infinite_gold",
checked: this.infiniteGold,
},
input: renderToggleInputCardInput({
id: "starting-gold-value",
min: 0,
max: 1000000000,
step: 100000,
value: this.startingGoldValue ?? "",
ariaLabel: translateText("single_modal.starting_gold"),
placeholder: translateText(
"single_modal.starting_gold_placeholder",
),
onInput: this.handleStartingGoldValueChanges,
onKeyDown: this.handleStartingGoldValueKeyDown,
}),
})}
</div>
</div>
<!-- Enable Settings -->
<div class="space-y-6">
<div
class="flex items-center gap-4 pb-2 border-b border-white/10"
>
<div
class="w-8 h-8 rounded-lg bg-teal-500/20 flex items-center justify-center text-teal-400"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class="w-5 h-5"
>
<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"
/>
</svg>
</div>
<h3
class="text-lg font-bold text-white uppercase tracking-wider"
>
${translateText("single_modal.enables_title")}
</h3>
</div>
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4">
${renderUnitTypeOptions({
disabledUnits: this.disabledUnits,
toggleUnit: this.toggleUnit.bind(this),
})}
</div>
</div>
</div>
{
labelKey: "single_modal.infinite_troops",
checked: this.infiniteTroops,
},
{
labelKey: "single_modal.compact_map",
checked: this.compactMap,
},
],
inputCards,
},
unitTypes: {
titleKey: "single_modal.enables_title",
disabledUnits: this.disabledUnits,
},
}}
@map-selected=${this.handleConfigMapSelected}
@random-map-selected=${this.handleConfigRandomMapSelected}
@difficulty-selected=${this.handleConfigDifficultySelected}
@game-mode-selected=${this.handleConfigGameModeSelected}
@team-count-selected=${this.handleConfigTeamCountSelected}
@bots-changed=${this.handleBotsChange}
@option-toggle-changed=${this.handleConfigOptionToggleChanged}
@unit-toggle-changed=${this.handleConfigUnitToggleChanged}
></game-config-settings>
</div>
<!-- Footer Action -->
@@ -698,33 +383,6 @@ export class SinglePlayerModal extends BaseModal {
);
}
// Helper for consistent option buttons
private renderOptionToggle(
labelKey: string,
checked: boolean,
onChange: (val: boolean) => void,
hidden: boolean = false,
): TemplateResult {
if (hidden) return html``;
return html`
<button
class="relative p-4 rounded-xl border transition-all duration-200 flex flex-col items-center justify-center gap-2 h-full min-h-[100px] w-full cursor-pointer ${checked
? "bg-blue-500/20 border-blue-500/50 shadow-[0_0_15px_rgba(59,130,246,0.2)]"
: "bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20 opacity-80"}"
@click=${() => onChange(!checked)}
>
<div
class="text-xs uppercase font-bold tracking-wider text-center w-full leading-tight break-words hyphens-auto ${checked
? "text-white"
: "text-white/60"}"
>
${translateText(labelKey)}
</div>
</button>
`;
}
protected onClose(): void {
// Reset all transient form state to ensure clean slate
this.selectedMap = DEFAULT_OPTIONS.selectedMap;
@@ -752,29 +410,121 @@ export class SinglePlayerModal extends BaseModal {
this.useRandomMap = true;
}
private handleConfigRandomMapSelected = () => {
this.handleSelectRandomMap();
};
private handleMapSelection(value: GameMapType) {
this.selectedMap = value;
this.useRandomMap = false;
}
private handleConfigMapSelected = (e: Event) => {
const customEvent = e as CustomEvent<{ map: GameMapType }>;
this.handleMapSelection(customEvent.detail.map);
};
private handleDifficultySelection(value: Difficulty) {
this.selectedDifficulty = value;
}
private handleBotsChange(e: Event) {
private handleConfigDifficultySelected = (e: Event) => {
const customEvent = e as CustomEvent<{ difficulty: Difficulty }>;
this.handleDifficultySelection(customEvent.detail.difficulty);
};
private handleConfigGameModeSelected = (e: Event) => {
const customEvent = e as CustomEvent<{ mode: GameMode }>;
this.handleGameModeSelection(customEvent.detail.mode);
};
private handleConfigTeamCountSelected = (e: Event) => {
const customEvent = e as CustomEvent<{ count: TeamCountConfig }>;
this.handleTeamCountSelection(customEvent.detail.count);
};
private handleCompactMapChange(val: boolean) {
this.compactMap = val;
this.bots = getBotsForCompactMap(this.bots, val);
}
private handleConfigOptionToggleChanged = (e: Event) => {
const customEvent = e as CustomEvent<{
labelKey: string;
checked: boolean;
}>;
const { labelKey, checked } = customEvent.detail;
switch (labelKey) {
case "single_modal.disable_nations":
this.disableNations = checked;
break;
case "single_modal.instant_build":
this.instantBuild = checked;
break;
case "single_modal.random_spawn":
this.randomSpawn = checked;
break;
case "single_modal.infinite_gold":
this.infiniteGold = checked;
break;
case "single_modal.infinite_troops":
this.infiniteTroops = checked;
break;
case "single_modal.compact_map":
this.handleCompactMapChange(checked);
break;
default:
break;
}
};
private handleConfigUnitToggleChanged = (e: Event) => {
const customEvent = e as CustomEvent<{ unit: UnitType; checked: boolean }>;
const { unit, checked } = customEvent.detail;
this.disabledUnits = getUpdatedDisabledUnits(
this.disabledUnits,
unit,
checked,
);
};
private handleBotsChange = (e: Event) => {
const customEvent = e as CustomEvent<{ value: number }>;
const value = customEvent.detail.value;
if (isNaN(value) || value < 0 || value > 400) {
return;
}
this.bots = value;
}
};
private handleMaxTimerValueKeyDown(e: KeyboardEvent) {
if (["-", "+", "e"].includes(e.key)) {
e.preventDefault();
}
}
private handleMaxTimerToggle = (
checked: boolean,
value: number | string | undefined,
) => {
this.maxTimer = checked;
this.maxTimerValue = toOptionalNumber(value);
};
private handleGoldMultiplierToggle = (
checked: boolean,
value: number | string | undefined,
) => {
this.goldMultiplier = checked;
this.goldMultiplierValue = toOptionalNumber(value);
};
private handleStartingGoldToggle = (
checked: boolean,
value: number | string | undefined,
) => {
this.startingGold = checked;
this.startingGoldValue = toOptionalNumber(value);
};
private handleMaxTimerValueKeyDown = (e: KeyboardEvent) => {
preventDisallowedKeys(e, ["-", "+", "e"]);
};
private getEndTimerInput(): HTMLInputElement | null {
return (
@@ -785,55 +535,46 @@ export class SinglePlayerModal extends BaseModal {
);
}
private handleMaxTimerValueChanges(e: Event) {
private handleMaxTimerValueChanges = (e: Event) => {
const input = e.target as HTMLInputElement;
input.value = input.value.replace(/[e+-]/gi, "");
const value = parseInt(input.value);
const value = parseBoundedIntegerFromInput(input, {
min: 1,
max: 120,
stripPattern: /[e+-]/gi,
});
// Always update state to keep UI and internal state in sync
if (isNaN(value) || value < 1 || value > 120) {
// Set to undefined for invalid/empty/out-of-range values
this.maxTimerValue = undefined;
} else {
this.maxTimerValue = value;
}
}
this.maxTimerValue = value;
};
private handleGoldMultiplierValueKeyDown(e: KeyboardEvent) {
if (["+", "-", "e", "E"].includes(e.key)) {
e.preventDefault();
}
}
private handleGoldMultiplierValueKeyDown = (e: KeyboardEvent) => {
preventDisallowedKeys(e, ["+", "-", "e", "E"]);
};
private handleGoldMultiplierValueChanges(e: Event) {
private handleGoldMultiplierValueChanges = (e: Event) => {
const input = e.target as HTMLInputElement;
const value = parseFloat(input.value);
const value = parseBoundedFloatFromInput(input, { min: 0.1, max: 1000 });
if (isNaN(value) || value < 0.1 || value > 1000) {
if (value === undefined) {
this.goldMultiplierValue = undefined;
input.value = "";
} else {
this.goldMultiplierValue = value;
}
}
};
private handleStartingGoldValueKeyDown(e: KeyboardEvent) {
if (["-", "+", "e", "E"].includes(e.key)) {
e.preventDefault();
}
}
private handleStartingGoldValueKeyDown = (e: KeyboardEvent) => {
preventDisallowedKeys(e, ["-", "+", "e", "E"]);
};
private handleStartingGoldValueChanges(e: Event) {
private handleStartingGoldValueChanges = (e: Event) => {
const input = e.target as HTMLInputElement;
input.value = input.value.replace(/[eE+-]/g, "");
const value = parseInt(input.value);
const value = parseBoundedIntegerFromInput(input, {
min: 0,
max: 1000000000,
});
if (isNaN(value) || value < 0 || value > 1000000000) {
this.startingGoldValue = undefined;
} else {
this.startingGoldValue = value;
}
}
this.startingGoldValue = value;
};
private handleGameModeSelection(value: GameMode) {
this.gameMode = value;
@@ -843,18 +584,6 @@ export class SinglePlayerModal extends BaseModal {
this.teamCount = value;
}
private getRandomMap(): GameMapType {
const maps = Object.values(GameMapType);
const randIdx = Math.floor(Math.random() * maps.length);
return maps[randIdx] as GameMapType;
}
private toggleUnit(unit: UnitType, checked: boolean): void {
this.disabledUnits = checked
? [...this.disabledUnits, unit]
: this.disabledUnits.filter((u) => u !== unit);
}
private async startGame() {
// Validate and clamp maxTimer setting before starting
let finalMaxTimerValue: number | undefined = undefined;
@@ -879,7 +608,7 @@ export class SinglePlayerModal extends BaseModal {
// If random map is selected, choose a random map now
if (this.useRandomMap) {
this.selectedMap = this.getRandomMap();
this.selectedMap = getRandomMapType();
}
console.log(
+447
View File
@@ -0,0 +1,447 @@
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.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;
};
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 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.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>
`;
}
}
+173
View File
@@ -0,0 +1,173 @@
import { LitElement, PropertyValues, html, nothing } from "lit";
import { customElement, property } from "lit/decorators.js";
import { translateText } from "../Utils";
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 INPUT_CLASS =
"w-full text-center rounded bg-black/60 text-white text-sm font-bold border border-white/20 focus:outline-none focus:border-blue-500 p-1 my-1";
const CARD_LABEL_CLASS =
"text-xs uppercase font-bold tracking-wider leading-tight break-words hyphens-auto";
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}`;
}
@customElement("toggle-input-card")
export class ToggleInputCard extends LitElement {
@property({ attribute: false }) labelKey = "";
@property({ type: Boolean, attribute: false }) checked = false;
@property({ attribute: false }) inputId?: string;
@property({ attribute: false }) inputType = "number";
@property({ attribute: false }) inputMin?: number | string;
@property({ attribute: false }) inputMax?: number | string;
@property({ attribute: false }) inputStep?: number | string;
@property({ attribute: false }) inputValue?: number | string;
@property({ attribute: false }) inputAriaLabel?: string;
@property({ attribute: false }) inputPlaceholder?: string;
@property({ attribute: false }) defaultInputValue?: number | string;
@property({ attribute: false }) minValidOnEnable?: number;
@property({ attribute: false }) onToggle?: (
checked: boolean,
value: number | string | undefined,
) => void;
@property({ attribute: false }) onInput?: (e: Event) => void;
@property({ attribute: false }) onChange?: (e: Event) => void;
@property({ attribute: false }) onKeyDown?: (e: KeyboardEvent) => void;
createRenderRoot() {
return this;
}
protected updated(changedProperties: PropertyValues<this>) {
if (!changedProperties.has("checked")) return;
const previousChecked = changedProperties.get("checked");
if (previousChecked === false && this.checked) {
const input = this.querySelector("input");
if (input) {
input.focus();
input.select();
}
}
}
private toOptionalNumber(
value: number | string | undefined,
): number | undefined {
if (typeof value === "number") {
return Number.isFinite(value) ? value : undefined;
}
if (typeof value === "string") {
const trimmed = value.trim();
if (!trimmed) return undefined;
const numeric = Number(trimmed);
return Number.isFinite(numeric) ? numeric : undefined;
}
return undefined;
}
private resolveValueOnEnable(): number | string | undefined {
const currentValue = this.inputValue;
if (
currentValue === undefined ||
currentValue === null ||
currentValue === ""
) {
return this.defaultInputValue;
}
if (this.minValidOnEnable === undefined) {
return currentValue;
}
const numericValue = this.toOptionalNumber(currentValue);
if (numericValue === undefined || numericValue < this.minValidOnEnable) {
return this.defaultInputValue;
}
return numericValue;
}
private emitToggle() {
const nextChecked = !this.checked;
const nextValue = nextChecked ? this.resolveValueOnEnable() : undefined;
this.onToggle?.(nextChecked, nextValue);
}
private handleCardClick = () => {
this.emitToggle();
};
render() {
return html`
<div class="${cardClass(this.checked, "relative overflow-hidden")}">
<button
type="button"
aria-pressed=${this.checked}
@click=${this.handleCardClick}
class="w-full h-full p-3 flex flex-col items-center justify-between gap-2 focus:outline-none"
>
<div
class="w-5 h-5 rounded border flex items-center justify-center transition-colors mt-1 ${this
.checked
? "bg-blue-500 border-blue-500"
: "border-white/20 bg-white/5"}"
>
${this.checked
? html`<svg
xmlns="http://www.w3.org/2000/svg"
class="h-3 w-3 text-white"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clip-rule="evenodd"
/>
</svg>`
: ""}
</div>
${this.checked
? html`<div class="h-[30px] my-1"></div>`
: html`<div class="h-[2px] w-4 rounded my-3 bg-white/10"></div>`}
<span
class="${CARD_LABEL_CLASS} text-center ${this.checked
? "text-white"
: "text-white/60"}"
>
${translateText(this.labelKey)}
</span>
</button>
${this.checked
? html`
<div
class="absolute left-3 right-3 top-1/2 -translate-y-1/2 z-10"
>
<input
type=${this.inputType}
id=${this.inputId ?? nothing}
min=${this.inputMin ?? nothing}
max=${this.inputMax ?? nothing}
step=${this.inputStep ?? nothing}
.value=${String(this.inputValue ?? "")}
class=${INPUT_CLASS}
aria-label=${this.inputAriaLabel ?? nothing}
placeholder=${this.inputPlaceholder ?? nothing}
@input=${this.onInput}
@change=${this.onChange}
@keydown=${this.onKeyDown}
/>
</div>
`
: nothing}
</div>
`;
}
}
+8 -2
View File
@@ -53,6 +53,10 @@ export class MapDisplay extends LitElement {
}
}
private preventImageDrag(event: DragEvent) {
event.preventDefault();
}
render() {
return html`
<div
@@ -61,10 +65,10 @@ export class MapDisplay extends LitElement {
aria-selected="${this.selected}"
aria-label="${this.translation ?? this.mapName ?? this.mapKey}"
@keydown="${this.handleKeydown}"
class="w-full h-full p-3 flex flex-col items-center justify-between rounded-xl border cursor-pointer transition-all duration-200 gap-3 group ${this
class="w-full h-full p-3 flex flex-col items-center justify-between rounded-xl border cursor-pointer transition-all duration-200 active:scale-95 gap-3 group ${this
.selected
? "bg-blue-500/20 border-blue-500/50 shadow-[0_0_15px_rgba(59,130,246,0.3)]"
: "bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20 hover:-translate-y-1 active:scale-95"}"
: "bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20 hover:-translate-y-1"}"
>
${this.isLoading
? html`<div
@@ -79,6 +83,8 @@ export class MapDisplay extends LitElement {
<img
src="${this.mapWebpPath}"
alt="${this.translation || this.mapName}"
draggable="false"
@dragstart=${this.preventImageDrag}
class="w-full h-full object-cover ${this.selected
? "opacity-100"
: "opacity-80"} group-hover:opacity-100 transition-opacity duration-200"
+20 -13
View File
@@ -43,6 +43,10 @@ export class MapPicker extends LitElement {
this.onSelectRandom?.();
};
private preventImageDrag(event: DragEvent) {
event.preventDefault();
}
private getWins(mapValue: GameMapType): Set<Difficulty> {
return this.mapWins?.get(mapValue) ?? new Set();
}
@@ -54,7 +58,7 @@ export class MapPicker extends LitElement {
return html`
<div
@click=${() => this.handleMapSelection(mapValue)}
class="cursor-pointer transition-transform duration-200 active:scale-95"
class="cursor-pointer"
>
<map-display
.mapKey=${mapKey}
@@ -117,7 +121,7 @@ export class MapPicker extends LitElement {
type="button"
role="tab"
aria-selected=${!this.showAllMaps}
class="px-3 py-2 rounded-lg text-xs font-bold uppercase tracking-wider transition-all ${this
class="px-3 py-2 rounded-lg text-xs font-bold uppercase tracking-wider transition-all active:scale-95 ${this
.showAllMaps
? "text-white/60 hover:text-white"
: "bg-blue-500/20 text-blue-100 shadow-[0_0_12px_rgba(59,130,246,0.2)]"}"
@@ -129,7 +133,7 @@ export class MapPicker extends LitElement {
type="button"
role="tab"
aria-selected=${this.showAllMaps}
class="px-3 py-2 rounded-lg text-xs font-bold uppercase tracking-wider transition-all ${this
class="px-3 py-2 rounded-lg text-xs font-bold uppercase tracking-wider transition-all active:scale-95 ${this
.showAllMaps
? "bg-blue-500/20 text-blue-100 shadow-[0_0_12px_rgba(59,130,246,0.2)]"
: "text-white/60 hover:text-white"}"
@@ -152,27 +156,30 @@ export class MapPicker extends LitElement {
</h4>
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
<button
class="relative group rounded-xl border transition-all duration-200 overflow-hidden flex flex-col items-stretch ${this
type="button"
class="w-full h-full p-3 flex flex-col items-center justify-between rounded-xl border cursor-pointer transition-all duration-200 active:scale-95 gap-3 group ${this
.useRandomMap
? "bg-blue-500/20 border-blue-500/50 shadow-[0_0_15px_rgba(59,130,246,0.3)]"
: "bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20"}"
: "bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20 hover:-translate-y-1"}"
@click=${this.handleSelectRandomMap}
>
<div
class="aspect-[2/1] w-full relative overflow-hidden bg-black/20"
class="w-full aspect-[2/1] relative overflow-hidden rounded-lg bg-black/20"
>
<img
src=${randomMap}
alt=${translateText("map.random")}
class="w-full h-full object-cover opacity-60 group-hover:opacity-100 transition-opacity"
draggable="false"
@dragstart=${this.preventImageDrag}
class="w-full h-full object-cover ${this.useRandomMap
? "opacity-100"
: "opacity-80"} group-hover:opacity-100 transition-opacity duration-200"
/>
</div>
<div class="p-3 text-center border-t border-white/5">
<div
class="text-xs font-bold text-white uppercase tracking-wider break-words hyphens-auto"
>
${translateText("map.random")}
</div>
<div
class="text-xs font-bold text-white uppercase tracking-wider text-center leading-tight break-words hyphens-auto"
>
${translateText("map.random")}
</div>
</button>
</div>
+93
View File
@@ -0,0 +1,93 @@
import { GameMapType, UnitType } from "../../core/game/Game";
export function toOptionalNumber(
value: number | string | undefined,
): number | undefined {
if (typeof value === "number") {
return Number.isFinite(value) ? value : undefined;
}
if (typeof value === "string") {
const trimmed = value.trim();
if (!trimmed) return undefined;
const numeric = Number(trimmed);
return Number.isFinite(numeric) ? numeric : undefined;
}
return undefined;
}
export function preventDisallowedKeys(
e: KeyboardEvent,
disallowedKeys: string[],
): void {
if (disallowedKeys.includes(e.key)) {
e.preventDefault();
}
}
export function parseBoundedIntegerFromInput(
input: HTMLInputElement,
{
min,
max,
stripPattern = /[eE+-]/g,
radix = 10,
}: {
min: number;
max: number;
stripPattern?: RegExp;
radix?: number;
},
): number | undefined {
input.value = input.value.replace(stripPattern, "");
const value = parseInt(input.value, radix);
if (isNaN(value) || value < min || value > max) {
return undefined;
}
return value;
}
export function parseBoundedFloatFromInput(
input: HTMLInputElement,
{ min, max }: { min: number; max: number },
): number | undefined {
const value = parseFloat(input.value);
if (isNaN(value) || value < min || value > max) {
return undefined;
}
return value;
}
export function getBotsForCompactMap(
bots: number,
compactMapEnabled: boolean,
): number {
if (compactMapEnabled && bots === 400) {
return 100;
}
if (!compactMapEnabled && bots === 100) {
return 400;
}
return bots;
}
export function getRandomMapType(): GameMapType {
const maps = Object.values(GameMapType);
const randIdx = Math.floor(Math.random() * maps.length);
return maps[randIdx] as GameMapType;
}
export function getUpdatedDisabledUnits(
disabledUnits: UnitType[],
unit: UnitType,
checked: boolean,
): UnitType[] {
return checked
? [...disabledUnits, unit]
: disabledUnits.filter((u) => u !== unit);
}
@@ -1,162 +0,0 @@
import { TemplateResult, html, nothing } from "lit";
import { translateText } from "../Utils";
export const TOGGLE_INPUT_CARD_CLASSES = {
containerActive:
"bg-blue-500/20 border-blue-500/50 shadow-[0_0_15px_rgba(59,130,246,0.2)]",
containerInactive:
"bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20 opacity-80",
labelBase:
"text-[10px] uppercase font-bold tracking-wider text-center w-full leading-tight break-words hyphens-auto",
labelActive: "text-white",
labelInactive: "text-white/60",
input:
"w-full text-center rounded bg-black/60 text-white text-sm font-bold border border-white/20 focus:outline-none focus:border-blue-500 p-1 my-1",
};
export interface ToggleInputCardInputOptions {
id?: string;
type?: string;
min?: number | string;
max?: number | string;
step?: number | string;
value?: number | string;
ariaLabel?: string;
placeholder?: string;
onInput?: (e: Event) => void;
onChange?: (e: Event) => void;
onKeyDown?: (e: KeyboardEvent) => void;
onClick?: (e: Event) => void;
className?: string;
}
export function renderToggleInputCardInput({
id,
type = "number",
min,
max,
step,
value,
ariaLabel,
placeholder,
onInput,
onChange,
onKeyDown,
onClick,
className = TOGGLE_INPUT_CARD_CLASSES.input,
}: ToggleInputCardInputOptions): TemplateResult {
const resolvedValue = value ?? "";
const handleClick = onClick ?? ((e: Event) => e.stopPropagation());
return html`
<input
type=${type}
id=${id ?? nothing}
min=${min ?? nothing}
max=${max ?? nothing}
step=${step ?? nothing}
.value=${String(resolvedValue)}
class=${className}
aria-label=${ariaLabel ?? nothing}
placeholder=${placeholder ?? nothing}
@click=${handleClick}
@input=${onInput}
@change=${onChange}
@keydown=${onKeyDown}
/>
`;
}
export interface ToggleInputCardRenderContext {
labelKey: string;
checked: boolean;
input?: TemplateResult;
onClick?: (e: Event) => void;
onKeyDown?: (e: KeyboardEvent) => void;
activeClassName?: string;
inactiveClassName?: string;
labelBaseClassName?: string;
labelActiveClassName?: string;
labelInactiveClassName?: string;
role?: string;
tabIndex?: number;
}
export function renderToggleInputCard({
labelKey,
checked,
input,
onClick,
onKeyDown,
activeClassName = TOGGLE_INPUT_CARD_CLASSES.containerActive,
inactiveClassName = TOGGLE_INPUT_CARD_CLASSES.containerInactive,
labelBaseClassName = TOGGLE_INPUT_CARD_CLASSES.labelBase,
labelActiveClassName = TOGGLE_INPUT_CARD_CLASSES.labelActive,
labelInactiveClassName = TOGGLE_INPUT_CARD_CLASSES.labelInactive,
role,
tabIndex,
}: ToggleInputCardRenderContext): TemplateResult {
const shouldBehaveLikeButton = Boolean(onClick ?? onKeyDown);
const resolvedRole = role ?? (shouldBehaveLikeButton ? "button" : undefined);
const resolvedTabIndex = tabIndex ?? (shouldBehaveLikeButton ? 0 : undefined);
const resolvedOnKeyDown =
onKeyDown ??
(onClick
? (e: KeyboardEvent) => {
if ((e.target as HTMLElement).tagName.toLowerCase() === "input") {
return;
}
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onClick(e);
}
}
: undefined);
return html`
<div
role=${resolvedRole ?? nothing}
tabindex=${resolvedTabIndex ?? nothing}
@click=${onClick}
@keydown=${resolvedOnKeyDown}
class="relative p-3 rounded-xl border transition-all duration-200 flex flex-col items-center justify-between gap-2 h-full cursor-pointer min-h-[100px] ${checked
? activeClassName
: inactiveClassName}"
>
<div class="flex items-center justify-center w-full mt-1">
<div
class="w-5 h-5 rounded border flex items-center justify-center transition-colors ${checked
? "bg-blue-500 border-blue-500"
: "border-white/20 bg-white/5"}"
>
${checked
? html`<svg
xmlns="http://www.w3.org/2000/svg"
class="h-3 w-3 text-white"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clip-rule="evenodd"
/>
</svg>`
: ""}
</div>
</div>
${checked
? (input ?? html``)
: html`<div class="h-[2px] w-4 bg-white/10 rounded my-3"></div>`}
<div
class="${labelBaseClassName} ${checked
? labelActiveClassName
: labelInactiveClassName}"
>
${translateText(labelKey)}
</div>
</div>
`;
}
@@ -1,47 +0,0 @@
import { html, TemplateResult } from "lit";
import { UnitType } from "../../core/game/Game";
import { translateText } from "../Utils";
export interface UnitTypeRenderContext {
disabledUnits: UnitType[];
toggleUnit: (unit: UnitType, checked: boolean) => void;
}
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.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" },
];
export function renderUnitTypeOptions({
disabledUnits,
toggleUnit,
}: UnitTypeRenderContext): TemplateResult[] {
return unitOptions.map(({ type, translationKey }) => {
const isEnabled = !disabledUnits.includes(type);
return html`
<button
class="relative p-4 rounded-xl border transition-all duration-200 flex flex-col items-center justify-center gap-2 min-h-[100px] w-full cursor-pointer ${isEnabled
? "bg-blue-500/20 border-blue-500/50 shadow-[0_0_15px_rgba(59,130,246,0.2)]"
: "bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20 opacity-80"}"
aria-pressed="${isEnabled}"
@click=${() => toggleUnit(type, isEnabled)}
>
<div
class="text-xs uppercase font-bold tracking-wider text-center w-full leading-tight break-words hyphens-auto ${isEnabled
? "text-white"
: "text-white/60"}"
>
${translateText(translationKey)}
</div>
</button>
`;
});
}