-
Notifications
You must be signed in to change notification settings - Fork 1.1k
Add Public Achivements UI #3530
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 1 commit
a5c2d4f
3c4d1f9
a7a5dac
a65de08
5c30831
bbe4a74
f6bc47b
ebccc7c
014032b
7371063
f4aa73e
b957515
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| { | ||
| "win_no_nukes": { | ||
| "difficulty": "Hard" | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
| } | ||
|
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 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> | ||
| `; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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(), | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. why gameid? shouldn't game ids be private? just the game nmame |
||
| }); | ||
| export type PlayerAchievementJson = z.infer<typeof PlayerAchievementSchema>; | ||
|
|
||
| export const AchievementsResponseSchema = z.array( | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Just call this: AchievementsSchema |
||
| z.discriminatedUnion("type", [ | ||
| z.object({ | ||
| type: z.literal("singleplayer-map"), | ||
| data: z.array(SingleplayerMapAchievementSchema), | ||
| }), | ||
| z.object({ | ||
| type: z.literal("player"), | ||
| data: z.array(PlayerAchievementSchema), | ||
| }), | ||
| ]), | ||
| ); | ||
|
Comment on lines
+81
to
+92
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. i think it should look like: { cleaner than a discriminatedunion |
||
| 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>; | ||
|
|
||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.