mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-01 04:03:30 +00:00
Add map picker with Featured/All tabs (#3005)
Resolves #2996 ## Description: Replace map selection UI in src/client/SinglePlayerModal.ts and src/client/HostLobbyModal.ts with the picker (Featured/All tabs + random map card). Also, since the html was getting quite long, I extracted the shared parts into a separate component. <img width="575" height="592" alt="スクリーンショット 2026-01-23 21 57 03" src="https://github.com/user-attachments/assets/fc6bfbc3-cb66-452a-b971-436940b0fb99" /> <img width="633" height="648" alt="スクリーンショット 2026-01-23 21 57 12" src="https://github.com/user-attachments/assets/1aa409a1-b801-4a60-8b26-ba20e343d66e" /> I separated Map.ts because the display logic looked reusable in other places, but I’m also open to merging it back if that makes more sense. If the review prefers it integrated, I can combine them again. ## 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: aotumuri
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import { Difficulty, GameMapType } from "../../../core/game/Game";
|
||||
import { terrainMapFileLoader } from "../../TerrainMapFileLoader";
|
||||
import { translateText } from "../../Utils";
|
||||
|
||||
@customElement("map-display")
|
||||
export class MapDisplay extends LitElement {
|
||||
@property({ type: String }) mapKey = "";
|
||||
@property({ type: Boolean }) selected = false;
|
||||
@property({ type: String }) translation: string = "";
|
||||
@property({ type: Boolean }) showMedals = false;
|
||||
@property({ attribute: false }) wins: Set<Difficulty> = new Set();
|
||||
@state() private mapWebpPath: string | null = null;
|
||||
@state() private mapName: string | null = null;
|
||||
@state() private isLoading = true;
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.loadMapData();
|
||||
}
|
||||
|
||||
private async loadMapData() {
|
||||
if (!this.mapKey) return;
|
||||
|
||||
try {
|
||||
this.isLoading = true;
|
||||
const mapValue = GameMapType[this.mapKey as keyof typeof GameMapType];
|
||||
const data = terrainMapFileLoader.getMapData(mapValue);
|
||||
this.mapWebpPath = await data.webpPath();
|
||||
this.mapName = (await data.manifest()).name;
|
||||
} catch (error) {
|
||||
console.error("Failed to load map data:", error);
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private handleKeydown(event: KeyboardEvent) {
|
||||
// Trigger the same activation logic as click when Enter or Space is pressed
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
// Dispatch a click event to maintain compatibility with parent click handlers
|
||||
(event.target as HTMLElement).click();
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div
|
||||
role="button"
|
||||
tabindex="0"
|
||||
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
|
||||
.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"}"
|
||||
>
|
||||
${this.isLoading
|
||||
? html`<div
|
||||
class="w-full aspect-[2/1] text-white/40 transition-transform duration-200 rounded-lg bg-black/20 text-xs font-bold uppercase tracking-wider flex items-center justify-center animate-pulse"
|
||||
>
|
||||
${translateText("map_component.loading")}
|
||||
</div>`
|
||||
: this.mapWebpPath
|
||||
? html`<div
|
||||
class="w-full aspect-[2/1] relative overflow-hidden rounded-lg bg-black/20"
|
||||
>
|
||||
<img
|
||||
src="${this.mapWebpPath}"
|
||||
alt="${this.translation || this.mapName}"
|
||||
class="w-full h-full object-cover ${this.selected
|
||||
? "opacity-100"
|
||||
: "opacity-80"} group-hover:opacity-100 transition-opacity duration-200"
|
||||
/>
|
||||
</div>`
|
||||
: html`<div
|
||||
class="w-full aspect-[2/1] text-red-400 transition-transform duration-200 rounded-lg bg-red-500/10 text-xs font-bold uppercase tracking-wider flex items-center justify-center"
|
||||
>
|
||||
${translateText("map_component.error")}
|
||||
</div>`}
|
||||
${this.showMedals
|
||||
? html`<div class="flex gap-1 justify-center w-full">
|
||||
${this.renderMedals()}
|
||||
</div>`
|
||||
: null}
|
||||
<div
|
||||
class="text-xs font-bold text-white uppercase tracking-wider text-center leading-tight break-words hyphens-auto"
|
||||
>
|
||||
${this.translation || this.mapName}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderMedals() {
|
||||
const medalOrder: Difficulty[] = [
|
||||
Difficulty.Easy,
|
||||
Difficulty.Medium,
|
||||
Difficulty.Hard,
|
||||
Difficulty.Impossible,
|
||||
];
|
||||
const colors: Record<Difficulty, string> = {
|
||||
[Difficulty.Easy]: "var(--medal-easy)",
|
||||
[Difficulty.Medium]: "var(--medal-medium)",
|
||||
[Difficulty.Hard]: "var(--medal-hard)",
|
||||
[Difficulty.Impossible]: "var(--medal-impossible)",
|
||||
};
|
||||
const wins = this.readWins();
|
||||
return medalOrder.map((medal) => {
|
||||
const earned = wins.has(medal);
|
||||
const mask =
|
||||
"url('/images/MedalIconWhite.svg') no-repeat center / contain";
|
||||
return html`<div
|
||||
class="w-5 h-5 ${earned ? "opacity-100" : "opacity-25"}"
|
||||
style="background-color:${colors[
|
||||
medal
|
||||
]}; mask: ${mask}; -webkit-mask: ${mask};"
|
||||
title=${translateText(`difficulty.${medal.toLowerCase()}`)}
|
||||
></div>`;
|
||||
});
|
||||
}
|
||||
|
||||
private readWins(): Set<Difficulty> {
|
||||
return this.wins ?? new Set();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import {
|
||||
Difficulty,
|
||||
GameMapType,
|
||||
mapCategories,
|
||||
} from "../../../core/game/Game";
|
||||
import { translateText } from "../../Utils";
|
||||
import "./MapDisplay";
|
||||
import randomMap from "/images/RandomMap.webp?url";
|
||||
|
||||
const featuredMaps: GameMapType[] = [
|
||||
GameMapType.World,
|
||||
GameMapType.Europe,
|
||||
GameMapType.NorthAmerica,
|
||||
GameMapType.SouthAmerica,
|
||||
GameMapType.Asia,
|
||||
GameMapType.Africa,
|
||||
GameMapType.Japan,
|
||||
];
|
||||
|
||||
@customElement("map-picker")
|
||||
export class MapPicker extends LitElement {
|
||||
@property({ type: String }) selectedMap: GameMapType = GameMapType.World;
|
||||
@property({ type: Boolean }) useRandomMap = false;
|
||||
@property({ type: Boolean }) showMedals = false;
|
||||
@property({ type: Boolean }) randomMapDivider = false;
|
||||
@property({ attribute: false }) mapWins: Map<GameMapType, Set<Difficulty>> =
|
||||
new Map();
|
||||
@property({ attribute: false }) onSelectMap?: (map: GameMapType) => void;
|
||||
@property({ attribute: false }) onSelectRandom?: () => void;
|
||||
@state() private showAllMaps = false;
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
private handleMapSelection(mapValue: GameMapType) {
|
||||
this.onSelectMap?.(mapValue);
|
||||
}
|
||||
|
||||
private handleSelectRandomMap = () => {
|
||||
this.onSelectRandom?.();
|
||||
};
|
||||
|
||||
private getWins(mapValue: GameMapType): Set<Difficulty> {
|
||||
return this.mapWins?.get(mapValue) ?? new Set();
|
||||
}
|
||||
|
||||
private renderMapCard(mapValue: GameMapType) {
|
||||
const mapKey = Object.entries(GameMapType).find(
|
||||
([_, value]) => value === mapValue,
|
||||
)?.[0];
|
||||
return html`
|
||||
<div
|
||||
@click=${() => this.handleMapSelection(mapValue)}
|
||||
class="cursor-pointer transition-transform duration-200 active:scale-95"
|
||||
>
|
||||
<map-display
|
||||
.mapKey=${mapKey}
|
||||
.selected=${!this.useRandomMap && this.selectedMap === mapValue}
|
||||
.showMedals=${this.showMedals}
|
||||
.wins=${this.getWins(mapValue)}
|
||||
.translation=${translateText(`map.${mapKey?.toLowerCase()}`)}
|
||||
></map-display>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderAllMaps() {
|
||||
const mapCategoryEntries = Object.entries(mapCategories);
|
||||
return html`<div class="space-y-8">
|
||||
${mapCategoryEntries.map(
|
||||
([categoryKey, maps]) => html`
|
||||
<div class="w-full">
|
||||
<h4
|
||||
class="text-xs font-bold text-white/40 uppercase tracking-widest mb-4 pl-2"
|
||||
>
|
||||
${translateText(`map_categories.${categoryKey}`)}
|
||||
</h4>
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
${maps.map((mapValue) => this.renderMapCard(mapValue))}
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private renderFeaturedMaps() {
|
||||
let featuredMapList = featuredMaps;
|
||||
if (!featuredMapList.includes(this.selectedMap)) {
|
||||
featuredMapList = [this.selectedMap, ...featuredMaps];
|
||||
}
|
||||
return html`<div class="w-full">
|
||||
<h4
|
||||
class="text-xs font-bold text-white/40 uppercase tracking-widest mb-4 pl-2"
|
||||
>
|
||||
${translateText("map_categories.featured")}
|
||||
</h4>
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
${featuredMapList.map((mapValue) => this.renderMapCard(mapValue))}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div class="space-y-8">
|
||||
<div class="w-full">
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label="${translateText("map.map")}"
|
||||
class="grid grid-cols-2 gap-2 rounded-xl border border-white/10 bg-black/20 p-1"
|
||||
>
|
||||
<button
|
||||
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
|
||||
.showAllMaps
|
||||
? "text-white/60 hover:text-white"
|
||||
: "bg-blue-500/20 text-blue-100 shadow-[0_0_12px_rgba(59,130,246,0.2)]"}"
|
||||
@click=${() => (this.showAllMaps = false)}
|
||||
>
|
||||
${translateText("map.featured")}
|
||||
</button>
|
||||
<button
|
||||
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
|
||||
.showAllMaps
|
||||
? "bg-blue-500/20 text-blue-100 shadow-[0_0_12px_rgba(59,130,246,0.2)]"
|
||||
: "text-white/60 hover:text-white"}"
|
||||
@click=${() => (this.showAllMaps = true)}
|
||||
>
|
||||
${translateText("map.all")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
${this.showAllMaps ? this.renderAllMaps() : this.renderFeaturedMaps()}
|
||||
<div
|
||||
class="w-full ${this.randomMapDivider
|
||||
? "pt-4 border-t border-white/5"
|
||||
: ""}"
|
||||
>
|
||||
<h4
|
||||
class="text-xs font-bold text-white/40 uppercase tracking-widest mb-4 pl-2"
|
||||
>
|
||||
${translateText("map_categories.special")}
|
||||
</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
|
||||
.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"}"
|
||||
@click=${this.handleSelectRandomMap}
|
||||
>
|
||||
<div
|
||||
class="aspect-[2/1] w-full relative overflow-hidden 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"
|
||||
/>
|
||||
</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>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user