From bbdacf272c11f5cdc51c60b6c255c46b2041aaeb Mon Sep 17 00:00:00 2001 From: Hikari Date: Sat, 7 Mar 2026 14:57:53 -0800 Subject: [PATCH] feat: show equipped items on character sheet and public profile --- apps/api/src/routes/profile.ts | 5 + apps/web/src/components/game/AboutPanel.tsx | 4 + .../web/src/components/game/CharacterPage.tsx | 44 ++++++++- .../components/game/CharacterSheetPanel.tsx | 57 ++++++++++- apps/web/src/styles.css | 97 +++++++++++++++++++ packages/types/src/interfaces/Api.ts | 3 + 6 files changed, 208 insertions(+), 2 deletions(-) diff --git a/apps/api/src/routes/profile.ts b/apps/api/src/routes/profile.ts index 58030f1..bf38b2e 100644 --- a/apps/api/src/routes/profile.ts +++ b/apps/api/src/routes/profile.ts @@ -75,6 +75,10 @@ profileRouter.get("/:discordId", async (context) => { return { id, name: title?.name ?? id }; }); + const equippedItems = (state?.equipment ?? []) + .filter((e) => e.owned && e.equipped) + .map(({ name, type, rarity, bonus }) => ({ name, type, rarity, bonus })); + return context.json({ characterName: player.characterName, pronouns: player.pronouns ?? "", @@ -106,6 +110,7 @@ profileRouter.get("/:discordId", async (context) => { achievementsUnlocked, unlockedTitles, activeTitle: player.activeTitle ?? "", + equippedItems, }); }); diff --git a/apps/web/src/components/game/AboutPanel.tsx b/apps/web/src/components/game/AboutPanel.tsx index 1d7bfcf..7f64356 100644 --- a/apps/web/src/components/game/AboutPanel.tsx +++ b/apps/web/src/components/game/AboutPanel.tsx @@ -71,6 +71,10 @@ const HOW_TO_PLAY = [ title: "๐Ÿ… Titles", body: "Earn Titles by reaching milestones โ€” defeating bosses, completing quests, prestiging, and more. Once unlocked, titles are yours forever and are never lost on prestige or transcendence resets. Set your active title from the Character tab to display it on your character sheet and public profile.", }, + { + title: "๐Ÿ—ก๏ธ Equipment", + body: "Defeat bosses to earn equipment drops: weapons, armour, and trinkets. Each item provides bonuses to gold income, combat power, or click power. Only one item per slot can be equipped at a time โ€” visit the Equipment panel to manage your loadout. Your currently equipped items are displayed on your character sheet and public profile.", + }, { title: "โ˜๏ธ Cloud Saves", body: "Your progress is automatically saved to the cloud every 30 seconds whilst you play. You can also force a manual save at any time using the sync button in the resource bar. Your save is protected by HMAC validation to ensure data integrity.", diff --git a/apps/web/src/components/game/CharacterPage.tsx b/apps/web/src/components/game/CharacterPage.tsx index 6f642ad..6cf5dc9 100644 --- a/apps/web/src/components/game/CharacterPage.tsx +++ b/apps/web/src/components/game/CharacterPage.tsx @@ -1,4 +1,4 @@ -import type { PublicProfileResponse } from "@elysium/types"; +import type { EquipmentBonus, EquipmentType, PublicProfileResponse } from "@elysium/types"; import { useEffect, useState } from "react"; interface CharacterPageProps { @@ -48,6 +48,26 @@ export const CharacterPage = ({ discordId }: CharacterPageProps): React.JSX.Elem ); } + const SLOT_ICONS: Record = { + weapon: "โš”๏ธ", + armour: "๐Ÿ›ก๏ธ", + trinket: "๐Ÿ’", + }; + + const formatBonus = (bonus: EquipmentBonus): string => { + const parts: string[] = []; + if (bonus.goldMultiplier !== undefined) { + parts.push(`+${Math.round((bonus.goldMultiplier - 1) * 100)}% Gold Income`); + } + if (bonus.combatMultiplier !== undefined) { + parts.push(`+${Math.round((bonus.combatMultiplier - 1) * 100)}% Combat Power`); + } + if (bonus.clickMultiplier !== undefined) { + parts.push(`+${Math.round((bonus.clickMultiplier - 1) * 100)}% Click Power`); + } + return parts.join(" ยท "); + }; + const avatarUrl = profile.avatar ? `https://cdn.discordapp.com/avatars/${discordId}/${profile.avatar}.png?size=128` : `https://cdn.discordapp.com/embed/avatars/${parseInt(discordId, 10) % 5}.png`; @@ -119,6 +139,28 @@ export const CharacterPage = ({ discordId }: CharacterPageProps): React.JSX.Elem )} + {profile.equippedItems.length > 0 && ( +
+

๐Ÿ—ก๏ธ Equipment

+
+ {profile.equippedItems.map((item) => ( +
+
+ {SLOT_ICONS[item.type]} + + {item.name} + + + {item.rarity} + +
+

{formatBonus(item.bonus)}

+
+ ))} +
+
+ )} +

