generated from nhcarrigan/template
feat: initial prototype — core game systems (#30)
## Summary This PR represents the full v1 prototype, implementing the core game systems for Elysium. - Full idle/clicker RPG loop: resource collection, crafting, boss fights, exploration, and quests - Adventurer hiring with batch size selector and progressive tier cost scaling - Prestige, transcendence, and apotheosis systems with auto-prestige support - Character sheet, titles, leaderboards, companion system, and daily login bonuses - Auto-quest and auto-boss toggles - Discord webhook notifications on prestige/transcendence/apotheosis - Discord role awarded on apotheosis - Responsive design and overarching story/lore system - In-game sound effects and browser notifications for key events - Support link button in the resource bar - Full test coverage (100% on `apps/api` and `packages/types`) - CI pipeline: lint → build → test ## Closes Closes #1 Closes #2 Closes #3 Closes #4 Closes #5 Closes #6 Closes #7 Closes #8 Closes #9 Closes #10 Closes #11 Closes #12 Closes #13 Closes #14 Closes #16 Closes #19 Closes #20 Closes #21 Closes #22 Closes #23 Closes #24 Closes #25 Closes #26 Closes #27 Closes #29 ✨ This issue was created with help from Hikari~ 🌸 Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com> Reviewed-on: #30 Co-authored-by: Hikari <hikari@nhcarrigan.com> Co-committed-by: Hikari <hikari@nhcarrigan.com>
This commit was merged in pull request #30.
This commit is contained in:
@@ -0,0 +1,307 @@
|
||||
/**
|
||||
* @file Public character page for viewing a player's character sheet.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
|
||||
/* eslint-disable complexity -- Many conditional render paths for optional fields */
|
||||
import { type JSX, useEffect, useState } from "react";
|
||||
import type {
|
||||
EquipmentBonus,
|
||||
EquipmentType,
|
||||
PublicProfileResponse,
|
||||
} from "@elysium/types";
|
||||
|
||||
interface CharacterPageProperties {
|
||||
readonly discordId: string;
|
||||
}
|
||||
|
||||
const slotIcons: Record<EquipmentType, string> = {
|
||||
armour: "🛡️",
|
||||
trinket: "💍",
|
||||
weapon: "⚔️",
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats an equipment bonus as a human-readable string.
|
||||
* @param bonus - The equipment bonus to format.
|
||||
* @returns The formatted bonus string.
|
||||
*/
|
||||
const formatBonus = (bonus: EquipmentBonus): string => {
|
||||
const parts: Array<string> = [];
|
||||
if (bonus.goldMultiplier !== undefined) {
|
||||
const pct = Math.round((bonus.goldMultiplier - 1) * 100);
|
||||
parts.push(`+${String(pct)}% Gold Income`);
|
||||
}
|
||||
if (bonus.combatMultiplier !== undefined) {
|
||||
const pct = Math.round((bonus.combatMultiplier - 1) * 100);
|
||||
parts.push(`+${String(pct)}% Combat Power`);
|
||||
}
|
||||
if (bonus.clickMultiplier !== undefined) {
|
||||
const pct = Math.round((bonus.clickMultiplier - 1) * 100);
|
||||
parts.push(`+${String(pct)}% Click Power`);
|
||||
}
|
||||
return parts.join(" · ");
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the public character page for a given Discord user.
|
||||
* @param props - The character page properties.
|
||||
* @param props.discordId - The Discord ID of the player to display.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const CharacterPage = ({ discordId }: CharacterPageProperties): JSX.Element => {
|
||||
const [ profile, setProfile ] = useState<PublicProfileResponse | null>(null);
|
||||
const [ error, setError ] = useState<string | null>(null);
|
||||
const [ copied, setCopied ] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`/api/profile/${discordId}`).
|
||||
then(async(response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error("Player not found");
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- API response requires cast
|
||||
return await (response.json() as Promise<PublicProfileResponse>);
|
||||
}).
|
||||
then(setProfile).
|
||||
catch((error_: unknown) => {
|
||||
setError(
|
||||
error_ instanceof Error
|
||||
? error_.message
|
||||
: "Failed to load character sheet",
|
||||
);
|
||||
});
|
||||
}, [ discordId ]);
|
||||
|
||||
function handleCopy(): void {
|
||||
void navigator.clipboard.writeText(window.location.href).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => {
|
||||
setCopied(false);
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
|
||||
if (error !== null) {
|
||||
return (
|
||||
<div className="character-page">
|
||||
<div className="character-page-error">
|
||||
<p>
|
||||
{"⚠️ "}
|
||||
{error}
|
||||
</p>
|
||||
<a className="character-page-link" href="/">
|
||||
{"← Play Elysium"}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (profile === null) {
|
||||
return (
|
||||
<div className="character-page">
|
||||
<div className="character-page-loading">
|
||||
{"Loading character sheet…"}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const discordIndex = Number.parseInt(discordId, 10) % 5;
|
||||
const avatarUrl
|
||||
= profile.avatar === null
|
||||
? `https://cdn.discordapp.com/embed/avatars/${String(discordIndex)}.png`
|
||||
: `https://cdn.discordapp.com/avatars/${discordId}/${profile.avatar}.png?size=128`;
|
||||
|
||||
const subtitleParts = [
|
||||
profile.characterRace,
|
||||
profile.characterClass,
|
||||
].filter((part) => {
|
||||
return part !== "";
|
||||
});
|
||||
const subtitle = subtitleParts.join(" · ");
|
||||
|
||||
const activeTitleEntry
|
||||
= profile.activeTitle === ""
|
||||
? undefined
|
||||
: profile.unlockedTitles.find((title) => {
|
||||
return title.id === profile.activeTitle;
|
||||
});
|
||||
const activeTitleName
|
||||
= activeTitleEntry === undefined
|
||||
? null
|
||||
: activeTitleEntry.name;
|
||||
|
||||
const hasBadge
|
||||
= profile.apotheosisCount > 0
|
||||
|| profile.transcendenceCount > 0
|
||||
|| profile.prestigeCount > 0;
|
||||
|
||||
const displayName
|
||||
= profile.characterName === ""
|
||||
? profile.username
|
||||
: profile.characterName;
|
||||
|
||||
return (
|
||||
<div className="character-page">
|
||||
<div className="character-page-card">
|
||||
<div className="character-page-header">
|
||||
<img
|
||||
alt={`${displayName}'s avatar`}
|
||||
className="character-page-avatar"
|
||||
src={avatarUrl}
|
||||
/>
|
||||
<div className="character-page-identity">
|
||||
<h1 className="character-page-name">{displayName}</h1>
|
||||
{activeTitleName === null
|
||||
? null
|
||||
: <p className="character-page-title">{activeTitleName}</p>
|
||||
}
|
||||
{profile.pronouns === ""
|
||||
? null
|
||||
: <p className="character-page-pronouns">{profile.pronouns}</p>
|
||||
}
|
||||
{subtitle === ""
|
||||
? null
|
||||
: <p className="character-page-subtitle">{subtitle}</p>
|
||||
}
|
||||
{hasBadge
|
||||
? <div className="character-page-badges">
|
||||
{profile.apotheosisCount > 0
|
||||
&& <span
|
||||
className={
|
||||
"character-page-badge character-page-badge--apotheosis"
|
||||
}
|
||||
>
|
||||
{"✨ Apotheosis "}
|
||||
{profile.apotheosisCount}
|
||||
</span>
|
||||
}
|
||||
{profile.transcendenceCount > 0
|
||||
&& <span
|
||||
className={
|
||||
"character-page-badge"
|
||||
+ " character-page-badge--transcendence"
|
||||
}
|
||||
>
|
||||
{"🌌 Transcendence "}
|
||||
{profile.transcendenceCount}
|
||||
</span>
|
||||
}
|
||||
{profile.prestigeCount > 0
|
||||
&& <span
|
||||
className={
|
||||
"character-page-badge character-page-badge--prestige"
|
||||
}
|
||||
>
|
||||
{"⭐ Prestige "}
|
||||
{profile.prestigeCount}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
: null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{profile.bio === ""
|
||||
? null
|
||||
: <div className="character-page-section">
|
||||
<h2 className="character-page-section-title">{"⚔️ About"}</h2>
|
||||
<p className="character-page-bio">{profile.bio}</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
{profile.guildName === ""
|
||||
? null
|
||||
: <div className="character-page-section">
|
||||
<h2 className="character-page-section-title">{"🏰 Guild"}</h2>
|
||||
<p className="character-page-guild-name">{profile.guildName}</p>
|
||||
{profile.guildDescription === ""
|
||||
? null
|
||||
: <p className="character-page-guild-desc">
|
||||
{profile.guildDescription}
|
||||
</p>
|
||||
}
|
||||
</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) => {
|
||||
return (
|
||||
<div
|
||||
className="character-page-equipment-item"
|
||||
key={item.type}
|
||||
>
|
||||
<div className="character-page-equipment-header">
|
||||
<span className="character-page-equipment-slot">
|
||||
{slotIcons[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">
|
||||
{"Played by "}
|
||||
<span className="character-page-username">
|
||||
{"@"}
|
||||
{profile.username}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<div className="character-page-actions">
|
||||
<button
|
||||
className="character-page-share-btn"
|
||||
onClick={handleCopy}
|
||||
type="button"
|
||||
>
|
||||
{copied
|
||||
? "✓ Copied!"
|
||||
: "🔗 Share Character"}
|
||||
</button>
|
||||
<a
|
||||
className="character-page-profile-link"
|
||||
href={`/profile/${discordId}`}
|
||||
>
|
||||
{"📊 View Stats"}
|
||||
</a>
|
||||
<a className="character-page-play-link" href="/">
|
||||
{"⚔️ Play Elysium"}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { CharacterPage };
|
||||
Reference in New Issue
Block a user