From b9f4a8d77b488e67553f86c206ab958e5a3cbbd2 Mon Sep 17 00:00:00 2001 From: bijx Date: Mon, 5 Jan 2026 19:27:36 -0500 Subject: [PATCH] Feat: Singleplayer Achievements (#2734) Contributes towards the ongoing task of adding achievements: #2706 ## Description: Introduces a concept design and API implementation for singleplayer win achievements. New row of ~5~ 4 medals is added to the map select screen in the Singleplayer modal, one for each difficulty: image In order to achieve a medal in a particular map, you must win the singleplayer game (multiplayer and private match games don't count) in the selected difficulty **without tampering with the options or settings**. If any setting is changed from the default, regardless of the difficulty, you will ~receive a fifth "Custom" medal~ not receive the medal for that difficulty. Team games **do not** count towards the medal achievement. Completion of a medal will fill in the full correct color, as defined in our `variables.css`: image Completion medals can be toggled on or off (hidden by default) with the toggle button at the top of the section: https://github.com/user-attachments/assets/d08a58e0-b534-430e-9e8f-559134ad8852 [API implementation PR](https://github.com/openfrontio/infra/pull/234) ## 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: bijx --------- Co-authored-by: iamlewis Co-authored-by: Evan --- CREDITS.md | 1 + resources/images/MedalIconWhite.svg | 87 ++++++++++++++++++++++++++++ resources/lang/en.json | 1 + src/client/SinglePlayerModal.ts | 78 ++++++++++++++++++++++++- src/client/components/Maps.ts | 60 ++++++++++++++++++- src/client/styles/core/variables.css | 7 +++ src/core/ApiSchemas.ts | 13 +++++ 7 files changed, 243 insertions(+), 4 deletions(-) create mode 100644 resources/images/MedalIconWhite.svg diff --git a/CREDITS.md b/CREDITS.md index e17b2b575..aa8139f62 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 000000000..ef9fe4154 --- /dev/null +++ b/resources/images/MedalIconWhite.svg @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + diff --git a/resources/lang/en.json b/resources/lang/en.json index e759f71d1..fc787b8c2 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 76917c21d..4c7e95f8e 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 597d5b41c..213b010da 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"; @@ -55,6 +55,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; @@ -64,7 +66,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; @@ -74,6 +76,7 @@ export class MapDisplay extends LitElement { border-radius: 12px; cursor: pointer; transition: all 0.2s ease-in-out; + gap: 6px; } .option-card:hover { @@ -91,7 +94,7 @@ export class MapDisplay extends LitElement { font-size: 14px; color: #aaa; text-align: center; - margin: 0 0 4px 0; + margin: 0; } .option-image { @@ -106,6 +109,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() { @@ -143,8 +166,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 bb1b48fd0..eb57805ae 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 775e86c49..6461027ed 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;