diff --git a/apps/web/src/components/game/CharacterSheetPanel.tsx b/apps/web/src/components/game/CharacterSheetPanel.tsx index f50f90a..ad7253c 100644 --- a/apps/web/src/components/game/CharacterSheetPanel.tsx +++ b/apps/web/src/components/game/CharacterSheetPanel.tsx @@ -1,9 +1,16 @@ -import type { ProfileSettings } from "@elysium/types"; +import type { EquipmentBonus, EquipmentRarity, EquipmentType, ProfileSettings } from "@elysium/types"; import { DEFAULT_PROFILE_SETTINGS } from "@elysium/types"; import { useEffect, useRef, useState } from "react"; import { updateProfile } from "../../api/client.js"; import { useGame } from "../../context/GameContext.js"; +interface EquippedItem { + name: string; + type: EquipmentType; + rarity: EquipmentRarity; + bonus: EquipmentBonus; +} + interface CharacterSheetData { characterName: string; pronouns: string; @@ -14,6 +21,7 @@ interface CharacterSheetData { guildDescription: string; activeTitle: string; unlockedTitles: Array<{ id: string; name: string }>; + equippedItems: EquippedItem[]; } const EMPTY_SHEET: CharacterSheetData = { @@ -26,6 +34,27 @@ const EMPTY_SHEET: CharacterSheetData = { guildDescription: "", activeTitle: "", unlockedTitles: [], + equippedItems: [], +}; + +const SLOT_ICONS: Record = { + weapon: "โš”๏ธ", + armour: "๐Ÿ›ก๏ธ", + trinket: "๐Ÿ’", +}; + +const formatBonus = (bonus: EquipmentBonus): string => { + const parts: string[] = []; + if (bonus.goldMultiplier !== undefined) { + parts.push(`+${Math.round((bonus.goldMultiplier - 1) * 100)}% Gold Income`); + } + if (bonus.combatMultiplier !== undefined) { + parts.push(`+${Math.round((bonus.combatMultiplier - 1) * 100)}% Combat Power`); + } + if (bonus.clickMultiplier !== undefined) { + parts.push(`+${Math.round((bonus.clickMultiplier - 1) * 100)}% Click Power`); + } + return parts.join(" ยท "); }; export const CharacterSheetPanel = (): React.JSX.Element => { @@ -58,6 +87,7 @@ export const CharacterSheetPanel = (): React.JSX.Element => { profileSettings: ProfileSettings; activeTitle: string; unlockedTitles: Array<{ id: string; name: string }>; + equippedItems: EquippedItem[]; }; const loaded: CharacterSheetData = { characterName: data.characterName ?? "", @@ -69,6 +99,7 @@ export const CharacterSheetPanel = (): React.JSX.Element => { guildDescription: data.guildDescription ?? "", activeTitle: data.activeTitle ?? "", unlockedTitles: data.unlockedTitles ?? [], + equippedItems: data.equippedItems ?? [], }; setSheet(loaded); setDraft(loaded); @@ -322,6 +353,30 @@ export const CharacterSheetPanel = (): React.JSX.Element => { )}

+
+

๐Ÿ—ก๏ธ Equipment

+ {sheet.equippedItems.length > 0 ? ( +
+ {sheet.equippedItems.map((item) => ( +
+
+ {SLOT_ICONS[item.type]} + + {item.name} + + + {item.rarity} + +
+

{formatBonus(item.bonus)}

+
+ ))} +
+ ) : ( +

No equipment found. Defeat bosses to earn gear!

+ )} +
+

๐Ÿฐ Guild

{sheet.guildName ? ( diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index ec90ded..f26c83c 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -3467,3 +3467,100 @@ body { .character-page-link:hover { opacity: 0.85; } + +/* โ”€โ”€ Equipment rarity colours (shared by CharacterSheet + CharacterPage) โ”€โ”€ */ +.character-sheet-rarity--common { + color: var(--colour-text-muted); +} + +.character-sheet-rarity--rare { + color: #4a9eff; +} + +.character-sheet-rarity--epic { + color: #c084fc; +} + +.character-sheet-rarity--legendary { + color: #f59e0b; +} + +/* โ”€โ”€ Character Sheet equipment section โ”€โ”€ */ +.character-sheet-equipment-list { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.character-sheet-equipment-item { + background: var(--colour-bg-alt); + border-radius: var(--radius); + padding: 0.5rem 0.75rem; +} + +.character-sheet-equipment-header { + align-items: center; + display: flex; + gap: 0.4rem; +} + +.character-sheet-equipment-slot { + font-size: 1rem; +} + +.character-sheet-equipment-name { + flex: 1; + font-weight: 600; +} + +.character-sheet-equipment-rarity { + font-size: 0.7rem; + font-style: italic; + text-transform: capitalize; +} + +.character-sheet-equipment-bonus { + color: var(--colour-text-muted); + font-size: 0.78rem; + margin: 0.2rem 0 0; +} + +/* โ”€โ”€ Character Page equipment section โ”€โ”€ */ +.character-page-equipment-list { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.character-page-equipment-item { + background: rgba(255, 255, 255, 0.04); + border-radius: 6px; + padding: 0.5rem 0.75rem; +} + +.character-page-equipment-header { + align-items: center; + display: flex; + gap: 0.4rem; +} + +.character-page-equipment-slot { + font-size: 1rem; +} + +.character-page-equipment-name { + flex: 1; + font-weight: 600; +} + +.character-page-equipment-rarity { + font-size: 0.7rem; + font-style: italic; + text-transform: capitalize; +} + +.character-page-equipment-bonus { + color: rgba(255, 255, 255, 0.5); + font-size: 0.78rem; + margin: 0.2rem 0 0; +} diff --git a/packages/types/src/interfaces/Api.ts b/packages/types/src/interfaces/Api.ts index 4e8290d..d1d3991 100644 --- a/packages/types/src/interfaces/Api.ts +++ b/packages/types/src/interfaces/Api.ts @@ -1,3 +1,4 @@ +import type { EquipmentBonus, EquipmentRarity, EquipmentType } from "./Equipment.js"; import type { GameState } from "./GameState.js"; import type { Player } from "./Player.js"; import type { ProfileSettings } from "./ProfileSettings.js"; @@ -122,6 +123,8 @@ export interface PublicProfileResponse { unlockedTitles: Array<{ id: string; name: string }>; /** The player's active title display name (empty string if none set) */ activeTitle: string; + /** Items the player currently has equipped */ + equippedItems: Array<{ name: string; type: EquipmentType; rarity: EquipmentRarity; bonus: EquipmentBonus }>; } export interface UpdateProfileRequest {