-
${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;