This commit is contained in:
Aotumuri
2026-03-29 09:58:14 +09:00
parent 217a2c4548
commit a5c2d4f52f
5 changed files with 286 additions and 8 deletions
+11
View File
@@ -220,6 +220,13 @@
"title": "Account",
"connected_as": "Connected as",
"stats_overview": "Stats Overview",
"achievements": "Achievements",
"achievement_label": "Achievement",
"achieved_on": "Achieved on",
"status": "Status",
"no_achievements": "No player achievements unlocked yet.",
"not_unlocked_yet": "Not unlocked yet",
"unknown_difficulty": "Unknown",
"link_discord": "Link Discord Account",
"log_out": "Log Out",
"sign_in_desc": "Sign in to save your stats and progress",
@@ -235,6 +242,10 @@
"enter_email_address": "Please enter an email address",
"personal_player_id": "Personal Player ID:"
},
"achivements": {
"win_no_nukes": "Win Without Nukes",
"win_no_nukes_desc": "Win a free-for-all match without launching any nukes."
},
"leaderboard_modal": {
"title": "Leaderboard",
"ranked_tab": "1v1 Ranked",
+5
View File
@@ -0,0 +1,5 @@
{
"win_no_nukes": {
"difficulty": "Hard"
}
}
+11
View File
@@ -11,6 +11,7 @@ import { fetchPlayerById, getUserMe } from "./Api";
import { discordLogin, logOut, sendMagicLink } from "./Auth";
import "./components/baseComponents/stats/DiscordUserHeader";
import "./components/baseComponents/stats/GameList";
import "./components/baseComponents/stats/PlayerAchievements";
import "./components/baseComponents/stats/PlayerStatsTable";
import "./components/baseComponents/stats/PlayerStatsTree";
import { BaseModal } from "./components/BaseModal";
@@ -132,6 +133,7 @@ export class AccountModal extends BaseModal {
private renderAccountInfo() {
const me = this.userMeResponse?.user;
const isLinked = me?.discord ?? me?.email;
const achievements = this.userMeResponse?.player?.achievements ?? [];
if (!isLinked) {
return this.renderLoginOptions();
@@ -174,6 +176,15 @@ export class AccountModal extends BaseModal {
</div>`
: ""}
<div class="bg-white/5 rounded-xl border border-white/10 p-6">
<h3 class="text-lg font-bold text-white mb-4">
${translateText("account_modal.achievements")}
</h3>
<player-achievements
.achievementGroups=${achievements}
></player-achievements>
</div>
<!-- Bottom Row: Recent Games Section -->
<div class="bg-white/5 rounded-xl border border-white/10 p-6">
<h3
@@ -0,0 +1,234 @@
import { LitElement, html } from "lit";
import { customElement, property } from "lit/decorators.js";
import playerAchievementMetadataJson from "../../../../../resources/playerAchievementMetadata.json" with { type: "json" };
import type {
AchievementsResponse,
PlayerAchievementJson,
} from "../../../../core/ApiSchemas";
import type { Difficulty } from "../../../../core/game/Game";
import { translateText } from "../../../Utils";
type PlayerAchievementMetadata = {
difficulty: Difficulty;
};
type PlayerAchievementCard = {
achievement: string;
achievedAt: string | null;
isUnlocked: boolean;
};
const playerAchievementMetadata = playerAchievementMetadataJson as Record<
string,
PlayerAchievementMetadata
>;
@customElement("player-achievements")
export class PlayerAchievements extends LitElement {
createRenderRoot() {
return this;
}
@property({ attribute: false }) achievementGroups: AchievementsResponse = [];
private get unlockedAchievements(): PlayerAchievementJson[] {
return this.achievementGroups
.flatMap((group) => (group.type === "player" ? group.data : []))
.slice()
.sort(
(a, b) =>
new Date(b.achievedAt).getTime() - new Date(a.achievedAt).getTime(),
);
}
private get achievements(): PlayerAchievementCard[] {
const unlockedByKey = new Map(
this.unlockedAchievements.map((achievement) => [
achievement.achievement,
achievement,
]),
);
const knownKeys = Object.keys(playerAchievementMetadata);
const achievementKeys = [
...knownKeys,
...this.unlockedAchievements
.map((achievement) => achievement.achievement)
.filter((achievement) => !knownKeys.includes(achievement)),
];
const originalOrder = new Map(
achievementKeys.map((achievement, index) => [achievement, index]),
);
return achievementKeys
.map((achievement) => {
const unlockedAchievement = unlockedByKey.get(achievement);
return {
achievement,
achievedAt: unlockedAchievement?.achievedAt ?? null,
isUnlocked: unlockedAchievement !== undefined,
};
})
.sort((a, b) => {
if (a.isUnlocked !== b.isUnlocked) {
return Number(b.isUnlocked) - Number(a.isUnlocked);
}
if (a.achievedAt && b.achievedAt) {
return (
new Date(b.achievedAt).getTime() - new Date(a.achievedAt).getTime()
);
}
return (
(originalOrder.get(a.achievement) ?? 0) -
(originalOrder.get(b.achievement) ?? 0)
);
});
}
private formatDate(achievedAt: string): string {
const date = new Date(achievedAt);
if (Number.isNaN(date.getTime())) {
return achievedAt;
}
return new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
}).format(date);
}
private resolveTitle(achievementKey: string): string {
const translationKey = `achivements.${achievementKey}`;
const translated = translateText(translationKey);
return translated === translationKey ? achievementKey : translated;
}
private resolveDescription(achievementKey: string): string | null {
const translationKey = `achivements.${achievementKey}_desc`;
const translated = translateText(translationKey);
return translated === translationKey ? null : translated;
}
private resolveDifficulty(achievementKey: string): Difficulty | null {
return playerAchievementMetadata[achievementKey]?.difficulty ?? null;
}
private difficultyClasses(difficulty: Difficulty): string {
switch (difficulty) {
case "Easy":
return "bg-emerald-500/15 text-emerald-300 border-emerald-400/25";
case "Medium":
return "bg-amber-500/15 text-amber-200 border-amber-400/25";
case "Hard":
return "bg-rose-500/15 text-rose-200 border-rose-400/25";
case "Impossible":
return "bg-violet-500/15 text-violet-200 border-violet-400/25";
default:
return "bg-white/5 text-white/60 border-white/10";
}
}
private renderDifficultyBadge(difficulty: Difficulty | null) {
if (!difficulty) {
return html`
<span
class="inline-flex items-center rounded-full border border-white/10 bg-white/5 px-3 py-1 text-xs font-semibold uppercase tracking-wider text-white/50"
>
${translateText("account_modal.unknown_difficulty")}
</span>
`;
}
const translationKey = `difficulty.${difficulty.toLowerCase()}`;
const translated = translateText(translationKey);
const label = translated === translationKey ? difficulty : translated;
return html`
<span
class="inline-flex items-center rounded-full border px-3 py-1 text-xs font-semibold uppercase tracking-wider ${this.difficultyClasses(
difficulty,
)}"
>
${label}
</span>
`;
}
private renderAchievementCard(achievement: PlayerAchievementCard) {
const difficulty = this.resolveDifficulty(achievement.achievement);
const description = this.resolveDescription(achievement.achievement);
const cardClasses = achievement.isUnlocked
? "border-white/10 bg-gradient-to-br from-slate-900/70 via-slate-900/40 to-black/20"
: "border-white/6 bg-gradient-to-br from-slate-900/40 via-slate-900/20 to-black/10 opacity-80";
return html`
<article
class="rounded-2xl border p-5 shadow-lg shadow-black/20 ${cardClasses}"
>
<div class="flex items-start justify-between gap-4">
<div class="min-w-0">
<div
class="text-[11px] font-bold uppercase tracking-[0.24em] text-white/35"
>
${translateText("account_modal.achievement_label")}
</div>
<h4 class="mt-2 text-lg font-semibold text-white">
${this.resolveTitle(achievement.achievement)}
</h4>
${description
? html`
<p class="mt-2 text-sm leading-6 text-white/60">
${description}
</p>
`
: null}
</div>
${this.renderDifficultyBadge(difficulty)}
</div>
<div class="mt-5 rounded-xl border border-white/10 bg-black/20 p-4">
<div
class="text-[11px] font-bold uppercase tracking-[0.24em] text-white/35"
>
${achievement.isUnlocked
? translateText("account_modal.achieved_on")
: translateText("account_modal.status")}
</div>
${achievement.isUnlocked && achievement.achievedAt
? html`
<time
class="mt-2 block text-sm font-medium text-white/80"
datetime=${achievement.achievedAt}
>
${this.formatDate(achievement.achievedAt)}
</time>
`
: html`
<div class="mt-2 text-sm font-medium text-white/50">
${translateText("account_modal.not_unlocked_yet")}
</div>
`}
</div>
</article>
`;
}
render() {
if (this.achievements.length === 0) {
return html`
<div
class="rounded-2xl border border-dashed border-white/10 bg-black/10 px-5 py-6 text-sm text-white/45"
>
${translateText("account_modal.no_achievements")}
</div>
`;
}
return html`
<div class="max-h-[36rem] overflow-y-auto pr-1 custom-scrollbar">
<div class="grid grid-cols-1 gap-4 xl:grid-cols-2">
${this.achievements.map((achievement) =>
this.renderAchievementCard(achievement),
)}
</div>
</div>
`;
}
}
+25 -8
View File
@@ -60,6 +60,29 @@ const SingleplayerMapAchievementSchema = z.object({
difficulty: z.enum(Difficulty),
});
export const PlayerAchievementSchema = z.object({
playerId: z.string(),
achievement: z.string(),
achievedAt: z.iso.datetime(),
gameId: z.string(),
game: z.string(),
});
export type PlayerAchievementJson = z.infer<typeof PlayerAchievementSchema>;
export const AchievementsResponseSchema = z.array(
z.discriminatedUnion("type", [
z.object({
type: z.literal("singleplayer-map"),
data: z.array(SingleplayerMapAchievementSchema),
}),
z.object({
type: z.literal("player"),
data: z.array(PlayerAchievementSchema),
}),
]),
);
export type AchievementsResponse = z.infer<typeof AchievementsResponseSchema>;
export const UserMeResponseSchema = z.object({
user: z.object({
discord: DiscordUserSchema.optional(),
@@ -69,14 +92,7 @@ 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(),
achievements: AchievementsResponseSchema.optional(),
leaderboard: z
.object({
oneVone: z
@@ -127,6 +143,7 @@ export const PlayerProfileSchema = z.object({
user: DiscordUserSchema.optional(),
games: PlayerGameSchema.array(),
stats: PlayerStatsTreeSchema,
achievements: AchievementsResponseSchema.optional(),
});
export type PlayerProfile = z.infer<typeof PlayerProfileSchema>;