Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions resources/lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,12 @@
"sub_renews_on": "Renews {date}",
"sub_price_monthly": "{price}/mo",
"stats_overview": "Stats Overview",
"achievements": "Achievements",
"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",
Expand All @@ -382,6 +388,10 @@
"enter_email_address": "Please enter an email address",
"public_player_id": "Public Player ID:"
},
"achievements": {
"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",
Expand Down
5 changes: 5 additions & 0 deletions resources/playerAchievementMetadata.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"win_no_nukes": {
"difficulty": "Hard"
}
}
44 changes: 44 additions & 0 deletions src/client/AccountModal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { html, TemplateResult } from "lit";
import { customElement, state } from "lit/decorators.js";
import { ClientEnv } from "src/client/ClientEnv";
import {
AchievementsResponse,
PlayerGame,
PlayerStatsTree,
UserMeResponse,
Expand All @@ -12,6 +13,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";
Expand All @@ -33,6 +35,7 @@ export class AccountModal extends BaseModal {
private userMeResponse: UserMeResponse | null = null;
private statsTree: PlayerStatsTree | null = null;
private recentGames: PlayerGame[] = [];
private achievementGroups: AchievementsResponse = [];
private cosmetics: Cosmetics | null = null;

constructor() {
Expand All @@ -45,15 +48,39 @@ export class AccountModal extends BaseModal {
if (this.userMeResponse?.player?.publicId === undefined) {
this.statsTree = null;
this.recentGames = [];
this.achievementGroups = [];
} else {
this.achievementGroups = this.getUserMeAchievementGroups(
this.userMeResponse,
);
}
} else {
this.statsTree = null;
this.recentGames = [];
this.achievementGroups = [];
this.requestUpdate();
}
});
}

private getUserMeAchievementGroups(
userMeResponse: UserMeResponse | null,
): AchievementsResponse {
const achievements = userMeResponse?.player?.achievements;
if (!achievements) return [];

return [
{
type: "singleplayer-map",
data: achievements.singleplayerMap,
},
{
type: "player",
data: achievements.player ?? [],
},
];
}

private hasAnyStats(): boolean {
if (!this.statsTree) return false;
// Check if statsTree has any data
Expand Down Expand Up @@ -110,6 +137,10 @@ export class AccountModal extends BaseModal {
private renderAccountInfo() {
const me = this.userMeResponse?.user;
const isLinked = me?.discord ?? me?.email;
const achievements =
this.achievementGroups.length > 0
? this.achievementGroups
: this.getUserMeAchievementGroups(this.userMeResponse);

if (!isLinked) {
return this.renderLoginOptions();
Expand Down Expand Up @@ -154,6 +185,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
Expand Down Expand Up @@ -376,6 +416,7 @@ export class AccountModal extends BaseModal {
.then((userMe) => {
if (userMe) {
this.userMeResponse = userMe;
this.achievementGroups = this.getUserMeAchievementGroups(userMe);
if (this.userMeResponse?.player?.publicId) {
this.loadPlayerProfile(this.userMeResponse.player.publicId);
}
Expand Down Expand Up @@ -414,6 +455,9 @@ export class AccountModal extends BaseModal {

this.recentGames = data.games;
this.statsTree = data.stats;
this.achievementGroups =
data.achievements ??
this.getUserMeAchievementGroups(this.userMeResponse);

this.requestUpdate();
} catch (err) {
Expand Down
246 changes: 246 additions & 0 deletions src/client/components/baseComponents/stats/PlayerAchievements.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
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 { assetUrl } from "../../../../core/AssetUrls";
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 = `achievements.${achievementKey}`;
const translated = translateText(translationKey);
return translated === translationKey ? achievementKey : translated;
}

private resolveDescription(achievementKey: string): string | null {
const translationKey = `achievements.${achievementKey}_desc`;
const translated = translateText(translationKey);
return translated === translationKey ? null : translated;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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 renderAchievementIcon(isUnlocked: boolean) {
const mask = `url('${assetUrl("images/MedalIconWhite.svg")}') no-repeat center / contain`;
const iconClasses = isUnlocked
? "bg-yellow-300 opacity-100"
: "bg-white/40 opacity-35";

return html`
<div
class="mt-0.5 h-7 w-7 shrink-0 ${iconClasses}"
style="mask: ${mask}; -webkit-mask: ${mask};"
></div>
`;
}

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-yellow-400/25 bg-gradient-to-br from-yellow-500/10 via-slate-900/55 to-black/20 shadow-yellow-950/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="flex h-full flex-col rounded-2xl border p-5 shadow-lg shadow-black/20 ${cardClasses}"
>
<div class="flex items-start justify-between gap-4">
<div class="flex min-w-0 items-start gap-3">
${this.renderAchievementIcon(achievement.isUnlocked)}
<h4 class="text-lg font-semibold text-white">
${this.resolveTitle(achievement.achievement)}
</h4>
</div>
${this.renderDifficultyBadge(difficulty)}
</div>

${description
? html`
<p class="mt-2 text-sm leading-6 text-white/60">${description}</p>
`
: null}

<div class="mt-auto pt-5">
<div class="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>
</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>
`;
}
}
Loading
Loading