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,357 @@
|
||||
/**
|
||||
* @file About panel component displaying changelog and how-to-play guide.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- HOW_TO_PLAY data and render logic */
|
||||
/* eslint-disable max-lines -- HOW_TO_PLAY data makes this file long */
|
||||
import { type JSX, useEffect, useState } from "react";
|
||||
import { getAbout } from "../../api/client.js";
|
||||
import type { AboutResponse } from "@elysium/types";
|
||||
|
||||
const howToPlay = [
|
||||
{
|
||||
body:
|
||||
"Hire adventurers to earn gold and essence automatically. Each tier is"
|
||||
+ " more powerful than the last. Adventurers also contribute combat"
|
||||
+ " power for boss fights — the more you recruit, the stronger your"
|
||||
+ " party becomes.",
|
||||
title: "⚔️ Adventurers",
|
||||
},
|
||||
{
|
||||
body:
|
||||
"Click the guild hall to earn gold manually. Upgrades and equipment can"
|
||||
+ " dramatically increase your gold per click. Clicking is especially"
|
||||
+ " powerful in the early game and when saving up for big purchases.",
|
||||
title: "👆 Clicking",
|
||||
},
|
||||
{
|
||||
body:
|
||||
"Purchase upgrades to multiply the gold and essence output of specific"
|
||||
+ " adventurer tiers, or boost your whole guild. Upgrades are permanent"
|
||||
+ " for the current run and compound with each other.",
|
||||
title: "🔧 Upgrades",
|
||||
},
|
||||
{
|
||||
body:
|
||||
"Send your guild on quests that complete over time and reward gold,"
|
||||
+ " essence, crystals, equipment, and upgrades. Multiple quests can run"
|
||||
+ " simultaneously. Completing quests also unlocks new zones.",
|
||||
title: "📜 Quests",
|
||||
},
|
||||
{
|
||||
body:
|
||||
"Challenge zone bosses to earn large one-time rewards and unlock new"
|
||||
+ " zones. Your party's combat power is based on the number and tier of"
|
||||
+ " adventurers you've recruited. Defeated bosses cannot be re-fought,"
|
||||
+ " but undefeated bosses regenerate HP over time.",
|
||||
title: "👹 Boss Fights",
|
||||
},
|
||||
{
|
||||
body:
|
||||
"New zones unlock when you defeat the final boss AND complete the final"
|
||||
+ " quest of the previous zone. Each zone contains new bosses and"
|
||||
+ " quests with progressively greater rewards.",
|
||||
title: "🗺️ Zones",
|
||||
},
|
||||
{
|
||||
body:
|
||||
"Earn equipment from boss drops and quest rewards. Each piece provides"
|
||||
+ " bonuses to gold income, click power, or combat. Rarer equipment"
|
||||
+ " provides stronger bonuses. Equip matching set pieces (2 or 3 of a"
|
||||
+ " named set) to unlock escalating set bonuses shown at the top of the"
|
||||
+ " Equipment panel.",
|
||||
title: "🗡️ Equipment & Sets",
|
||||
},
|
||||
{
|
||||
body:
|
||||
"When you've progressed far enough, you can prestige to earn runestones"
|
||||
+ " — a permanent currency that persists across all runs. Prestige"
|
||||
+ " resets your current run but grants a production multiplier that"
|
||||
+ " stacks with every prestige.",
|
||||
title: "⭐ Prestige",
|
||||
},
|
||||
{
|
||||
body:
|
||||
"Spend runestones in the Prestige Shop on permanent upgrades that carry"
|
||||
+ " over across all future runs. These upgrades multiply income, click"
|
||||
+ " power, essence, and crystal gain — making each new run more powerful"
|
||||
+ " than the last.",
|
||||
title: "🔮 Runestones & Prestige Upgrades",
|
||||
},
|
||||
{
|
||||
body:
|
||||
"Purchase the Autonomous Ascension upgrade in the Prestige Shop"
|
||||
+ " (100 runestones) to unlock the Auto-Prestige toggle. When enabled,"
|
||||
+ " you will automatically ascend the moment you reach the prestige"
|
||||
+ " threshold, using your current character name. Toggle it on and off"
|
||||
+ " freely from the Prestige Shop.",
|
||||
title: "⚙️ Auto-Prestige",
|
||||
},
|
||||
{
|
||||
body:
|
||||
"Earn achievements by hitting milestones — total gold earned, bosses"
|
||||
+ " defeated, quests completed, and more. Achievements are purely"
|
||||
+ " cosmetic and track your long-term progress across all prestige runs.",
|
||||
title: "🏆 Achievements",
|
||||
},
|
||||
{
|
||||
body:
|
||||
"Complete daily challenges for bonus rewards including gold, essence,"
|
||||
+ " crystals, and runestones. Challenges reset each day and vary in"
|
||||
+ " difficulty. Completing all daily challenges gives an extra bonus"
|
||||
+ " reward.",
|
||||
title: "📅 Daily Challenges",
|
||||
},
|
||||
{
|
||||
body:
|
||||
"Send scouts to explore areas within each zone. Explorations run in"
|
||||
+ " real-time and reward gold, essence, and crafting materials when"
|
||||
+ " collected. Each area has a set duration — short explorations are"
|
||||
+ " faster but longer ones offer rarer finds. A 📖 icon marks areas"
|
||||
+ " you've collected from at least once, unlocking a Codex entry.",
|
||||
title: "🗺️ Exploration",
|
||||
},
|
||||
{
|
||||
body:
|
||||
"Use materials gathered from exploration to craft permanent bonuses."
|
||||
+ " Each recipe provides a multiplier to gold income, essence income,"
|
||||
+ " click power, or combat power — all of which stack and persist across"
|
||||
+ " prestige runs. Check the Crafting tab to see your material inventory"
|
||||
+ " and available recipes per zone.",
|
||||
title: "⚗️ Crafting",
|
||||
},
|
||||
{
|
||||
body:
|
||||
"Defeating bosses, completing quests, acquiring equipment, hiring"
|
||||
+ " adventurers, purchasing upgrades, unlocking prestige upgrades,"
|
||||
+ " discovering new zones, collecting from exploration areas, and"
|
||||
+ " crafting recipes all permanently unlock lore entries in the Codex."
|
||||
+ " A badge appears on the Codex tab and a toast notification pops up"
|
||||
+ " each time new lore is discovered. Collect all 472 entries to build"
|
||||
+ " a complete picture of the world of Elysium.",
|
||||
title: "📖 Codex",
|
||||
},
|
||||
{
|
||||
body:
|
||||
"Visit the Character tab to write about your character and guild. Fill"
|
||||
+ " in your character's name, pronouns, race, class, and backstory,"
|
||||
+ " then create a guild with its own name and lore. Your character sheet"
|
||||
+ " is visible on your public profile page.",
|
||||
title: "📋 Character Sheet",
|
||||
},
|
||||
{
|
||||
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: "🏅 Titles",
|
||||
},
|
||||
{
|
||||
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: "🗡️ Equipment",
|
||||
},
|
||||
{
|
||||
body:
|
||||
"Compete with other adventurers on the public Leaderboards page!"
|
||||
+ " Categories include Lifetime Gold, Bosses Defeated, Quests"
|
||||
+ " Completed, Achievements, Prestige Count, Transcendence Count, and"
|
||||
+ " Apotheosis Count. Click any player's row to view their character"
|
||||
+ " sheet. You can opt out of appearing on leaderboards via the Privacy"
|
||||
+ " section in your profile settings.",
|
||||
title: "🏆 Leaderboards",
|
||||
},
|
||||
{
|
||||
body:
|
||||
"Log in every day to earn escalating rewards! Each consecutive day"
|
||||
+ " awards more gold, and the 7th day of your streak grants bonus"
|
||||
+ " crystals. Your streak resets if you miss a day. A week multiplier"
|
||||
+ " increases all rewards the longer your overall streak runs. Your"
|
||||
+ " current streak is displayed on your character sheet.",
|
||||
title: "🔥 Daily Login Bonus",
|
||||
},
|
||||
{
|
||||
body:
|
||||
"Toggle automation in the Quests and Boss Encounters panels! Auto-Quest"
|
||||
+ " automatically sends your party on the highest-zone available quest"
|
||||
+ " as soon as one completes, skipping quests whose combat power"
|
||||
+ " requirement isn't met. Auto-Boss automatically challenges the"
|
||||
+ " highest available boss as soon as one is ready. Both can be toggled"
|
||||
+ " on or off at any time using the 🤖 Auto button in each panel"
|
||||
+ " header.",
|
||||
title: "🤖 Auto-Quest & Auto-Boss",
|
||||
},
|
||||
{
|
||||
body:
|
||||
"Unlock companions by reaching certain milestones across all your runs."
|
||||
+ " Each companion provides a powerful permanent bonus: increased"
|
||||
+ " passive gold, click gold, boss damage, essence income, or reduced"
|
||||
+ " quest time. You can only have one companion active at a time —"
|
||||
+ " choose wisely based on your current strategy! Companions are"
|
||||
+ " unlocked permanently once their condition is met and will never be"
|
||||
+ " lost.",
|
||||
title: "👥 Companions",
|
||||
},
|
||||
{
|
||||
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.",
|
||||
title: "☁️ Cloud Saves",
|
||||
},
|
||||
{
|
||||
body:
|
||||
"Transcendence is the ultimate prestige layer, unlocked by defeating"
|
||||
+ " The Absolute One (requires Prestige 90). Transcending performs a"
|
||||
+ " nuclear reset — wiping resources, prestige, runestones, upgrades,"
|
||||
+ " and equipment — but grants Echoes based on your prestige count"
|
||||
+ " (fewer prestiges = more Echoes). Echoes are permanent and survive"
|
||||
+ " all future resets. Spend them in the Echo Shop on lasting"
|
||||
+ " multipliers: passive income, combat power, prestige"
|
||||
+ " quality-of-life, and Echo meta upgrades that amplify future Echo"
|
||||
+ " yields.",
|
||||
title: "🌌 Transcendence",
|
||||
},
|
||||
{
|
||||
body:
|
||||
"Apotheosis is the final act — a complete dissolution of everything you"
|
||||
+ " have built, including your prestige and transcendence progress. It"
|
||||
+ " is unlocked once you have purchased every Transcendence upgrade. In"
|
||||
+ " exchange for this total reset, you receive the Apotheosis badge:"
|
||||
+ " pure bragging rights, a mark of reaching the absolute pinnacle of"
|
||||
+ " the game. Apotheosis can be achieved multiple times; each cycle"
|
||||
+ " requires purchasing all Transcendence upgrades again. Your Codex"
|
||||
+ " entries and lifetime profile statistics are always preserved.",
|
||||
title: "✨ Apotheosis",
|
||||
},
|
||||
{
|
||||
body:
|
||||
"The Story tab contains 22 chapters that unlock as you progress. The"
|
||||
+ " first 18 unlock when you defeat the final boss of each zone."
|
||||
+ " Chapters 19 and 20 unlock after your first and fifth prestige"
|
||||
+ " respectively. Chapter 21 unlocks on your first transcendence, and"
|
||||
+ " Chapter 22 on your first apotheosis. Each chapter presents a"
|
||||
+ " narrative moment and three choices — the choice you make is recorded"
|
||||
+ " on your Character Sheet and shapes your guild's story. Story"
|
||||
+ " progress is permanent and survives all resets.",
|
||||
title: "📖 Story",
|
||||
},
|
||||
{
|
||||
body:
|
||||
"Enable sound effects and browser notifications in your profile settings"
|
||||
+ " (click your character name in the top bar). Sound effects play when"
|
||||
+ " you defeat a boss, complete or fail a quest, unlock an achievement,"
|
||||
+ " prestige, transcend, or achieve apotheosis. Browser notifications"
|
||||
+ " alert you to the same events even when the game tab is in the"
|
||||
+ " background. You will be prompted to grant notification permission"
|
||||
+ " when you first enable them.",
|
||||
title: "🔔 Sounds & Notifications",
|
||||
},
|
||||
];
|
||||
|
||||
const formatDate = (dateString: string): string => {
|
||||
return new Date(dateString).toLocaleDateString("en-GB", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the about panel with changelog and how-to-play sections.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const aboutPanel = (): JSX.Element => {
|
||||
const [ about, setAbout ] = useState<AboutResponse | null>(null);
|
||||
const [ error, setError ] = useState<string | null>(null);
|
||||
const [ expandedRelease, setExpandedRelease ] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getAbout().
|
||||
then(setAbout).
|
||||
catch((caughtError: unknown) => {
|
||||
setError(
|
||||
caughtError instanceof Error
|
||||
? caughtError.message
|
||||
: "Failed to load about data.",
|
||||
);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section className="panel about-panel">
|
||||
<h2>{"ℹ️ About"}</h2>
|
||||
|
||||
<h3 className="stats-section-header">{"📋 Changelog"}</h3>
|
||||
{error !== null && <p className="about-error">{error}</p>}
|
||||
{about === null && error === null
|
||||
&& <p className="about-loading">{"Loading changelog..."}</p>
|
||||
}
|
||||
{about !== null && about.releases.length === 0
|
||||
&& <p className="about-empty">{"No releases yet."}</p>
|
||||
}
|
||||
{about !== null && about.releases.length > 0
|
||||
&& <ul className="about-releases">
|
||||
{about.releases.map((release) => {
|
||||
function handleToggle(): void {
|
||||
setExpandedRelease(
|
||||
expandedRelease === release.tag_name
|
||||
? null
|
||||
: release.tag_name,
|
||||
);
|
||||
}
|
||||
return (
|
||||
<li className="about-release" key={release.tag_name}>
|
||||
<button
|
||||
className="about-release-header"
|
||||
onClick={handleToggle}
|
||||
type="button"
|
||||
>
|
||||
<span className="about-release-tag">
|
||||
{release.name.length > 0
|
||||
? release.name
|
||||
: release.tag_name}
|
||||
</span>
|
||||
<span className="about-release-date">
|
||||
{formatDate(release.published_at)}
|
||||
</span>
|
||||
<span className="about-release-chevron">
|
||||
{expandedRelease === release.tag_name
|
||||
? "▲"
|
||||
: "▼"}
|
||||
</span>
|
||||
</button>
|
||||
{expandedRelease === release.tag_name
|
||||
&& <pre className="about-release-body">{release.body}</pre>
|
||||
}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
}
|
||||
|
||||
<h3 className="stats-section-header">{"📖 How to Play"}</h3>
|
||||
<ul className="about-how-to-play">
|
||||
{howToPlay.map((section) => {
|
||||
return (
|
||||
<li className="about-htp-section" key={section.title}>
|
||||
<h4 className="about-htp-title">{section.title}</h4>
|
||||
<p className="about-htp-body">{section.body}</p>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { aboutPanel as AboutPanel };
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* @file Achievement panel component displaying all game achievements.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable react/no-multi-comp -- Sub-component is tightly coupled to this panel */
|
||||
import { type JSX, useState } from "react";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import { LockToggle } from "../ui/lockToggle.js";
|
||||
import type { Achievement } from "@elysium/types";
|
||||
|
||||
/**
|
||||
* Returns the plural form of a word based on a count.
|
||||
* @param count - The count to check.
|
||||
* @param word - The base word to pluralise.
|
||||
* @returns The pluralised word string.
|
||||
*/
|
||||
const pluralise = (count: number, word: string): string => {
|
||||
return count > 1
|
||||
? `${word}s`
|
||||
: word;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates a human-readable condition description for an achievement.
|
||||
* @param achievement - The achievement to describe.
|
||||
* @param formatNumber - The number formatting utility function.
|
||||
* @returns A string describing the achievement condition.
|
||||
*/
|
||||
const conditionDescription = (
|
||||
achievement: Achievement,
|
||||
formatNumber: (n: number)=> string,
|
||||
): string => {
|
||||
const { condition } = achievement;
|
||||
switch (condition.type) {
|
||||
case "totalGoldEarned":
|
||||
return `Earn ${formatNumber(condition.amount)} total gold`;
|
||||
case "totalClicks":
|
||||
return `Click ${formatNumber(condition.amount)} times`;
|
||||
case "bossesDefeated":
|
||||
return `Defeat ${String(condition.amount)} ${pluralise(condition.amount, "boss")}`;
|
||||
case "questsCompleted":
|
||||
return `Complete ${String(condition.amount)} ${pluralise(condition.amount, "quest")}`;
|
||||
case "adventurerTotal":
|
||||
return `Recruit ${formatNumber(condition.amount)} total adventurers`;
|
||||
case "prestigeCount":
|
||||
return `Prestige ${String(condition.amount)} ${pluralise(condition.amount, "time")}`;
|
||||
case "equipmentOwned":
|
||||
return `Own ${String(condition.amount)} equipment ${pluralise(condition.amount, "item")}`;
|
||||
default:
|
||||
return "Unknown condition";
|
||||
}
|
||||
};
|
||||
|
||||
interface AchievementCardProperties {
|
||||
readonly achievement: Achievement;
|
||||
readonly formatNumber: (n: number)=> string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single achievement card.
|
||||
* @param props - The achievement card properties.
|
||||
* @param props.achievement - The achievement to display.
|
||||
* @param props.formatNumber - The number formatting utility function.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const AchievementCard = ({
|
||||
achievement,
|
||||
formatNumber,
|
||||
}: AchievementCardProperties): JSX.Element => {
|
||||
const isUnlocked = achievement.unlockedAt !== null;
|
||||
const crystals = achievement.reward?.crystals;
|
||||
|
||||
return (
|
||||
<div className={`achievement-card ${isUnlocked
|
||||
? "unlocked"
|
||||
: "locked"}`}>
|
||||
<div className="achievement-icon">{achievement.icon}</div>
|
||||
<div className="achievement-info">
|
||||
<h3>{achievement.name}</h3>
|
||||
<p>{achievement.description}</p>
|
||||
<p className="achievement-condition">
|
||||
{conditionDescription(achievement, formatNumber)}
|
||||
</p>
|
||||
{crystals !== undefined
|
||||
&& <p className="achievement-reward">
|
||||
{"💎 +"}
|
||||
{crystals}
|
||||
{" Crystals"}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
<div className="achievement-status">
|
||||
{isUnlocked
|
||||
? <span className="achievement-unlocked-badge">{"✓ Unlocked"}</span>
|
||||
: <span className="achievement-locked-badge">{"🔒"}</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the achievement panel with all achievements.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
// eslint-disable-next-line max-lines-per-function -- Achievement panel renders many achievement states
|
||||
const AchievementPanel = (): JSX.Element => {
|
||||
const { state, formatNumber } = useGame();
|
||||
const [ showLocked, setShowLocked ] = useState(true);
|
||||
|
||||
if (state === null) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"Loading..."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const achievementList = state.achievements;
|
||||
const unlocked = achievementList.filter((a) => {
|
||||
return a.unlockedAt !== null;
|
||||
});
|
||||
const locked = achievementList.filter((a) => {
|
||||
return a.unlockedAt === null;
|
||||
});
|
||||
const visible = showLocked
|
||||
? achievementList
|
||||
: unlocked;
|
||||
|
||||
function handleToggle(): void {
|
||||
setShowLocked((current) => {
|
||||
return !current;
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="panel achievement-panel">
|
||||
<div className="panel-header">
|
||||
<h2>{"Achievements"}</h2>
|
||||
<LockToggle
|
||||
lockedCount={locked.length}
|
||||
onToggle={handleToggle}
|
||||
showLocked={showLocked}
|
||||
/>
|
||||
</div>
|
||||
<p className="achievement-progress">
|
||||
{unlocked.length}
|
||||
{" / "}
|
||||
{achievementList.length}
|
||||
{" unlocked"}
|
||||
</p>
|
||||
<div className="achievement-list">
|
||||
{visible.map((achievement) => {
|
||||
return (
|
||||
<AchievementCard
|
||||
achievement={achievement}
|
||||
formatNumber={formatNumber}
|
||||
key={achievement.id}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { AchievementPanel };
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* @file Achievement toast notification component.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable react/no-multi-comp -- Sub-component is tightly coupled to the toast container */
|
||||
import { type JSX, useEffect } from "react";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import type { Achievement } from "@elysium/types";
|
||||
|
||||
interface ToastItemProperties {
|
||||
readonly achievement: Achievement;
|
||||
readonly onDismiss: (id: string)=> void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single achievement toast item.
|
||||
* @param props - The toast item properties.
|
||||
* @param props.achievement - The achievement to display.
|
||||
* @param props.onDismiss - Callback to dismiss the toast.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const ToastItem = ({
|
||||
achievement,
|
||||
onDismiss,
|
||||
}: ToastItemProperties): JSX.Element => {
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
onDismiss(achievement.id);
|
||||
}, 4000);
|
||||
return (): void => {
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [ achievement.id, onDismiss ]);
|
||||
|
||||
function handleClick(): void {
|
||||
onDismiss(achievement.id);
|
||||
}
|
||||
|
||||
const crystals = achievement.reward?.crystals;
|
||||
|
||||
return (
|
||||
<div className="achievement-toast" onClick={handleClick}>
|
||||
<span className="toast-icon">{achievement.icon}</span>
|
||||
<div className="toast-content">
|
||||
<span className="toast-label">{"Achievement Unlocked!"}</span>
|
||||
<span className="toast-name">{achievement.name}</span>
|
||||
{crystals !== undefined
|
||||
&& <span className="toast-reward">
|
||||
{"💎 +"}
|
||||
{crystals}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the achievement toast container with pending achievement notifications.
|
||||
* @returns The JSX element or null if there are no pending achievements.
|
||||
*/
|
||||
const AchievementToast = (): JSX.Element | null => {
|
||||
const { unlockedAchievements: pendingAchievements, dismissAchievement }
|
||||
= useGame();
|
||||
|
||||
if (pendingAchievements.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="achievement-toast-container">
|
||||
{pendingAchievements.map((achievement) => {
|
||||
return (
|
||||
<ToastItem
|
||||
achievement={achievement}
|
||||
key={achievement.id}
|
||||
onDismiss={dismissAchievement}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { AchievementToast };
|
||||
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* @file Adventurer panel component for hiring and managing adventurers.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable react/no-multi-comp -- Sub-component is tightly coupled to the panel */
|
||||
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
|
||||
/* eslint-disable complexity -- Complex component with many render paths */
|
||||
import { type JSX, useState } from "react";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import { LockToggle } from "../ui/lockToggle.js";
|
||||
import type { Adventurer } from "@elysium/types";
|
||||
|
||||
const iconByClass: Record<string, string> = {
|
||||
cleric: "✝️",
|
||||
mage: "🔮",
|
||||
paladin: "🛡️",
|
||||
ranger: "🏹",
|
||||
rogue: "🗝️",
|
||||
warrior: "🗡️",
|
||||
};
|
||||
|
||||
type BatchSize = 1 | 5 | 10 | 25 | 100 | "max";
|
||||
const batchOptions: Array<BatchSize> = [ 1, 5, 10, 25, 100, "max" ];
|
||||
|
||||
/**
|
||||
* Computes the total cost to buy a batch of adventurers.
|
||||
* @param adventurer - The adventurer to buy.
|
||||
* @param quantity - The number to buy.
|
||||
* @returns The total gold cost.
|
||||
*/
|
||||
const computeBatchCost = (adventurer: Adventurer, quantity: number): number => {
|
||||
let total = 0;
|
||||
for (let index = 0; index < quantity; index = index + 1) {
|
||||
const cost = adventurer.baseCost * Math.pow(1.15, adventurer.count + index);
|
||||
total = total + cost;
|
||||
}
|
||||
return total;
|
||||
};
|
||||
|
||||
/**
|
||||
* Computes the maximum number of adventurers affordable with given gold.
|
||||
* @param adventurer - The adventurer type.
|
||||
* @param gold - The available gold.
|
||||
* @returns The maximum affordable quantity.
|
||||
*/
|
||||
const computeMaxAffordable = (adventurer: Adventurer, gold: number): number => {
|
||||
let total = 0;
|
||||
let quantity = 0;
|
||||
for (let index = 0; index < 100_000; index = index + 1) {
|
||||
const cost = adventurer.baseCost * Math.pow(1.15, adventurer.count + index);
|
||||
if (total + cost > gold) {
|
||||
break;
|
||||
}
|
||||
total = total + cost;
|
||||
quantity = quantity + 1;
|
||||
}
|
||||
return quantity;
|
||||
};
|
||||
|
||||
interface AdventurerCardProperties {
|
||||
readonly adventurer: Adventurer;
|
||||
readonly currentGold: number;
|
||||
readonly batchSize: BatchSize;
|
||||
readonly unlockHint: string | undefined;
|
||||
readonly formatNumber: (n: number)=> string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single adventurer card with buy controls.
|
||||
* @param props - The adventurer card properties.
|
||||
* @param props.adventurer - The adventurer data.
|
||||
* @param props.currentGold - The current gold available.
|
||||
* @param props.batchSize - The selected batch size.
|
||||
* @param props.unlockHint - Optional quest name that unlocks this adventurer.
|
||||
* @param props.formatNumber - The number formatting utility function.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const AdventurerCard = ({
|
||||
adventurer,
|
||||
currentGold,
|
||||
batchSize,
|
||||
unlockHint,
|
||||
formatNumber,
|
||||
}: AdventurerCardProperties): JSX.Element => {
|
||||
const { buyAdventurer } = useGame();
|
||||
|
||||
const resolvedQuantity
|
||||
= batchSize === "max"
|
||||
? computeMaxAffordable(adventurer, currentGold)
|
||||
: batchSize;
|
||||
const cost = computeBatchCost(adventurer, resolvedQuantity);
|
||||
const canAfford = resolvedQuantity > 0 && currentGold >= cost;
|
||||
|
||||
function handleBuy(): void {
|
||||
buyAdventurer(adventurer.id, resolvedQuantity);
|
||||
}
|
||||
|
||||
const maxSuffix
|
||||
= batchSize === "max" && resolvedQuantity > 0
|
||||
? ` (×${String(resolvedQuantity)})`
|
||||
: "";
|
||||
const buttonLabel = adventurer.unlocked
|
||||
? `🪙 ${formatNumber(Math.ceil(cost))}${maxSuffix}`
|
||||
: "🔒 Locked";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/dot-notation -- "class" is a reserved word
|
||||
const adventurerIcon = iconByClass[adventurer["class"]] ?? "⚔️";
|
||||
|
||||
return (
|
||||
<div className={`adventurer-card ${adventurer.unlocked
|
||||
? ""
|
||||
: "locked"}`}>
|
||||
<div className="adventurer-icon">{adventurerIcon}</div>
|
||||
<div className="adventurer-info">
|
||||
<h3>{adventurer.name}</h3>
|
||||
<p>
|
||||
{formatNumber(adventurer.goldPerSecond)}
|
||||
{" gold/s each"}
|
||||
</p>
|
||||
{adventurer.essencePerSecond > 0
|
||||
&& <p>
|
||||
{formatNumber(adventurer.essencePerSecond)}
|
||||
{" essence/s each"}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
<div className="adventurer-count">
|
||||
{"×"}
|
||||
{adventurer.count}
|
||||
</div>
|
||||
<button
|
||||
className="buy-button"
|
||||
disabled={!canAfford || !adventurer.unlocked}
|
||||
onClick={handleBuy}
|
||||
type="button"
|
||||
>
|
||||
{buttonLabel}
|
||||
</button>
|
||||
{!adventurer.unlocked && unlockHint !== undefined
|
||||
? <p className="unlock-hint">
|
||||
{"📜 Complete: "}
|
||||
{unlockHint}
|
||||
</p>
|
||||
: null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the adventurer panel with all available adventurers.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const AdventurerPanel = (): JSX.Element => {
|
||||
const { state, formatNumber } = useGame();
|
||||
const [ showLocked, setShowLocked ] = useState(true);
|
||||
const [ batchSize, setBatchSize ] = useState<BatchSize>(1);
|
||||
|
||||
if (state === null) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"Loading..."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const locked = state.adventurers.filter((adventurer) => {
|
||||
return !adventurer.unlocked;
|
||||
});
|
||||
const visible = showLocked
|
||||
? state.adventurers
|
||||
: state.adventurers.filter((adventurer) => {
|
||||
return adventurer.unlocked;
|
||||
});
|
||||
|
||||
const adventurerUnlockHints = new Map<string, string>();
|
||||
for (const quest of state.quests) {
|
||||
for (const reward of quest.rewards) {
|
||||
if (reward.type === "adventurer" && reward.targetId !== undefined) {
|
||||
adventurerUnlockHints.set(reward.targetId, quest.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleToggle(): void {
|
||||
setShowLocked((current) => {
|
||||
return !current;
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="panel adventurer-panel">
|
||||
<div className="panel-header">
|
||||
<h2>{"Adventurers"}</h2>
|
||||
<LockToggle
|
||||
lockedCount={locked.length}
|
||||
onToggle={handleToggle}
|
||||
showLocked={showLocked}
|
||||
/>
|
||||
</div>
|
||||
<div className="batch-selector">
|
||||
{batchOptions.map((option) => {
|
||||
function handleBatchSelect(): void {
|
||||
setBatchSize(option);
|
||||
}
|
||||
return (
|
||||
<button
|
||||
className={`batch-button ${batchSize === option
|
||||
? "active"
|
||||
: ""}`}
|
||||
key={option}
|
||||
onClick={handleBatchSelect}
|
||||
type="button"
|
||||
>
|
||||
{option === "max"
|
||||
? "xMax"
|
||||
: `x${String(option)}`}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="adventurer-list">
|
||||
{visible.map((adventurer) => {
|
||||
return (
|
||||
<AdventurerCard
|
||||
adventurer={adventurer}
|
||||
batchSize={batchSize}
|
||||
currentGold={state.resources.gold}
|
||||
formatNumber={formatNumber}
|
||||
key={adventurer.id}
|
||||
unlockHint={adventurerUnlockHints.get(adventurer.id)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { AdventurerPanel };
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* @file Apotheosis panel component for the final prestige layer.
|
||||
* @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 -- Complex component with many conditional render paths */
|
||||
import { type JSX, useState } from "react";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import { TRANSCENDENCE_UPGRADES } from "../../data/transcendenceUpgrades.js";
|
||||
|
||||
const totalEchoUpgrades = TRANSCENDENCE_UPGRADES.length;
|
||||
|
||||
/**
|
||||
* Renders the apotheosis panel for achieving the final game milestone.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const ApotheosisPanel = (): JSX.Element => {
|
||||
const { state, apotheosis } = useGame();
|
||||
const [ isPending, setIsPending ] = useState(false);
|
||||
const [ result, setResult ] = useState<number | null>(null);
|
||||
const [ error, setError ] = useState<string | null>(null);
|
||||
|
||||
if (state === null) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"Loading..."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const purchasedIds = state.transcendence?.purchasedUpgradeIds ?? [];
|
||||
const purchasedCount = TRANSCENDENCE_UPGRADES.filter((upgrade) => {
|
||||
return purchasedIds.includes(upgrade.id);
|
||||
}).length;
|
||||
const isEligible = purchasedCount >= totalEchoUpgrades;
|
||||
const apotheosisCount = state.apotheosis?.count ?? 0;
|
||||
|
||||
async function handleApotheosis(): Promise<void> {
|
||||
setIsPending(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await apotheosis();
|
||||
setResult(data.newApotheosisCount);
|
||||
} catch (caughtError) {
|
||||
setError(
|
||||
caughtError instanceof Error
|
||||
? caughtError.message
|
||||
: "Apotheosis failed",
|
||||
);
|
||||
} finally {
|
||||
setIsPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleApotheosisClick(): void {
|
||||
void handleApotheosis();
|
||||
}
|
||||
|
||||
const plural = apotheosisCount === 1
|
||||
? ""
|
||||
: "s";
|
||||
|
||||
return (
|
||||
<section className="panel apotheosis-panel">
|
||||
<h2>{"✨ Apotheosis"}</h2>
|
||||
|
||||
<p className="apotheosis-intro">
|
||||
{"Apotheosis is the final act — a complete dissolution of everything"
|
||||
+ " you have built. Prestige, Transcendence, Echoes, upgrades,"
|
||||
+ " equipment, resources: all of it returns to nothing."
|
||||
+ " In exchange, you receive only one thing:"}
|
||||
</p>
|
||||
<p className="apotheosis-reward">
|
||||
{"The "}
|
||||
<strong>{"✨ Apotheosis"}</strong>
|
||||
{" badge. Proof that you have done it all."}
|
||||
</p>
|
||||
<p className="apotheosis-intro">
|
||||
{"Apotheosis can be achieved multiple times. Each cycle requires"
|
||||
+ " you to purchase every Transcendence upgrade again before the"
|
||||
+ " next Apotheosis becomes available. There is no mechanical"
|
||||
+ " benefit — only the knowledge that you have reached the"
|
||||
+ " pinnacle, dissolved it, and climbed back up."}
|
||||
</p>
|
||||
|
||||
{apotheosisCount > 0
|
||||
&& <div className="apotheosis-count">
|
||||
<span>
|
||||
{"You have achieved Apotheosis "}
|
||||
<strong>{apotheosisCount}</strong>
|
||||
{" time"}
|
||||
{plural}
|
||||
{"."}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div className="apotheosis-status">
|
||||
<p>
|
||||
{"Transcendence upgrades purchased: "}
|
||||
<strong>
|
||||
{purchasedCount}
|
||||
{" / "}
|
||||
{totalEchoUpgrades}
|
||||
</strong>
|
||||
</p>
|
||||
{isEligible
|
||||
? null
|
||||
: <p className="apotheosis-missing">
|
||||
{"🔒 Purchase all "}
|
||||
{totalEchoUpgrades}
|
||||
{" Transcendence upgrades to unlock Apotheosis. ("}
|
||||
{totalEchoUpgrades - purchasedCount}
|
||||
{" remaining)"}
|
||||
</p>
|
||||
}
|
||||
{isEligible
|
||||
? <p className="apotheosis-ready">
|
||||
{"✅ All Transcendence upgrades purchased. You are ready."}
|
||||
</p>
|
||||
: null}
|
||||
</div>
|
||||
|
||||
{isEligible
|
||||
? <div className="prestige-form">
|
||||
<p>
|
||||
{"This action is "}
|
||||
<strong>{"permanent and irreversible"}</strong>
|
||||
{"."}
|
||||
</p>
|
||||
<button
|
||||
className="apotheosis-button"
|
||||
disabled={isPending}
|
||||
onClick={handleApotheosisClick}
|
||||
type="button"
|
||||
>
|
||||
{isPending
|
||||
? "Ascending..."
|
||||
: "✨ Achieve Apotheosis"}
|
||||
</button>
|
||||
{error === null
|
||||
? null
|
||||
: <p className="error">{error}</p>}
|
||||
{result !== null
|
||||
&& <p className="success">
|
||||
{"Apotheosis achieved. This is cycle "}
|
||||
<strong>{result}</strong>
|
||||
{". The infinite loop continues."}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
: null}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { ApotheosisPanel };
|
||||
@@ -0,0 +1,237 @@
|
||||
/**
|
||||
* @file Battle modal component displaying animated battle results.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- Complex battle animation and result display */
|
||||
/* eslint-disable complexity -- Battle result display requires many conditional paths */
|
||||
import { type JSX, useEffect, useState } from "react";
|
||||
import { type BattleResult, useGame } from "../../context/gameContext.js";
|
||||
|
||||
/**
|
||||
* Converts HP values to a percentage for display.
|
||||
* @param current - The current HP value.
|
||||
* @param maximum - The maximum HP value.
|
||||
* @returns The percentage as a number between 0 and 100.
|
||||
*/
|
||||
const toHpPercent = (current: number, maximum: number): number => {
|
||||
if (maximum === 0) {
|
||||
return 0;
|
||||
}
|
||||
const scaled = current * 100;
|
||||
return scaled / maximum;
|
||||
};
|
||||
|
||||
interface BattleModalProperties {
|
||||
readonly battle: BattleResult;
|
||||
readonly onDismiss: ()=> void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the battle modal with HP bars and animated battle results.
|
||||
* @param props - The battle modal properties.
|
||||
* @param props.battle - The battle result data to display.
|
||||
* @param props.onDismiss - Callback to dismiss the modal.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const BattleModal = ({
|
||||
battle,
|
||||
onDismiss,
|
||||
}: BattleModalProperties): JSX.Element => {
|
||||
const { result, bossName } = battle;
|
||||
const { formatNumber } = useGame();
|
||||
|
||||
const [ phase, setPhase ] = useState<"animating" | "result">("animating");
|
||||
|
||||
const bossStartPercent = toHpPercent(result.bossHpBefore, result.bossMaxHp);
|
||||
const partyStartPercent = 100;
|
||||
|
||||
const bossEndPercent = toHpPercent(
|
||||
result.bossHpAtBattleEnd,
|
||||
result.bossMaxHp,
|
||||
);
|
||||
const partyEndPercent = toHpPercent(
|
||||
result.partyHpRemaining,
|
||||
result.partyMaxHp,
|
||||
);
|
||||
|
||||
const [ bossHpPercent, setBossHpPercent ] = useState(bossStartPercent);
|
||||
const [ partyHpPercent, setPartyHpPercent ] = useState(partyStartPercent);
|
||||
|
||||
useEffect(() => {
|
||||
const startAnimation = setTimeout(() => {
|
||||
setBossHpPercent(bossEndPercent);
|
||||
setPartyHpPercent(partyEndPercent);
|
||||
}, 200);
|
||||
|
||||
const revealResult = setTimeout(() => {
|
||||
setPhase("result");
|
||||
}, 5200);
|
||||
|
||||
return (): void => {
|
||||
clearTimeout(startAnimation);
|
||||
clearTimeout(revealResult);
|
||||
};
|
||||
}, [ bossEndPercent, partyEndPercent ]);
|
||||
|
||||
let bossHpBarColour = "#c0392b";
|
||||
if (bossHpPercent > 50) {
|
||||
bossHpBarColour = "#e74c3c";
|
||||
} else if (bossHpPercent > 25) {
|
||||
bossHpBarColour = "#e67e22";
|
||||
}
|
||||
|
||||
let partyHpBarColour = "#e74c3c";
|
||||
if (partyHpPercent > 50) {
|
||||
partyHpBarColour = "#27ae60";
|
||||
} else if (partyHpPercent > 25) {
|
||||
partyHpBarColour = "#f39c12";
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-overlay">
|
||||
<div className="modal battle-modal">
|
||||
<h2>
|
||||
{"⚔️ Battle: "}
|
||||
{bossName}
|
||||
</h2>
|
||||
|
||||
<div className="battle-stats">
|
||||
<div className="battle-stat">
|
||||
<span className="stat-label">{"Your Party DPS"}</span>
|
||||
<span className="stat-value">{formatNumber(result.partyDPS)}</span>
|
||||
</div>
|
||||
<div className="battle-stat-divider">{"vs"}</div>
|
||||
<div className="battle-stat">
|
||||
<span className="stat-label">{"Boss DPS"}</span>
|
||||
<span className="stat-value">{formatNumber(result.bossDPS)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="battle-bars">
|
||||
<div className="battle-bar-row">
|
||||
<span className="bar-label">
|
||||
{"👹 "}
|
||||
{bossName}
|
||||
</span>
|
||||
<div className="hp-bar-container">
|
||||
<div
|
||||
className="hp-bar-fill"
|
||||
style={{
|
||||
backgroundColor: bossHpBarColour,
|
||||
transition: "width 5s ease-in-out",
|
||||
width: `${bossHpPercent.toFixed(1)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="bar-hp">
|
||||
{formatNumber(result.bossHpAtBattleEnd)}
|
||||
{" / "}
|
||||
{formatNumber(result.bossMaxHp)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="vs-divider">{"⚔️ VS ⚔️"}</div>
|
||||
|
||||
<div className="battle-bar-row">
|
||||
<span className="bar-label">{"🛡️ Your Party"}</span>
|
||||
<div className="hp-bar-container">
|
||||
<div
|
||||
className="hp-bar-fill party-hp"
|
||||
style={{
|
||||
backgroundColor: partyHpBarColour,
|
||||
transition: "width 5s ease-in-out",
|
||||
width: `${partyHpPercent.toFixed(1)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="bar-hp">
|
||||
{formatNumber(result.partyHpRemaining)}
|
||||
{" / "}
|
||||
{formatNumber(result.partyMaxHp)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{phase === "animating"
|
||||
&& <p className="battle-in-progress">{"Battling…"}</p>
|
||||
}
|
||||
|
||||
{phase === "result"
|
||||
&& <div
|
||||
className={`battle-outcome ${result.won
|
||||
? "victory"
|
||||
: "defeat"}`}
|
||||
>
|
||||
{result.won
|
||||
? <>
|
||||
<h3>{"🏆 Victory!"}</h3>
|
||||
{result.rewards === undefined
|
||||
? null
|
||||
: <div className="battle-rewards">
|
||||
<p>{"Rewards:"}</p>
|
||||
<span>
|
||||
{"🪙 "}
|
||||
{formatNumber(result.rewards.gold)}
|
||||
{" gold"}
|
||||
</span>
|
||||
{result.rewards.essence > 0
|
||||
&& <span>
|
||||
{"✨ "}
|
||||
{formatNumber(result.rewards.essence)}
|
||||
{" essence"}
|
||||
</span>
|
||||
}
|
||||
{result.rewards.crystals > 0
|
||||
&& <span>
|
||||
{"💎 "}
|
||||
{formatNumber(result.rewards.crystals)}
|
||||
{" crystals"}
|
||||
</span>
|
||||
}
|
||||
{result.rewards.bountyRunestones > 0
|
||||
&& <span className="battle-bounty">
|
||||
{"🔮 "}
|
||||
{formatNumber(result.rewards.bountyRunestones)}
|
||||
{" runestones (first kill!)"}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</>
|
||||
: <>
|
||||
<h3>{"💀 Defeat"}</h3>
|
||||
<p>{"Your party was defeated. The boss has reset."}</p>
|
||||
{result.casualties !== undefined
|
||||
&& result.casualties.length > 0
|
||||
? <div className="battle-casualties">
|
||||
<p>{"Casualties:"}</p>
|
||||
{result.casualties.map((casualty) => {
|
||||
return (
|
||||
<span key={casualty.adventurerId}>
|
||||
{"☠️ "}
|
||||
{casualty.killed} {casualty.adventurerId}
|
||||
{" lost"}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
: null}
|
||||
</>
|
||||
}
|
||||
<button
|
||||
className="dismiss-button"
|
||||
onClick={onDismiss}
|
||||
type="button"
|
||||
>
|
||||
{"Continue"}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { BattleModal };
|
||||
@@ -0,0 +1,383 @@
|
||||
/**
|
||||
* @file Boss panel component for viewing and challenging zone bosses.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable react/no-multi-comp -- Sub-component is tightly coupled to the panel */
|
||||
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
|
||||
/* eslint-disable complexity -- Boss card requires many conditional render paths */
|
||||
/* eslint-disable max-statements -- Boss panel requires many variable declarations */
|
||||
/* eslint-disable max-lines -- Boss panel with sub-component and helper function */
|
||||
import { type JSX, useState } from "react";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import { LockToggle } from "../ui/lockToggle.js";
|
||||
import { ZoneSelector } from "./zoneSelector.js";
|
||||
import type { Boss, GameState } from "@elysium/types";
|
||||
|
||||
interface BossCardProperties {
|
||||
readonly boss: Boss;
|
||||
readonly prestigeCount: number;
|
||||
readonly onChallenge: (bossId: string)=> void;
|
||||
readonly isChallenging: boolean;
|
||||
readonly unlockHint: string | undefined;
|
||||
readonly formatNumber: (n: number)=> string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single boss card.
|
||||
* @param props - The boss card properties.
|
||||
* @param props.boss - The boss data.
|
||||
* @param props.prestigeCount - The current prestige count for lock checking.
|
||||
* @param props.onChallenge - Callback to challenge this boss.
|
||||
* @param props.isChallenging - Whether this boss is currently being challenged.
|
||||
* @param props.unlockHint - Optional hint for how to unlock this boss.
|
||||
* @param props.formatNumber - The number formatting utility function.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const BossCard = ({
|
||||
boss,
|
||||
prestigeCount,
|
||||
onChallenge,
|
||||
isChallenging,
|
||||
unlockHint,
|
||||
formatNumber,
|
||||
}: BossCardProperties): JSX.Element => {
|
||||
const scaled = boss.currentHp * 100;
|
||||
const hpPercent = scaled / boss.maxHp;
|
||||
const isPrestigeLocked = boss.prestigeRequirement > prestigeCount;
|
||||
const canChallenge
|
||||
= (boss.status === "available" || boss.status === "in_progress")
|
||||
&& !isChallenging;
|
||||
|
||||
function handleChallenge(): void {
|
||||
onChallenge(boss.id);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`boss-card boss-${boss.status}`}>
|
||||
<div className="boss-info">
|
||||
<h3>{boss.name}</h3>
|
||||
<p>{boss.description}</p>
|
||||
{isPrestigeLocked && boss.status === "locked"
|
||||
? <p className="prestige-lock">
|
||||
{"🔒 Requires Prestige "}
|
||||
{boss.prestigeRequirement}
|
||||
</p>
|
||||
: null}
|
||||
{!isPrestigeLocked
|
||||
&& boss.status === "locked"
|
||||
&& unlockHint !== undefined
|
||||
? <p className="unlock-hint">{unlockHint}</p>
|
||||
: null}
|
||||
</div>
|
||||
|
||||
{boss.status !== "locked" && boss.status !== "defeated"
|
||||
&& <div className="boss-hp">
|
||||
<div className="hp-bar">
|
||||
<div
|
||||
className="hp-fill"
|
||||
style={{ width: `${hpPercent.toFixed(1)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="hp-text">
|
||||
{formatNumber(boss.currentHp)}
|
||||
{" / "}
|
||||
{formatNumber(boss.maxHp)}
|
||||
{" HP"}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div className="boss-meta">
|
||||
<span className="boss-dps">
|
||||
{"💢 Boss DPS: "}
|
||||
{formatNumber(boss.damagePerSecond)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="boss-rewards">
|
||||
<span>
|
||||
{"🪙 "}
|
||||
{formatNumber(boss.goldReward)}
|
||||
</span>
|
||||
{boss.essenceReward > 0
|
||||
&& <span>
|
||||
{"✨ "}
|
||||
{formatNumber(boss.essenceReward)}
|
||||
</span>
|
||||
}
|
||||
{boss.crystalReward > 0
|
||||
&& <span>
|
||||
{"💎 "}
|
||||
{formatNumber(boss.crystalReward)}
|
||||
</span>
|
||||
}
|
||||
{boss.equipmentRewards.length > 0
|
||||
&& <span>
|
||||
{"🗡️ "}
|
||||
{boss.equipmentRewards.length}
|
||||
{" Equipment"}
|
||||
</span>
|
||||
}
|
||||
{boss.status !== "defeated" && boss.bountyRunestones > 0
|
||||
&& <span className="boss-bounty">
|
||||
{"🔮 "}
|
||||
{boss.bountyRunestones}
|
||||
{" (first kill)"}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
{(boss.status === "available" || boss.status === "in_progress")
|
||||
&& <button
|
||||
className="attack-button"
|
||||
disabled={!canChallenge}
|
||||
onClick={handleChallenge}
|
||||
type="button"
|
||||
>
|
||||
{isChallenging
|
||||
? "⚔️ Battling…"
|
||||
: "⚔️ Challenge"}
|
||||
</button>
|
||||
}
|
||||
|
||||
{boss.status === "defeated"
|
||||
&& <span className="boss-badge defeated">{"☠️ Defeated"}</span>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Computes party DPS and HP from the current game state.
|
||||
* @param state - The full game state.
|
||||
* @returns The computed party DPS and HP values.
|
||||
*/
|
||||
const computePartyStats = (
|
||||
state: GameState,
|
||||
): {
|
||||
partyDps: number;
|
||||
partyHp: number;
|
||||
} => {
|
||||
const { upgrades, adventurers, equipment, prestige } = state;
|
||||
let globalMultiplier = 1;
|
||||
for (const upgrade of upgrades) {
|
||||
const { purchased, target, multiplier } = upgrade;
|
||||
if (purchased && target === "global") {
|
||||
globalMultiplier = globalMultiplier * multiplier;
|
||||
}
|
||||
}
|
||||
const prestigeBonus = prestige.count * 0.1;
|
||||
const prestigeMultiplier = 1 + prestigeBonus;
|
||||
const equipmentCombatMultiplier = equipment.
|
||||
filter((item) => {
|
||||
return item.equipped && item.bonus.combatMultiplier !== undefined;
|
||||
}).
|
||||
reduce((multiplier, item) => {
|
||||
return multiplier * (item.bonus.combatMultiplier ?? 1);
|
||||
}, 1);
|
||||
|
||||
let partyDps = 0;
|
||||
let partyHp = 0;
|
||||
for (const adventurer of adventurers) {
|
||||
const { count, id: adventurerId, combatPower, level } = adventurer;
|
||||
if (count === 0) {
|
||||
continue;
|
||||
}
|
||||
let adventurerMultiplier = 1;
|
||||
for (const upgrade of upgrades) {
|
||||
const {
|
||||
purchased,
|
||||
target,
|
||||
multiplier,
|
||||
adventurerId: upgradeAdventurerId,
|
||||
} = upgrade;
|
||||
if (
|
||||
purchased
|
||||
&& target === "adventurer"
|
||||
&& upgradeAdventurerId === adventurerId
|
||||
) {
|
||||
adventurerMultiplier = adventurerMultiplier * multiplier;
|
||||
}
|
||||
}
|
||||
const dps
|
||||
= combatPower
|
||||
* count
|
||||
* adventurerMultiplier
|
||||
* globalMultiplier
|
||||
* prestigeMultiplier;
|
||||
partyDps = partyDps + dps;
|
||||
const hp = level * 50 * count;
|
||||
partyHp = partyHp + hp;
|
||||
}
|
||||
partyDps = partyDps * equipmentCombatMultiplier;
|
||||
return { partyDps, partyHp };
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the boss panel with zone selection and boss list.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const BossPanel = (): JSX.Element => {
|
||||
const { state, challengeBoss, formatNumber, toggleAutoBoss } = useGame();
|
||||
const [ challengingBossId, setChallengingBossId ] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [ activeZoneId, setActiveZoneId ] = useState("verdant_vale");
|
||||
const [ showLocked, setShowLocked ] = useState(true);
|
||||
|
||||
if (state === null) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"Loading..."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
async function handleChallenge(bossId: string): Promise<void> {
|
||||
setChallengingBossId(bossId);
|
||||
try {
|
||||
await challengeBoss(bossId);
|
||||
} finally {
|
||||
setChallengingBossId(null);
|
||||
}
|
||||
}
|
||||
|
||||
function handleChallengeClick(bossId: string): void {
|
||||
void handleChallenge(bossId);
|
||||
}
|
||||
|
||||
const { zones, bosses, quests, autoBoss, prestige: playerPrestige } = state;
|
||||
const zoneBosses = bosses.filter((boss) => {
|
||||
return boss.zoneId === activeZoneId;
|
||||
});
|
||||
const lockedCount = zoneBosses.filter((boss) => {
|
||||
return boss.status === "locked";
|
||||
}).length;
|
||||
const visibleBosses = showLocked
|
||||
? zoneBosses
|
||||
: zoneBosses.filter((boss) => {
|
||||
return boss.status !== "locked";
|
||||
});
|
||||
|
||||
const bossUnlockHints = new Map<string, string>();
|
||||
for (const zone of zones) {
|
||||
const { id: zoneId, unlockBossId, unlockQuestId } = zone;
|
||||
const allZoneBosses = bosses.filter((boss) => {
|
||||
return boss.zoneId === zoneId;
|
||||
});
|
||||
for (let index = 0; index < allZoneBosses.length; index = index + 1) {
|
||||
const boss = allZoneBosses[index];
|
||||
if (boss === undefined || boss.status !== "locked") {
|
||||
continue;
|
||||
}
|
||||
if (index === 0) {
|
||||
const parts: Array<string> = [];
|
||||
if (unlockBossId !== null) {
|
||||
const gateBoss = bosses.find((candidate) => {
|
||||
return candidate.id === unlockBossId;
|
||||
});
|
||||
if (gateBoss !== undefined) {
|
||||
parts.push(`⚔️ Defeat: ${gateBoss.name}`);
|
||||
}
|
||||
}
|
||||
if (unlockQuestId !== null) {
|
||||
const gateQuest = quests.find((candidate) => {
|
||||
return candidate.id === unlockQuestId;
|
||||
});
|
||||
if (gateQuest !== undefined) {
|
||||
parts.push(`📜 Complete: ${gateQuest.name}`);
|
||||
}
|
||||
}
|
||||
if (parts.length > 0) {
|
||||
bossUnlockHints.set(boss.id, parts.join(" & "));
|
||||
}
|
||||
} else {
|
||||
const previousBoss = allZoneBosses[index - 1];
|
||||
if (previousBoss !== undefined) {
|
||||
bossUnlockHints.set(boss.id, `⚔️ Defeat: ${previousBoss.name} first`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleToggle(): void {
|
||||
setShowLocked((current) => {
|
||||
return !current;
|
||||
});
|
||||
}
|
||||
|
||||
const autoBossOn = autoBoss === true;
|
||||
const { partyDps, partyHp } = computePartyStats(state);
|
||||
const { count: prestigeCount } = playerPrestige;
|
||||
|
||||
return (
|
||||
<section className="panel boss-panel">
|
||||
<div className="panel-header">
|
||||
<h2>{"Boss Encounters"}</h2>
|
||||
<div className="panel-header-controls">
|
||||
<button
|
||||
className={`auto-toggle-btn ${
|
||||
autoBossOn
|
||||
? "auto-toggle-on"
|
||||
: "auto-toggle-off"
|
||||
}`}
|
||||
onClick={toggleAutoBoss}
|
||||
title="Automatically challenge the highest available boss"
|
||||
type="button"
|
||||
>
|
||||
{"🤖 Auto: "}
|
||||
{autoBossOn
|
||||
? "ON"
|
||||
: "OFF"}
|
||||
</button>
|
||||
<LockToggle
|
||||
lockedCount={lockedCount}
|
||||
onToggle={handleToggle}
|
||||
showLocked={showLocked}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ZoneSelector
|
||||
activeZoneId={activeZoneId}
|
||||
onSelectZone={setActiveZoneId}
|
||||
zones={zones}
|
||||
/>
|
||||
|
||||
<div className="party-combat-stats">
|
||||
<div className="combat-stat">
|
||||
<span className="stat-label">{"⚔️ Party DPS"}</span>
|
||||
<span className="stat-value">{formatNumber(partyDps)}</span>
|
||||
</div>
|
||||
<div className="combat-stat">
|
||||
<span className="stat-label">{"❤️ Party HP"}</span>
|
||||
<span className="stat-value">{formatNumber(partyHp)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="boss-list">
|
||||
{visibleBosses.map((boss) => {
|
||||
const { id: bossId } = boss;
|
||||
return (
|
||||
<BossCard
|
||||
boss={boss}
|
||||
formatNumber={formatNumber}
|
||||
isChallenging={challengingBossId === bossId}
|
||||
key={bossId}
|
||||
onChallenge={handleChallengeClick}
|
||||
prestigeCount={prestigeCount}
|
||||
unlockHint={bossUnlockHints.get(bossId)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{visibleBosses.length === 0
|
||||
&& <p className="empty-zone">{"No bosses to show in this zone."}</p>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { BossPanel };
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,681 @@
|
||||
/**
|
||||
* @file Character sheet panel for viewing and editing the player's character.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- Complex component with many fields */
|
||||
/* eslint-disable complexity -- Many conditional render paths for optional fields */
|
||||
/* eslint-disable max-statements -- Component requires many state declarations */
|
||||
/* eslint-disable max-lines -- Large component with editing and view modes */
|
||||
import {
|
||||
DEFAULT_PROFILE_SETTINGS,
|
||||
STORY_CHAPTERS,
|
||||
type EquipmentBonus,
|
||||
type EquipmentRarity,
|
||||
type EquipmentType,
|
||||
type ProfileSettings,
|
||||
} from "@elysium/types";
|
||||
import { type ChangeEvent, type JSX, 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;
|
||||
characterRace: string;
|
||||
characterClass: string;
|
||||
bio: string;
|
||||
guildName: string;
|
||||
guildDescription: string;
|
||||
activeTitle: string;
|
||||
unlockedTitles: Array<{ id: string; name: string }>;
|
||||
equippedItems: Array<EquippedItem>;
|
||||
}
|
||||
|
||||
const emptySheet: CharacterSheetData = {
|
||||
activeTitle: "",
|
||||
bio: "",
|
||||
characterClass: "",
|
||||
characterName: "",
|
||||
characterRace: "",
|
||||
equippedItems: [],
|
||||
guildDescription: "",
|
||||
guildName: "",
|
||||
pronouns: "",
|
||||
unlockedTitles: [],
|
||||
};
|
||||
|
||||
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 character sheet panel for viewing and editing player profile.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const CharacterSheetPanel = (): JSX.Element => {
|
||||
const { state, loginStreak } = useGame();
|
||||
const player = state?.player;
|
||||
|
||||
const [ sheet, setSheet ] = useState<CharacterSheetData>(emptySheet);
|
||||
const [ draft, setDraft ] = useState<CharacterSheetData>(emptySheet);
|
||||
const [ editing, setEditing ] = useState(false);
|
||||
const [ loading, setLoading ] = useState(true);
|
||||
const [ saving, setSaving ] = useState(false);
|
||||
const [ error, setError ] = useState<string | null>(null);
|
||||
const [ saved, setSaved ] = useState(false);
|
||||
const [ copied, setCopied ] = useState(false);
|
||||
const savedSettingsReference = useRef<ProfileSettings>({
|
||||
...DEFAULT_PROFILE_SETTINGS,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (player?.discordId === undefined || player.discordId === "") {
|
||||
return;
|
||||
}
|
||||
fetch(`/api/profile/${player.discordId}`).
|
||||
then(async(response) => {
|
||||
if (!response.ok) {
|
||||
return;
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- API response cast
|
||||
const data = (await response.json()) as {
|
||||
characterName: string;
|
||||
pronouns: string;
|
||||
characterRace: string;
|
||||
characterClass: string;
|
||||
bio: string;
|
||||
guildName: string;
|
||||
guildDescription: string;
|
||||
profileSettings: ProfileSettings;
|
||||
activeTitle: string;
|
||||
unlockedTitles: Array<{ id: string; name: string }>;
|
||||
equippedItems: Array<EquippedItem>;
|
||||
};
|
||||
const loaded: CharacterSheetData = {
|
||||
activeTitle: data.activeTitle,
|
||||
bio: data.bio,
|
||||
characterClass: data.characterClass,
|
||||
characterName: data.characterName,
|
||||
characterRace: data.characterRace,
|
||||
equippedItems: data.equippedItems,
|
||||
guildDescription: data.guildDescription,
|
||||
guildName: data.guildName,
|
||||
pronouns: data.pronouns,
|
||||
unlockedTitles: data.unlockedTitles,
|
||||
};
|
||||
setSheet(loaded);
|
||||
setDraft(loaded);
|
||||
savedSettingsReference.current = {
|
||||
...DEFAULT_PROFILE_SETTINGS,
|
||||
...data.profileSettings,
|
||||
};
|
||||
}).
|
||||
catch(() => {
|
||||
|
||||
/* Fall back to empty */
|
||||
}).
|
||||
finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}, [ player?.discordId ]);
|
||||
|
||||
function handleEdit(): void {
|
||||
setDraft({ ...sheet });
|
||||
setEditing(true);
|
||||
setError(null);
|
||||
setSaved(false);
|
||||
}
|
||||
|
||||
function handleCancel(): void {
|
||||
setEditing(false);
|
||||
setError(null);
|
||||
}
|
||||
|
||||
async function handleSave(): Promise<void> {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const characterName
|
||||
= draft.characterName === ""
|
||||
? player?.characterName ?? ""
|
||||
: draft.characterName;
|
||||
await updateProfile({
|
||||
activeTitle: draft.activeTitle,
|
||||
bio: draft.bio,
|
||||
characterClass: draft.characterClass,
|
||||
characterName: characterName,
|
||||
characterRace: draft.characterRace,
|
||||
guildDescription: draft.guildDescription,
|
||||
guildName: draft.guildName,
|
||||
profileSettings: savedSettingsReference.current,
|
||||
pronouns: draft.pronouns,
|
||||
});
|
||||
setSheet({ ...draft });
|
||||
setSaved(true);
|
||||
setTimeout(() => {
|
||||
setEditing(false);
|
||||
setSaved(false);
|
||||
}, 900);
|
||||
} catch (error_) {
|
||||
setError(error_ instanceof Error
|
||||
? error_.message
|
||||
: "Failed to save");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleSaveClick(): void {
|
||||
void handleSave();
|
||||
}
|
||||
|
||||
function handleShareClick(): void {
|
||||
const discordId = player?.discordId ?? "";
|
||||
const url = `${window.location.origin}/character/${discordId}`;
|
||||
void navigator.clipboard.writeText(url).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => {
|
||||
setCopied(false);
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
|
||||
function handleNameChange(event: ChangeEvent<HTMLInputElement>): void {
|
||||
const { value } = event.target;
|
||||
setDraft((current) => {
|
||||
return { ...current, characterName: value };
|
||||
});
|
||||
}
|
||||
|
||||
function handlePronounsChange(event: ChangeEvent<HTMLInputElement>): void {
|
||||
const { value } = event.target;
|
||||
setDraft((current) => {
|
||||
return { ...current, pronouns: value };
|
||||
});
|
||||
}
|
||||
|
||||
function handleRaceChange(event: ChangeEvent<HTMLInputElement>): void {
|
||||
const { value } = event.target;
|
||||
setDraft((current) => {
|
||||
return { ...current, characterRace: value };
|
||||
});
|
||||
}
|
||||
|
||||
function handleClassChange(event: ChangeEvent<HTMLInputElement>): void {
|
||||
const { value } = event.target;
|
||||
setDraft((current) => {
|
||||
return { ...current, characterClass: value };
|
||||
});
|
||||
}
|
||||
|
||||
function handleBioChange(event: ChangeEvent<HTMLTextAreaElement>): void {
|
||||
const { value } = event.target;
|
||||
setDraft((current) => {
|
||||
return { ...current, bio: value };
|
||||
});
|
||||
}
|
||||
|
||||
function handleTitleChange(event: ChangeEvent<HTMLSelectElement>): void {
|
||||
const { value } = event.target;
|
||||
setDraft((current) => {
|
||||
return { ...current, activeTitle: value };
|
||||
});
|
||||
}
|
||||
|
||||
function handleGuildNameChange(event: ChangeEvent<HTMLInputElement>): void {
|
||||
const { value } = event.target;
|
||||
setDraft((current) => {
|
||||
return { ...current, guildName: value };
|
||||
});
|
||||
}
|
||||
|
||||
function handleGuildDescChange(
|
||||
event: ChangeEvent<HTMLTextAreaElement>,
|
||||
): void {
|
||||
const { value } = event.target;
|
||||
setDraft((current) => {
|
||||
return { ...current, guildDescription: value };
|
||||
});
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"Loading character sheet…"}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (editing) {
|
||||
const isSaveDisabled = saving || draft.characterName.trim() === "";
|
||||
let saveLabel = "Save";
|
||||
if (saving) {
|
||||
saveLabel = "Saving…";
|
||||
}
|
||||
if (saved) {
|
||||
saveLabel = "✓ Saved!";
|
||||
}
|
||||
return (
|
||||
<section className="panel character-sheet-panel">
|
||||
<div className="panel-header">
|
||||
<h2>{"📋 Character Sheet"}</h2>
|
||||
</div>
|
||||
|
||||
<div className="character-sheet-form">
|
||||
<div className="character-sheet-section">
|
||||
<h3 className="character-sheet-section-title">{"⚔️ Character"}</h3>
|
||||
|
||||
<label className="character-sheet-label" htmlFor="cs-name">
|
||||
{"Character Name"}
|
||||
</label>
|
||||
<input
|
||||
className="character-sheet-input"
|
||||
id="cs-name"
|
||||
maxLength={32}
|
||||
onChange={handleNameChange}
|
||||
placeholder="Your character's name"
|
||||
type="text"
|
||||
value={draft.characterName}
|
||||
/>
|
||||
<span className="character-sheet-hint">
|
||||
{draft.characterName.length}
|
||||
{" / 32"}
|
||||
</span>
|
||||
|
||||
<label className="character-sheet-label" htmlFor="cs-pronouns">
|
||||
{"Pronouns"}
|
||||
</label>
|
||||
<input
|
||||
className="character-sheet-input"
|
||||
id="cs-pronouns"
|
||||
maxLength={20}
|
||||
onChange={handlePronounsChange}
|
||||
placeholder="e.g. she/her, he/him, they/them"
|
||||
type="text"
|
||||
value={draft.pronouns}
|
||||
/>
|
||||
<span className="character-sheet-hint">
|
||||
{draft.pronouns.length}
|
||||
{" / 20"}
|
||||
</span>
|
||||
|
||||
<label className="character-sheet-label" htmlFor="cs-race">
|
||||
{"Race"}
|
||||
</label>
|
||||
<input
|
||||
className="character-sheet-input"
|
||||
id="cs-race"
|
||||
maxLength={32}
|
||||
onChange={handleRaceChange}
|
||||
placeholder="e.g. Elf, Dwarf, Human, Tiefling…"
|
||||
type="text"
|
||||
value={draft.characterRace}
|
||||
/>
|
||||
<span className="character-sheet-hint">
|
||||
{draft.characterRace.length}
|
||||
{" / 32"}
|
||||
</span>
|
||||
|
||||
<label className="character-sheet-label" htmlFor="cs-class">
|
||||
{"Class"}
|
||||
</label>
|
||||
<input
|
||||
className="character-sheet-input"
|
||||
id="cs-class"
|
||||
maxLength={32}
|
||||
onChange={handleClassChange}
|
||||
placeholder="e.g. Paladin, Archmage, Shadow Rogue…"
|
||||
type="text"
|
||||
value={draft.characterClass}
|
||||
/>
|
||||
<span className="character-sheet-hint">
|
||||
{draft.characterClass.length}
|
||||
{" / 32"}
|
||||
</span>
|
||||
|
||||
<label className="character-sheet-label" htmlFor="cs-bio">
|
||||
{"About Your Character"}
|
||||
</label>
|
||||
<textarea
|
||||
className="character-sheet-textarea"
|
||||
id="cs-bio"
|
||||
maxLength={200}
|
||||
onChange={handleBioChange}
|
||||
placeholder={
|
||||
"Describe your character's story, personality, or appearance…"
|
||||
}
|
||||
rows={4}
|
||||
value={draft.bio}
|
||||
/>
|
||||
<span className="character-sheet-hint">
|
||||
{draft.bio.length}
|
||||
{" / 200"}
|
||||
</span>
|
||||
|
||||
{draft.unlockedTitles.length > 0
|
||||
&& <>
|
||||
<label className="character-sheet-label" htmlFor="cs-title">
|
||||
{"Active Title"}
|
||||
</label>
|
||||
<select
|
||||
className="character-sheet-input"
|
||||
id="cs-title"
|
||||
onChange={handleTitleChange}
|
||||
value={draft.activeTitle}
|
||||
>
|
||||
<option value="">{"— None —"}</option>
|
||||
{draft.unlockedTitles.map((title) => {
|
||||
return (
|
||||
<option key={title.id} value={title.id}>
|
||||
{title.name}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div className="character-sheet-section">
|
||||
<h3 className="character-sheet-section-title">{"🏰 Guild"}</h3>
|
||||
|
||||
<label className="character-sheet-label" htmlFor="cs-guild-name">
|
||||
{"Guild Name"}
|
||||
</label>
|
||||
<input
|
||||
className="character-sheet-input"
|
||||
id="cs-guild-name"
|
||||
maxLength={64}
|
||||
onChange={handleGuildNameChange}
|
||||
placeholder="Name your guild"
|
||||
type="text"
|
||||
value={draft.guildName}
|
||||
/>
|
||||
<span className="character-sheet-hint">
|
||||
{draft.guildName.length}
|
||||
{" / 64"}
|
||||
</span>
|
||||
|
||||
<label className="character-sheet-label" htmlFor="cs-guild-desc">
|
||||
{"Guild Description"}
|
||||
</label>
|
||||
<textarea
|
||||
className="character-sheet-textarea"
|
||||
id="cs-guild-desc"
|
||||
maxLength={500}
|
||||
onChange={handleGuildDescChange}
|
||||
placeholder="Describe your guild's history, goals, or lore…"
|
||||
rows={6}
|
||||
value={draft.guildDescription}
|
||||
/>
|
||||
<span className="character-sheet-hint">
|
||||
{draft.guildDescription.length}
|
||||
{" / 500"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{error === null
|
||||
? null
|
||||
: <p className="character-sheet-error">{error}</p>
|
||||
}
|
||||
|
||||
<div className="character-sheet-actions">
|
||||
<button
|
||||
className="character-sheet-cancel"
|
||||
onClick={handleCancel}
|
||||
type="button"
|
||||
>
|
||||
{"Cancel"}
|
||||
</button>
|
||||
<button
|
||||
className="character-sheet-save"
|
||||
disabled={isSaveDisabled}
|
||||
onClick={handleSaveClick}
|
||||
type="button"
|
||||
>
|
||||
{saveLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const subtitleParts = [ sheet.characterRace, sheet.characterClass ].filter(
|
||||
(part) => {
|
||||
return part !== "";
|
||||
},
|
||||
);
|
||||
const subtitle = subtitleParts.join(" · ");
|
||||
|
||||
const completedChapters = state?.story?.completedChapters ?? [];
|
||||
|
||||
return (
|
||||
<section className="panel character-sheet-panel">
|
||||
<div className="panel-header">
|
||||
<h2>{"📋 Character Sheet"}</h2>
|
||||
<div className="character-sheet-header-actions">
|
||||
<button
|
||||
className="character-sheet-edit-btn"
|
||||
onClick={handleShareClick}
|
||||
type="button"
|
||||
>
|
||||
{copied
|
||||
? "✓ Copied!"
|
||||
: "🔗 Share"}
|
||||
</button>
|
||||
<a className="character-sheet-edit-btn" href="/leaderboards">
|
||||
{"🏆 Boards"}
|
||||
</a>
|
||||
<button
|
||||
className="character-sheet-edit-btn"
|
||||
onClick={handleEdit}
|
||||
type="button"
|
||||
>
|
||||
{"✏️ Edit"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="character-sheet-view">
|
||||
<div className="character-sheet-section">
|
||||
<h3 className="character-sheet-section-title">{"⚔️ Character"}</h3>
|
||||
<div className="character-sheet-field">
|
||||
<span className="character-sheet-field-label">{"Name"}</span>
|
||||
<span className="character-sheet-field-value">
|
||||
{sheet.characterName === ""
|
||||
? <em className="character-sheet-empty">{"Not set"}</em>
|
||||
: sheet.characterName
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
<div className="character-sheet-field">
|
||||
<span className="character-sheet-field-label">{"Streak"}</span>
|
||||
<span className="character-sheet-streak">
|
||||
{"🔥 "}
|
||||
{loginStreak}
|
||||
{"-day login streak"}
|
||||
</span>
|
||||
</div>
|
||||
{sheet.activeTitle === ""
|
||||
? null
|
||||
: <div className="character-sheet-field">
|
||||
<span className="character-sheet-field-label">{"Title"}</span>
|
||||
<span
|
||||
className={"character-sheet-field-value character-sheet-title"}
|
||||
>
|
||||
{sheet.unlockedTitles.find((title) => {
|
||||
return title.id === sheet.activeTitle;
|
||||
})?.name ?? sheet.activeTitle}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
{sheet.pronouns === ""
|
||||
? null
|
||||
: <div className="character-sheet-field">
|
||||
<span className="character-sheet-field-label">{"Pronouns"}</span>
|
||||
<span className="character-sheet-field-value">
|
||||
{sheet.pronouns}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
{subtitle === ""
|
||||
? null
|
||||
: <div className="character-sheet-field">
|
||||
<span className="character-sheet-field-label">{"Identity"}</span>
|
||||
<span className="character-sheet-field-value">{subtitle}</span>
|
||||
</div>
|
||||
}
|
||||
{sheet.bio === ""
|
||||
? null
|
||||
: <div className="character-sheet-bio">
|
||||
<span className="character-sheet-field-label">{"About"}</span>
|
||||
<p className="character-sheet-bio-text">{sheet.bio}</p>
|
||||
</div>
|
||||
}
|
||||
</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) => {
|
||||
return (
|
||||
<div
|
||||
className="character-sheet-equipment-item"
|
||||
key={item.type}
|
||||
>
|
||||
<div className="character-sheet-equipment-header">
|
||||
<span className="character-sheet-equipment-slot">
|
||||
{slotIcons[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 === ""
|
||||
? <p className="character-sheet-empty">
|
||||
{"No guild registered yet. Click ✏️ Edit to add one!"}
|
||||
</p>
|
||||
: <>
|
||||
<div className="character-sheet-field">
|
||||
<span className="character-sheet-field-label">{"Name"}</span>
|
||||
<span className="character-sheet-field-value">
|
||||
{sheet.guildName}
|
||||
</span>
|
||||
</div>
|
||||
{sheet.guildDescription === ""
|
||||
? null
|
||||
: <div className="character-sheet-bio">
|
||||
<span className="character-sheet-field-label">{"Lore"}</span>
|
||||
<p className="character-sheet-bio-text">
|
||||
{sheet.guildDescription}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
</>
|
||||
}
|
||||
</div>
|
||||
|
||||
{completedChapters.length === 0
|
||||
? null
|
||||
: <div className="character-sheet-section">
|
||||
<h3 className="character-sheet-section-title">
|
||||
{"📖 Story Choices"}
|
||||
</h3>
|
||||
{completedChapters.map((completion) => {
|
||||
const chapter = STORY_CHAPTERS.find((candidate) => {
|
||||
return candidate.id === completion.chapterId;
|
||||
});
|
||||
if (chapter === undefined) {
|
||||
return null;
|
||||
}
|
||||
const choice = chapter.choices.find((candidate) => {
|
||||
return candidate.id === completion.choiceId;
|
||||
});
|
||||
if (choice === undefined) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className="character-sheet-story-entry"
|
||||
key={completion.chapterId}
|
||||
>
|
||||
<span className="character-sheet-story-chapter">
|
||||
{chapter.title}
|
||||
</span>
|
||||
<span className="character-sheet-story-choice">
|
||||
{choice.label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { CharacterSheetPanel };
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* @file Click area component - the main guild hall click target.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- Complex useCallback with float management */
|
||||
import {
|
||||
type JSX,
|
||||
type MouseEvent,
|
||||
useCallback,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import { calculateClickPower } from "../../engine/tick.js";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention, no-underscore-dangle -- Vite define constant
|
||||
declare const __WEB_VERSION__: string;
|
||||
|
||||
interface FloatText {
|
||||
id: number;
|
||||
x: number;
|
||||
y: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the guild hall click area with floating gold text on click.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const ClickArea = (): JSX.Element => {
|
||||
const {
|
||||
state,
|
||||
handleClick,
|
||||
formatNumber,
|
||||
saveSchemaVersion,
|
||||
currentSchemaVersion,
|
||||
} = useGame();
|
||||
const [ floats, setFloats ] = useState<Array<FloatText>>([]);
|
||||
const nextIdReference = useRef(0);
|
||||
|
||||
const handleClickWithFloat = useCallback(
|
||||
(event: MouseEvent<HTMLButtonElement>) => {
|
||||
if (state === null) {
|
||||
return;
|
||||
}
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
const x = event.clientX - rect.left;
|
||||
const y = event.clientY - rect.top;
|
||||
const id = nextIdReference.current;
|
||||
nextIdReference.current = nextIdReference.current + 1;
|
||||
const clickPower = calculateClickPower(state);
|
||||
const text = `+${formatNumber(clickPower)}`;
|
||||
|
||||
setFloats((previous) => {
|
||||
return [ ...previous, { id, text, x, y } ];
|
||||
});
|
||||
handleClick();
|
||||
|
||||
setTimeout(() => {
|
||||
// eslint-disable-next-line max-nested-callbacks -- Float cleanup requires nesting within setTimeout
|
||||
setFloats((previous) => {
|
||||
// eslint-disable-next-line max-nested-callbacks -- Float cleanup requires nesting within setTimeout
|
||||
return previous.filter((floatItem) => {
|
||||
return floatItem.id !== id;
|
||||
});
|
||||
});
|
||||
}, 900);
|
||||
},
|
||||
[ state, handleClick, formatNumber ],
|
||||
);
|
||||
|
||||
if (state === null) {
|
||||
return <div className="click-area-placeholder" />;
|
||||
}
|
||||
|
||||
const clickPower = calculateClickPower(state);
|
||||
|
||||
return (
|
||||
<section className="click-area">
|
||||
<h1 className="game-title">{"Elysium"}</h1>
|
||||
<p className="game-version">
|
||||
{"v"}
|
||||
{__WEB_VERSION__}
|
||||
</p>
|
||||
{currentSchemaVersion > 0
|
||||
&& <p className="game-schema-version">
|
||||
{"Save: v"}
|
||||
{saveSchemaVersion}
|
||||
{" / Latest: v"}
|
||||
{currentSchemaVersion}
|
||||
</p>
|
||||
}
|
||||
<h2>{"Guild Hall"}</h2>
|
||||
<div className="click-button-wrapper">
|
||||
<button
|
||||
aria-label={`Click to earn ${formatNumber(clickPower)} gold`}
|
||||
className="click-button"
|
||||
onClick={handleClickWithFloat}
|
||||
type="button"
|
||||
>
|
||||
<img
|
||||
alt="Guild Hall"
|
||||
className="click-button-image"
|
||||
src="https://cdn.nhcarrigan.com/avatars/elysium.png"
|
||||
/>
|
||||
</button>
|
||||
{floats.map((floatItem) => {
|
||||
return (
|
||||
<span
|
||||
className="click-float"
|
||||
key={floatItem.id}
|
||||
style={{ left: floatItem.x, top: floatItem.y }}
|
||||
>
|
||||
{floatItem.text}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="click-power">
|
||||
{"+"}
|
||||
{formatNumber(clickPower)}
|
||||
{" gold/click"}
|
||||
</p>
|
||||
<p className="early-access-notice">
|
||||
{"⚠️ Early Access — this build is subject to change. "}
|
||||
<strong>
|
||||
{"All game progress WILL be reset upon v1.0.0 release."}
|
||||
</strong>
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { ClickArea };
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* @file Codex panel component displaying discovered lore entries.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- Complex component with zone and entry rendering */
|
||||
import { type JSX, useState } from "react";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import { CODEX_ENTRIES, ZONE_LABELS } from "../../data/codex.js";
|
||||
import type { CodexEntry } from "@elysium/types";
|
||||
|
||||
/**
|
||||
* Converts a fraction to a percentage value.
|
||||
* @param numerator - The numerator value.
|
||||
* @param denominator - The denominator value.
|
||||
* @returns The percentage as a number between 0 and 100.
|
||||
*/
|
||||
const toPercent = (numerator: number, denominator: number): number => {
|
||||
if (denominator === 0) {
|
||||
return 0;
|
||||
}
|
||||
const scaled = numerator * 100;
|
||||
return scaled / denominator;
|
||||
};
|
||||
|
||||
const sourceBadge: Record<CodexEntry["sourceType"], string> = {
|
||||
adventurer: "👥",
|
||||
boss: "⚔️",
|
||||
equipment: "🛡️",
|
||||
exploration: "🧭",
|
||||
prestige: "🔮",
|
||||
quest: "📜",
|
||||
recipe: "⚗️",
|
||||
upgrade: "🔧",
|
||||
zone: "🗺️",
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the codex panel with lore entries grouped by zone.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const CodexPanel = (): JSX.Element => {
|
||||
const { state } = useGame();
|
||||
const [ expandedId, setExpandedId ] = useState<string | null>(null);
|
||||
|
||||
if (state === null) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"Loading..."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const unlockedIds = new Set(state.codex?.unlockedEntryIds ?? []);
|
||||
const totalEntries = CODEX_ENTRIES.length;
|
||||
const unlockedCount = CODEX_ENTRIES.filter((entry) => {
|
||||
return unlockedIds.has(entry.id);
|
||||
}).length;
|
||||
const progressPercent = toPercent(unlockedCount, totalEntries);
|
||||
|
||||
const entriesByZone = Object.entries(ZONE_LABELS).
|
||||
map(([ zoneId, zoneName ]) => {
|
||||
const entries = CODEX_ENTRIES.filter((entry) => {
|
||||
return entry.zoneId === zoneId;
|
||||
});
|
||||
const unlockedEntries = entries.filter((entry) => {
|
||||
return unlockedIds.has(entry.id);
|
||||
});
|
||||
return {
|
||||
entries,
|
||||
unlockedEntries,
|
||||
zoneId,
|
||||
zoneName,
|
||||
};
|
||||
}).
|
||||
filter(({ entries }) => {
|
||||
return entries.length > 0;
|
||||
});
|
||||
|
||||
return (
|
||||
<section className="panel codex-panel">
|
||||
<h2>{"📖 Codex"}</h2>
|
||||
|
||||
<div className="codex-progress">
|
||||
<p className="codex-progress-text">
|
||||
{"Lore discovered: "}
|
||||
<strong>
|
||||
{unlockedCount}
|
||||
{" / "}
|
||||
{totalEntries}
|
||||
</strong>
|
||||
</p>
|
||||
<div className="codex-progress-bar">
|
||||
<div
|
||||
className="codex-progress-fill"
|
||||
style={{ width: `${String(Math.round(progressPercent))}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{entriesByZone.map(({ zoneId, zoneName, entries, unlockedEntries }) => {
|
||||
return (
|
||||
<div className="codex-zone" key={zoneId}>
|
||||
<h3 className="codex-zone-header">
|
||||
{zoneName}
|
||||
<span className="codex-zone-count">
|
||||
{unlockedEntries.length}
|
||||
{"/"}
|
||||
{entries.length}
|
||||
</span>
|
||||
</h3>
|
||||
|
||||
<div className="codex-entries">
|
||||
{entries.map((entry) => {
|
||||
const isUnlocked = unlockedIds.has(entry.id);
|
||||
const isExpanded = expandedId === entry.id;
|
||||
|
||||
if (!isUnlocked) {
|
||||
return (
|
||||
<div className="codex-entry locked" key={entry.id}>
|
||||
<div className="codex-entry-header">
|
||||
<span className="codex-lock">{"🔒"}</span>
|
||||
<span className="codex-entry-title">{"???"}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function handleExpand(): void {
|
||||
setExpandedId(isExpanded
|
||||
? null
|
||||
: entry.id);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`codex-entry unlocked ${
|
||||
isExpanded
|
||||
? "expanded"
|
||||
: ""
|
||||
}`}
|
||||
key={entry.id}
|
||||
onClick={handleExpand}
|
||||
>
|
||||
<div className="codex-entry-header">
|
||||
<span className="codex-source-badge">
|
||||
{sourceBadge[entry.sourceType]}
|
||||
</span>
|
||||
<span className="codex-entry-title">{entry.title}</span>
|
||||
<span className="codex-chevron">
|
||||
{isExpanded
|
||||
? "▲"
|
||||
: "▼"}
|
||||
</span>
|
||||
</div>
|
||||
{isExpanded
|
||||
? <p className="codex-entry-content">{entry.content}</p>
|
||||
: null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { CodexPanel };
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* @file Codex toast notification component for new lore discoveries.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable react/no-multi-comp -- Sub-component is tightly coupled to the toast container */
|
||||
import { type JSX, useEffect } from "react";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import { CODEX_ENTRIES } from "../../data/codex.js";
|
||||
|
||||
interface CodexToastItemProperties {
|
||||
readonly entryId: string;
|
||||
readonly onDismiss: (id: string)=> void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single codex lore toast notification.
|
||||
* @param props - The toast item properties.
|
||||
* @param props.entryId - The codex entry ID to display.
|
||||
* @param props.onDismiss - Callback to dismiss the toast.
|
||||
* @returns The JSX element or null if entry is not found.
|
||||
*/
|
||||
const CodexToastItem = ({
|
||||
entryId,
|
||||
onDismiss,
|
||||
}: CodexToastItemProperties): JSX.Element | null => {
|
||||
const entry = CODEX_ENTRIES.find((codexEntry) => {
|
||||
return codexEntry.id === entryId;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
onDismiss(entryId);
|
||||
}, 4000);
|
||||
return (): void => {
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [ entryId, onDismiss ]);
|
||||
|
||||
if (entry === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function handleClick(): void {
|
||||
onDismiss(entryId);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="codex-toast" onClick={handleClick}>
|
||||
<span className="toast-icon">{"📖"}</span>
|
||||
<div className="toast-content">
|
||||
<span className="toast-label">{"✨ Lore Unlocked!"}</span>
|
||||
<span className="toast-name">{entry.title}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the codex toast container with pending lore notifications.
|
||||
* @returns The JSX element or null if there are no pending entries.
|
||||
*/
|
||||
const CodexToast = (): JSX.Element | null => {
|
||||
const { unlockedCodexEntryIds: pendingEntryIds, dismissCodexEntry }
|
||||
= useGame();
|
||||
|
||||
if (pendingEntryIds.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="achievement-toast-container">
|
||||
{pendingEntryIds.map((id) => {
|
||||
return (
|
||||
<CodexToastItem entryId={id} key={id} onDismiss={dismissCodexEntry} />
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { CodexToast };
|
||||
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* @file Companion panel component for managing active companions.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable react/no-multi-comp -- Sub-component is tightly coupled to the panel */
|
||||
/* eslint-disable max-lines-per-function -- Complex companion card with conditional renders */
|
||||
import { COMPANIONS, type Companion } from "@elysium/types";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import type { JSX } from "react";
|
||||
|
||||
const bonusLabels: Record<string, string> = {
|
||||
bossDamage: "Boss Damage",
|
||||
clickGold: "Click Gold",
|
||||
essenceIncome: "Essence Income",
|
||||
passiveGold: "Passive Gold",
|
||||
questTime: "Quest Time",
|
||||
};
|
||||
|
||||
const unlockLabels: Record<string, string> = {
|
||||
apotheosis: "apotheosis",
|
||||
lifetimeBosses: "lifetime bosses defeated",
|
||||
lifetimeGold: "lifetime gold earned",
|
||||
lifetimeQuests: "lifetime quests completed",
|
||||
prestige: "prestige(s)",
|
||||
transcendence: "transcendence(s)",
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats a companion unlock threshold for display.
|
||||
* @param type - The unlock condition type.
|
||||
* @param threshold - The threshold value.
|
||||
* @returns The formatted threshold string.
|
||||
*/
|
||||
const formatThreshold = (type: string, threshold: number): string => {
|
||||
if (type === "lifetimeGold") {
|
||||
if (threshold >= 1e18) {
|
||||
return `${(threshold / 1e18).toFixed(0)}Qt`;
|
||||
}
|
||||
if (threshold >= 1e15) {
|
||||
return `${(threshold / 1e15).toFixed(0)}Q`;
|
||||
}
|
||||
if (threshold >= 1e12) {
|
||||
return `${(threshold / 1e12).toFixed(0)}T`;
|
||||
}
|
||||
if (threshold >= 1e9) {
|
||||
return `${(threshold / 1e9).toFixed(0)}B`;
|
||||
}
|
||||
if (threshold >= 1e6) {
|
||||
return `${(threshold / 1e6).toFixed(0)}M`;
|
||||
}
|
||||
if (threshold >= 1e3) {
|
||||
return `${(threshold / 1e3).toFixed(0)}K`;
|
||||
}
|
||||
}
|
||||
return threshold.toString();
|
||||
};
|
||||
|
||||
interface CompanionCardProperties {
|
||||
readonly companion: Companion;
|
||||
readonly isUnlocked: boolean;
|
||||
readonly isActive: boolean;
|
||||
readonly onSelect: ()=> void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single companion card.
|
||||
* @param props - The companion card properties.
|
||||
* @param props.companion - The companion data.
|
||||
* @param props.isUnlocked - Whether this companion is unlocked.
|
||||
* @param props.isActive - Whether this companion is currently active.
|
||||
* @param props.onSelect - Callback when the companion is selected/deselected.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const CompanionCard = ({
|
||||
companion,
|
||||
isUnlocked,
|
||||
isActive,
|
||||
onSelect,
|
||||
}: CompanionCardProperties): JSX.Element => {
|
||||
const bonusSign = companion.bonus.type === "questTime"
|
||||
? "-"
|
||||
: "+";
|
||||
const bonusPercent = Math.round(companion.bonus.value * 100);
|
||||
const bonusLabel = bonusLabels[companion.bonus.type] ?? companion.bonus.type;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`companion-card ${
|
||||
isUnlocked
|
||||
? "companion-unlocked"
|
||||
: "companion-locked"
|
||||
} ${isActive
|
||||
? "companion-active"
|
||||
: ""}`}
|
||||
>
|
||||
<div className="companion-header">
|
||||
<div className="companion-name-block">
|
||||
<span className="companion-name">{companion.name}</span>
|
||||
<span className="companion-title">{companion.title}</span>
|
||||
</div>
|
||||
{isActive
|
||||
? <span className="companion-active-badge">{"Active"}</span>
|
||||
: null}
|
||||
</div>
|
||||
|
||||
<p className="companion-description">{companion.description}</p>
|
||||
|
||||
<div className="companion-bonus">
|
||||
<span className="companion-bonus-label">{bonusLabel}</span>
|
||||
<span className="companion-bonus-value">
|
||||
{bonusSign}
|
||||
{bonusPercent}
|
||||
{"%"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{isUnlocked
|
||||
? <button
|
||||
className={`companion-select-btn ${
|
||||
isActive
|
||||
? "companion-select-active"
|
||||
: ""
|
||||
}`}
|
||||
onClick={onSelect}
|
||||
type="button"
|
||||
>
|
||||
{isActive
|
||||
? "Deactivate"
|
||||
: "Activate"}
|
||||
</button>
|
||||
: <div className="companion-unlock-requirement">
|
||||
{"🔒 Unlock: "}
|
||||
{formatThreshold(
|
||||
companion.unlock.type,
|
||||
companion.unlock.threshold,
|
||||
)}{" "}
|
||||
{unlockLabels[companion.unlock.type] ?? companion.unlock.type}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the companion panel with all companions.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const CompanionPanel = (): JSX.Element => {
|
||||
const { state, setActiveCompanion } = useGame();
|
||||
|
||||
if (state === null) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"Loading..."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const unlockedIds = state.companions?.unlockedCompanionIds ?? [];
|
||||
const activeId = state.companions?.activeCompanionId ?? null;
|
||||
|
||||
function handleSelect(companionId: string): void {
|
||||
setActiveCompanion(activeId === companionId
|
||||
? null
|
||||
: companionId);
|
||||
}
|
||||
|
||||
const activeCompanion
|
||||
= activeId === null
|
||||
? undefined
|
||||
: COMPANIONS.find((companion) => {
|
||||
return companion.id === activeId;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="companion-panel">
|
||||
<h2>{"👥 Companions"}</h2>
|
||||
<p className="companion-intro">
|
||||
{"Companions provide powerful bonuses while active."
|
||||
+ " You can only have one companion active at a time."}
|
||||
{activeId === null
|
||||
? null
|
||||
: <>
|
||||
{" Currently active: "}
|
||||
<strong>{activeCompanion?.name ?? activeId}</strong>
|
||||
{"."}
|
||||
</>
|
||||
}
|
||||
</p>
|
||||
|
||||
<div className="companion-grid">
|
||||
{COMPANIONS.map((companion) => {
|
||||
function handleCompanionSelect(): void {
|
||||
handleSelect(companion.id);
|
||||
}
|
||||
return (
|
||||
<CompanionCard
|
||||
companion={companion}
|
||||
isActive={activeId === companion.id}
|
||||
isUnlocked={unlockedIds.includes(companion.id)}
|
||||
key={companion.id}
|
||||
onSelect={handleCompanionSelect}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { CompanionPanel };
|
||||
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* @file Crafting panel component for crafting items from materials.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
|
||||
/* eslint-disable max-nested-callbacks -- Nested recipe/material maps require nesting */
|
||||
import { type JSX, useState } from "react";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import { MATERIALS } from "../../data/materials.js";
|
||||
import { RECIPES } from "../../data/recipes.js";
|
||||
import { ZoneSelector } from "./zoneSelector.js";
|
||||
|
||||
const bonusLabel: Record<string, string> = {
|
||||
click_power: "👆 Click Power",
|
||||
combat_power: "⚔️ Combat Power",
|
||||
essence_income: "✨ Essence Income",
|
||||
gold_income: "🪙 Gold Income",
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the crafting panel for crafting recipes from gathered materials.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const CraftingPanel = (): JSX.Element => {
|
||||
const { state, craftRecipe, formatNumber } = useGame();
|
||||
const [ activeZoneId, setActiveZoneId ] = useState("verdant_vale");
|
||||
const [ pendingRecipeId, setPendingRecipeId ] = useState<string | null>(null);
|
||||
|
||||
if (state === null) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"Loading..."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const { zones, exploration: explorationState } = state;
|
||||
const playerMaterials = explorationState?.materials ?? [];
|
||||
const craftedIds = explorationState?.craftedRecipeIds ?? [];
|
||||
|
||||
const zoneRecipes = RECIPES.filter((recipe) => {
|
||||
return recipe.zoneId === activeZoneId;
|
||||
});
|
||||
const zoneMaterials = MATERIALS.filter((material) => {
|
||||
return material.zoneId === activeZoneId;
|
||||
});
|
||||
|
||||
function getQuantity(materialId: string): number {
|
||||
return (
|
||||
playerMaterials.find((playerMaterial) => {
|
||||
return playerMaterial.materialId === materialId;
|
||||
})?.quantity ?? 0
|
||||
);
|
||||
}
|
||||
|
||||
function canAffordRecipe(recipeId: string): boolean {
|
||||
const recipe = RECIPES.find((candidateRecipe) => {
|
||||
return candidateRecipe.id === recipeId;
|
||||
});
|
||||
if (recipe === undefined) {
|
||||
return false;
|
||||
}
|
||||
return recipe.requiredMaterials.every((request) => {
|
||||
return getQuantity(request.materialId) >= request.quantity;
|
||||
});
|
||||
}
|
||||
|
||||
async function handleCraft(recipeId: string): Promise<void> {
|
||||
setPendingRecipeId(recipeId);
|
||||
try {
|
||||
await craftRecipe(recipeId);
|
||||
} finally {
|
||||
setPendingRecipeId(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="panel crafting-panel">
|
||||
<div className="panel-header">
|
||||
<h2>{"⚗️ Crafting"}</h2>
|
||||
</div>
|
||||
|
||||
<ZoneSelector
|
||||
activeZoneId={activeZoneId}
|
||||
onSelectZone={setActiveZoneId}
|
||||
zones={zones}
|
||||
/>
|
||||
|
||||
<div className="crafting-content">
|
||||
<div className="materials-section">
|
||||
<h3>{"📦 Materials"}</h3>
|
||||
{zoneMaterials.length === 0
|
||||
? <p className="empty-zone">{"No materials in this zone."}</p>
|
||||
: <div className="materials-list">
|
||||
{zoneMaterials.map((material) => {
|
||||
const qty = getQuantity(material.id);
|
||||
return (
|
||||
<div
|
||||
className={`material-card rarity-${material.rarity} ${
|
||||
qty === 0
|
||||
? "material-empty"
|
||||
: ""
|
||||
}`}
|
||||
key={material.id}
|
||||
>
|
||||
<div className="material-info">
|
||||
<span className="material-name">{material.name}</span>
|
||||
<span className="material-rarity">{material.rarity}</span>
|
||||
</div>
|
||||
<span className="material-quantity">
|
||||
{formatNumber(qty)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div className="recipes-section">
|
||||
<h3>{"📜 Recipes"}</h3>
|
||||
{zoneRecipes.length === 0
|
||||
? <p className="empty-zone">{"No recipes in this zone."}</p>
|
||||
: <div className="recipes-list">
|
||||
{zoneRecipes.map((recipe) => {
|
||||
const crafted = craftedIds.includes(recipe.id);
|
||||
const affordable = canAffordRecipe(recipe.id);
|
||||
const isPending = pendingRecipeId === recipe.id;
|
||||
|
||||
function handleCraftClick(): void {
|
||||
void handleCraft(recipe.id);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`recipe-card ${
|
||||
crafted
|
||||
? "recipe-crafted"
|
||||
: ""
|
||||
} ${!affordable && !crafted
|
||||
? "recipe-unaffordable"
|
||||
: ""}`}
|
||||
key={recipe.id}
|
||||
>
|
||||
<div className="recipe-info">
|
||||
<h4>{recipe.name}</h4>
|
||||
<p className="recipe-description">{recipe.description}</p>
|
||||
<div className="recipe-bonus">
|
||||
<span className="bonus-label">
|
||||
{bonusLabel[recipe.bonus.type] ?? recipe.bonus.type}
|
||||
</span>
|
||||
<span className="bonus-value">
|
||||
{"×"}
|
||||
{recipe.bonus.value.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="recipe-requirements">
|
||||
{recipe.requiredMaterials.map((request) => {
|
||||
const have = getQuantity(request.materialId);
|
||||
const enough = have >= request.quantity;
|
||||
const matName
|
||||
= MATERIALS.find((mat) => {
|
||||
return mat.id === request.materialId;
|
||||
})?.name ?? request.materialId;
|
||||
return (
|
||||
<span
|
||||
className={`req-tag ${
|
||||
enough
|
||||
? "req-met"
|
||||
: "req-missing"
|
||||
}`}
|
||||
key={request.materialId}
|
||||
>
|
||||
{matName}
|
||||
{": "}
|
||||
{formatNumber(have)}
|
||||
{"/"}
|
||||
{formatNumber(request.quantity)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="recipe-action">
|
||||
{crafted
|
||||
? <span className="quest-badge active">
|
||||
{"✅ Crafted"}
|
||||
</span>
|
||||
: <button
|
||||
className="craft-button"
|
||||
disabled={
|
||||
!affordable || isPending || pendingRecipeId !== null
|
||||
}
|
||||
onClick={handleCraftClick}
|
||||
type="button"
|
||||
>
|
||||
{isPending
|
||||
? "Crafting..."
|
||||
: "⚗️ Craft"}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { CraftingPanel };
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* @file Daily challenge panel component showing today's challenges.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import type { JSX } from "react";
|
||||
|
||||
/**
|
||||
* Formats the time remaining until the daily reset.
|
||||
* @returns The formatted time string.
|
||||
*/
|
||||
const formatTimeUntilReset = (): string => {
|
||||
const now = new Date();
|
||||
const nowAsPst = new Date(
|
||||
now.toLocaleString("en-US", { timeZone: "America/Los_Angeles" }),
|
||||
);
|
||||
const tomorrowMidnightPst = new Date(nowAsPst);
|
||||
tomorrowMidnightPst.setDate(tomorrowMidnightPst.getDate() + 1);
|
||||
tomorrowMidnightPst.setHours(0, 0, 0, 0);
|
||||
const pstOffset = nowAsPst.getTime() - now.getTime();
|
||||
const resetAt = new Date(tomorrowMidnightPst.getTime() - pstOffset);
|
||||
const msRemaining = resetAt.getTime() - now.getTime();
|
||||
const msPerHour = 1000 * 60 * 60;
|
||||
const msPerMinute = 1000 * 60;
|
||||
const hoursRemaining = Math.floor(msRemaining / msPerHour);
|
||||
const msAfterHours = msRemaining % msPerHour;
|
||||
const minutesRemaining = Math.floor(msAfterHours / msPerMinute);
|
||||
return `${String(hoursRemaining)}h ${String(minutesRemaining)}m`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the daily challenge panel with progress tracking.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const DailyChallengePanel = (): JSX.Element => {
|
||||
const { state, formatNumber } = useGame();
|
||||
|
||||
if (state === null) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"Loading..."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const { dailyChallenges } = state;
|
||||
|
||||
if (dailyChallenges === undefined) {
|
||||
return (
|
||||
<section className="panel daily-challenge-panel">
|
||||
<h2>{"📅 Daily Challenges"}</h2>
|
||||
<p className="daily-challenge-subtitle">
|
||||
{"Load the game to generate today's challenges!"}
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const completedCount = dailyChallenges.challenges.filter((challenge) => {
|
||||
return challenge.completed;
|
||||
}).length;
|
||||
|
||||
return (
|
||||
<section className="panel daily-challenge-panel">
|
||||
<h2>{"📅 Daily Challenges"}</h2>
|
||||
<div className="daily-challenge-header">
|
||||
<p className="daily-challenge-subtitle">
|
||||
{"Complete challenges for bonus 💎 crystals! Resets in "}
|
||||
<strong>{formatTimeUntilReset()}</strong>
|
||||
{" (PST midnight)."}
|
||||
</p>
|
||||
<p className="daily-challenge-progress">
|
||||
{completedCount}
|
||||
{" / "}
|
||||
{dailyChallenges.challenges.length}
|
||||
{" completed"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="daily-challenge-list">
|
||||
{dailyChallenges.challenges.map((challenge) => {
|
||||
const progressScaled = challenge.progress * 100;
|
||||
const progressPercent = Math.min(
|
||||
100,
|
||||
Math.floor(progressScaled / challenge.target),
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`daily-challenge-card ${
|
||||
challenge.completed
|
||||
? "completed"
|
||||
: ""
|
||||
}`}
|
||||
key={challenge.id}
|
||||
>
|
||||
<div className="daily-challenge-info">
|
||||
<h3 className="daily-challenge-label">{challenge.label}</h3>
|
||||
<p className="daily-challenge-reward">
|
||||
{"Reward: "}
|
||||
<strong>
|
||||
{"💎 "}
|
||||
{formatNumber(challenge.rewardCrystals)}
|
||||
{" crystals"}
|
||||
</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="daily-challenge-right">
|
||||
{challenge.completed
|
||||
? <span className="daily-challenge-done">
|
||||
{"✅ Complete!"}
|
||||
</span>
|
||||
|
||||
: <>
|
||||
<p className="daily-challenge-count">
|
||||
{formatNumber(challenge.progress)}
|
||||
{" / "}
|
||||
{formatNumber(challenge.target)}
|
||||
</p>
|
||||
<div className="daily-challenge-bar-track">
|
||||
<div
|
||||
className="daily-challenge-bar-fill"
|
||||
style={{ width: `${String(progressPercent)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { DailyChallengePanel };
|
||||
@@ -0,0 +1,482 @@
|
||||
/**
|
||||
* @file Edit profile modal component for updating player profile settings.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- Complex form with many fields */
|
||||
/* eslint-disable complexity -- Many conditional render paths for toggles */
|
||||
/* eslint-disable max-lines -- Large modal with profile and settings forms */
|
||||
/* eslint-disable max-statements -- Many state initialisations and handlers */
|
||||
import {
|
||||
DEFAULT_PROFILE_SETTINGS,
|
||||
type NumberFormat,
|
||||
type ProfileSettings,
|
||||
} from "@elysium/types";
|
||||
import { type ChangeEvent, type JSX, useEffect, useState } from "react";
|
||||
import { updateProfile } from "../../api/client.js";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import {
|
||||
requestNotificationPermission,
|
||||
} from "../../utils/notification.js";
|
||||
|
||||
interface EditProfileModalProperties {
|
||||
readonly onClose: ()=> void;
|
||||
}
|
||||
|
||||
interface StatToggle {
|
||||
key: keyof ProfileSettings;
|
||||
label: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
const currentRunToggles: Array<StatToggle> = [
|
||||
{ icon: "🪙", key: "showCurrentGold", label: "Gold Earned This Run" },
|
||||
{ icon: "👆", key: "showCurrentClicks", label: "Clicks This Run" },
|
||||
{ icon: "✨", key: "showApotheosis", label: "Apotheosis Badge" },
|
||||
{ icon: "🌌", key: "showTranscendence", label: "Transcendence Badge" },
|
||||
{ icon: "⭐", key: "showPrestige", label: "Prestige Level" },
|
||||
{ icon: "💀", key: "showBossesDefeated", label: "Bosses Defeated" },
|
||||
{ icon: "📜", key: "showQuestsCompleted", label: "Quests Completed" },
|
||||
{
|
||||
icon: "⚔️",
|
||||
key: "showAdventurersRecruited",
|
||||
label: "Adventurers Recruited",
|
||||
},
|
||||
{
|
||||
icon: "🏆",
|
||||
key: "showAchievementsUnlocked",
|
||||
label: "Achievements Unlocked",
|
||||
},
|
||||
];
|
||||
|
||||
const allTimeToggles: Array<StatToggle> = [
|
||||
{ icon: "🪙", key: "showTotalGold", label: "Total Gold Earned" },
|
||||
{ icon: "👆", key: "showTotalClicks", label: "Total Clicks" },
|
||||
{
|
||||
icon: "💀",
|
||||
key: "showLifetimeBossesDefeated",
|
||||
label: "Bosses Defeated",
|
||||
},
|
||||
{
|
||||
icon: "📜",
|
||||
key: "showLifetimeQuestsCompleted",
|
||||
label: "Quests Completed",
|
||||
},
|
||||
{
|
||||
icon: "⚔️",
|
||||
key: "showLifetimeAdventurersRecruited",
|
||||
label: "Adventurers Recruited",
|
||||
},
|
||||
{
|
||||
icon: "🏆",
|
||||
key: "showLifetimeAchievementsUnlocked",
|
||||
label: "Achievements Unlocked",
|
||||
},
|
||||
{ icon: "📅", key: "showGuildFounded", label: "Guild Founded Date" },
|
||||
];
|
||||
|
||||
const numberFormatOptions: Array<{
|
||||
value: NumberFormat;
|
||||
label: string;
|
||||
example: string;
|
||||
}> = [
|
||||
{ example: "1.23Qa", label: "Suffix", value: "suffix" },
|
||||
{ example: "1.23e15", label: "Scientific", value: "scientific" },
|
||||
{ example: "1.23E15", label: "Engineering", value: "engineering" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Renders the edit profile modal for updating player display settings.
|
||||
* @param props - The modal properties.
|
||||
* @param props.onClose - Callback to close the modal.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const EditProfileModal = ({
|
||||
onClose,
|
||||
}: EditProfileModalProperties): JSX.Element => {
|
||||
const {
|
||||
state,
|
||||
numberFormat: currentNumberFormat,
|
||||
setNumberFormat,
|
||||
setEnableSounds,
|
||||
setEnableNotifications,
|
||||
} = useGame();
|
||||
const player = state?.player;
|
||||
|
||||
const [ characterName, setCharacterName ] = useState(
|
||||
player?.characterName ?? "",
|
||||
);
|
||||
const [ bio, setBio ] = useState("");
|
||||
const [ profileSettings, setProfileSettings ] = useState<ProfileSettings>({
|
||||
...DEFAULT_PROFILE_SETTINGS,
|
||||
numberFormat: currentNumberFormat,
|
||||
});
|
||||
const [ loadingProfile, setLoadingProfile ] = useState(true);
|
||||
const [ saving, setSaving ] = useState(false);
|
||||
const [ error, setError ] = useState<string | null>(null);
|
||||
const [ saved, setSaved ] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (player?.discordId === undefined || player.discordId === "") {
|
||||
return;
|
||||
}
|
||||
fetch(`/api/profile/${player.discordId}`).
|
||||
then(async(response) => {
|
||||
if (!response.ok) {
|
||||
return;
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- API response cast
|
||||
const data = (await response.json()) as {
|
||||
bio: string;
|
||||
profileSettings: ProfileSettings;
|
||||
characterName: string;
|
||||
};
|
||||
setBio(data.bio);
|
||||
setProfileSettings({
|
||||
...DEFAULT_PROFILE_SETTINGS,
|
||||
...data.profileSettings,
|
||||
});
|
||||
setCharacterName(
|
||||
data.characterName === ""
|
||||
? player.characterName
|
||||
: data.characterName,
|
||||
);
|
||||
}).
|
||||
catch(() => {
|
||||
|
||||
/* Fall back to local state if fetch fails — not a blocking error */
|
||||
}).
|
||||
finally(() => {
|
||||
setLoadingProfile(false);
|
||||
});
|
||||
}, [ player?.discordId, player?.characterName ]);
|
||||
|
||||
async function handleSave(): Promise<void> {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await updateProfile({
|
||||
bio,
|
||||
characterName,
|
||||
profileSettings,
|
||||
});
|
||||
setNumberFormat(profileSettings.numberFormat);
|
||||
setEnableSounds(profileSettings.enableSounds);
|
||||
setEnableNotifications(profileSettings.enableNotifications);
|
||||
setSaved(true);
|
||||
setTimeout(onClose, 900);
|
||||
} catch (error_: unknown) {
|
||||
setError(error_ instanceof Error
|
||||
? error_.message
|
||||
: "Failed to save");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleSaveClick(): void {
|
||||
void handleSave();
|
||||
}
|
||||
|
||||
function toggleSetting(key: keyof ProfileSettings): void {
|
||||
setProfileSettings((previous) => {
|
||||
const current = previous[key];
|
||||
const toggled = typeof current === "boolean"
|
||||
? !current
|
||||
: current;
|
||||
return { ...previous, [key]: toggled };
|
||||
});
|
||||
}
|
||||
|
||||
function handleLeaderboardToggle(): void {
|
||||
toggleSetting("showOnLeaderboards");
|
||||
}
|
||||
|
||||
function handleNameChange(event: ChangeEvent<HTMLInputElement>): void {
|
||||
setCharacterName(event.target.value);
|
||||
}
|
||||
|
||||
function handleBioChange(event: ChangeEvent<HTMLTextAreaElement>): void {
|
||||
setBio(event.target.value);
|
||||
}
|
||||
|
||||
function handleSoundsToggle(): void {
|
||||
toggleSetting("enableSounds");
|
||||
}
|
||||
|
||||
async function handleNotificationsEnable(): Promise<void> {
|
||||
if (profileSettings.enableNotifications) {
|
||||
toggleSetting("enableNotifications");
|
||||
return;
|
||||
}
|
||||
const granted = await requestNotificationPermission();
|
||||
if (granted) {
|
||||
toggleSetting("enableNotifications");
|
||||
} else {
|
||||
setError(
|
||||
"Browser notification permission was denied."
|
||||
+ " Please enable it in your browser settings.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function handleNotificationsToggle(): void {
|
||||
void handleNotificationsEnable();
|
||||
}
|
||||
|
||||
const isSaveDisabled = saving || characterName.trim() === "";
|
||||
|
||||
let saveLabel = "Save Profile";
|
||||
if (saving) {
|
||||
saveLabel = "Saving…";
|
||||
}
|
||||
if (saved) {
|
||||
saveLabel = "✓ Saved!";
|
||||
}
|
||||
|
||||
return (
|
||||
<div aria-modal="true" className="modal-overlay" role="dialog">
|
||||
<div className="modal edit-profile-modal">
|
||||
<div className="modal-header">
|
||||
<h2>{"Edit Profile"}</h2>
|
||||
<button
|
||||
aria-label="Close"
|
||||
className="modal-close"
|
||||
onClick={onClose}
|
||||
type="button"
|
||||
>
|
||||
{"✕"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loadingProfile
|
||||
? <p className="edit-profile-loading">{"Loading your profile…"}</p>
|
||||
: <div className="edit-profile-form">
|
||||
<label className="edit-profile-label" htmlFor="edit-char-name">
|
||||
{"Display Name"}
|
||||
</label>
|
||||
<input
|
||||
className="edit-profile-input"
|
||||
id="edit-char-name"
|
||||
maxLength={32}
|
||||
onChange={handleNameChange}
|
||||
placeholder="Your character's name"
|
||||
type="text"
|
||||
value={characterName}
|
||||
/>
|
||||
<span className="edit-profile-hint">
|
||||
{characterName.length}
|
||||
{" / 32"}
|
||||
</span>
|
||||
|
||||
<label className="edit-profile-label" htmlFor="edit-bio">
|
||||
{"Bio"}
|
||||
</label>
|
||||
<textarea
|
||||
className="edit-profile-textarea"
|
||||
id="edit-bio"
|
||||
maxLength={200}
|
||||
onChange={handleBioChange}
|
||||
placeholder="Tell the world about your guild… (optional)"
|
||||
rows={3}
|
||||
value={bio}
|
||||
/>
|
||||
<span className="edit-profile-hint">
|
||||
{bio.length}
|
||||
{" / 200"}
|
||||
</span>
|
||||
|
||||
<div className="edit-profile-section">
|
||||
<p className="edit-profile-label">{"Visible Stats"}</p>
|
||||
<p className="edit-profile-sublabel">
|
||||
{"Choose which stats appear on your public profile."}
|
||||
</p>
|
||||
|
||||
<p className="edit-profile-stat-group-heading">{"Current Run"}</p>
|
||||
<div className="stat-toggles">
|
||||
{currentRunToggles.map(({ key, label, icon }) => {
|
||||
const isOn = profileSettings[key] === true;
|
||||
const toggleClass = isOn
|
||||
? "stat-toggle-on"
|
||||
: "stat-toggle-off";
|
||||
const toggleIndicator = isOn
|
||||
? "✓ Shown"
|
||||
: "Hidden";
|
||||
function handleToggle(): void {
|
||||
toggleSetting(key);
|
||||
}
|
||||
return (
|
||||
<button
|
||||
className={`stat-toggle-btn ${toggleClass}`}
|
||||
key={key}
|
||||
onClick={handleToggle}
|
||||
type="button"
|
||||
>
|
||||
<span>
|
||||
{icon} {label}
|
||||
</span>
|
||||
<span className="stat-toggle-indicator">
|
||||
{toggleIndicator}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<p className="edit-profile-stat-group-heading">{"All Time"}</p>
|
||||
<div className="stat-toggles">
|
||||
{allTimeToggles.map(({ key, label, icon }) => {
|
||||
const isOn = profileSettings[key] === true;
|
||||
const toggleClass = isOn
|
||||
? "stat-toggle-on"
|
||||
: "stat-toggle-off";
|
||||
const toggleIndicator = isOn
|
||||
? "✓ Shown"
|
||||
: "Hidden";
|
||||
function handleToggle(): void {
|
||||
toggleSetting(key);
|
||||
}
|
||||
return (
|
||||
<button
|
||||
className={`stat-toggle-btn ${toggleClass}`}
|
||||
key={key}
|
||||
onClick={handleToggle}
|
||||
type="button"
|
||||
>
|
||||
<span>
|
||||
{icon} {label}
|
||||
</span>
|
||||
<span className="stat-toggle-indicator">
|
||||
{toggleIndicator}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="edit-profile-section">
|
||||
<p className="edit-profile-label">{"Privacy"}</p>
|
||||
<p className="edit-profile-sublabel">
|
||||
{"Control your visibility on public leaderboards."}
|
||||
</p>
|
||||
<button
|
||||
className={`stat-toggle-btn ${
|
||||
profileSettings.showOnLeaderboards
|
||||
? "stat-toggle-on"
|
||||
: "stat-toggle-off"
|
||||
}`}
|
||||
onClick={handleLeaderboardToggle}
|
||||
type="button"
|
||||
>
|
||||
<span>{"🏆 Appear on Leaderboards"}</span>
|
||||
<span className="stat-toggle-indicator">
|
||||
{profileSettings.showOnLeaderboards
|
||||
? "✓ Shown"
|
||||
: "Hidden"}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="edit-profile-section">
|
||||
<p className="edit-profile-label">{"Sounds & Notifications"}</p>
|
||||
<p className="edit-profile-sublabel">
|
||||
{"Control in-game sound effects and browser notifications."}
|
||||
</p>
|
||||
<button
|
||||
className={`stat-toggle-btn ${
|
||||
profileSettings.enableSounds
|
||||
? "stat-toggle-on"
|
||||
: "stat-toggle-off"
|
||||
}`}
|
||||
onClick={handleSoundsToggle}
|
||||
type="button"
|
||||
>
|
||||
<span>{"🔊 Sound Effects"}</span>
|
||||
<span className="stat-toggle-indicator">
|
||||
{profileSettings.enableSounds
|
||||
? "✓ On"
|
||||
: "Off"}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
className={`stat-toggle-btn ${
|
||||
profileSettings.enableNotifications
|
||||
? "stat-toggle-on"
|
||||
: "stat-toggle-off"
|
||||
}`}
|
||||
onClick={handleNotificationsToggle}
|
||||
type="button"
|
||||
>
|
||||
<span>{"🔔 Browser Notifications"}</span>
|
||||
<span className="stat-toggle-indicator">
|
||||
{profileSettings.enableNotifications
|
||||
? "✓ On"
|
||||
: "Off"
|
||||
}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="edit-profile-section">
|
||||
<p className="edit-profile-label">{"Number Format"}</p>
|
||||
<p className="edit-profile-sublabel">
|
||||
{"How large numbers appear across the game."}
|
||||
</p>
|
||||
<div className="number-format-picker">
|
||||
{numberFormatOptions.map(({ value, label, example }) => {
|
||||
function handleFormatSelect(): void {
|
||||
setProfileSettings((previous) => {
|
||||
return { ...previous, numberFormat: value };
|
||||
});
|
||||
}
|
||||
return (
|
||||
<button
|
||||
className={`number-format-btn ${
|
||||
profileSettings.numberFormat === value
|
||||
? "number-format-active"
|
||||
: ""
|
||||
}`}
|
||||
key={value}
|
||||
onClick={handleFormatSelect}
|
||||
type="button"
|
||||
>
|
||||
<span className="number-format-label">{label}</span>
|
||||
<span className="number-format-example">{example}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error === null
|
||||
? null
|
||||
: <p className="edit-profile-error">{error}</p>
|
||||
}
|
||||
|
||||
<div className="edit-profile-actions">
|
||||
<button
|
||||
className="edit-profile-cancel"
|
||||
onClick={onClose}
|
||||
type="button"
|
||||
>
|
||||
{"Cancel"}
|
||||
</button>
|
||||
<button
|
||||
className="edit-profile-save"
|
||||
disabled={isSaveDisabled}
|
||||
onClick={handleSaveClick}
|
||||
type="button"
|
||||
>
|
||||
{saveLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { EditProfileModal };
|
||||
@@ -0,0 +1,359 @@
|
||||
/**
|
||||
* @file Equipment panel component for managing owned and available equipment.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable react/no-multi-comp -- Sub-component is tightly coupled to the panel */
|
||||
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
|
||||
/* eslint-disable complexity -- Complex component with many conditional render paths */
|
||||
import { type JSX, useState } from "react";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import { EQUIPMENT_SETS } from "../../data/equipmentSets.js";
|
||||
import { LockToggle } from "../ui/lockToggle.js";
|
||||
import type { Equipment, EquipmentType } from "@elysium/types";
|
||||
|
||||
const rarityLabel: Record<string, string> = {
|
||||
common: "Common",
|
||||
epic: "Epic",
|
||||
legendary: "Legendary",
|
||||
rare: "Rare",
|
||||
};
|
||||
|
||||
const typeIcon: Record<EquipmentType, string> = {
|
||||
armour: "🛡️",
|
||||
trinket: "💍",
|
||||
weapon: "⚔️",
|
||||
};
|
||||
|
||||
/**
|
||||
* Computes a human-readable bonus description for a piece of equipment.
|
||||
* @param item - The equipment item.
|
||||
* @returns The formatted bonus description.
|
||||
*/
|
||||
const bonusDescription = (item: Equipment): string => {
|
||||
const parts: Array<string> = [];
|
||||
if (item.bonus.combatMultiplier !== undefined) {
|
||||
const pct = Math.round((item.bonus.combatMultiplier - 1) * 100);
|
||||
parts.push(`+${String(pct)}% Combat`);
|
||||
}
|
||||
if (item.bonus.goldMultiplier !== undefined) {
|
||||
const pct = Math.round((item.bonus.goldMultiplier - 1) * 100);
|
||||
parts.push(`+${String(pct)}% Gold/s`);
|
||||
}
|
||||
if (item.bonus.clickMultiplier !== undefined) {
|
||||
const pct = Math.round((item.bonus.clickMultiplier - 1) * 100);
|
||||
parts.push(`+${String(pct)}% Click`);
|
||||
}
|
||||
return parts.join(", ");
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats an equipment cost as a readable string.
|
||||
* @param cost - The cost object with gold, essence, and crystals.
|
||||
* @param cost.gold - The gold component of the cost.
|
||||
* @param cost.essence - The essence component of the cost.
|
||||
* @param cost.crystals - The crystals component of the cost.
|
||||
* @returns The formatted cost string.
|
||||
*/
|
||||
const costLabel = (cost: {
|
||||
gold: number;
|
||||
essence: number;
|
||||
crystals: number;
|
||||
}): string => {
|
||||
const parts: Array<string> = [];
|
||||
if (cost.gold > 0) {
|
||||
parts.push(`🪙 ${cost.gold.toLocaleString()}`);
|
||||
}
|
||||
if (cost.essence > 0) {
|
||||
parts.push(`✨ ${cost.essence.toLocaleString()}`);
|
||||
}
|
||||
if (cost.crystals > 0) {
|
||||
parts.push(`💎 ${cost.crystals.toLocaleString()}`);
|
||||
}
|
||||
return parts.join(" ");
|
||||
};
|
||||
|
||||
interface EquipmentCardProperties {
|
||||
readonly item: Equipment;
|
||||
readonly gold: number;
|
||||
readonly essence: number;
|
||||
readonly crystals: number;
|
||||
readonly dropBossName: string | undefined;
|
||||
readonly setName: string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single equipment card.
|
||||
* @param props - The equipment card properties.
|
||||
* @param props.item - The equipment item data.
|
||||
* @param props.gold - The current gold amount.
|
||||
* @param props.essence - The current essence amount.
|
||||
* @param props.crystals - The current crystals amount.
|
||||
* @param props.dropBossName - The name of the boss that drops this item.
|
||||
* @param props.setName - The name of the set this item belongs to.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const EquipmentCard = ({
|
||||
item,
|
||||
gold,
|
||||
essence,
|
||||
crystals,
|
||||
dropBossName,
|
||||
setName,
|
||||
}: EquipmentCardProperties): JSX.Element => {
|
||||
const { equipItem, buyEquipment } = useGame();
|
||||
|
||||
const canAfford
|
||||
= item.cost !== undefined
|
||||
&& gold >= item.cost.gold
|
||||
&& essence >= item.cost.essence
|
||||
&& crystals >= item.cost.crystals;
|
||||
|
||||
function handleBuy(): void {
|
||||
buyEquipment(item.id);
|
||||
}
|
||||
function handleEquip(): void {
|
||||
equipItem(item.id);
|
||||
}
|
||||
|
||||
const ownedClass = item.owned
|
||||
? ""
|
||||
: "not-owned";
|
||||
const equippedClass = item.equipped
|
||||
? "equipped"
|
||||
: "";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`equipment-card rarity-${item.rarity} ${equippedClass} ${ownedClass}`}
|
||||
>
|
||||
<div className="equipment-icon">{typeIcon[item.type]}</div>
|
||||
<div className="equipment-info">
|
||||
<div className="equipment-name-row">
|
||||
<h3>{item.name}</h3>
|
||||
<span className={`rarity-badge rarity-${item.rarity}`}>
|
||||
{rarityLabel[item.rarity]}
|
||||
</span>
|
||||
</div>
|
||||
<p className="equipment-description">{item.description}</p>
|
||||
<p className="equipment-bonus">{bonusDescription(item)}</p>
|
||||
{setName === undefined
|
||||
? null
|
||||
: <span className="equipment-set-badge">
|
||||
{"🔗 "}
|
||||
{setName}
|
||||
</span>
|
||||
}
|
||||
{item.owned || item.cost === undefined
|
||||
? null
|
||||
: <p className="equipment-cost">{costLabel(item.cost)}</p>
|
||||
}
|
||||
</div>
|
||||
<div className="equipment-action">
|
||||
{!item.owned && item.cost === undefined
|
||||
&& <span className="equipment-locked">
|
||||
{dropBossName === undefined
|
||||
? "🔒 Boss drop"
|
||||
: `⚔️ Drop: ${dropBossName}`}
|
||||
</span>
|
||||
}
|
||||
{item.owned || item.cost === undefined
|
||||
? null
|
||||
: <button
|
||||
className="equip-button"
|
||||
disabled={!canAfford}
|
||||
onClick={handleBuy}
|
||||
type="button"
|
||||
>
|
||||
{canAfford
|
||||
? "Purchase"
|
||||
: "Can't afford"}
|
||||
</button>
|
||||
}
|
||||
{item.owned && item.equipped
|
||||
? <span className="equipment-equipped-badge">{"✓ Equipped"}</span>
|
||||
: null}
|
||||
{item.owned && !item.equipped
|
||||
? <button
|
||||
className="equip-button"
|
||||
onClick={handleEquip}
|
||||
type="button"
|
||||
>
|
||||
{"Equip"}
|
||||
</button>
|
||||
|
||||
: null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const slotOrder: Array<EquipmentType> = [ "weapon", "armour", "trinket" ];
|
||||
const slotLabel: Record<EquipmentType, string> = {
|
||||
armour: "🛡️ Armour",
|
||||
trinket: "💍 Trinkets",
|
||||
weapon: "⚔️ Weapons",
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the equipment panel with all owned and available equipment.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const EquipmentPanel = (): JSX.Element => {
|
||||
const { state } = useGame();
|
||||
const [ showLocked, setShowLocked ] = useState(true);
|
||||
|
||||
if (state === null) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"Loading..."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const { bosses, equipment, resources } = state;
|
||||
const unownedCount = equipment.filter((item) => {
|
||||
return !item.owned;
|
||||
}).length;
|
||||
|
||||
const equipmentDropSources = new Map<string, string>();
|
||||
for (const { equipmentRewards, name: bossName } of bosses) {
|
||||
for (const equipmentId of equipmentRewards) {
|
||||
equipmentDropSources.set(equipmentId, bossName);
|
||||
}
|
||||
}
|
||||
|
||||
const setNameById = new Map<string, string>(
|
||||
EQUIPMENT_SETS.map((equipSet) => {
|
||||
return [ equipSet.id, equipSet.name ];
|
||||
}),
|
||||
);
|
||||
|
||||
const equippedItemIds = new Set(
|
||||
equipment.
|
||||
filter((item) => {
|
||||
return item.equipped;
|
||||
}).
|
||||
map((item) => {
|
||||
return item.id;
|
||||
}),
|
||||
);
|
||||
const activeSets = EQUIPMENT_SETS.map((set) => {
|
||||
const count = set.pieces.filter((id) => {
|
||||
return equippedItemIds.has(id);
|
||||
}).length;
|
||||
return { count, set };
|
||||
}).filter(({ count }) => {
|
||||
return count >= 2;
|
||||
});
|
||||
|
||||
function setBonusDescription(
|
||||
equipSet: (typeof EQUIPMENT_SETS)[number],
|
||||
count: number,
|
||||
): string {
|
||||
const parts: Array<string> = [];
|
||||
for (const threshold of [ 2, 3 ] as const) {
|
||||
if (count >= threshold) {
|
||||
const bonus = equipSet.bonuses[threshold];
|
||||
if (bonus.goldMultiplier !== undefined) {
|
||||
const pct = Math.round((bonus.goldMultiplier - 1) * 100);
|
||||
parts.push(`+${String(pct)}% Gold/s (${String(threshold)}pc)`);
|
||||
}
|
||||
if (bonus.combatMultiplier !== undefined) {
|
||||
const pct = Math.round((bonus.combatMultiplier - 1) * 100);
|
||||
parts.push(`+${String(pct)}% Combat (${String(threshold)}pc)`);
|
||||
}
|
||||
if (bonus.clickMultiplier !== undefined) {
|
||||
const pct = Math.round((bonus.clickMultiplier - 1) * 100);
|
||||
parts.push(`+${String(pct)}% Click (${String(threshold)}pc)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return parts.join(", ");
|
||||
}
|
||||
|
||||
function handleToggle(): void {
|
||||
setShowLocked((current) => {
|
||||
return !current;
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="panel equipment-panel">
|
||||
<div className="panel-header">
|
||||
<h2>{"Equipment"}</h2>
|
||||
<LockToggle
|
||||
lockedCount={unownedCount}
|
||||
onToggle={handleToggle}
|
||||
showLocked={showLocked}
|
||||
/>
|
||||
</div>
|
||||
<p className="equipment-intro">
|
||||
{"Equipment drops from bosses and grants passive bonuses."
|
||||
+ " Only one item per slot can be equipped at a time."
|
||||
+ " Equip matching set pieces for bonus effects!"}
|
||||
</p>
|
||||
|
||||
{activeSets.length > 0
|
||||
&& <div className="active-sets">
|
||||
<h3 className="active-sets-heading">{"✨ Active Set Bonuses"}</h3>
|
||||
{activeSets.map(({ set, count }) => {
|
||||
return (
|
||||
<div className="active-set-row" key={set.id}>
|
||||
<span className="active-set-name">
|
||||
{set.name}
|
||||
{" ("}
|
||||
{count}
|
||||
{"/"}
|
||||
{set.pieces.length}
|
||||
{")"}
|
||||
</span>
|
||||
<span className="active-set-bonus">
|
||||
{setBonusDescription(set, count)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
|
||||
{slotOrder.map((slotType) => {
|
||||
const items = equipment.filter((item) => {
|
||||
return item.type === slotType && (showLocked || item.owned);
|
||||
});
|
||||
return (
|
||||
<div className="equipment-slot-section" key={slotType}>
|
||||
<h3 className="slot-heading">{slotLabel[slotType]}</h3>
|
||||
<div className="equipment-list">
|
||||
{items.map((item) => {
|
||||
return (
|
||||
<EquipmentCard
|
||||
crystals={resources.crystals}
|
||||
dropBossName={equipmentDropSources.get(item.id)}
|
||||
essence={resources.essence}
|
||||
gold={resources.gold}
|
||||
item={item}
|
||||
key={item.id}
|
||||
setName={
|
||||
item.setId === undefined
|
||||
? undefined
|
||||
: setNameById.get(item.setId)
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{items.length === 0
|
||||
&& <p className="empty-zone">
|
||||
{"No items to show in this slot."}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { EquipmentPanel };
|
||||
@@ -0,0 +1,302 @@
|
||||
/**
|
||||
* @file Exploration panel component for exploring areas and collecting materials.
|
||||
* @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 -- Complex component with many conditional render paths */
|
||||
import { type JSX, useState } from "react";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import { EXPLORATION_AREAS } from "../../data/explorations.js";
|
||||
import { ZoneSelector } from "./zoneSelector.js";
|
||||
import type { ExploreCollectResponse } from "@elysium/types";
|
||||
|
||||
/**
|
||||
* Formats a duration in seconds to a human-readable string.
|
||||
* @param seconds - The total number of seconds to format.
|
||||
* @returns The formatted duration string.
|
||||
*/
|
||||
const formatDuration = (seconds: number): string => {
|
||||
const secondsPerDay = 86_400;
|
||||
const secondsPerHour = 3600;
|
||||
const secondsPerMinute = 60;
|
||||
if (seconds >= secondsPerDay) {
|
||||
const days = Math.floor(seconds / secondsPerDay);
|
||||
const remainingAfterDays = seconds % secondsPerDay;
|
||||
const hours = Math.floor(remainingAfterDays / secondsPerHour);
|
||||
return hours > 0
|
||||
? `${String(days)}d ${String(hours)}h`
|
||||
: `${String(days)}d`;
|
||||
}
|
||||
if (seconds >= secondsPerHour) {
|
||||
const hours = Math.floor(seconds / secondsPerHour);
|
||||
const remainingAfterHours = seconds % secondsPerHour;
|
||||
const minutes = Math.floor(remainingAfterHours / secondsPerMinute);
|
||||
return `${String(hours)}h ${String(minutes)}m`;
|
||||
}
|
||||
if (seconds >= secondsPerMinute) {
|
||||
const minutes = Math.floor(seconds / secondsPerMinute);
|
||||
const secs = seconds % secondsPerMinute;
|
||||
return `${String(minutes)}m ${String(secs)}s`;
|
||||
}
|
||||
return `${String(seconds)}s`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Computes the time remaining for an exploration in progress.
|
||||
* @param startedAt - The timestamp when exploration started.
|
||||
* @param durationSeconds - The total duration in seconds.
|
||||
* @returns The remaining seconds.
|
||||
*/
|
||||
const timeRemaining = (startedAt: number, durationSeconds: number): number => {
|
||||
const elapsed = (Date.now() - startedAt) / 1000;
|
||||
return Math.max(0, durationSeconds - elapsed);
|
||||
};
|
||||
|
||||
interface CollectResult {
|
||||
areaId: string;
|
||||
response: ExploreCollectResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the exploration panel for managing area explorations.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const ExplorationPanel = (): JSX.Element => {
|
||||
const { state, startExploration, collectExploration, formatNumber }
|
||||
= useGame();
|
||||
const [ activeZoneId, setActiveZoneId ] = useState("verdant_vale");
|
||||
const [ pendingAreaId, setPendingAreaId ] = useState<string | null>(null);
|
||||
const [ lastResult, setLastResult ] = useState<CollectResult | null>(null);
|
||||
|
||||
if (state === null) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"Loading..."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const { zones, exploration: explorationState } = state;
|
||||
|
||||
const zoneAreas = EXPLORATION_AREAS.filter((area) => {
|
||||
return area.zoneId === activeZoneId;
|
||||
});
|
||||
|
||||
const hasActiveExploration
|
||||
= explorationState?.areas.some((area) => {
|
||||
return area.status === "in_progress";
|
||||
}) ?? false;
|
||||
|
||||
async function handleStart(areaId: string): Promise<void> {
|
||||
setPendingAreaId(areaId);
|
||||
try {
|
||||
await startExploration(areaId);
|
||||
} finally {
|
||||
setPendingAreaId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCollect(areaId: string): Promise<void> {
|
||||
setPendingAreaId(areaId);
|
||||
try {
|
||||
const result = await collectExploration(areaId);
|
||||
setLastResult({ areaId: areaId, response: result });
|
||||
} finally {
|
||||
setPendingAreaId(null);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDismissResult(): void {
|
||||
setLastResult(null);
|
||||
}
|
||||
|
||||
function handleZoneSelect(id: string): void {
|
||||
setActiveZoneId(id);
|
||||
setLastResult(null);
|
||||
}
|
||||
|
||||
const goldChange = lastResult?.response.event?.goldChange ?? 0;
|
||||
const essenceChange = lastResult?.response.event?.essenceChange ?? 0;
|
||||
|
||||
return (
|
||||
<section className="panel exploration-panel">
|
||||
<div className="panel-header">
|
||||
<h2>{"🗺️ Exploration"}</h2>
|
||||
</div>
|
||||
|
||||
{lastResult === null
|
||||
? null
|
||||
: <div className="exploration-result">
|
||||
<button
|
||||
className="exploration-result-close"
|
||||
onClick={handleDismissResult}
|
||||
type="button"
|
||||
>
|
||||
{"✕"}
|
||||
</button>
|
||||
{lastResult.response.foundNothing
|
||||
? <p className="exploration-nothing">
|
||||
{lastResult.response.nothingMessage}
|
||||
</p>
|
||||
: <>
|
||||
{lastResult.response.event === null
|
||||
? null
|
||||
: <p className="exploration-event-text">
|
||||
{lastResult.response.event.text}
|
||||
</p>
|
||||
}
|
||||
<div className="exploration-rewards">
|
||||
{goldChange !== 0
|
||||
&& <span
|
||||
className={`reward-tag ${goldChange > 0
|
||||
? ""
|
||||
: "negative"}`}
|
||||
>
|
||||
{"🪙 "}
|
||||
{goldChange > 0
|
||||
? "+"
|
||||
: ""}
|
||||
{formatNumber(goldChange)}
|
||||
{" gold"}
|
||||
</span>
|
||||
}
|
||||
{essenceChange > 0
|
||||
&& <span className="reward-tag">
|
||||
{"✨ +"}
|
||||
{formatNumber(essenceChange)}
|
||||
{" essence"}
|
||||
</span>
|
||||
}
|
||||
{lastResult.response.event?.materialGained !== null
|
||||
&& lastResult.response.event?.materialGained !== undefined
|
||||
? <span className="reward-tag material-tag">
|
||||
{"📦 +"}
|
||||
{lastResult.response.event.materialGained.quantity}{" "}
|
||||
{/* eslint-disable-next-line stylistic/max-len -- long property chain cannot be shortened */}
|
||||
{lastResult.response.event.materialGained.materialId.replaceAll(
|
||||
"_",
|
||||
" ",
|
||||
)}
|
||||
{" (event)"}
|
||||
</span>
|
||||
: null}
|
||||
{lastResult.response.materialsFound.map((foundMaterial) => {
|
||||
return (
|
||||
<span
|
||||
className="reward-tag material-tag"
|
||||
key={foundMaterial.materialId}
|
||||
>
|
||||
{"📦 +"}
|
||||
{foundMaterial.quantity}{" "}
|
||||
{foundMaterial.materialId.replaceAll("_", " ")}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<ZoneSelector
|
||||
activeZoneId={activeZoneId}
|
||||
onSelectZone={handleZoneSelect}
|
||||
zones={zones}
|
||||
/>
|
||||
|
||||
<div className="exploration-list">
|
||||
{zoneAreas.map((area) => {
|
||||
const areaState = explorationState?.areas.find((explorationArea) => {
|
||||
return explorationArea.id === area.id;
|
||||
});
|
||||
const status = areaState?.status ?? "locked";
|
||||
const startedAt = areaState?.startedAt ?? 0;
|
||||
const isReady
|
||||
= status === "in_progress"
|
||||
&& timeRemaining(startedAt, area.durationSeconds) <= 0;
|
||||
const isPending = pendingAreaId === area.id;
|
||||
|
||||
function handleStartClick(): void {
|
||||
void handleStart(area.id);
|
||||
}
|
||||
function handleCollectClick(): void {
|
||||
void handleCollect(area.id);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`exploration-card exploration-${status}`}
|
||||
key={area.id}
|
||||
>
|
||||
<div className="exploration-info">
|
||||
<h3>
|
||||
{area.name}
|
||||
{areaState?.completedOnce === true
|
||||
? <span className="exploration-discovered">{" 📖"}</span>
|
||||
: null}
|
||||
</h3>
|
||||
<p>{area.description}</p>
|
||||
<span className="exploration-duration">
|
||||
{"⏱️ "}
|
||||
{formatDuration(area.durationSeconds)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="exploration-action">
|
||||
{status === "locked"
|
||||
&& <span className="quest-badge locked">{"🔒 Locked"}</span>
|
||||
}
|
||||
{status === "available"
|
||||
&& <button
|
||||
className="start-quest-button"
|
||||
disabled={isPending || hasActiveExploration}
|
||||
onClick={handleStartClick}
|
||||
title={
|
||||
hasActiveExploration
|
||||
? "An exploration is already in progress"
|
||||
: undefined
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
{isPending
|
||||
? "Departing..."
|
||||
: `Explore (${formatDuration(area.durationSeconds)})`}
|
||||
</button>
|
||||
}
|
||||
{status === "in_progress" && !isReady
|
||||
&& <span className="quest-badge active">
|
||||
{"⏳ "}
|
||||
{formatDuration(
|
||||
Math.ceil(timeRemaining(startedAt, area.durationSeconds)),
|
||||
)}
|
||||
{" remaining"}
|
||||
</span>
|
||||
}
|
||||
{status === "in_progress" && isReady
|
||||
? <button
|
||||
className="collect-button"
|
||||
disabled={isPending}
|
||||
onClick={handleCollectClick}
|
||||
type="button"
|
||||
>
|
||||
{isPending
|
||||
? "Collecting..."
|
||||
: "📦 Collect Results"}
|
||||
</button>
|
||||
: null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{zoneAreas.length === 0
|
||||
&& <p className="empty-zone">
|
||||
{"No exploration areas in this zone."}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { ExplorationPanel };
|
||||
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* @file Game layout component rendering the main game UI.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- Complex layout with many conditional renders */
|
||||
/* eslint-disable complexity -- Many tab render paths */
|
||||
import { type JSX, useState } from "react";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import { ResourceBar } from "../ui/resourceBar.js";
|
||||
import { AboutPanel } from "./aboutPanel.js";
|
||||
import { AchievementPanel } from "./achievementPanel.js";
|
||||
import { AchievementToast } from "./achievementToast.js";
|
||||
import { AdventurerPanel } from "./adventurerPanel.js";
|
||||
import { ApotheosisPanel } from "./apotheosisPanel.js";
|
||||
import { BattleModal } from "./battleModal.js";
|
||||
import { BossPanel } from "./bossPanel.js";
|
||||
import { CharacterSheetPanel } from "./characterSheetPanel.js";
|
||||
import { ClickArea } from "./clickArea.js";
|
||||
import { CodexPanel } from "./codexPanel.js";
|
||||
import { CodexToast } from "./codexToast.js";
|
||||
import { CompanionPanel } from "./companionPanel.js";
|
||||
import { CraftingPanel } from "./craftingPanel.js";
|
||||
import { DailyChallengePanel } from "./dailyChallengePanel.js";
|
||||
import { EditProfileModal } from "./editProfileModal.js";
|
||||
import { EquipmentPanel } from "./equipmentPanel.js";
|
||||
import { ExplorationPanel } from "./explorationPanel.js";
|
||||
import { LoginBonusModal } from "./loginBonusModal.js";
|
||||
import { OfflineModal } from "./offlineModal.js";
|
||||
import { OutdatedSchemaModal } from "./outdatedSchemaModal.js";
|
||||
import { PrestigePanel } from "./prestigePanel.js";
|
||||
import { QuestPanel } from "./questPanel.js";
|
||||
import { StatisticsPanel } from "./statisticsPanel.js";
|
||||
import { StoryPanel } from "./storyPanel.js";
|
||||
import { StoryToast } from "./storyToast.js";
|
||||
import { TranscendencePanel } from "./transcendencePanel.js";
|
||||
import { UpgradePanel } from "./upgradePanel.js";
|
||||
|
||||
type Tab =
|
||||
| "adventurers"
|
||||
| "upgrades"
|
||||
| "quests"
|
||||
| "bosses"
|
||||
| "equipment"
|
||||
| "achievements"
|
||||
| "prestige"
|
||||
| "transcendence"
|
||||
| "apotheosis"
|
||||
| "statistics"
|
||||
| "daily"
|
||||
| "codex"
|
||||
| "about"
|
||||
| "exploration"
|
||||
| "crafting"
|
||||
| "character"
|
||||
| "companions"
|
||||
| "story";
|
||||
|
||||
const baseTabs: Array<{ id: Tab; label: string }> = [
|
||||
{ id: "adventurers", label: "⚔️ Adventurers" },
|
||||
{ id: "upgrades", label: "🔧 Upgrades" },
|
||||
{ id: "quests", label: "📜 Quests" },
|
||||
{ id: "bosses", label: "👹 Bosses" },
|
||||
{ id: "equipment", label: "🗡️ Equipment" },
|
||||
{ id: "exploration", label: "🗺️ Exploration" },
|
||||
{ id: "crafting", label: "⚗️ Crafting" },
|
||||
{ id: "daily", label: "📅 Daily" },
|
||||
{ id: "prestige", label: "⭐ Prestige" },
|
||||
{ id: "transcendence", label: "🌌 Transcendence" },
|
||||
{ id: "apotheosis", label: "✨ Apotheosis" },
|
||||
{ id: "statistics", label: "📊 Statistics" },
|
||||
{ id: "companions", label: "👥 Companions" },
|
||||
{ id: "character", label: "📋 Character" },
|
||||
{ id: "achievements", label: "🏆 Achievements" },
|
||||
{ id: "story", label: "📖 Story" },
|
||||
{ id: "codex", label: "🗺️ Codex" },
|
||||
{ id: "about", label: "ℹ️ About" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Renders the main game layout with tabs and panels.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const GameLayout = (): JSX.Element => {
|
||||
const {
|
||||
state,
|
||||
isLoading,
|
||||
error,
|
||||
battleResult,
|
||||
dismissBattle,
|
||||
lastSavedAt,
|
||||
isSyncing,
|
||||
forceSync,
|
||||
unlockedCodexEntryIds: pendingCodexEntryIds,
|
||||
unlockedStoryChapterIds: pendingStoryChapterIds,
|
||||
loginBonus,
|
||||
dismissLoginBonus,
|
||||
schemaOutdated,
|
||||
} = useGame();
|
||||
const [ activeTab, setActiveTab ] = useState<Tab>("adventurers");
|
||||
const [ editingProfile, setEditingProfile ] = useState(false);
|
||||
const [ dismissedOutdatedWarning, setDismissedOutdatedWarning ]
|
||||
= useState(false);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="loading-screen">
|
||||
<p>{"Loading your adventure..."}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error !== null && error !== "") {
|
||||
return (
|
||||
<div className="error-screen">
|
||||
<p>
|
||||
{"Error: "}
|
||||
{error}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (state === null) {
|
||||
return (
|
||||
<div className="loading-screen">
|
||||
<p>{"Loading..."}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const profileUrl = `/profile/${state.player.discordId}`;
|
||||
const codexBadgeCount = pendingCodexEntryIds.length;
|
||||
const storyBadgeCount = pendingStoryChapterIds.length;
|
||||
|
||||
function handleOpenEditProfile(): void {
|
||||
setEditingProfile(true);
|
||||
}
|
||||
|
||||
function handleCloseEditProfile(): void {
|
||||
setEditingProfile(false);
|
||||
}
|
||||
|
||||
function handleDismissOutdated(): void {
|
||||
setDismissedOutdatedWarning(true);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="game-layout">
|
||||
<ResourceBar
|
||||
apotheosisCount={state.apotheosis?.count ?? 0}
|
||||
isSyncing={isSyncing}
|
||||
lastSavedAt={lastSavedAt}
|
||||
onEditProfile={handleOpenEditProfile}
|
||||
onForceSync={forceSync}
|
||||
prestigeCount={state.prestige.count}
|
||||
profileUrl={profileUrl}
|
||||
resources={state.resources}
|
||||
runestones={state.prestige.runestones}
|
||||
transcendenceCount={state.transcendence?.count ?? 0}
|
||||
/>
|
||||
<OfflineModal />
|
||||
{schemaOutdated && !dismissedOutdatedWarning
|
||||
? <OutdatedSchemaModal onDismiss={handleDismissOutdated} />
|
||||
: null}
|
||||
<AchievementToast />
|
||||
<CodexToast />
|
||||
<StoryToast />
|
||||
{loginBonus === null
|
||||
? null
|
||||
: <LoginBonusModal bonus={loginBonus} onClose={dismissLoginBonus} />
|
||||
}
|
||||
{battleResult === null
|
||||
? null
|
||||
: <BattleModal battle={battleResult} onDismiss={dismissBattle} />
|
||||
}
|
||||
{editingProfile
|
||||
? <EditProfileModal onClose={handleCloseEditProfile} />
|
||||
: null}
|
||||
|
||||
<div className="game-main">
|
||||
<aside className="game-sidebar">
|
||||
<ClickArea />
|
||||
<p className="game-copyright">{"© NHCarrigan"}</p>
|
||||
</aside>
|
||||
|
||||
<main className="game-content">
|
||||
<nav className="tab-bar">
|
||||
{baseTabs.map((tab) => {
|
||||
const { id: tabId, label } = tab;
|
||||
function handleTabClick(): void {
|
||||
setActiveTab(tabId);
|
||||
}
|
||||
return (
|
||||
<button
|
||||
className={`tab-button ${
|
||||
activeTab === tabId
|
||||
? "active"
|
||||
: ""
|
||||
}`}
|
||||
key={tabId}
|
||||
onClick={handleTabClick}
|
||||
type="button"
|
||||
>
|
||||
{label}
|
||||
{tabId === "codex" && codexBadgeCount > 0
|
||||
&& <span className="tab-badge">{codexBadgeCount}</span>
|
||||
}
|
||||
{tabId === "story" && storyBadgeCount > 0
|
||||
&& <span className="tab-badge">{storyBadgeCount}</span>
|
||||
}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="tab-content">
|
||||
{activeTab === "adventurers" && <AdventurerPanel />}
|
||||
{activeTab === "upgrades" && <UpgradePanel />}
|
||||
{activeTab === "quests" && <QuestPanel />}
|
||||
{activeTab === "bosses" && <BossPanel />}
|
||||
{activeTab === "equipment" && <EquipmentPanel />}
|
||||
{activeTab === "achievements" && <AchievementPanel />}
|
||||
{activeTab === "prestige" && <PrestigePanel />}
|
||||
{activeTab === "transcendence" && <TranscendencePanel />}
|
||||
{activeTab === "apotheosis" && <ApotheosisPanel />}
|
||||
{activeTab === "exploration" && <ExplorationPanel />}
|
||||
{activeTab === "crafting" && <CraftingPanel />}
|
||||
{activeTab === "statistics" && <StatisticsPanel />}
|
||||
{activeTab === "daily" && <DailyChallengePanel />}
|
||||
{activeTab === "companions" && <CompanionPanel />}
|
||||
{activeTab === "character" && <CharacterSheetPanel />}
|
||||
{activeTab === "story" && <StoryPanel />}
|
||||
{activeTab === "codex" && <CodexPanel />}
|
||||
{activeTab === "about" && <AboutPanel />}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { GameLayout };
|
||||
@@ -0,0 +1,273 @@
|
||||
/**
|
||||
* @file Leaderboard page component showing top players across categories.
|
||||
* @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 categories and entries */
|
||||
import { useEffect, useState, type JSX } from "react";
|
||||
import type { LeaderboardCategory, LeaderboardEntry } from "@elysium/types";
|
||||
|
||||
interface CategoryConfig {
|
||||
id: LeaderboardCategory;
|
||||
label: string;
|
||||
icon: string;
|
||||
formatValue: (value: number)=> string;
|
||||
}
|
||||
|
||||
const goldSuffixes = [
|
||||
"",
|
||||
"K",
|
||||
"M",
|
||||
"B",
|
||||
"T",
|
||||
"Qa",
|
||||
"Qt",
|
||||
"S",
|
||||
"Sp",
|
||||
"O",
|
||||
"N",
|
||||
"D",
|
||||
];
|
||||
|
||||
/**
|
||||
* Formats a gold value with a short suffix.
|
||||
* @param value - The gold amount to format.
|
||||
* @returns The formatted string.
|
||||
*/
|
||||
const formatGold = (value: number): string => {
|
||||
if (value === 0) {
|
||||
return "0";
|
||||
}
|
||||
const tier = Math.floor(Math.log10(Math.abs(value)) / 3);
|
||||
const clamped = Math.min(tier, goldSuffixes.length - 1);
|
||||
const scaled = value / Math.pow(1000, clamped);
|
||||
return `${String(Number.parseFloat(scaled.toFixed(2)))}${goldSuffixes[clamped] ?? ""}`;
|
||||
};
|
||||
|
||||
const categories: Array<CategoryConfig> = [
|
||||
{
|
||||
formatValue: (v): string => {
|
||||
return formatGold(v);
|
||||
},
|
||||
icon: "🪙",
|
||||
id: "totalGold",
|
||||
label: "Lifetime Gold",
|
||||
},
|
||||
{
|
||||
formatValue: (v): string => {
|
||||
return v.toLocaleString();
|
||||
},
|
||||
icon: "💀",
|
||||
id: "bossesDefeated",
|
||||
label: "Bosses Defeated",
|
||||
},
|
||||
{
|
||||
formatValue: (v): string => {
|
||||
return v.toLocaleString();
|
||||
},
|
||||
icon: "📜",
|
||||
id: "questsCompleted",
|
||||
label: "Quests Completed",
|
||||
},
|
||||
{
|
||||
formatValue: (v): string => {
|
||||
return v.toLocaleString();
|
||||
},
|
||||
icon: "🏆",
|
||||
id: "achievementsUnlocked",
|
||||
label: "Achievements",
|
||||
},
|
||||
{
|
||||
formatValue: (v): string => {
|
||||
return v.toLocaleString();
|
||||
},
|
||||
icon: "⭐",
|
||||
id: "prestigeCount",
|
||||
label: "Prestige",
|
||||
},
|
||||
{
|
||||
formatValue: (v): string => {
|
||||
return v.toLocaleString();
|
||||
},
|
||||
icon: "🌌",
|
||||
id: "transcendenceCount",
|
||||
label: "Transcendence",
|
||||
},
|
||||
{
|
||||
formatValue: (v): string => {
|
||||
return v.toLocaleString();
|
||||
},
|
||||
icon: "✨",
|
||||
id: "apotheosisCount",
|
||||
label: "Apotheosis",
|
||||
},
|
||||
];
|
||||
|
||||
const rankBadges: Record<number, string> = { 1: "🥇", 2: "🥈", 3: "🥉" };
|
||||
|
||||
/**
|
||||
* Renders the leaderboard page with category tabs and player rankings.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const LeaderboardPage = (): JSX.Element => {
|
||||
const [ category, setCategory ] = useState<LeaderboardCategory>("totalGold");
|
||||
const [ entries, setEntries ] = useState<Array<LeaderboardEntry>>([]);
|
||||
const [ loading, setLoading ] = useState(true);
|
||||
const [ error, setError ] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
fetch(`/api/leaderboards?category=${category}&limit=100`).
|
||||
then(async(response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to load leaderboard");
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- API response cast
|
||||
const data = (await response.json()) as {
|
||||
entries: Array<LeaderboardEntry>;
|
||||
};
|
||||
setEntries(data.entries);
|
||||
}).
|
||||
catch((error_: unknown) => {
|
||||
setError(
|
||||
error_ instanceof Error
|
||||
? error_.message
|
||||
: "Failed to load leaderboard",
|
||||
);
|
||||
}).
|
||||
finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}, [ category ]);
|
||||
|
||||
const currentConfig
|
||||
= categories.find((cat) => {
|
||||
return cat.id === category;
|
||||
}) ?? categories[0];
|
||||
|
||||
return (
|
||||
<div className="leaderboard-page">
|
||||
<div className="leaderboard-card">
|
||||
<div className="leaderboard-header">
|
||||
<h1 className="leaderboard-title">{"🏆 Leaderboards"}</h1>
|
||||
<p className="leaderboard-subtitle">
|
||||
{"The mightiest adventurers in Elysium"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="leaderboard-tabs">
|
||||
{categories.map((cat) => {
|
||||
function handleCategoryClick(): void {
|
||||
setCategory(cat.id);
|
||||
}
|
||||
return (
|
||||
<button
|
||||
className={`leaderboard-tab ${
|
||||
category === cat.id
|
||||
? "leaderboard-tab--active"
|
||||
: ""
|
||||
}`}
|
||||
key={cat.id}
|
||||
onClick={handleCategoryClick}
|
||||
type="button"
|
||||
>
|
||||
{cat.icon} {cat.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{loading
|
||||
? <div className="leaderboard-loading">{"Loading…"}</div>
|
||||
: null}
|
||||
|
||||
{error === null
|
||||
? null
|
||||
: <div className="leaderboard-error">
|
||||
{"⚠️ "}
|
||||
{error}
|
||||
</div>
|
||||
}
|
||||
|
||||
{!loading && error === null && entries.length === 0
|
||||
&& <div className="leaderboard-empty">
|
||||
{"No entries yet — be the first on the board!"}
|
||||
</div>
|
||||
}
|
||||
|
||||
{!loading && error === null && entries.length > 0
|
||||
&& <div className="leaderboard-table">
|
||||
<div className="leaderboard-table-header">
|
||||
<span className="leaderboard-col-rank">{"Rank"}</span>
|
||||
<span className="leaderboard-col-player">{"Player"}</span>
|
||||
<span className="leaderboard-col-value">
|
||||
{currentConfig?.icon} {currentConfig?.label}
|
||||
</span>
|
||||
</div>
|
||||
{entries.map((entry) => {
|
||||
const avatarUrl
|
||||
= entry.avatar === null
|
||||
? `https://cdn.discordapp.com/embed/avatars/${String(Number.parseInt(entry.discordId, 10) % 5)}.png`
|
||||
: `https://cdn.discordapp.com/avatars/${entry.discordId}/${entry.avatar}.png?size=32`;
|
||||
const displayName
|
||||
= entry.characterName === ""
|
||||
? entry.username
|
||||
: entry.characterName;
|
||||
|
||||
return (
|
||||
<a
|
||||
className={`leaderboard-row ${
|
||||
entry.rank <= 3
|
||||
? `leaderboard-row--top${String(entry.rank)}`
|
||||
: ""
|
||||
}`}
|
||||
href={`/character/${entry.discordId}`}
|
||||
key={entry.discordId}
|
||||
>
|
||||
<span className="leaderboard-col-rank">
|
||||
{rankBadges[entry.rank] ?? `#${String(entry.rank)}`}
|
||||
</span>
|
||||
<span className="leaderboard-col-player">
|
||||
<img
|
||||
alt={displayName}
|
||||
className="leaderboard-avatar"
|
||||
src={avatarUrl}
|
||||
/>
|
||||
<span className="leaderboard-player-info">
|
||||
<span className="leaderboard-player-name">
|
||||
{displayName}
|
||||
</span>
|
||||
{entry.activeTitle === ""
|
||||
? null
|
||||
: <span className="leaderboard-player-title">
|
||||
{entry.activeTitle}
|
||||
</span>
|
||||
}
|
||||
</span>
|
||||
</span>
|
||||
<span className="leaderboard-col-value">
|
||||
{currentConfig?.formatValue(entry.value)}
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div className="leaderboard-footer">
|
||||
<a className="leaderboard-play-link" href="/">
|
||||
{"⚔️ Play Elysium"}
|
||||
</a>
|
||||
<p className="leaderboard-privacy-note">
|
||||
{"Players can opt out via their profile settings."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { LeaderboardPage };
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* @file Login bonus modal component displaying daily login rewards.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- Complex modal with many render paths */
|
||||
import type { LoginBonusResult } from "@elysium/types";
|
||||
import type { JSX } from "react";
|
||||
|
||||
interface LoginBonusModalProperties {
|
||||
readonly bonus: LoginBonusResult;
|
||||
readonly onClose: ()=> void;
|
||||
}
|
||||
|
||||
const dayIcons = [ "🌱", "🌿", "⚔️", "🛡️", "💎", "👑", "🔥" ];
|
||||
|
||||
/**
|
||||
* Formats a gold value with a short suffix.
|
||||
* @param value - The gold amount to format.
|
||||
* @returns The formatted string.
|
||||
*/
|
||||
const formatGold = (value: number): string => {
|
||||
const suffixes = [ "", "K", "M", "B", "T" ];
|
||||
if (value < 1000) {
|
||||
return value.toLocaleString();
|
||||
}
|
||||
const tier = Math.min(Math.floor(Math.log10(value) / 3), suffixes.length - 1);
|
||||
const scaled = value / Math.pow(1000, tier);
|
||||
return `${String(Number.parseFloat(scaled.toFixed(1)))}${suffixes[tier] ?? ""}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the login bonus modal showing daily reward details.
|
||||
* @param props - The modal properties.
|
||||
* @param props.bonus - The login bonus result data.
|
||||
* @param props.onClose - Callback when the modal is closed.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const LoginBonusModal = ({
|
||||
bonus,
|
||||
onClose,
|
||||
}: LoginBonusModalProperties): JSX.Element => {
|
||||
const isWeeklyBonus = bonus.day === 7;
|
||||
const dayIcon = dayIcons[bonus.day - 1] ?? "⭐";
|
||||
|
||||
return (
|
||||
<div aria-modal="true" className="modal-overlay" role="dialog">
|
||||
<div className="modal login-bonus-modal">
|
||||
<div className="login-bonus-streak">
|
||||
<span className="login-bonus-fire">{"🔥"}</span>
|
||||
<span className="login-bonus-streak-count">{bonus.streak}</span>
|
||||
<span className="login-bonus-streak-label">{"Day Streak"}</span>
|
||||
</div>
|
||||
|
||||
<div className="login-bonus-day-badge">
|
||||
<span className="login-bonus-day-icon">{dayIcon}</span>
|
||||
<span className="login-bonus-day-label">
|
||||
{"Day "}
|
||||
{bonus.day}
|
||||
{" Reward"}
|
||||
</span>
|
||||
{bonus.weekMultiplier > 1
|
||||
&& <span className="login-bonus-week-tag">
|
||||
{"×"}
|
||||
{bonus.weekMultiplier}
|
||||
{" Week Bonus!"}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div className="login-bonus-rewards">
|
||||
<div className="login-bonus-reward-item">
|
||||
<span className="login-bonus-reward-icon">{"🪙"}</span>
|
||||
<span className="login-bonus-reward-value">
|
||||
{"+"}
|
||||
{formatGold(bonus.goldEarned)}
|
||||
{" Gold"}
|
||||
</span>
|
||||
</div>
|
||||
{bonus.crystalsEarned > 0
|
||||
&& <div className="login-bonus-reward-item">
|
||||
<span className="login-bonus-reward-icon">{"💎"}</span>
|
||||
<span className="login-bonus-reward-value">
|
||||
{"+"}
|
||||
{bonus.crystalsEarned}
|
||||
{" Crystals"}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
{isWeeklyBonus
|
||||
? <p className="login-bonus-weekly-message">
|
||||
{"🎉 Weekly bonus — keep the streak going!"}
|
||||
</p>
|
||||
: null}
|
||||
|
||||
<div className="login-bonus-calendar">
|
||||
{dayIcons.map((icon, index) => {
|
||||
const dayNumber = index + 1;
|
||||
const isLastDayCompleted = bonus.day === 7 && dayNumber === 7;
|
||||
const isCompleted = dayNumber < bonus.day || isLastDayCompleted;
|
||||
const isToday = dayNumber === bonus.day;
|
||||
return (
|
||||
<div
|
||||
className={`login-bonus-cal-day ${
|
||||
isToday
|
||||
? "login-bonus-cal-day--today"
|
||||
: ""
|
||||
} ${isCompleted
|
||||
? "login-bonus-cal-day--done"
|
||||
: ""}`}
|
||||
key={dayNumber}
|
||||
>
|
||||
<span className="login-bonus-cal-icon">{icon}</span>
|
||||
<span className="login-bonus-cal-num">{dayNumber}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="login-bonus-claim-btn"
|
||||
onClick={onClose}
|
||||
type="button"
|
||||
>
|
||||
{"Claim Reward"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { LoginBonusModal };
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* @file Login page component with Discord OAuth authentication.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- Authentication flow requires many render paths */
|
||||
import { type JSX, useEffect, useState } from "react";
|
||||
import { getAuthUrl, handleAuthCallback } from "../../api/client.js";
|
||||
|
||||
interface LoginPageProperties {
|
||||
readonly onLogin: ()=> void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the login page with Discord OAuth authentication.
|
||||
* @param props - The login page properties.
|
||||
* @param props.onLogin - Callback when authentication completes successfully.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const LoginPage = ({ onLogin }: LoginPageProperties): JSX.Element => {
|
||||
const [ authUrl, setAuthUrl ] = useState<string | null>(null);
|
||||
const [ isLoading, setIsLoading ] = useState(true);
|
||||
const [ error, setError ] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const parameters = new URLSearchParams(window.location.search);
|
||||
const code = parameters.get("code");
|
||||
|
||||
if (code !== null) {
|
||||
setIsLoading(true);
|
||||
handleAuthCallback(code).
|
||||
then(() => {
|
||||
window.history.replaceState({}, "", "/");
|
||||
onLogin();
|
||||
}).
|
||||
catch((error_: unknown) => {
|
||||
setError(
|
||||
error_ instanceof Error
|
||||
? error_.message
|
||||
: "Authentication failed",
|
||||
);
|
||||
setIsLoading(false);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
getAuthUrl().
|
||||
then((url) => {
|
||||
setAuthUrl(url);
|
||||
setIsLoading(false);
|
||||
}).
|
||||
catch(() => {
|
||||
setError("Failed to load authentication URL");
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, [ onLogin ]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="login-page">
|
||||
<div className="login-card">
|
||||
<p>{"Loading..."}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error !== null) {
|
||||
function handleReload(): void {
|
||||
window.location.reload();
|
||||
}
|
||||
return (
|
||||
<div className="login-page">
|
||||
<div className="login-card">
|
||||
<p className="error">{error}</p>
|
||||
<button onClick={handleReload} type="button">
|
||||
{"Try Again"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="login-page">
|
||||
<div className="login-card">
|
||||
<h1>{"⚔️ Elysium"}</h1>
|
||||
<p>
|
||||
{"An idle fantasy RPG. Hire adventurers, defeat bosses,"
|
||||
+ " and ascend to glory."}
|
||||
</p>
|
||||
<a className="discord-login-button" href={authUrl ?? "#"}>
|
||||
{"Login with Discord"}
|
||||
</a>
|
||||
<p className="login-note">
|
||||
{"Your progress is saved to your Discord account and shareable"
|
||||
+ " with others!"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { LoginPage };
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* @file Offline modal component showing gold earned while away.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import type { JSX } from "react";
|
||||
|
||||
/**
|
||||
* Renders the offline earnings modal if the player earned resources offline.
|
||||
* @returns The JSX element or null if no offline earnings.
|
||||
*/
|
||||
const OfflineModal = (): JSX.Element | null => {
|
||||
const { offlineGold, offlineEssence, dismissOfflineGold, formatNumber }
|
||||
= useGame();
|
||||
|
||||
if (offlineGold <= 0 && offlineEssence <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-overlay">
|
||||
<div className="modal">
|
||||
<h2>{"Welcome back!"}</h2>
|
||||
<p>
|
||||
{"Your adventurers kept working whilst you were away and earned:"}
|
||||
</p>
|
||||
{offlineGold > 0
|
||||
&& <p>
|
||||
<strong>
|
||||
{"🪙 "}
|
||||
{formatNumber(offlineGold)}
|
||||
{" gold"}
|
||||
</strong>
|
||||
</p>
|
||||
}
|
||||
{offlineEssence > 0
|
||||
&& <p>
|
||||
<strong>
|
||||
{"✨ "}
|
||||
{formatNumber(offlineEssence)}
|
||||
{" essence"}
|
||||
</strong>
|
||||
</p>
|
||||
}
|
||||
<p className="modal-note">
|
||||
{"Offline progress is calculated up to 8 hours."}
|
||||
</p>
|
||||
<button
|
||||
className="modal-close-button"
|
||||
onClick={dismissOfflineGold}
|
||||
type="button"
|
||||
>
|
||||
{"Collect!"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { OfflineModal };
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* @file Outdated schema modal component warning about incompatible save data.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
import { type JSX, useState } from "react";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
|
||||
interface OutdatedSchemaModalProperties {
|
||||
readonly onDismiss: ()=> void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the outdated schema modal prompting the user to reset or continue.
|
||||
* @param props - The modal properties.
|
||||
* @param props.onDismiss - Callback to dismiss the modal without resetting.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const OutdatedSchemaModal = ({
|
||||
onDismiss,
|
||||
}: OutdatedSchemaModalProperties): JSX.Element => {
|
||||
const { resetProgress } = useGame();
|
||||
const [ isResetting, setIsResetting ] = useState(false);
|
||||
|
||||
async function handleReset(): Promise<void> {
|
||||
setIsResetting(true);
|
||||
await resetProgress();
|
||||
setIsResetting(false);
|
||||
}
|
||||
|
||||
function handleResetClick(): void {
|
||||
void handleReset();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-overlay">
|
||||
<div className="modal offline-modal">
|
||||
<h2>{"⚠️ Outdated Save Data"}</h2>
|
||||
<p>
|
||||
{"Your save data is from an older version of Elysium and may cause"
|
||||
+ " bugs or unexpected behaviour. Cloud saves are "}
|
||||
<strong>{"disabled"}</strong>
|
||||
{" until you reset your progress."}
|
||||
</p>
|
||||
<p>{"Resetting will start you fresh — all progress will be lost."}</p>
|
||||
<div className="outdated-modal-actions">
|
||||
<button
|
||||
className="outdated-modal-reset-button"
|
||||
disabled={isResetting}
|
||||
onClick={handleResetClick}
|
||||
type="button"
|
||||
>
|
||||
{isResetting
|
||||
? "Resetting…"
|
||||
: "Reset Progress"}
|
||||
</button>
|
||||
<button
|
||||
className="modal-close-button"
|
||||
onClick={onDismiss}
|
||||
type="button"
|
||||
>
|
||||
{"Proceed with Bugs"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { OutdatedSchemaModal };
|
||||
@@ -0,0 +1,419 @@
|
||||
/**
|
||||
* @file Prestige panel component for ascending and purchasing runestone upgrades.
|
||||
* @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 */
|
||||
/* eslint-disable max-lines -- Large panel with prestige and shop tabs */
|
||||
/* eslint-disable max-statements -- Prestige panel manages many local state variables */
|
||||
import { useState, type JSX } from "react";
|
||||
import { prestige } from "../../api/client.js";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import {
|
||||
PRESTIGE_UPGRADES,
|
||||
PRESTIGE_UPGRADE_CATEGORY_LABELS,
|
||||
} from "../../data/prestigeUpgrades.js";
|
||||
import { sendNotification } from "../../utils/notification.js";
|
||||
import { playSound } from "../../utils/sound.js";
|
||||
import type { PrestigeUpgradeCategory } from "@elysium/types";
|
||||
|
||||
const baseThreshold = 1_000_000;
|
||||
const thresholdScale = 5;
|
||||
const runestonesPerLevel = 10;
|
||||
|
||||
/**
|
||||
* Calculates the prestige threshold for a given prestige count.
|
||||
* @param prestigeCount - The current prestige count.
|
||||
* @returns The required gold to prestige.
|
||||
*/
|
||||
const calculateThreshold = (prestigeCount: number): number => {
|
||||
return baseThreshold * Math.pow(thresholdScale, prestigeCount);
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates the production multiplier for a given prestige count.
|
||||
* @param prestigeCount - The number of times the player has prestiged.
|
||||
* @returns The compounding multiplier applied to all income sources.
|
||||
*/
|
||||
const calculateProductionMultiplier = (prestigeCount: number): number => {
|
||||
return Math.pow(1.15, prestigeCount);
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates the runestone preview for a prestige.
|
||||
* @param totalGoldEarned - Total gold earned this run.
|
||||
* @param prestigeCount - The current prestige count.
|
||||
* @param purchasedUpgradeIds - IDs of purchased prestige upgrades.
|
||||
* @returns The predicted runestone reward.
|
||||
*/
|
||||
const calculateRunestonePreview = (
|
||||
totalGoldEarned: number,
|
||||
prestigeCount: number,
|
||||
purchasedUpgradeIds: Array<string>,
|
||||
): number => {
|
||||
const threshold = calculateThreshold(prestigeCount);
|
||||
const base
|
||||
= Math.floor(Math.sqrt(totalGoldEarned / threshold)) * runestonesPerLevel;
|
||||
const runestoneMult = PRESTIGE_UPGRADES.filter((upgrade) => {
|
||||
return (
|
||||
upgrade.category === "runestones"
|
||||
&& purchasedUpgradeIds.includes(upgrade.id)
|
||||
);
|
||||
}).reduce((mult, upgrade) => {
|
||||
return mult * upgrade.multiplier;
|
||||
}, 1);
|
||||
return Math.floor(base * runestoneMult);
|
||||
};
|
||||
|
||||
const categoryOrder: Array<PrestigeUpgradeCategory> = [
|
||||
"income",
|
||||
"click",
|
||||
"essence",
|
||||
"crystals",
|
||||
"runestones",
|
||||
"utility",
|
||||
];
|
||||
|
||||
/**
|
||||
* Renders the prestige panel with ascension and runestone shop tabs.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const PrestigePanel = (): JSX.Element => {
|
||||
const {
|
||||
state,
|
||||
reload,
|
||||
formatNumber,
|
||||
buyPrestigeUpgrade,
|
||||
enableNotifications,
|
||||
enableSounds,
|
||||
toggleAutoPrestige,
|
||||
} = useGame();
|
||||
const [ isPending, setIsPending ] = useState(false);
|
||||
const [ result, setResult ] = useState<{
|
||||
runestones: number;
|
||||
count: number;
|
||||
milestoneRunestones: number;
|
||||
} | null>(null);
|
||||
const [ prestigeError, setPrestigeError ] = useState<string | null>(null);
|
||||
const [ buyingId, setBuyingId ] = useState<string | null>(null);
|
||||
const [ activeTab, setActiveTab ] = useState<"prestige" | "shop">("prestige");
|
||||
|
||||
if (state === null) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"Loading..."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const { prestige: prestigeData, player } = state;
|
||||
const threshold = calculateThreshold(prestigeData.count);
|
||||
const isEligible = player.totalGoldEarned >= threshold;
|
||||
const runestonePreview = calculateRunestonePreview(
|
||||
player.totalGoldEarned,
|
||||
prestigeData.count,
|
||||
prestigeData.purchasedUpgradeIds,
|
||||
);
|
||||
const nextMultiplier = calculateProductionMultiplier(prestigeData.count + 1);
|
||||
|
||||
async function handlePrestige(): Promise<void> {
|
||||
setIsPending(true);
|
||||
setPrestigeError(null);
|
||||
try {
|
||||
const data = await prestige({});
|
||||
setResult({
|
||||
count: data.newPrestigeCount,
|
||||
milestoneRunestones: data.milestoneRunestones,
|
||||
runestones: data.runestones,
|
||||
});
|
||||
if (enableSounds) {
|
||||
playSound("prestige");
|
||||
}
|
||||
if (enableNotifications) {
|
||||
sendNotification(
|
||||
"⭐ Prestige!",
|
||||
`You've reached prestige level ${data.newPrestigeCount.toString()}!`,
|
||||
);
|
||||
}
|
||||
await reload();
|
||||
} catch (error_: unknown) {
|
||||
setPrestigeError(
|
||||
error_ instanceof Error
|
||||
? error_.message
|
||||
: "Prestige failed",
|
||||
);
|
||||
} finally {
|
||||
setIsPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBuyUpgrade(upgradeId: string): Promise<void> {
|
||||
setBuyingId(upgradeId);
|
||||
try {
|
||||
await buyPrestigeUpgrade(upgradeId);
|
||||
} finally {
|
||||
setBuyingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
const upgradesByCategory = categoryOrder.map((categoryId) => {
|
||||
const label = PRESTIGE_UPGRADE_CATEGORY_LABELS[categoryId] ?? categoryId;
|
||||
const upgrades = PRESTIGE_UPGRADES.filter((upgrade) => {
|
||||
return upgrade.category === categoryId;
|
||||
});
|
||||
return { categoryId, label, upgrades };
|
||||
});
|
||||
|
||||
function handlePrestigeClick(): void {
|
||||
void handlePrestige();
|
||||
}
|
||||
|
||||
function handleAutoPrestigeToggle(): void {
|
||||
toggleAutoPrestige();
|
||||
}
|
||||
|
||||
function handlePrestigeTabClick(): void {
|
||||
setActiveTab("prestige");
|
||||
}
|
||||
|
||||
function handleShopTabClick(): void {
|
||||
setActiveTab("shop");
|
||||
}
|
||||
|
||||
const progressRatio = player.totalGoldEarned / threshold;
|
||||
const progressPct = (progressRatio * 100).toFixed(1);
|
||||
|
||||
return (
|
||||
<section className="panel prestige-panel">
|
||||
<h2>{"⭐ Prestige"}</h2>
|
||||
|
||||
<div className="prestige-tabs">
|
||||
<button
|
||||
className={`prestige-tab ${activeTab === "prestige"
|
||||
? "active"
|
||||
: ""}`}
|
||||
onClick={handlePrestigeTabClick}
|
||||
type="button"
|
||||
>
|
||||
{"Ascend"}
|
||||
</button>
|
||||
<button
|
||||
className={`prestige-tab ${activeTab === "shop"
|
||||
? "active"
|
||||
: ""}`}
|
||||
onClick={handleShopTabClick}
|
||||
type="button"
|
||||
>
|
||||
{"🔮 Runestone Shop ("}
|
||||
{formatNumber(prestigeData.runestones)}
|
||||
{" stones)"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeTab === "prestige"
|
||||
&& <>
|
||||
<p>
|
||||
{"Prestige resets your progress but grants "}
|
||||
<strong>{"Runestones"}</strong>
|
||||
{"— permanent currency used for powerful upgrades."}
|
||||
{" Each prestige multiplies your global production by ×1.15"}
|
||||
{" (compounding each run)."}
|
||||
</p>
|
||||
|
||||
<div className="prestige-status">
|
||||
<p>
|
||||
{"Total gold this run: "}
|
||||
<strong>{formatNumber(player.totalGoldEarned)}</strong>
|
||||
</p>
|
||||
<p>
|
||||
{"Required to prestige: "}
|
||||
<strong>{formatNumber(threshold)}</strong>
|
||||
</p>
|
||||
<p>
|
||||
{"Prestige count: "}
|
||||
<strong>{prestigeData.count}</strong>
|
||||
</p>
|
||||
<p>
|
||||
{"Current production multiplier: "}
|
||||
<strong>
|
||||
{"×"}
|
||||
{prestigeData.productionMultiplier.toFixed(2)}
|
||||
</strong>
|
||||
</p>
|
||||
<p>
|
||||
{"After next prestige: "}
|
||||
<strong>
|
||||
{"×"}
|
||||
{nextMultiplier.toFixed(2)}
|
||||
</strong>
|
||||
</p>
|
||||
<p>
|
||||
{"Runestones: "}
|
||||
<strong>{formatNumber(prestigeData.runestones)}</strong>
|
||||
</p>
|
||||
{isEligible
|
||||
? <p className="runestone-preview">
|
||||
{"Runestones on prestige: "}
|
||||
<strong>
|
||||
{"+"}
|
||||
{formatNumber(runestonePreview)}
|
||||
</strong>
|
||||
</p>
|
||||
: null}
|
||||
{isEligible
|
||||
? null
|
||||
: <p className="prestige-progress">
|
||||
{"Progress: "}
|
||||
{formatNumber(player.totalGoldEarned)}
|
||||
{" / "}
|
||||
{formatNumber(threshold)}
|
||||
{" ("}
|
||||
{progressPct}
|
||||
{"%"}
|
||||
{")"}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
{isEligible
|
||||
? <div className="prestige-form">
|
||||
<p>{"You are ready to prestige!"}</p>
|
||||
<button
|
||||
className="prestige-button"
|
||||
disabled={isPending}
|
||||
onClick={handlePrestigeClick}
|
||||
type="button"
|
||||
>
|
||||
{isPending
|
||||
? "Ascending..."
|
||||
: `✨ Ascend (+${formatNumber(runestonePreview)} Runestones)`}
|
||||
</button>
|
||||
{prestigeError === null
|
||||
? null
|
||||
: <p className="error">{prestigeError}</p>
|
||||
}
|
||||
{result === null
|
||||
? null
|
||||
: <p className="success">
|
||||
{"Ascended to Prestige "}
|
||||
{result.count}
|
||||
{"! Earned "}
|
||||
{formatNumber(result.runestones)}
|
||||
{" Runestones."}
|
||||
{result.milestoneRunestones > 0
|
||||
&& <>
|
||||
{" 🎉 Milestone bonus: +"}
|
||||
{formatNumber(result.milestoneRunestones)}
|
||||
{" Runestones!"}
|
||||
</>
|
||||
}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
: <p className="prestige-locked">
|
||||
{"Earn "}
|
||||
{formatNumber(threshold - player.totalGoldEarned)}
|
||||
{" more gold to unlock prestige."}
|
||||
</p>
|
||||
}
|
||||
</>
|
||||
}
|
||||
|
||||
{activeTab === "shop"
|
||||
&& <div className="runestone-shop">
|
||||
<p className="shop-balance">
|
||||
{"Balance: "}
|
||||
<strong>
|
||||
{formatNumber(prestigeData.runestones)}
|
||||
{" Runestones"}
|
||||
</strong>
|
||||
</p>
|
||||
|
||||
{upgradesByCategory.map(({ categoryId, label, upgrades }) => {
|
||||
return (
|
||||
<div className="shop-category" key={categoryId}>
|
||||
<h3>{label}</h3>
|
||||
<div className="shop-upgrades">
|
||||
{upgrades.map((upgrade) => {
|
||||
const purchased = prestigeData.purchasedUpgradeIds.includes(
|
||||
upgrade.id,
|
||||
);
|
||||
const canAfford
|
||||
= prestigeData.runestones >= upgrade.runestonesCost;
|
||||
const isLoading = buyingId === upgrade.id;
|
||||
|
||||
const isAutoPrestigeToggle
|
||||
= upgrade.id === "auto_prestige" && purchased;
|
||||
const autoPrestigeEnabled
|
||||
= prestigeData.autoPrestigeEnabled ?? false;
|
||||
|
||||
function handleBuyClick(): void {
|
||||
void handleBuyUpgrade(upgrade.id);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`shop-upgrade-card ${
|
||||
purchased
|
||||
? "purchased"
|
||||
: ""
|
||||
} ${!canAfford && !purchased
|
||||
? "unaffordable"
|
||||
: ""}`}
|
||||
key={upgrade.id}
|
||||
>
|
||||
<div className="shop-upgrade-info">
|
||||
<h4>{upgrade.name}</h4>
|
||||
<p>{upgrade.description}</p>
|
||||
<p className="upgrade-cost">
|
||||
{purchased
|
||||
? "✅ Purchased"
|
||||
: `🔮 ${formatNumber(upgrade.runestonesCost)} Runestones`}
|
||||
</p>
|
||||
</div>
|
||||
{isAutoPrestigeToggle
|
||||
? <button
|
||||
className={`auto-prestige-toggle ${
|
||||
autoPrestigeEnabled
|
||||
? "enabled"
|
||||
: "disabled"
|
||||
}`}
|
||||
onClick={handleAutoPrestigeToggle}
|
||||
type="button"
|
||||
>
|
||||
{autoPrestigeEnabled
|
||||
? "⚡ Auto ON"
|
||||
: "⏸ Auto OFF"}
|
||||
</button>
|
||||
: null}
|
||||
{purchased
|
||||
? null
|
||||
: <button
|
||||
className="buy-upgrade-button"
|
||||
disabled={
|
||||
!canAfford || isLoading || buyingId !== null
|
||||
}
|
||||
onClick={handleBuyClick}
|
||||
type="button"
|
||||
>
|
||||
{isLoading
|
||||
? "Buying..."
|
||||
: "Buy"}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { PrestigePanel };
|
||||
@@ -0,0 +1,293 @@
|
||||
/**
|
||||
* @file Profile page component displaying a player's public profile.
|
||||
* @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 stat visibility checks */
|
||||
import { useEffect, useState, type JSX } from "react";
|
||||
import { formatNumber } from "../../utils/format.js";
|
||||
import type { PublicProfileResponse } from "@elysium/types";
|
||||
|
||||
interface ProfilePageProperties {
|
||||
readonly discordId: string;
|
||||
}
|
||||
|
||||
interface StatEntry {
|
||||
icon: string;
|
||||
value: string;
|
||||
label: string;
|
||||
date: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the public profile page for a given player.
|
||||
* @param props - The profile page properties.
|
||||
* @param props.discordId - The Discord ID of the player to display.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const ProfilePage = ({ discordId }: ProfilePageProperties): 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 cast
|
||||
return await (response.json() as Promise<PublicProfileResponse>);
|
||||
}).
|
||||
then(setProfile).
|
||||
catch((error_: unknown) => {
|
||||
setError(
|
||||
error_ instanceof Error
|
||||
? error_.message
|
||||
: "Failed to load profile",
|
||||
);
|
||||
});
|
||||
}, [ discordId ]);
|
||||
|
||||
function handleCopy(): void {
|
||||
void navigator.clipboard.writeText(window.location.href).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => {
|
||||
setCopied(false);
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
|
||||
if (error !== null) {
|
||||
return (
|
||||
<div className="profile-page">
|
||||
<div className="profile-error">
|
||||
<p>
|
||||
{"⚠️ "}
|
||||
{error}
|
||||
</p>
|
||||
<a className="profile-play-link" href="/">
|
||||
{"← Play Elysium"}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (profile === null) {
|
||||
return (
|
||||
<div className="profile-page">
|
||||
<div className="profile-loading">{"Loading profile…"}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const settings = profile.profileSettings;
|
||||
function fmt(value: number): string {
|
||||
return formatNumber(value, settings.numberFormat);
|
||||
}
|
||||
|
||||
const avatarUrl
|
||||
= profile.avatar === null
|
||||
? `https://cdn.discordapp.com/embed/avatars/${String(Number.parseInt(discordId, 10) % 5)}.png`
|
||||
: `https://cdn.discordapp.com/avatars/${discordId}/${profile.avatar}.png?size=128`;
|
||||
|
||||
const memberSince = new Date(profile.createdAt).toLocaleDateString("en-GB", {
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
const currentRunStatsRaw: Array<StatEntry | false> = [
|
||||
settings.showCurrentGold && {
|
||||
date: false,
|
||||
icon: "🪙",
|
||||
label: "Gold Earned",
|
||||
value: fmt(profile.currentRunGold),
|
||||
},
|
||||
settings.showCurrentClicks && {
|
||||
date: false,
|
||||
icon: "👆",
|
||||
label: "Clicks",
|
||||
value: fmt(profile.currentRunClicks),
|
||||
},
|
||||
settings.showBossesDefeated && {
|
||||
date: false,
|
||||
icon: "💀",
|
||||
label: "Bosses Defeated",
|
||||
value: String(profile.bossesDefeated),
|
||||
},
|
||||
settings.showQuestsCompleted && {
|
||||
date: false,
|
||||
icon: "📜",
|
||||
label: "Quests Completed",
|
||||
value: String(profile.questsCompleted),
|
||||
},
|
||||
settings.showAdventurersRecruited && {
|
||||
date: false,
|
||||
icon: "⚔️",
|
||||
label: "Adventurers Recruited",
|
||||
value: fmt(profile.adventurersRecruited),
|
||||
},
|
||||
settings.showAchievementsUnlocked && {
|
||||
date: false,
|
||||
icon: "🏆",
|
||||
label: "Achievements Unlocked",
|
||||
value: String(profile.achievementsUnlocked),
|
||||
},
|
||||
];
|
||||
const currentRunStats = currentRunStatsRaw.filter(
|
||||
(entry): entry is StatEntry => {
|
||||
return entry !== false;
|
||||
},
|
||||
);
|
||||
|
||||
const allTimeStatsRaw: Array<StatEntry | false> = [
|
||||
settings.showTotalGold && {
|
||||
date: false,
|
||||
icon: "🪙",
|
||||
label: "Total Gold Earned",
|
||||
value: fmt(profile.totalGoldEarned),
|
||||
},
|
||||
settings.showTotalClicks && {
|
||||
date: false,
|
||||
icon: "👆",
|
||||
label: "Total Clicks",
|
||||
value: fmt(profile.totalClicks),
|
||||
},
|
||||
settings.showLifetimeBossesDefeated && {
|
||||
date: false,
|
||||
icon: "💀",
|
||||
label: "Bosses Defeated",
|
||||
value: String(profile.lifetimeBossesDefeated),
|
||||
},
|
||||
settings.showLifetimeQuestsCompleted && {
|
||||
date: false,
|
||||
icon: "📜",
|
||||
label: "Quests Completed",
|
||||
value: String(profile.lifetimeQuestsCompleted),
|
||||
},
|
||||
settings.showLifetimeAdventurersRecruited && {
|
||||
date: false,
|
||||
icon: "⚔️",
|
||||
label: "Adventurers Recruited",
|
||||
value: fmt(profile.lifetimeAdventurersRecruited),
|
||||
},
|
||||
settings.showLifetimeAchievementsUnlocked && {
|
||||
date: false,
|
||||
icon: "🏆",
|
||||
label: "Achievements Unlocked",
|
||||
value: String(profile.lifetimeAchievementsUnlocked),
|
||||
},
|
||||
settings.showGuildFounded && {
|
||||
date: true,
|
||||
icon: "📅",
|
||||
label: "Guild Founded",
|
||||
value: memberSince,
|
||||
},
|
||||
];
|
||||
const allTimeStats = allTimeStatsRaw.filter((entry): entry is StatEntry => {
|
||||
return entry !== false;
|
||||
});
|
||||
|
||||
function renderStats(stats: Array<StatEntry>): JSX.Element {
|
||||
return (
|
||||
<div className="profile-stats">
|
||||
{stats.map((stat) => {
|
||||
return (
|
||||
<div className="profile-stat" key={stat.label}>
|
||||
<span className="profile-stat-icon">{stat.icon}</span>
|
||||
<span
|
||||
className={`profile-stat-value ${
|
||||
stat.date
|
||||
? "profile-stat-date"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{stat.value}
|
||||
</span>
|
||||
<span className="profile-stat-label">{stat.label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="profile-page">
|
||||
<div className="profile-card">
|
||||
<div className="profile-header">
|
||||
<img
|
||||
alt={`${profile.username}'s avatar`}
|
||||
className="profile-avatar"
|
||||
src={avatarUrl}
|
||||
/>
|
||||
<div className="profile-identity">
|
||||
<h1 className="profile-character-name">{profile.characterName}</h1>
|
||||
<p className="profile-username">
|
||||
{"@"}
|
||||
{profile.username}
|
||||
</p>
|
||||
{settings.showApotheosis && profile.apotheosisCount > 0
|
||||
? <span className="profile-apotheosis-badge">
|
||||
{"✨ Apotheosis "}
|
||||
{profile.apotheosisCount}
|
||||
</span>
|
||||
: null}
|
||||
{settings.showTranscendence && profile.transcendenceCount > 0
|
||||
? <span className="profile-transcendence-badge">
|
||||
{"🌌 Transcendence "}
|
||||
{profile.transcendenceCount}
|
||||
</span>
|
||||
: null}
|
||||
{settings.showPrestige && profile.prestigeCount > 0
|
||||
? <span className="profile-prestige-badge">
|
||||
{"⭐ Prestige "}
|
||||
{profile.prestigeCount}
|
||||
</span>
|
||||
: null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{profile.bio === ""
|
||||
? null
|
||||
: <p className="profile-bio">{profile.bio}</p>
|
||||
}
|
||||
|
||||
{currentRunStats.length > 0
|
||||
&& <div className="profile-stats-section">
|
||||
<h3 className="profile-stats-heading">{"Current Run"}</h3>
|
||||
{renderStats(currentRunStats)}
|
||||
</div>
|
||||
}
|
||||
|
||||
{allTimeStats.length > 0
|
||||
&& <div className="profile-stats-section">
|
||||
<h3 className="profile-stats-heading">{"All Time"}</h3>
|
||||
{renderStats(allTimeStats)}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div className="profile-actions">
|
||||
<button
|
||||
className="profile-share-button"
|
||||
onClick={handleCopy}
|
||||
type="button"
|
||||
>
|
||||
{copied
|
||||
? "✓ Copied!"
|
||||
: "🔗 Copy Profile Link"}
|
||||
</button>
|
||||
<a className="profile-play-link" href="/">
|
||||
{"⚔️ Play Elysium"}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { ProfilePage };
|
||||
@@ -0,0 +1,306 @@
|
||||
/**
|
||||
* @file Quest panel component for managing and completing quests.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable react/no-multi-comp -- QuestCard sub-component is tightly coupled */
|
||||
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
|
||||
/* eslint-disable complexity -- Many conditional render paths */
|
||||
/* eslint-disable max-statements -- Many local variables needed for quest state */
|
||||
import { useState, type JSX } from "react";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import { LockToggle } from "../ui/lockToggle.js";
|
||||
import { ZoneSelector } from "./zoneSelector.js";
|
||||
import type { Quest } from "@elysium/types";
|
||||
|
||||
/**
|
||||
* Formats a duration in seconds to a human-readable string.
|
||||
* @param seconds - The total number of seconds to format.
|
||||
* @returns The formatted duration string.
|
||||
*/
|
||||
const formatDuration = (seconds: number): string => {
|
||||
const secondsPerHour = 3600;
|
||||
const secondsPerMinute = 60;
|
||||
if (seconds >= secondsPerHour) {
|
||||
const hours = Math.floor(seconds / secondsPerHour);
|
||||
const remainderSeconds = seconds % secondsPerHour;
|
||||
const minutes = Math.floor(remainderSeconds / secondsPerMinute);
|
||||
return `${String(hours)}h ${String(minutes)}m`;
|
||||
}
|
||||
if (seconds >= secondsPerMinute) {
|
||||
const minutes = Math.floor(seconds / secondsPerMinute);
|
||||
const secs = seconds % secondsPerMinute;
|
||||
return `${String(minutes)}m ${String(secs)}s`;
|
||||
}
|
||||
return `${String(seconds)}s`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Computes the time remaining for an active quest.
|
||||
* @param quest - The quest to check.
|
||||
* @returns The remaining seconds.
|
||||
*/
|
||||
const questTimeRemaining = (quest: Quest): number => {
|
||||
if (quest.status !== "active" || quest.startedAt === undefined) {
|
||||
return 0;
|
||||
}
|
||||
const elapsed = (Date.now() - quest.startedAt) / 1000;
|
||||
return Math.max(0, quest.durationSeconds - elapsed);
|
||||
};
|
||||
|
||||
interface QuestCardProperties {
|
||||
readonly quest: Quest;
|
||||
readonly partyCombatPower: number;
|
||||
readonly unlockHint: string | undefined;
|
||||
readonly zoneHint: string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single quest card.
|
||||
* @param props - The quest card properties.
|
||||
* @param props.quest - The quest to display.
|
||||
* @param props.partyCombatPower - The current party's combat power.
|
||||
* @param props.unlockHint - Optional hint for how to unlock this quest.
|
||||
* @param props.zoneHint - Optional hint for which zone to unlock.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const QuestCard = ({
|
||||
quest,
|
||||
partyCombatPower,
|
||||
unlockHint,
|
||||
zoneHint,
|
||||
}: QuestCardProperties): JSX.Element => {
|
||||
const { startQuest, formatNumber } = useGame();
|
||||
const cpRequired = quest.combatPowerRequired ?? 0;
|
||||
const meetsCP = partyCombatPower >= cpRequired;
|
||||
|
||||
function handleStartQuest(): void {
|
||||
startQuest(quest.id);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`quest-card quest-${quest.status}`}>
|
||||
<div className="quest-info">
|
||||
<h3>{quest.name}</h3>
|
||||
<p>{quest.description}</p>
|
||||
{cpRequired > 0
|
||||
&& <p
|
||||
className={`quest-cp-requirement ${
|
||||
meetsCP
|
||||
? "cp-met"
|
||||
: "cp-unmet"
|
||||
}`}
|
||||
>
|
||||
{"⚔️ Requires "}
|
||||
{formatNumber(cpRequired)}
|
||||
{" Combat Power"}
|
||||
{quest.status === "available"
|
||||
&& (meetsCP
|
||||
? " ✓"
|
||||
: ` (you have ${formatNumber(partyCombatPower)})`)}
|
||||
</p>
|
||||
}
|
||||
<div className="quest-rewards">
|
||||
{quest.rewards.map((reward) => {
|
||||
return (
|
||||
<span className="reward-tag" key={`${reward.type}-${String(reward.amount ?? "")}`}>
|
||||
{reward.type === "gold"
|
||||
&& `🪙 ${formatNumber(reward.amount ?? 0)}`}
|
||||
{reward.type === "essence"
|
||||
&& `✨ ${formatNumber(reward.amount ?? 0)}`}
|
||||
{reward.type === "crystals"
|
||||
&& `💎 ${formatNumber(reward.amount ?? 0)}`}
|
||||
{reward.type === "upgrade" && "🔓 Upgrade"}
|
||||
{reward.type === "adventurer" && "👥 New Adventurer"}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="quest-action">
|
||||
{quest.status === "locked"
|
||||
&& <>
|
||||
<span className="quest-badge locked">{"🔒 Locked"}</span>
|
||||
{zoneHint === undefined
|
||||
? null
|
||||
: <p className="unlock-hint">
|
||||
{"🗺️ Unlock zone: "}
|
||||
{zoneHint}
|
||||
</p>
|
||||
}
|
||||
{zoneHint === undefined && unlockHint !== undefined
|
||||
? <p className="unlock-hint">
|
||||
{"📜 Complete: "}
|
||||
{unlockHint}
|
||||
</p>
|
||||
: null}
|
||||
</>
|
||||
}
|
||||
{quest.status === "available" && quest.lastFailedAt !== undefined
|
||||
&& <p className="quest-failed-hint">{"⚠️ Last attempt failed"}</p>
|
||||
}
|
||||
{quest.status === "available"
|
||||
&& <button
|
||||
className="start-quest-button"
|
||||
disabled={!meetsCP}
|
||||
onClick={handleStartQuest}
|
||||
title={
|
||||
meetsCP
|
||||
? undefined
|
||||
: `Need ${formatNumber(cpRequired)} combat power`
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
{"Send Party ("}
|
||||
{formatDuration(quest.durationSeconds)}
|
||||
{")"}
|
||||
</button>
|
||||
}
|
||||
{quest.status === "active"
|
||||
&& <span className="quest-badge active">
|
||||
{"⏳ "}
|
||||
{formatDuration(Math.ceil(questTimeRemaining(quest)))}
|
||||
{" remaining"}
|
||||
</span>
|
||||
}
|
||||
{quest.status === "completed"
|
||||
&& <span className="quest-badge completed">{"✅ Complete"}</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the quest panel with zone selection and quest list.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const QuestPanel = (): JSX.Element => {
|
||||
const { state, toggleAutoQuest } = useGame();
|
||||
const [ activeZoneId, setActiveZoneId ] = useState("verdant_vale");
|
||||
const [ showLocked, setShowLocked ] = useState(true);
|
||||
|
||||
if (state === null) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"Loading..."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const { adventurers, autoQuest, quests, zones } = state;
|
||||
// eslint-disable-next-line unicorn/no-array-reduce -- Need the total!
|
||||
const partyCombatPower = adventurers.reduce((total, adventurer) => {
|
||||
const power = total + adventurer.combatPower;
|
||||
return power * adventurer.count;
|
||||
}, 0);
|
||||
const zoneQuests = quests.filter(({ zoneId }) => {
|
||||
return zoneId === activeZoneId;
|
||||
});
|
||||
const lockedCount = zoneQuests.filter(({ status }) => {
|
||||
return status === "locked";
|
||||
}).length;
|
||||
const visibleQuests = showLocked
|
||||
? zoneQuests
|
||||
: zoneQuests.filter(({ status }) => {
|
||||
return status !== "locked";
|
||||
});
|
||||
|
||||
const questNameById = new Map(
|
||||
quests.map(({ id, name }) => {
|
||||
return [ id, name ];
|
||||
}),
|
||||
);
|
||||
const zoneById = new Map(
|
||||
zones.map((zone) => {
|
||||
return [ zone.id, zone ];
|
||||
}),
|
||||
);
|
||||
const questUnlockHints = new Map<string, string>();
|
||||
const questZoneHints = new Map<string, string>();
|
||||
for (const { id: questId, status, zoneId, prerequisiteIds } of quests) {
|
||||
if (status !== "locked") {
|
||||
continue;
|
||||
}
|
||||
const zone = zoneById.get(zoneId);
|
||||
if (zone?.status === "locked") {
|
||||
questZoneHints.set(questId, zone.name);
|
||||
} else if (prerequisiteIds.length > 0) {
|
||||
const [ prereqId ] = prerequisiteIds;
|
||||
if (prereqId !== undefined) {
|
||||
const prereqName = questNameById.get(prereqId);
|
||||
if (prereqName !== undefined) {
|
||||
questUnlockHints.set(questId, prereqName);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleToggle(): void {
|
||||
setShowLocked((current) => {
|
||||
return !current;
|
||||
});
|
||||
}
|
||||
|
||||
function handleAutoQuest(): void {
|
||||
toggleAutoQuest();
|
||||
}
|
||||
|
||||
const autoQuestOn = autoQuest === true;
|
||||
|
||||
return (
|
||||
<section className="panel quest-panel">
|
||||
<div className="panel-header">
|
||||
<h2>{"Quests"}</h2>
|
||||
<div className="panel-header-controls">
|
||||
<button
|
||||
className={`auto-toggle-btn ${
|
||||
autoQuestOn
|
||||
? "auto-toggle-on"
|
||||
: "auto-toggle-off"
|
||||
}`}
|
||||
onClick={handleAutoQuest}
|
||||
title="Automatically send the party on the highest available quest"
|
||||
type="button"
|
||||
>
|
||||
{"🤖 Auto: "}
|
||||
{autoQuestOn
|
||||
? "ON"
|
||||
: "OFF"}
|
||||
</button>
|
||||
<LockToggle
|
||||
lockedCount={lockedCount}
|
||||
onToggle={handleToggle}
|
||||
showLocked={showLocked}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ZoneSelector
|
||||
activeZoneId={activeZoneId}
|
||||
onSelectZone={setActiveZoneId}
|
||||
zones={zones}
|
||||
/>
|
||||
|
||||
<div className="quest-list">
|
||||
{visibleQuests.map((quest) => {
|
||||
return (
|
||||
<QuestCard
|
||||
key={quest.id}
|
||||
partyCombatPower={partyCombatPower}
|
||||
quest={quest}
|
||||
unlockHint={questUnlockHints.get(quest.id)}
|
||||
zoneHint={questZoneHints.get(quest.id)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{visibleQuests.length === 0
|
||||
&& <p className="empty-zone">{"No quests to show in this zone."}</p>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { QuestPanel };
|
||||
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* @file Statistics panel component showing player progress and all-time stats.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable react/no-multi-comp -- Sub-component is tightly coupled to the panel */
|
||||
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
|
||||
/* eslint-disable react/require-default-props -- TypeScript optional props with default parameters are sufficient */
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import { PRESTIGE_UPGRADES } from "../../data/prestigeUpgrades.js";
|
||||
import type { JSX } from "react";
|
||||
|
||||
const formatDate = (timestamp: number): string => {
|
||||
return new Date(timestamp).toLocaleDateString("en-GB", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
interface StatCardProperties {
|
||||
readonly icon: string;
|
||||
readonly label: string;
|
||||
readonly value: string;
|
||||
readonly sub?: string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single statistic card.
|
||||
* @param props - The stat card properties.
|
||||
* @param props.icon - The icon to display.
|
||||
* @param props.label - The label for the stat.
|
||||
* @param props.value - The value to display.
|
||||
* @param props.sub - Optional sub-label.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const StatCard = ({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
sub = undefined,
|
||||
}: StatCardProperties): JSX.Element => {
|
||||
return (
|
||||
<div className="profile-stat">
|
||||
<span className="profile-stat-icon">{icon}</span>
|
||||
<span className="profile-stat-value">{value}</span>
|
||||
<span className="profile-stat-label">{label}</span>
|
||||
{sub === undefined
|
||||
? null
|
||||
: <span className="profile-stat-date">{sub}</span>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the statistics panel with player progress and all-time stats.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const StatisticsPanel = (): JSX.Element => {
|
||||
const { state, formatNumber } = useGame();
|
||||
|
||||
if (state === null) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"Loading..."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const {
|
||||
player,
|
||||
resources,
|
||||
prestige,
|
||||
bosses,
|
||||
quests,
|
||||
zones,
|
||||
adventurers,
|
||||
upgrades,
|
||||
equipment,
|
||||
achievements,
|
||||
} = state;
|
||||
|
||||
const bossesDefeated = bosses.filter((boss) => {
|
||||
return boss.status === "defeated";
|
||||
}).length;
|
||||
const questsCompleted = quests.filter((quest) => {
|
||||
return quest.status === "completed";
|
||||
}).length;
|
||||
const zonesUnlocked = zones.filter((zone) => {
|
||||
return zone.status === "unlocked";
|
||||
}).length;
|
||||
const adventurersRecruited = adventurers.reduce((sum, adventurer) => {
|
||||
return sum + adventurer.count;
|
||||
}, 0);
|
||||
const equipmentOwned = equipment.filter((item) => {
|
||||
return item.owned;
|
||||
}).length;
|
||||
const upgradesPurchased = upgrades.filter((upgrade) => {
|
||||
return upgrade.purchased;
|
||||
}).length;
|
||||
const achievementsUnlocked = achievements.filter((achievement) => {
|
||||
return achievement.unlockedAt !== null;
|
||||
}).length;
|
||||
const prestigeUpgradesPurchased = prestige.purchasedUpgradeIds.length;
|
||||
|
||||
return (
|
||||
<section className="panel statistics-panel">
|
||||
<h2>{"📊 Statistics"}</h2>
|
||||
|
||||
<h3 className="stats-section-header">{"All-Time"}</h3>
|
||||
<div className="profile-stats">
|
||||
<StatCard
|
||||
icon="🪙"
|
||||
label="Total Gold Earned"
|
||||
sub="across all runs"
|
||||
value={formatNumber(player.totalGoldEarned)}
|
||||
/>
|
||||
<StatCard
|
||||
icon="👆"
|
||||
label="Total Clicks"
|
||||
value={formatNumber(player.totalClicks)}
|
||||
/>
|
||||
<StatCard icon="⭐" label="Prestiges" value={String(prestige.count)} />
|
||||
<StatCard
|
||||
icon="📅"
|
||||
label="Guild Founded"
|
||||
value={formatDate(player.createdAt)}
|
||||
/>
|
||||
<StatCard
|
||||
icon="☁️"
|
||||
label="Last Cloud Save"
|
||||
value={formatDate(player.lastSavedAt)}
|
||||
/>
|
||||
<StatCard
|
||||
icon="✖️"
|
||||
label="Production Multiplier"
|
||||
sub="from prestige"
|
||||
value={`×${prestige.productionMultiplier.toFixed(2)}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h3 className="stats-section-header">{"Current Run"}</h3>
|
||||
<div className="profile-stats">
|
||||
<StatCard icon="🪙" label="Gold" value={formatNumber(resources.gold)} />
|
||||
<StatCard
|
||||
icon="✨"
|
||||
label="Essence"
|
||||
value={formatNumber(resources.essence)}
|
||||
/>
|
||||
<StatCard
|
||||
icon="💎"
|
||||
label="Crystals"
|
||||
value={formatNumber(resources.crystals)}
|
||||
/>
|
||||
<StatCard
|
||||
icon="🔮"
|
||||
label="Runestones"
|
||||
sub="permanent currency"
|
||||
value={formatNumber(prestige.runestones)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h3 className="stats-section-header">{"Progress"}</h3>
|
||||
<div className="profile-stats">
|
||||
<StatCard
|
||||
icon="👹"
|
||||
label="Bosses Defeated"
|
||||
value={`${String(bossesDefeated)} / ${String(bosses.length)}`}
|
||||
/>
|
||||
<StatCard
|
||||
icon="📜"
|
||||
label="Quests Completed"
|
||||
value={`${String(questsCompleted)} / ${String(quests.length)}`}
|
||||
/>
|
||||
<StatCard
|
||||
icon="🗺️"
|
||||
label="Zones Unlocked"
|
||||
value={`${String(zonesUnlocked)} / ${String(zones.length)}`}
|
||||
/>
|
||||
<StatCard
|
||||
icon="⚔️"
|
||||
label="Adventurers Recruited"
|
||||
value={formatNumber(adventurersRecruited)}
|
||||
/>
|
||||
<StatCard
|
||||
icon="🗡️"
|
||||
label="Equipment Owned"
|
||||
value={`${String(equipmentOwned)} / ${String(equipment.length)}`}
|
||||
/>
|
||||
<StatCard
|
||||
icon="🔧"
|
||||
label="Upgrades Purchased"
|
||||
value={`${String(upgradesPurchased)} / ${String(upgrades.length)}`}
|
||||
/>
|
||||
<StatCard
|
||||
icon="🏆"
|
||||
label="Achievements"
|
||||
value={`${String(achievementsUnlocked)} / ${String(achievements.length)}`}
|
||||
/>
|
||||
<StatCard
|
||||
icon="🔮"
|
||||
label="Prestige Upgrades"
|
||||
value={`${String(prestigeUpgradesPurchased)} / ${String(PRESTIGE_UPGRADES.length)}`}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { StatisticsPanel };
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* @file Story panel component displaying the main questline narrative.
|
||||
* @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 -- Complex component with many conditional render paths */
|
||||
import { STORY_CHAPTERS } from "@elysium/types";
|
||||
import { type JSX, useState } from "react";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
|
||||
/**
|
||||
* Substitutes the character name placeholder in story text.
|
||||
* @param text - The story text with placeholders.
|
||||
* @param characterName - The player's character name.
|
||||
* @returns The text with placeholders replaced.
|
||||
*/
|
||||
const substituteCharacterName = (
|
||||
text: string,
|
||||
characterName: string,
|
||||
): string => {
|
||||
const fallback = characterName === ""
|
||||
? "the guild leader"
|
||||
: characterName;
|
||||
return text.replaceAll("{characterName}", fallback);
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the story panel with chapter navigation and content.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const StoryPanel = (): JSX.Element => {
|
||||
const { state, completeChapter } = useGame();
|
||||
const [ activeChapterIndex, setActiveChapterIndex ] = useState(0);
|
||||
|
||||
if (state === null) {
|
||||
return (
|
||||
<div className="story-panel">
|
||||
<p>{"Loading…"}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const unlockedIds = state.story?.unlockedChapterIds ?? [];
|
||||
const completedChapters = state.story?.completedChapters ?? [];
|
||||
const { characterName } = state.player;
|
||||
|
||||
const activeChapter = STORY_CHAPTERS[activeChapterIndex];
|
||||
const isUnlocked = unlockedIds.includes(activeChapter?.id ?? "");
|
||||
const completion
|
||||
= activeChapter === undefined
|
||||
? null
|
||||
: completedChapters.find((completedChapter) => {
|
||||
return completedChapter.chapterId === activeChapter.id;
|
||||
}) ?? null;
|
||||
const isUnread = isUnlocked && completion === null;
|
||||
|
||||
return (
|
||||
<div className="story-panel">
|
||||
<div className="story-chapter-tabs">
|
||||
{STORY_CHAPTERS.map((chapter, index) => {
|
||||
const unlocked = unlockedIds.includes(chapter.id);
|
||||
const completed = completedChapters.some((completedChapter) => {
|
||||
return completedChapter.chapterId === chapter.id;
|
||||
});
|
||||
const unread = unlocked && !completed;
|
||||
function handleChapterSelect(): void {
|
||||
setActiveChapterIndex(index);
|
||||
}
|
||||
return (
|
||||
<button
|
||||
aria-label={
|
||||
unlocked
|
||||
? chapter.title
|
||||
: `Chapter ${String(index + 1)} (locked)`
|
||||
}
|
||||
className={[
|
||||
"story-tab-btn",
|
||||
activeChapterIndex === index
|
||||
? "active"
|
||||
: "",
|
||||
unlocked
|
||||
? ""
|
||||
: "locked",
|
||||
].join(" ")}
|
||||
key={chapter.id}
|
||||
onClick={handleChapterSelect}
|
||||
type="button"
|
||||
>
|
||||
{index + 1}
|
||||
{unread
|
||||
? <span className="story-unread-dot" />
|
||||
: null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{activeChapter === undefined
|
||||
? null
|
||||
: <div className="story-chapter-view">
|
||||
{isUnlocked
|
||||
? <>
|
||||
<h2 className="story-chapter-title">
|
||||
{"Chapter "}
|
||||
{activeChapterIndex + 1}
|
||||
{": "}
|
||||
{activeChapter.title}
|
||||
</h2>
|
||||
<div className="story-chapter-content">
|
||||
{substituteCharacterName(activeChapter.content, characterName).
|
||||
split("\n\n").
|
||||
map((paragraph, paraIndex) => {
|
||||
// eslint-disable-next-line react/no-array-index-key -- Static content paragraphs have no stable id
|
||||
return <p key={paraIndex}>{paragraph}</p>;
|
||||
})}
|
||||
</div>
|
||||
|
||||
{completion === null && isUnread
|
||||
? <div className="story-choices">
|
||||
<p className="story-choices-prompt">{"What do you do?"}</p>
|
||||
{activeChapter.choices.map((storyChoice) => {
|
||||
const chapterForClosure = activeChapter;
|
||||
function handleChoice(): void {
|
||||
completeChapter(chapterForClosure.id, storyChoice.id);
|
||||
}
|
||||
return (
|
||||
<button
|
||||
className="story-choice-btn"
|
||||
key={storyChoice.id}
|
||||
onClick={handleChoice}
|
||||
type="button"
|
||||
>
|
||||
{storyChoice.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
: null}
|
||||
{completion === null
|
||||
? null
|
||||
: <div className="story-choice-result">
|
||||
<p className="story-choice-label">
|
||||
<strong>{"Your choice:"}</strong>{" "}
|
||||
{
|
||||
activeChapter.choices.find((storyChoice) => {
|
||||
return storyChoice.id === completion.choiceId;
|
||||
})?.label
|
||||
}
|
||||
</p>
|
||||
<p className="story-choice-outcome">
|
||||
{substituteCharacterName(
|
||||
activeChapter.choices.find((storyChoice) => {
|
||||
return storyChoice.id === completion.choiceId;
|
||||
})?.outcome ?? "",
|
||||
characterName,
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
</>
|
||||
: <div className="story-locked">
|
||||
<p className="story-locked-title">
|
||||
{"Chapter "}
|
||||
{activeChapterIndex + 1}
|
||||
</p>
|
||||
<p className="story-locked-hint">
|
||||
{"🔒 This chapter has not yet been unlocked."}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { StoryPanel };
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* @file Story toast notification component for new chapter unlocks.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable react/no-multi-comp -- Sub-component is tightly coupled to the toast container */
|
||||
import { STORY_CHAPTERS } from "@elysium/types";
|
||||
import { type JSX, useEffect } from "react";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
|
||||
interface StoryToastItemProperties {
|
||||
readonly chapterId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single story chapter toast notification.
|
||||
* @param props - The toast item properties.
|
||||
* @param props.chapterId - The chapter ID to display.
|
||||
* @returns The JSX element or null if chapter is not found.
|
||||
*/
|
||||
const StoryToastItem = ({
|
||||
chapterId,
|
||||
}: StoryToastItemProperties): JSX.Element | null => {
|
||||
const { dismissStoryChapter } = useGame();
|
||||
const chapter = STORY_CHAPTERS.find((storyChapter) => {
|
||||
return storyChapter.id === chapterId;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
dismissStoryChapter(chapterId);
|
||||
}, 4000);
|
||||
return (): void => {
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [ chapterId, dismissStoryChapter ]);
|
||||
|
||||
if (chapter === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function handleClick(): void {
|
||||
dismissStoryChapter(chapterId);
|
||||
}
|
||||
|
||||
return (
|
||||
<button className="achievement-toast" onClick={handleClick} type="button">
|
||||
<span className="achievement-toast-icon">{"📖"}</span>
|
||||
<div className="achievement-toast-content">
|
||||
<span className="achievement-toast-label">{"✨ New Chapter!"}</span>
|
||||
<span className="achievement-toast-name">{chapter.title}</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the story toast container with pending chapter notifications.
|
||||
* @returns The JSX element or null if there are no pending chapters.
|
||||
*/
|
||||
const StoryToast = (): JSX.Element | null => {
|
||||
const { unlockedStoryChapterIds: pendingChapterIds } = useGame();
|
||||
if (pendingChapterIds.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div className="achievement-toast-container">
|
||||
{pendingChapterIds.map((id) => {
|
||||
return <StoryToastItem chapterId={id} key={id} />;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { StoryToast };
|
||||
@@ -0,0 +1,341 @@
|
||||
/**
|
||||
* @file Transcendence panel component for the second prestige layer.
|
||||
* @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 */
|
||||
/* eslint-disable max-statements -- Transcendence panel manages many local state variables */
|
||||
import { useState, type JSX } from "react";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import {
|
||||
TRANSCENDENCE_UPGRADES,
|
||||
TRANSCENDENCE_UPGRADE_CATEGORY_LABELS,
|
||||
} from "../../data/transcendenceUpgrades.js";
|
||||
import type { TranscendenceUpgradeCategory } from "@elysium/types";
|
||||
|
||||
const echoFormulaConstant = 853;
|
||||
const finalBossId = "the_absolute_one";
|
||||
|
||||
/**
|
||||
* Calculates the echo preview for a transcendence.
|
||||
* @param prestigeCount - The current prestige count.
|
||||
* @param echoMetaMultiplier - The echo meta multiplier from upgrades.
|
||||
* @returns The predicted echo reward.
|
||||
*/
|
||||
const calculateEchoPreview = (
|
||||
prestigeCount: number,
|
||||
echoMetaMultiplier: number,
|
||||
): number => {
|
||||
const safeCount = Math.max(prestigeCount, 1);
|
||||
return Math.floor(
|
||||
// eslint-disable-next-line stylistic/no-extra-parens -- Required by no-mixed-operators rule
|
||||
(echoFormulaConstant / Math.sqrt(safeCount)) * echoMetaMultiplier,
|
||||
);
|
||||
};
|
||||
|
||||
const categoryOrder: Array<TranscendenceUpgradeCategory> = [
|
||||
"income",
|
||||
"combat",
|
||||
"prestige_threshold",
|
||||
"prestige_runestones",
|
||||
"echo_meta",
|
||||
];
|
||||
|
||||
/**
|
||||
* Renders the transcendence panel with transcendence and echo shop tabs.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const TranscendencePanel = (): JSX.Element => {
|
||||
const { state, formatNumber, transcend, buyEchoUpgrade } = useGame();
|
||||
const [ isPending, setIsPending ] = useState(false);
|
||||
const [ result, setResult ] = useState<{
|
||||
echoes: number;
|
||||
count: number;
|
||||
} | null>(null);
|
||||
const [ error, setError ] = useState<string | null>(null);
|
||||
const [ buyingId, setBuyingId ] = useState<string | null>(null);
|
||||
type TranscendTab = "transcend" | "shop";
|
||||
const [ activeTab, setActiveTab ] = useState<TranscendTab>("transcend");
|
||||
|
||||
if (state === null) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"Loading..."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const { bosses, prestige: prestigeData, transcendence } = state;
|
||||
const hasDefeatedFinalBoss = bosses.some((boss) => {
|
||||
return boss.id === finalBossId && boss.status === "defeated";
|
||||
});
|
||||
const echoMetaMultiplier = transcendence?.echoMetaMultiplier ?? 1;
|
||||
const echoPreview = calculateEchoPreview(
|
||||
prestigeData.count,
|
||||
echoMetaMultiplier,
|
||||
);
|
||||
const currentEchoes = transcendence?.echoes ?? 0;
|
||||
const transcendenceCount = transcendence?.count ?? 0;
|
||||
|
||||
async function handleTranscend(): Promise<void> {
|
||||
setIsPending(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await transcend();
|
||||
setResult({ count: data.newTranscendenceCount, echoes: data.echoes });
|
||||
} catch (error_: unknown) {
|
||||
setError(
|
||||
error_ instanceof Error
|
||||
? error_.message
|
||||
: "Transcendence failed",
|
||||
);
|
||||
} finally {
|
||||
setIsPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBuyUpgrade(upgradeId: string): Promise<void> {
|
||||
setBuyingId(upgradeId);
|
||||
try {
|
||||
await buyEchoUpgrade(upgradeId);
|
||||
} finally {
|
||||
setBuyingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
const upgradesByCategory = categoryOrder.map((catId) => {
|
||||
const categoryLabels = TRANSCENDENCE_UPGRADE_CATEGORY_LABELS;
|
||||
const label = categoryLabels[catId] ?? catId;
|
||||
const upgrades = TRANSCENDENCE_UPGRADES.filter((upgrade) => {
|
||||
return upgrade.category === catId;
|
||||
});
|
||||
return { catId, label, upgrades };
|
||||
});
|
||||
|
||||
function handleTranscendClick(): void {
|
||||
void handleTranscend();
|
||||
}
|
||||
|
||||
function handleTranscendTabClick(): void {
|
||||
setActiveTab("transcend");
|
||||
}
|
||||
|
||||
function handleShopTabClick(): void {
|
||||
setActiveTab("shop");
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="panel transcendence-panel">
|
||||
<h2>{"🌌 Transcendence"}</h2>
|
||||
|
||||
<div className="prestige-tabs">
|
||||
<button
|
||||
className={`prestige-tab ${
|
||||
activeTab === "transcend"
|
||||
? "active"
|
||||
: ""
|
||||
}`}
|
||||
onClick={handleTranscendTabClick}
|
||||
type="button"
|
||||
>
|
||||
{"Transcend"}
|
||||
</button>
|
||||
<button
|
||||
className={`prestige-tab ${activeTab === "shop"
|
||||
? "active"
|
||||
: ""}`}
|
||||
onClick={handleShopTabClick}
|
||||
type="button"
|
||||
>
|
||||
{"✨ Echo Shop ("}
|
||||
{formatNumber(currentEchoes)}
|
||||
{" echoes)"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeTab === "transcend"
|
||||
&& <>
|
||||
<p className="transcendence-intro">
|
||||
{"Transcendence is the ultimate reset. It wipes "}
|
||||
<strong>{"everything"}</strong>
|
||||
{" — resources, prestige, runestones, upgrades, and equipment"
|
||||
+ " — but grants "}
|
||||
<strong>{"Echoes"}</strong>
|
||||
{", a permanent currency that survives all future resets."}
|
||||
{" Echoes power upgrades that permanently amplify every run."}
|
||||
</p>
|
||||
<p className="transcendence-intro">
|
||||
<em>
|
||||
{"Fewer prestiges = more Echoes."}
|
||||
{" Optimise your run for maximum yield!"}
|
||||
</em>
|
||||
</p>
|
||||
|
||||
<div className="transcendence-status">
|
||||
{transcendenceCount > 0
|
||||
&& <p>
|
||||
{"Transcendence count: "}
|
||||
<strong>{transcendenceCount}</strong>
|
||||
</p>
|
||||
}
|
||||
<p>
|
||||
{"Current Echoes: "}
|
||||
<strong>{formatNumber(currentEchoes)}</strong>
|
||||
</p>
|
||||
<p>
|
||||
{"Current prestige count: "}
|
||||
<strong>{prestigeData.count}</strong>
|
||||
</p>
|
||||
{hasDefeatedFinalBoss
|
||||
? <p className="echo-preview">
|
||||
{"Echoes on transcendence: "}
|
||||
<strong>
|
||||
{"+"}
|
||||
{formatNumber(echoPreview)}
|
||||
</strong>
|
||||
{echoMetaMultiplier > 1
|
||||
&& <span className="echo-meta-bonus">
|
||||
{" (×"}
|
||||
{echoMetaMultiplier.toFixed(2)}
|
||||
{" meta bonus applied)"}
|
||||
</span>
|
||||
}
|
||||
</p>
|
||||
: null}
|
||||
</div>
|
||||
|
||||
{hasDefeatedFinalBoss
|
||||
? null
|
||||
: <div className="transcendence-locked">
|
||||
<p>
|
||||
{"🔒 "}
|
||||
<strong>{"Defeat The Absolute One"}</strong>
|
||||
{" to unlock transcendence."}
|
||||
</p>
|
||||
<p className="transcendence-hint">
|
||||
{"The Absolute One is the final boss of The Absolute zone,"
|
||||
+ " requiring Prestige 90 to challenge."}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
{hasDefeatedFinalBoss
|
||||
? <div className="prestige-form">
|
||||
<p>
|
||||
{"You are ready to transcend. This action is "}
|
||||
<strong>{"irreversible"}</strong>
|
||||
{"."}
|
||||
</p>
|
||||
<button
|
||||
className="transcendence-button"
|
||||
disabled={isPending}
|
||||
onClick={handleTranscendClick}
|
||||
type="button"
|
||||
>
|
||||
{isPending
|
||||
? "Transcending..."
|
||||
: `🌌 Transcend (+${formatNumber(echoPreview)} Echoes)`}
|
||||
</button>
|
||||
{error === null
|
||||
? null
|
||||
: <p className="error">{error}</p>}
|
||||
{result === null
|
||||
? null
|
||||
: <p className="success">
|
||||
{"Transcended! Earned "}
|
||||
<strong>
|
||||
{formatNumber(result.echoes)}
|
||||
{" Echoes"}
|
||||
</strong>
|
||||
{". This is Transcendence "}
|
||||
{result.count}
|
||||
{". A new cycle begins."}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
: null}
|
||||
</>
|
||||
}
|
||||
|
||||
{activeTab === "shop"
|
||||
&& <div className="echo-shop">
|
||||
<p className="shop-balance">
|
||||
{"Balance: "}
|
||||
<strong>
|
||||
{formatNumber(currentEchoes)}
|
||||
{" Echoes"}
|
||||
</strong>
|
||||
</p>
|
||||
<p className="echo-shop-description">
|
||||
{"Echo upgrades are "}
|
||||
<strong>{"permanent"}</strong>
|
||||
{" — they survive all future prestiges and transcendences."}
|
||||
</p>
|
||||
|
||||
{upgradesByCategory.map(({ catId, label, upgrades }) => {
|
||||
return (
|
||||
<div className="shop-category" key={catId}>
|
||||
<h3>{label}</h3>
|
||||
<div className="shop-upgrades">
|
||||
{upgrades.map((upgrade) => {
|
||||
const purchased = (
|
||||
transcendence?.purchasedUpgradeIds ?? []
|
||||
).includes(upgrade.id);
|
||||
const canAfford = currentEchoes >= upgrade.cost;
|
||||
const isLoading = buyingId === upgrade.id;
|
||||
|
||||
function handleBuyClick(): void {
|
||||
void handleBuyUpgrade(upgrade.id);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`shop-upgrade-card echo-upgrade-card ${
|
||||
purchased
|
||||
? "purchased"
|
||||
: ""
|
||||
} ${!canAfford && !purchased
|
||||
? "unaffordable"
|
||||
: ""}`}
|
||||
key={upgrade.id}
|
||||
>
|
||||
<div className="shop-upgrade-info">
|
||||
<h4>{upgrade.name}</h4>
|
||||
<p>{upgrade.description}</p>
|
||||
<p className="upgrade-cost">
|
||||
{purchased
|
||||
? "✅ Purchased"
|
||||
: `✨ ${formatNumber(upgrade.cost)} Echoes`}
|
||||
</p>
|
||||
</div>
|
||||
{purchased
|
||||
? null
|
||||
: <button
|
||||
className="buy-upgrade-button echo-buy-button"
|
||||
disabled={
|
||||
!canAfford || isLoading || buyingId !== null
|
||||
}
|
||||
onClick={handleBuyClick}
|
||||
type="button"
|
||||
>
|
||||
{isLoading
|
||||
? "Buying..."
|
||||
: "Buy"}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { TranscendencePanel };
|
||||
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* @file Upgrade panel component for purchasing game upgrades.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable react/no-multi-comp -- Sub-component is tightly coupled to the panel */
|
||||
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
|
||||
/* eslint-disable complexity -- UpgradeCard has many conditional render paths for states */
|
||||
import { type JSX, useState } from "react";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import { LockToggle } from "../ui/lockToggle.js";
|
||||
import type { Upgrade } from "@elysium/types";
|
||||
|
||||
interface UpgradeCardProperties {
|
||||
readonly upgrade: Upgrade;
|
||||
readonly currentGold: number;
|
||||
readonly currentEssence: number;
|
||||
readonly currentCrystals: number;
|
||||
readonly unlockHint: string | undefined;
|
||||
readonly formatNumber: (n: number)=> string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single upgrade card.
|
||||
* @param props - The upgrade card properties.
|
||||
* @param props.upgrade - The upgrade data.
|
||||
* @param props.currentGold - The current gold amount.
|
||||
* @param props.currentEssence - The current essence amount.
|
||||
* @param props.currentCrystals - The current crystals amount.
|
||||
* @param props.unlockHint - Optional hint for how to unlock this upgrade.
|
||||
* @param props.formatNumber - The number formatting utility function.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const UpgradeCard = ({
|
||||
upgrade,
|
||||
currentGold,
|
||||
currentEssence,
|
||||
currentCrystals,
|
||||
unlockHint,
|
||||
formatNumber,
|
||||
}: UpgradeCardProperties): JSX.Element => {
|
||||
const { buyUpgrade } = useGame();
|
||||
const canAfford
|
||||
= currentGold >= upgrade.costGold
|
||||
&& currentEssence >= upgrade.costEssence
|
||||
&& currentCrystals >= upgrade.costCrystals;
|
||||
|
||||
function handleBuy(): void {
|
||||
buyUpgrade(upgrade.id);
|
||||
}
|
||||
|
||||
if (upgrade.unlocked && upgrade.purchased) {
|
||||
return (
|
||||
<div className="upgrade-card purchased">
|
||||
<span className="upgrade-name">
|
||||
{"✅ "}
|
||||
{upgrade.name}
|
||||
</span>
|
||||
<span className="upgrade-desc">{upgrade.description}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (upgrade.unlocked) {
|
||||
return (
|
||||
<div className="upgrade-card">
|
||||
<div className="upgrade-info">
|
||||
<h3>{upgrade.name}</h3>
|
||||
<p>{upgrade.description}</p>
|
||||
<p className="upgrade-multiplier">
|
||||
{"×"}
|
||||
{upgrade.multiplier}
|
||||
{" multiplier"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="upgrade-cost">
|
||||
{upgrade.costGold > 0
|
||||
&& <span>
|
||||
{"🪙 "}
|
||||
{formatNumber(upgrade.costGold)}
|
||||
</span>
|
||||
}
|
||||
{upgrade.costEssence > 0
|
||||
&& <span>
|
||||
{"✨ "}
|
||||
{formatNumber(upgrade.costEssence)}
|
||||
</span>
|
||||
}
|
||||
{upgrade.costCrystals > 0
|
||||
&& <span>
|
||||
{"💎 "}
|
||||
{formatNumber(upgrade.costCrystals)}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<button
|
||||
className="buy-button"
|
||||
disabled={!canAfford}
|
||||
onClick={handleBuy}
|
||||
type="button"
|
||||
>
|
||||
{"Buy"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="upgrade-card locked">
|
||||
<div className="upgrade-info">
|
||||
<h3>
|
||||
{"🔒 "}
|
||||
{upgrade.name}
|
||||
</h3>
|
||||
<p>{upgrade.description}</p>
|
||||
<p className="upgrade-multiplier">
|
||||
{"×"}
|
||||
{upgrade.multiplier}
|
||||
{" multiplier"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="upgrade-cost">
|
||||
{upgrade.costGold > 0
|
||||
&& <span>
|
||||
{"🪙 "}
|
||||
{formatNumber(upgrade.costGold)}
|
||||
</span>
|
||||
}
|
||||
{upgrade.costEssence > 0
|
||||
&& <span>
|
||||
{"✨ "}
|
||||
{formatNumber(upgrade.costEssence)}
|
||||
</span>
|
||||
}
|
||||
{upgrade.costCrystals > 0
|
||||
&& <span>
|
||||
{"💎 "}
|
||||
{formatNumber(upgrade.costCrystals)}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<span className="upgrade-locked-label">{"Locked"}</span>
|
||||
{unlockHint === undefined
|
||||
? null
|
||||
: <p className="unlock-hint">{unlockHint}</p>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the upgrade panel with all available, locked, and purchased upgrades.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const UpgradePanel = (): JSX.Element => {
|
||||
const { state, formatNumber } = useGame();
|
||||
const [ showLocked, setShowLocked ] = useState(true);
|
||||
|
||||
if (state === null) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"Loading..."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const { bosses, quests, upgrades, resources } = state;
|
||||
const purchased = upgrades.filter((upgrade) => {
|
||||
return upgrade.purchased;
|
||||
});
|
||||
const available = upgrades.filter((upgrade) => {
|
||||
return upgrade.unlocked && !upgrade.purchased;
|
||||
});
|
||||
const locked = upgrades.filter((upgrade) => {
|
||||
return !upgrade.unlocked;
|
||||
});
|
||||
|
||||
const upgradeUnlockHints = new Map<string, string>();
|
||||
for (const { upgradeRewards, name: bossName } of bosses) {
|
||||
for (const upgradeId of upgradeRewards) {
|
||||
upgradeUnlockHints.set(upgradeId, `⚔️ Defeat: ${bossName}`);
|
||||
}
|
||||
}
|
||||
for (const { rewards, name: questName } of quests) {
|
||||
for (const reward of rewards) {
|
||||
if (
|
||||
reward.type === "upgrade"
|
||||
&& reward.targetId !== undefined
|
||||
&& !upgradeUnlockHints.has(reward.targetId)
|
||||
) {
|
||||
upgradeUnlockHints.set(reward.targetId, `📜 Complete: ${questName}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleToggle(): void {
|
||||
setShowLocked((current) => {
|
||||
return !current;
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="panel upgrade-panel">
|
||||
<div className="panel-header">
|
||||
<h2>{"Upgrades"}</h2>
|
||||
<LockToggle
|
||||
lockedCount={locked.length}
|
||||
onToggle={handleToggle}
|
||||
showLocked={showLocked}
|
||||
/>
|
||||
</div>
|
||||
<p className="upgrade-progress">
|
||||
{purchased.length}
|
||||
{" / "}
|
||||
{upgrades.length}
|
||||
{" purchased"}
|
||||
</p>
|
||||
{upgrades.length === 0
|
||||
? <p className="empty-state">
|
||||
{"No upgrades available yet — keep adventuring!"}
|
||||
</p>
|
||||
: <div className="upgrade-list">
|
||||
{available.map((upgrade) => {
|
||||
return (
|
||||
<UpgradeCard
|
||||
currentCrystals={resources.crystals}
|
||||
currentEssence={resources.essence}
|
||||
currentGold={resources.gold}
|
||||
formatNumber={formatNumber}
|
||||
key={upgrade.id}
|
||||
unlockHint={undefined}
|
||||
upgrade={upgrade}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{purchased.map((upgrade) => {
|
||||
return (
|
||||
<UpgradeCard
|
||||
currentCrystals={resources.crystals}
|
||||
currentEssence={resources.essence}
|
||||
currentGold={resources.gold}
|
||||
formatNumber={formatNumber}
|
||||
key={upgrade.id}
|
||||
unlockHint={undefined}
|
||||
upgrade={upgrade}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{showLocked
|
||||
? locked.map((upgrade) => {
|
||||
return (
|
||||
<UpgradeCard
|
||||
currentCrystals={resources.crystals}
|
||||
currentEssence={resources.essence}
|
||||
currentGold={resources.gold}
|
||||
formatNumber={formatNumber}
|
||||
key={upgrade.id}
|
||||
unlockHint={upgradeUnlockHints.get(upgrade.id)}
|
||||
upgrade={upgrade}
|
||||
/>
|
||||
);
|
||||
})
|
||||
: null}
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { UpgradePanel };
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* @file Zone selector component for choosing the active zone.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
import type { Zone } from "@elysium/types";
|
||||
import type { JSX } from "react";
|
||||
|
||||
interface ZoneSelectorProperties {
|
||||
readonly zones: Array<Zone>;
|
||||
readonly activeZoneId: string;
|
||||
readonly onSelectZone: (zoneId: string)=> void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a zone selector with buttons for each available zone.
|
||||
* @param props - The zone selector properties.
|
||||
* @param props.zones - The list of zones to display.
|
||||
* @param props.activeZoneId - The currently active zone ID.
|
||||
* @param props.onSelectZone - Callback when a zone is selected.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const ZoneSelector = ({
|
||||
zones,
|
||||
activeZoneId,
|
||||
onSelectZone,
|
||||
}: ZoneSelectorProperties): JSX.Element => {
|
||||
return (
|
||||
<div className="zone-selector">
|
||||
{zones.map((zone) => {
|
||||
function handleSelect(): void {
|
||||
onSelectZone(zone.id);
|
||||
}
|
||||
return (
|
||||
<button
|
||||
className={`zone-tab ${
|
||||
zone.id === activeZoneId
|
||||
? "zone-tab-active"
|
||||
: ""
|
||||
}`}
|
||||
key={zone.id}
|
||||
onClick={handleSelect}
|
||||
title={zone.description}
|
||||
type="button"
|
||||
>
|
||||
<span className="zone-emoji">{zone.emoji}</span>
|
||||
<span className="zone-name">{zone.name}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { ZoneSelector };
|
||||
Reference in New Issue
Block a user