Files
elysium/apps/web/src/components/game/achievementPanel.tsx
T
hikari a618a8a5e9
CI / Lint, Build & Test (pull_request) Successful in 1m8s
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 1m10s
feat: integrate CDN art assets across all game panels
Add cdnImage utility and wire zone/boss/quest/adventurer/companion/
equipment/upgrade/prestige/transcendence/achievement/exploration/
crafting/story panels to load art from cdn.nhcarrigan.com/elysium/.
Zone selector tabs display 16:9 zone art thumbnails. Background
image added as fixed overlay at 15% opacity. Document art generation
process in CLAUDE.md for future expansions.

Resolves #15
2026-03-09 16:13:41 -07:00

175 lines
5.1 KiB
TypeScript

/**
* @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 { cdnImage } from "../../utils/cdn.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"}`}>
<img
alt={achievement.name}
className="card-thumbnail"
src={cdnImage("achievements", achievement.id)}
/>
<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 };