diff --git a/resources/lang/en.json b/resources/lang/en.json index 2d91fe793..bb908775f 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -1253,6 +1253,8 @@ "max_timer": "Game length (minutes)", "max_timer_invalid": "Please enter a valid max timer value (1-120 minutes)", "max_timer_placeholder": "Mins", + "medals_earned": "Medals", + "medals_of_maps": "out of {total} maps", "nations": "Nations: ", "nations_disabled": "Disabled", "options_changed_no_achievements": "Custom settings – achievements disabled", diff --git a/src/client/SinglePlayerModal.ts b/src/client/SinglePlayerModal.ts index 994996ddd..f79ef39b9 100644 --- a/src/client/SinglePlayerModal.ts +++ b/src/client/SinglePlayerModal.ts @@ -1,4 +1,4 @@ -import { TemplateResult, html } from "lit"; +import { html, TemplateResult } from "lit"; import { customElement, state } from "lit/decorators.js"; import { translateText } from "../client/Utils"; import { UserMeResponse } from "../core/ApiSchemas"; @@ -9,6 +9,7 @@ import { GameMapType, GameMode, GameType, + maps, UnitType, } from "../core/game/Game"; import { TeamCountConfig } from "../core/Schemas"; @@ -18,6 +19,7 @@ import "./components/baseComponents/Button"; import "./components/baseComponents/Modal"; import { BaseModal } from "./components/BaseModal"; import "./components/GameConfigSettings"; +import { MEDAL_ORDER, medalIcon } from "./components/map/Medals"; import "./components/ToggleInputCard"; import { modalHeader } from "./components/ui/ModalHeader"; import { getPlayerCosmetics } from "./Cosmetics"; @@ -61,6 +63,48 @@ const DEFAULT_OPTIONS = { waterNukes: false, } as const; +// A map earns achievements only if it has nations to conquer — the same rule +// MapDisplay uses to decide whether to draw medals. Maps without nations (e.g. +// Baikal Nuke Wars) must be excluded from the medal totals. The complete set is +// cached for the page session and concurrent callers share the in-flight +// promise so we never fetch the manifests twice. A load that hits any fetch +// error resolves to null (not a partial set) and clears the shared promise, so +// a transient failure retries on the next call rather than locking in an +// undercount for the whole session. +let eligibleMapsCache: Set | null = null; +let eligibleMapsPromise: Promise | null> | null = null; + +async function loadAchievementEligibleMaps(): Promise | null> { + if (eligibleMapsCache) return eligibleMapsCache; + eligibleMapsPromise ??= (async () => { + const eligible = new Set(); + let hadFailure = false; + await Promise.all( + maps.map(async (m) => { + try { + const manifest = await terrainMapFileLoader + .getMapData(m.type) + .manifest(); + if (manifest.nations.length > 0) { + eligible.add(m.type); + } + } catch { + // A missing manifest would undercount the total; remember the failure + // so we don't cache this incomplete set below. + hadFailure = true; + } + }), + ); + if (hadFailure) { + eligibleMapsPromise = null; // allow a later call to retry + return null; + } + eligibleMapsCache = eligible; + return eligible; + })(); + return eligibleMapsPromise; +} + @customElement("single-player-modal") export class SinglePlayerModal extends BaseModal { protected routerName = "single-player"; @@ -84,6 +128,9 @@ export class SinglePlayerModal extends BaseModal { @state() private teamCount: TeamCountConfig = DEFAULT_OPTIONS.teamCount; @state() private showAchievements: boolean = false; @state() private mapWins: Map> = new Map(); + // Maps that support achievements (have nations). null until loaded — the + // medal overview shows a placeholder total meanwhile. + @state() private eligibleMaps: Set | null = null; @state() private userMeResponse: UserMeResponse | false = false; @state() private goldMultiplier: boolean = DEFAULT_OPTIONS.goldMultiplier; @state() private goldMultiplierValue: number | undefined = @@ -119,8 +166,35 @@ export class SinglePlayerModal extends BaseModal { private toggleAchievements = () => { this.showAchievements = !this.showAchievements; + if (this.showAchievements) void this.ensureEligibleMaps(); }; + private async ensureEligibleMaps() { + if (this.eligibleMaps) return; + const eligible = await loadAchievementEligibleMaps(); + // Leave eligibleMaps null on a failed/incomplete load so the overview keeps + // its placeholder total and the next toggle retries. + if (eligible) this.eligibleMaps = eligible; + } + + // Medals earned per difficulty, counted only on achievement-eligible maps. + private medalCounts(): Record { + const counts: Record = { + [Difficulty.Easy]: 0, + [Difficulty.Medium]: 0, + [Difficulty.Hard]: 0, + [Difficulty.Impossible]: 0, + }; + // Until eligibility is loaded, count nothing — otherwise the overview would + // briefly include wins on non-eligible maps before the manifests resolve. + if (!this.eligibleMaps) return counts; + for (const [map, difficulties] of this.mapWins) { + if (!this.eligibleMaps.has(map)) continue; + for (const difficulty of difficulties) counts[difficulty]++; + } + return counts; + } + private handleUserMeResponse = ( event: CustomEvent, ) => { @@ -178,26 +252,74 @@ export class SinglePlayerModal extends BaseModal { ariaLabel: translateText("common.back"), rightContent: hasLinkedAccount(this.userMeResponse) ? html`` + + ${translateText("single_modal.toggle_achievements")} + + ${this.showAchievements ? this.renderMedalOverview() : null}` : this.renderNotLoggedInBanner(), }); } + // Compact summary that expands under the header while achievements are on: + // each colored medal with how many maps you've earned it on, plus the shared + // "out of N maps" total (N = achievement-eligible maps). + private renderMedalOverview(): TemplateResult { + const counts = this.medalCounts(); + const total = this.eligibleMaps?.size ?? null; + return html`
+
+ + ${translateText("single_modal.medals_earned")} + +
+ ${MEDAL_ORDER.map((difficulty) => + this.renderMedalStat(difficulty, counts[difficulty]), + )} +
+ + ${translateText("single_modal.medals_of_maps", { + total: total ?? "…", + })} + +
+
`; + } + + private renderMedalStat( + difficulty: Difficulty, + count: number, + ): TemplateResult { + return html`
+ ${medalIcon(difficulty, "w-4 h-4")} + + ${count} +
`; + } + protected renderBody() { const inputCards = [ html` = { - [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 = medalMaskUrl; - return html`
`; - }); + return MEDAL_ORDER.map((medal) => + medalIcon(medal, "w-5 h-5", wins.has(medal)), + ); } private readWins(): Set { diff --git a/src/client/components/map/Medals.ts b/src/client/components/map/Medals.ts new file mode 100644 index 000000000..ee9b9594d --- /dev/null +++ b/src/client/components/map/Medals.ts @@ -0,0 +1,40 @@ +import { html, TemplateResult } from "lit"; +import medalIconRaw from "../../../../resources/images/MedalIconWhite.svg?raw"; +import { Difficulty } from "../../../core/game/Game"; +import { translateText } from "../../Utils"; + +// CSS mask that renders the medal glyph; tint it via `background-color`. +export const MEDAL_MASK = `url('data:image/svg+xml;utf8,${encodeURIComponent(medalIconRaw)}') no-repeat center / contain`; + +// Difficulty medals, easiest to hardest — the canonical display order. +export const MEDAL_ORDER: readonly Difficulty[] = [ + Difficulty.Easy, + Difficulty.Medium, + Difficulty.Hard, + Difficulty.Impossible, +]; + +export const MEDAL_COLORS: Record = { + [Difficulty.Easy]: "var(--medal-easy)", + [Difficulty.Medium]: "var(--medal-medium)", + [Difficulty.Hard]: "var(--medal-hard)", + [Difficulty.Impossible]: "var(--medal-impossible)", +}; + +/** + * A single colored medal glyph. Pass `earned=false` to dim it so it reads as + * "not yet won" (used on map cards); the overview keeps them full-color. + */ +export function medalIcon( + difficulty: Difficulty, + sizeClass = "w-5 h-5", + earned = true, +): TemplateResult { + return html`
`; +}