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:
<img width="3132" height="779" alt="image"
src="https://github.com/user-attachments/assets/da8f0314-ccad-4f45-a03f-1beb46981301"
/>

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`:
<img width="694" height="778" alt="image"
src="https://github.com/user-attachments/assets/1b2d8370-aa86-4329-9402-adf43f3ef799"
/>

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 <lewismmmm@gmail.com>
Co-authored-by: Evan <evanpelle@gmail.com>
This commit is contained in:
bijx
2026-01-05 19:27:36 -05:00
committed by GitHub
parent ecc174d248
commit b9f4a8d77b
7 changed files with 243 additions and 4 deletions
+1
View File
@@ -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/
+87
View File
@@ -0,0 +1,87 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
viewBox="0 0 100 100"
fill="none"
x="0px"
y="0px"
version="1.1"
id="svg67"
sodipodi:docname="noun-medal-4567887.svg"
width="100"
height="100"
inkscape:version="1.2.2 (732a01da63, 2022-12-09)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs71">
<filter
style="color-interpolation-filters:sRGB;"
inkscape:label="Invert"
id="filter203"
x="0"
y="0"
width="1"
height="1">
<feColorMatrix
type="hueRotate"
values="180"
result="color1"
id="feColorMatrix199" />
<feColorMatrix
values="-1 0 0 0 1 0 -1 0 0 1 0 0 -1 0 1 -0.21 -0.72 -0.07 2 0 "
result="color2"
id="feColorMatrix201" />
</filter>
<filter
style="color-interpolation-filters:sRGB;"
inkscape:label="Invert"
id="filter209"
x="0"
y="0"
width="1"
height="1">
<feColorMatrix
type="hueRotate"
values="180"
result="color1"
id="feColorMatrix205" />
<feColorMatrix
values="-1 0 0 0 1 0 -1 0 0 1 0 0 -1 0 1 -0.21 -0.72 -0.07 2 0 "
result="color2"
id="feColorMatrix207" />
</filter>
</defs>
<sodipodi:namedview
id="namedview69"
pagecolor="#505050"
bordercolor="#eeeeee"
borderopacity="1"
inkscape:showpageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#505050"
showgrid="false"
inkscape:zoom="6.456"
inkscape:cx="49.953532"
inkscape:cy="37.716853"
inkscape:window-width="1920"
inkscape:window-height="1010"
inkscape:window-x="1913"
inkscape:window-y="-6"
inkscape:window-maximized="1"
inkscape:current-layer="svg67" />
<path
d="m 59.903346,13.687732 v 6.602231 h 6.60223 v 19.806691 h -6.60223 v 6.602231 H 40.096654 v -6.602231 h -6.60223 V 20.289963 h 6.60223 v -6.602231 z"
fill="#000000"
id="path59"
style="stroke-width:1.65056;filter:url(#filter209)" />
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M 33.494424,0.48327138 V 7.0855019 h -6.602231 v 6.6022301 h -6.60223 v 33.011153 h 6.60223 v 6.60223 h 6.602231 v 6.602231 h -6.602231 v 39.613383 h 6.602231 v -6.602231 h 6.60223 v -6.60223 h 4.951673 v -6.602231 h 6.602231 v 6.602231 h 6.60223 v 6.60223 h 6.602231 v 6.602231 h 6.60223 V 59.903346 h -4.951673 v -6.602231 h 6.602231 v -6.60223 h 6.60223 V 13.687732 h -6.60223 V 7.0855019 H 66.505576 V 0.48327138 Z M 58.252788,86.312268 v -6.602231 h -6.60223 v -6.60223 h -6.602231 v 6.60223 h -6.60223 v 6.602231 H 33.494424 V 59.903346 H 64.855019 V 86.312268 Z M 59.903346,7.0855019 H 40.096654 v 6.6022301 h -6.60223 v 6.602231 h -6.602231 v 19.806691 h 6.602231 v 6.602231 h 6.60223 v 6.60223 h 19.806692 v -6.60223 h 6.60223 v -6.602231 h 6.602231 V 20.289963 h -6.602231 v -6.602231 h -6.60223 z"
fill="#000000"
id="path61"
style="stroke-width:1.65056;filter:url(#filter203)" />
</svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

+1
View File
@@ -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",
+77 -1
View File
@@ -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<GameMapType, Set<Difficulty>> = 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<UserMeResponse | false>,
) => {
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<GameMapType, Set<Difficulty>>();
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<Difficulty>();
set.add(difficulty as Difficulty);
winsMap.set(map, set);
}
this.mapWins = winsMap;
}
render() {
return html`
<o-modal title=${translateText("single_modal.title")}>
<div class="options-layout">
<!-- Map Selection -->
<div class="options-section">
<div class="option-title">${translateText("map.map")}</div>
<div
class="option-title"
style="position:relative; display:flex; align-items:center; justify-content:center; width:100%;"
>
<span style="text-align:center; width:100%;">
${translateText("map.map")}
</span>
<button
@click=${this.toggleAchievements}
title=${translateText("single_modal.toggle_achievements")}
style="display:flex; align-items:center; justify-content:center; width:28px; height:28px; border:1px solid rgba(255,255,255,0.2); border-radius:6px; background:rgba(255,255,255,0.06); cursor:pointer; padding:4px; position:absolute; right:0; top:50%; transform:translateY(-50%);"
>
<img
src="/images/MedalIconWhite.svg"
alt="Toggle achievements"
style=${`width:18px; height:18px; opacity:${this.showAchievements ? "1" : "0.5"};`}
/>
</button>
</div>
<div class="option-cards flex-col">
<!-- Use the imported mapCategories -->
${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()}`,
)}
+57 -3
View File
@@ -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<Difficulty> = 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`<div class="option-image">Error</div>`}
${this.showMedals
? html`<div class="medal-row">${this.renderMedals()}</div>`
: null}
<div class="option-card-title">${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);
return html`<div
class="medal-icon ${earned ? "earned" : ""}"
style="background-color:${colors[medal]};"
title=${medal}
></div>`;
});
}
private readWins(): Set<Difficulty> {
return this.wins ?? new Set();
}
}
+7
View File
@@ -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;
}
+13
View File
@@ -42,6 +42,11 @@ export const DiscordUserSchema = z.object({
});
export type DiscordUser = z.infer<typeof DiscordUserSchema>;
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<typeof UserMeResponseSchema>;