feat: add equipment, achievements, and visual polish

- Equipment system: 12 items across weapon/armour/trinket slots with
  common/rare/epic/legendary rarities; starter commons auto-equipped,
  higher tiers drop from boss victories
- Achievement system: 15 milestones with typed conditions; checked
  each tick and crystal rewards applied automatically
- Achievement toast: slide-in notification, auto-dismisses after 4s
- Floating click text: +X gold floats on each manual click
- Expanded quests (9 total) and upgrades (12 total)
- Upgrade panel now shows locked upgrades so players can see their
  progression path
- formatNumber utility (K/M/B/T) used consistently across all panels
- Backfill logic for existing saves to add new content gracefully
- types package now emits .d.ts declarations
This commit is contained in:
2026-03-06 13:27:48 -08:00
committed by Naomi Carrigan
parent a3daed1683
commit e9e0df31fd
33 changed files with 2066 additions and 133 deletions
@@ -0,0 +1,75 @@
import type { Achievement } from "@elysium/types";
import { useGame } from "../../context/GameContext.js";
import { formatNumber } from "../../utils/format.js";
const conditionDescription = (achievement: Achievement): 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 ${condition.amount} boss${condition.amount > 1 ? "es" : ""}`;
case "questsCompleted":
return `Complete ${condition.amount} quest${condition.amount > 1 ? "s" : ""}`;
case "adventurerTotal":
return `Recruit ${formatNumber(condition.amount)} total adventurers`;
case "prestigeCount":
return `Prestige ${condition.amount} time${condition.amount > 1 ? "s" : ""}`;
case "equipmentOwned":
return `Own ${condition.amount} equipment item${condition.amount > 1 ? "s" : ""}`;
}
};
interface AchievementCardProps {
achievement: Achievement;
}
const AchievementCard = ({ achievement }: AchievementCardProps): React.JSX.Element => {
const isUnlocked = achievement.unlockedAt !== null;
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)}</p>
{achievement.reward?.crystals != null && (
<p className="achievement-reward">💎 +{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>
);
};
export const AchievementPanel = (): React.JSX.Element => {
const { state } = useGame();
if (!state) return <section className="panel"><p>Loading...</p></section>;
const achievements = state.achievements ?? [];
const unlocked = achievements.filter((a) => a.unlockedAt !== null).length;
return (
<section className="panel achievement-panel">
<h2>Achievements</h2>
<p className="achievement-progress">
{unlocked} / {achievements.length} unlocked
</p>
<div className="achievement-list">
{achievements.map((achievement) => (
<AchievementCard key={achievement.id} achievement={achievement} />
))}
</div>
</section>
);
};