Merge branch 'main' into bomb-confirmation

This commit is contained in:
Ryan
2026-01-06 15:03:26 +00:00
committed by GitHub
20 changed files with 2443 additions and 25 deletions
+13 -15
View File
@@ -264,21 +264,19 @@ export class LangSelector extends LitElement {
});
return html`
<div class="container__row">
<button
id="lang-selector"
@click=${this.openModal}
class="text-center appearance-none w-full bg-blue-100 dark:bg-gray-700 hover:bg-blue-200 dark:hover:bg-gray-600 text-blue-900 dark:text-gray-100 p-3 sm:p-4 lg:p-5 font-medium text-sm sm:text-base lg:text-lg rounded-md border-none cursor-pointer transition-colors duration-300 flex items-center gap-2 justify-center"
>
<img
id="lang-flag"
class="w-6 h-4"
src="/flags/${currentLang.svg}.svg"
alt="flag"
/>
<span id="lang-name">${currentLang.native} (${currentLang.en})</span>
</button>
</div>
<button
id="lang-selector"
title="Change Language"
@click=${this.openModal}
class="fixed bottom-4 left-4 z-50 border-none bg-none cursor-pointer"
>
<img
id="lang-flag"
class="w-20 h-14"
src="/flags/${currentLang.svg}.svg"
alt="flag"
/>
</button>
<language-modal
.visible=${this.showModal}
+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()}`,
)}
+58 -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";
@@ -47,6 +47,7 @@ export const MapDescription: Record<keyof typeof GameMapType, string> = {
TwoLakes: "Two Lakes",
StraitOfHormuz: "Strait of Hormuz",
Surrounded: "Surrounded",
Didier: "Didier",
};
@customElement("map-display")
@@ -54,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;
@@ -63,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;
@@ -73,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 {
@@ -90,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 {
@@ -105,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() {
@@ -142,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>;
+1
View File
@@ -88,6 +88,7 @@ const numPlayersConfig = {
[GameMapType.TwoLakes]: [60, 50, 40],
[GameMapType.StraitOfHormuz]: [40, 36, 30],
[GameMapType.Surrounded]: [42, 28, 14], // 3, 2, 1 player(s) per island
[GameMapType.Didier]: [100, 70, 50],
} as const satisfies Record<GameMapType, [number, number, number]>;
export abstract class DefaultServerConfig implements ServerConfig {
+2
View File
@@ -110,6 +110,7 @@ export enum GameMapType {
TwoLakes = "Two Lakes",
StraitOfHormuz = "Strait of Hormuz",
Surrounded = "Surrounded",
Didier = "Didier",
}
export type GameMapName = keyof typeof GameMapType;
@@ -161,6 +162,7 @@ export const mapCategories: Record<string, GameMapType[]> = {
GameMapType.FourIslands,
GameMapType.Svalmel,
GameMapType.Surrounded,
GameMapType.Didier,
],
};
+1
View File
@@ -60,6 +60,7 @@ const frequency: Partial<Record<GameMapName, number>> = {
TwoLakes: 6,
StraitOfHormuz: 4,
Surrounded: 4,
Didier: 2,
};
interface MapWithMode {