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,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 };
|
||||
Reference in New Issue
Block a user