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
+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();
}
}