mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 07:40:43 +00:00
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:
@@ -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/
|
||||
|
||||
@@ -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 |
@@ -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",
|
||||
|
||||
@@ -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()}`,
|
||||
)}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user