diff --git a/CREDITS.md b/CREDITS.md index e17b2b575f..aa8139f62a 100644 --- a/CREDITS.md +++ b/CREDITS.md @@ -50,3 +50,4 @@ Copyright © opentopography.org. All Rights Reserved. [Terms of Use](https://ope Stats icon by [Meko](https://thenounproject.com/mekoda/) – https://thenounproject.com/icon/stats-4942475/ Pay Per Click icon by [Fauzan Adiima](https://thenounproject.com/creator/fauzan94/) – https://thenounproject.com/icon/pay-per-click-2586454/ +Medal icon by [Snow](https://thenounproject.com/snowdoll/) – https://thenounproject.com/icon/medal-4567887/ diff --git a/resources/images/MedalIconWhite.svg b/resources/images/MedalIconWhite.svg new file mode 100644 index 0000000000..ef9fe41541 --- /dev/null +++ b/resources/images/MedalIconWhite.svg @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + diff --git a/resources/lang/en.json b/resources/lang/en.json index 0e1e5fd533..f795e7e62e 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -139,6 +139,7 @@ "title": "Single Player", "random_spawn": "Random spawn", "allow_alliances": "Allow alliances", + "toggle_achievements": "Toggle achievements", "options_title": "Options", "bots": "Bots: ", "bots_disabled": "Disabled", diff --git a/src/client/SinglePlayerModal.ts b/src/client/SinglePlayerModal.ts index 76917c21d8..4c7e95f8e5 100644 --- a/src/client/SinglePlayerModal.ts +++ b/src/client/SinglePlayerModal.ts @@ -1,6 +1,7 @@ import { LitElement, html } from "lit"; import { customElement, query, state } from "lit/decorators.js"; import { translateText } from "../client/Utils"; +import { UserMeResponse } from "../core/ApiSchemas"; import { Difficulty, Duos, @@ -49,6 +50,8 @@ export class SinglePlayerModal extends LitElement { @state() private useRandomMap: boolean = false; @state() private gameMode: GameMode = GameMode.FFA; @state() private teamCount: TeamCountConfig = 2; + @state() private showAchievements: boolean = false; + @state() private mapWins: Map> = new Map(); @state() private disabledUnits: UnitType[] = []; @@ -57,9 +60,17 @@ export class SinglePlayerModal extends LitElement { connectedCallback() { super.connectedCallback(); window.addEventListener("keydown", this.handleKeyDown); + document.addEventListener( + "userMeResponse", + this.handleUserMeResponse as EventListener, + ); } disconnectedCallback() { + document.removeEventListener( + "userMeResponse", + this.handleUserMeResponse as EventListener, + ); window.removeEventListener("keydown", this.handleKeyDown); super.disconnectedCallback(); } @@ -71,13 +82,76 @@ export class SinglePlayerModal extends LitElement { } }; + private toggleAchievements = () => { + this.showAchievements = !this.showAchievements; + }; + + private handleUserMeResponse = ( + event: CustomEvent, + ) => { + this.applyAchievements(event.detail); + }; + + private applyAchievements(userMe: UserMeResponse | false) { + if (!userMe) { + this.mapWins = new Map(); + return; + } + + const achievements = Array.isArray(userMe.player.achievements) + ? userMe.player.achievements + : []; + + const completions = + achievements.find( + (achievement) => achievement?.type === "singleplayer-map", + )?.data ?? []; + + const winsMap = new Map>(); + for (const entry of completions) { + const { mapName, difficulty } = entry ?? {}; + const isValidMap = + typeof mapName === "string" && + Object.values(GameMapType).includes(mapName as GameMapType); + const isValidDifficulty = + typeof difficulty === "string" && + Object.values(Difficulty).includes(difficulty as Difficulty); + if (!isValidMap || !isValidDifficulty) continue; + + const map = mapName as GameMapType; + const set = winsMap.get(map) ?? new Set(); + set.add(difficulty as Difficulty); + winsMap.set(map, set); + } + + this.mapWins = winsMap; + } + render() { return html`
-
${translateText("map.map")}
+
+ + ${translateText("map.map")} + + +
${Object.entries(mapCategories).map( @@ -103,6 +177,8 @@ export class SinglePlayerModal extends LitElement { .mapKey=${mapKey} .selected=${!this.useRandomMap && this.selectedMap === mapValue} + .showMedals=${this.showAchievements} + .wins=${this.mapWins.get(mapValue) ?? new Set()} .translation=${translateText( `map.${mapKey?.toLowerCase()}`, )} diff --git a/src/client/components/Maps.ts b/src/client/components/Maps.ts index 55ea40b821..5e05649b32 100644 --- a/src/client/components/Maps.ts +++ b/src/client/components/Maps.ts @@ -1,6 +1,6 @@ import { LitElement, css, html } from "lit"; import { customElement, property, state } from "lit/decorators.js"; -import { GameMapType } from "../../core/game/Game"; +import { Difficulty, GameMapType } from "../../core/game/Game"; import { terrainMapFileLoader } from "../TerrainMapFileLoader"; import { translateText } from "../Utils"; @@ -54,6 +54,8 @@ 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 = new Set(); @state() private mapWebpPath: string | null = null; @state() private mapName: string | null = null; @state() private isLoading = true; @@ -63,7 +65,7 @@ export class MapDisplay extends LitElement { width: 100%; min-width: 100px; max-width: 120px; - padding: 4px 4px 0 4px; + padding: 6px 6px 10px 6px; display: flex; flex-direction: column; align-items: center; @@ -73,6 +75,7 @@ export class MapDisplay extends LitElement { border-radius: 12px; cursor: pointer; transition: all 0.2s ease-in-out; + gap: 6px; } .option-card:hover { @@ -90,7 +93,7 @@ export class MapDisplay extends LitElement { font-size: 14px; color: #aaa; text-align: center; - margin: 0 0 4px 0; + margin: 0; } .option-image { @@ -105,6 +108,26 @@ export class MapDisplay extends LitElement { align-items: center; justify-content: center; } + + .medal-row { + display: flex; + gap: 6px; + justify-content: center; + width: 100%; + } + + .medal-icon { + width: 20px; + height: 20px; + background: rgba(255, 255, 255, 0.12); + mask: url("/images/MedalIconWhite.svg") no-repeat center / contain; + -webkit-mask: url("/images/MedalIconWhite.svg") no-repeat center / contain; + opacity: 0.25; + } + + .medal-icon.earned { + opacity: 1; + } `; connectedCallback() { @@ -142,8 +165,39 @@ export class MapDisplay extends LitElement { class="option-image" />` : html`
Error
`} + ${this.showMedals + ? html`
${this.renderMedals()}
` + : null}
${this.translation || this.mapName}
`; } + + private renderMedals() { + const medalOrder: Difficulty[] = [ + Difficulty.Easy, + Difficulty.Medium, + Difficulty.Hard, + Difficulty.Impossible, + ]; + const colors: Record = { + [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); + return html`
`; + }); + } + + private readWins(): Set { + return this.wins ?? new Set(); + } } diff --git a/src/client/styles/core/variables.css b/src/client/styles/core/variables.css index bb1b48fd0b..eb57805aea 100644 --- a/src/client/styles/core/variables.css +++ b/src/client/styles/core/variables.css @@ -23,4 +23,11 @@ --secondaryColorDark: #374151; --secondaryColorHoverDark: #4b5563; --fontColorDark: #f3f4f6; + + /* Achievements */ + --medal-easy: #cd7f32; + --medal-medium: #c0c0c0; + --medal-hard: #ffd700; + --medal-impossible: #d32f2f; + --medal-custom: #2196f3; } diff --git a/src/core/ApiSchemas.ts b/src/core/ApiSchemas.ts index 775e86c49f..6461027edd 100644 --- a/src/core/ApiSchemas.ts +++ b/src/core/ApiSchemas.ts @@ -42,6 +42,11 @@ export const DiscordUserSchema = z.object({ }); export type DiscordUser = z.infer; +const SingleplayerMapAchievementSchema = z.object({ + mapName: z.enum(GameMapType), + difficulty: z.enum(Difficulty), +}); + export const UserMeResponseSchema = z.object({ user: z.object({ discord: DiscordUserSchema.optional(), @@ -51,6 +56,14 @@ export const UserMeResponseSchema = z.object({ publicId: z.string(), roles: z.string().array().optional(), flares: z.string().array().optional(), + achievements: z + .array( + z.object({ + type: z.literal("singleplayer-map"), // TODO: change the shape to be more flexible when we have more achievements + data: z.array(SingleplayerMapAchievementSchema), + }), + ) + .optional(), }), }); export type UserMeResponse = z.infer;