generated from nhcarrigan/template
11e97325cb
## Summary - Adds `apps/web/src/utils/cdn.ts` with a `cdnImage(folder, id)` helper that builds URLs from `https://cdn.nhcarrigan.com/elysium/` - Wires CDN art into all 13 game panels (bosses, quests, adventurers, companions, equipment, upgrades, prestige, transcendence, achievements, explorations, crafting, story, codex) - Zone selector tabs now display 16:9 zone art thumbnails in place of emoji icons - Adds a fixed background image at 15% opacity via `body::before` - Documents the art generation and CDN upload process in `CLAUDE.md` for future expansions Resolves #15 ✨ This PR was created with help from Hikari~ 🌸 Reviewed-on: #43 Co-authored-by: Hikari <hikari@nhcarrigan.com> Co-committed-by: Hikari <hikari@nhcarrigan.com>
288 lines
8.0 KiB
TypeScript
288 lines
8.0 KiB
TypeScript
/**
|
||
* @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 { cdnImage } from "../../utils/cdn.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">
|
||
<img
|
||
alt={upgrade.name}
|
||
className="card-thumbnail"
|
||
src={cdnImage("upgrades", upgrade.id)}
|
||
/>
|
||
<span className="upgrade-name">
|
||
{"✅ "}
|
||
{upgrade.name}
|
||
</span>
|
||
<span className="upgrade-desc">{upgrade.description}</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (upgrade.unlocked) {
|
||
return (
|
||
<div className="upgrade-card">
|
||
<img
|
||
alt={upgrade.name}
|
||
className="card-thumbnail"
|
||
src={cdnImage("upgrades", upgrade.id)}
|
||
/>
|
||
<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">
|
||
<img
|
||
alt={upgrade.name}
|
||
className="card-thumbnail"
|
||
src={cdnImage("upgrades", upgrade.id)}
|
||
/>
|
||
<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 };
|