mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-25 02:02:44 +00:00
Merge branch 'main' into bomb-confirmation
This commit is contained in:
+13
-15
@@ -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}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -60,6 +60,7 @@ const frequency: Partial<Record<GameMapName, number>> = {
|
||||
TwoLakes: 6,
|
||||
StraitOfHormuz: 4,
|
||||
Surrounded: 4,
|
||||
Didier: 2,
|
||||
};
|
||||
|
||||
interface MapWithMode {
|
||||
|
||||
Reference in New Issue
Block a user