mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-04 14:06:05 +00:00
feat: Achievement medal overview (#4487)
## Description: In the spirit of achievement hunting, seeing how many medals a player has achieved helps show overall progress. Overview only toggles on when user clicks toggle achievements button. Works on mobile too. https://github.com/user-attachments/assets/ea77075b-5e91-4e62-8ac9-52bcdf95cc64 ## 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 ## Please put your Discord username so you can be contacted if a bug or regression is found: bijx
This commit is contained in:
@@ -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",
|
||||
|
||||
+138
-16
@@ -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<GameMapType> | null = null;
|
||||
let eligibleMapsPromise: Promise<Set<GameMapType> | null> | null = null;
|
||||
|
||||
async function loadAchievementEligibleMaps(): Promise<Set<GameMapType> | null> {
|
||||
if (eligibleMapsCache) return eligibleMapsCache;
|
||||
eligibleMapsPromise ??= (async () => {
|
||||
const eligible = new Set<GameMapType>();
|
||||
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<GameMapType, Set<Difficulty>> = new Map();
|
||||
// Maps that support achievements (have nations). null until loaded — the
|
||||
// medal overview shows a placeholder total meanwhile.
|
||||
@state() private eligibleMaps: Set<GameMapType> | 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<Difficulty, number> {
|
||||
const counts: Record<Difficulty, number> = {
|
||||
[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<UserMeResponse | false>,
|
||||
) => {
|
||||
@@ -178,26 +252,74 @@ export class SinglePlayerModal extends BaseModal {
|
||||
ariaLabel: translateText("common.back"),
|
||||
rightContent: hasLinkedAccount(this.userMeResponse)
|
||||
? html`<button
|
||||
@click=${this.toggleAchievements}
|
||||
class="flex items-center gap-2 px-3 py-2 rounded-xl border border-white/10 bg-white/5 hover:bg-white/10 transition-all shrink-0 ${this
|
||||
.showAchievements
|
||||
? "bg-yellow-500/10 border-yellow-500/30 text-yellow-400"
|
||||
: "text-white/60"}"
|
||||
>
|
||||
<img
|
||||
src=${assetUrl("images/MedalIconWhite.svg")}
|
||||
class="w-4 h-4 opacity-80 shrink-0"
|
||||
style="${this.showAchievements ? "" : "filter: grayscale(1);"}"
|
||||
/>
|
||||
<span
|
||||
class="text-xs font-bold uppercase tracking-wider whitespace-nowrap"
|
||||
>${translateText("single_modal.toggle_achievements")}</span
|
||||
@click=${this.toggleAchievements}
|
||||
class="flex items-center gap-2 px-3 py-2 rounded-xl border border-white/10 bg-white/5 hover:bg-white/10 transition-all shrink-0 ${this
|
||||
.showAchievements
|
||||
? "bg-yellow-500/10 border-yellow-500/30 text-yellow-400"
|
||||
: "text-white/60"}"
|
||||
>
|
||||
</button>`
|
||||
<img
|
||||
src=${assetUrl("images/MedalIconWhite.svg")}
|
||||
class="w-4 h-4 opacity-80 shrink-0"
|
||||
style="${this.showAchievements ? "" : "filter: grayscale(1);"}"
|
||||
/>
|
||||
<span
|
||||
class="text-xs font-bold uppercase tracking-wider whitespace-nowrap"
|
||||
>${translateText("single_modal.toggle_achievements")}</span
|
||||
>
|
||||
</button>
|
||||
${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`<div class="basis-full w-full">
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-x-5 gap-y-2 px-4 py-2.5 rounded-xl border border-yellow-500/20 bg-yellow-500/5"
|
||||
>
|
||||
<span
|
||||
class="text-[11px] font-bold uppercase tracking-wider text-yellow-400/80 shrink-0"
|
||||
>
|
||||
${translateText("single_modal.medals_earned")}
|
||||
</span>
|
||||
<div class="flex flex-wrap items-center gap-x-4 gap-y-1.5">
|
||||
${MEDAL_ORDER.map((difficulty) =>
|
||||
this.renderMedalStat(difficulty, counts[difficulty]),
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
class="ml-auto text-[11px] font-semibold uppercase tracking-wider text-white/40 shrink-0"
|
||||
>
|
||||
${translateText("single_modal.medals_of_maps", {
|
||||
total: total ?? "…",
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private renderMedalStat(
|
||||
difficulty: Difficulty,
|
||||
count: number,
|
||||
): TemplateResult {
|
||||
return html`<div
|
||||
class="flex items-center gap-1.5"
|
||||
title=${translateText(`difficulty.${difficulty.toLowerCase()}`)}
|
||||
>
|
||||
${medalIcon(difficulty, "w-4 h-4")}
|
||||
<span class="text-xs font-medium text-white/50 hidden sm:inline"
|
||||
>${translateText(`difficulty.${difficulty.toLowerCase()}`)}</span
|
||||
>
|
||||
<span class="text-sm font-bold text-white tabular-nums">${count}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
protected renderBody() {
|
||||
const inputCards = [
|
||||
html`<toggle-input-card
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import medalIconRaw from "../../../../resources/images/MedalIconWhite.svg?raw";
|
||||
import { Difficulty, GameMapType } from "../../../core/game/Game";
|
||||
import { terrainMapFileLoader } from "../../TerrainMapFileLoader";
|
||||
import { translateText } from "../../Utils";
|
||||
import { starIcon } from "./MapFavorites";
|
||||
|
||||
const medalMaskUrl = `url('data:image/svg+xml;utf8,${encodeURIComponent(medalIconRaw)}') no-repeat center / contain`;
|
||||
import { MEDAL_ORDER, medalIcon } from "./Medals";
|
||||
|
||||
@customElement("map-display")
|
||||
export class MapDisplay extends LitElement {
|
||||
@@ -177,30 +175,10 @@ export class MapDisplay extends LitElement {
|
||||
}
|
||||
|
||||
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 = medalMaskUrl;
|
||||
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>`;
|
||||
});
|
||||
return MEDAL_ORDER.map((medal) =>
|
||||
medalIcon(medal, "w-5 h-5", wins.has(medal)),
|
||||
);
|
||||
}
|
||||
|
||||
private readWins(): Set<Difficulty> {
|
||||
|
||||
@@ -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, string> = {
|
||||
[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`<div
|
||||
class="${sizeClass} ${earned ? "opacity-100" : "opacity-25"}"
|
||||
style="background-color:${MEDAL_COLORS[
|
||||
difficulty
|
||||
]}; mask:${MEDAL_MASK}; -webkit-mask:${MEDAL_MASK};"
|
||||
title=${translateText(`difficulty.${difficulty.toLowerCase()}`)}
|
||||
></div>`;
|
||||
}
|
||||
Reference in New Issue
Block a user