generated from nhcarrigan/template
feat: v1 prototype — core game systems #30
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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<EquipmentType, string> = {
|
||||
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
|
||||
</div>
|
||||
)}
|
||||
|
||||
{profile.equippedItems.length > 0 && (
|
||||
<div className="character-page-section">
|
||||
<h2 className="character-page-section-title">🗡️ Equipment</h2>
|
||||
<div className="character-page-equipment-list">
|
||||
{profile.equippedItems.map((item) => (
|
||||
<div className="character-page-equipment-item" key={item.type}>
|
||||
<div className="character-page-equipment-header">
|
||||
<span className="character-page-equipment-slot">{SLOT_ICONS[item.type]}</span>
|
||||
<span className={`character-page-equipment-name character-sheet-rarity--${item.rarity}`}>
|
||||
{item.name}
|
||||
</span>
|
||||
<span className={`character-page-equipment-rarity character-sheet-rarity--${item.rarity}`}>
|
||||
{item.rarity}
|
||||
</span>
|
||||
</div>
|
||||
<p className="character-page-equipment-bonus">{formatBonus(item.bonus)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="character-page-divider" />
|
||||
|
||||
<p className="character-page-player-line">
|
||||
|
||||
@@ -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<EquipmentType, string> = {
|
||||
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 => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="character-sheet-section">
|
||||
<h3 className="character-sheet-section-title">🗡️ Equipment</h3>
|
||||
{sheet.equippedItems.length > 0 ? (
|
||||
<div className="character-sheet-equipment-list">
|
||||
{sheet.equippedItems.map((item) => (
|
||||
<div className="character-sheet-equipment-item" key={item.type}>
|
||||
<div className="character-sheet-equipment-header">
|
||||
<span className="character-sheet-equipment-slot">{SLOT_ICONS[item.type]}</span>
|
||||
<span className={`character-sheet-equipment-name character-sheet-rarity--${item.rarity}`}>
|
||||
{item.name}
|
||||
</span>
|
||||
<span className={`character-sheet-equipment-rarity character-sheet-rarity--${item.rarity}`}>
|
||||
{item.rarity}
|
||||
</span>
|
||||
</div>
|
||||
<p className="character-sheet-equipment-bonus">{formatBonus(item.bonus)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="character-sheet-empty">No equipment found. Defeat bosses to earn gear!</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="character-sheet-section">
|
||||
<h3 className="character-sheet-section-title">🏰 Guild</h3>
|
||||
{sheet.guildName ? (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user