Files
OpenFrontIO/src/client/components/map/MapDisplay.ts
T
Ryan 70f2abb181 Homepage update & add 3 public lobbies (#3191)
## Description:

Update UI 
check https://homepageupdate.openfront.dev/ 

Improved mobile UI (now fills whole screen for all modals) e.g.:
<img width="432" height="852" alt="image"
src="https://github.com/user-attachments/assets/56de40af-4137-4c57-96b7-3910c9a665b8"
/>

Converted PublicLobby to be "GameModeSelector" to get a nicer 4x4 grid
div, where <GameModeSelector> now handles all the username validation
now (removed redundant code from modals such as matchmaking) also fixed
a bug where someone could have "[XX] X" as thier username (when the
minimum should be 3 chars for their name)

Now visually displays the 3 lobbies ffa/team/special (which is a
continuation from the work done in: #3196 )
<img width="818" height="563" alt="image"
src="https://github.com/user-attachments/assets/a15cd31b-6061-4fb8-83ee-ffde6225cfa7"
/>

updated the background:
<img width="1919" height="807" alt="image"
src="https://github.com/user-attachments/assets/358a7434-51b8-4540-baf2-d1be05053c44"
/>



slightly updated the glassy-look to be less glassy:
<img width="825" height="729" alt="image"
src="https://github.com/user-attachments/assets/1801871b-bbf8-43db-ac53-489337ae80a5"
/>



## 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
2026-02-18 23:11:01 -06:00

162 lines
5.7 KiB
TypeScript

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;
@state() private hasNations = true;
private observer: IntersectionObserver | null = null;
private dataLoaded = false;
createRenderRoot() {
return this;
}
connectedCallback() {
super.connectedCallback();
this.observer = new IntersectionObserver(
(entries) => {
if (entries.some((e) => e.isIntersecting) && !this.dataLoaded) {
this.dataLoaded = true;
this.loadMapData();
this.observer?.disconnect();
}
},
{ rootMargin: "200px" },
);
this.observer.observe(this);
}
disconnectedCallback() {
this.observer?.disconnect();
this.observer = null;
super.disconnectedCallback();
}
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 = data.webpPath;
const manifest = await data.manifest();
this.mapName = manifest.name;
this.hasNations =
Array.isArray(manifest.nations) && manifest.nations.length > 0;
} 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();
}
}
private preventImageDrag(event: DragEvent) {
event.preventDefault();
}
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 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"}"
>
${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}"
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"
/>
</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 && this.hasNations
? 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();
}
}