238 lines
8.5 KiB
TypeScript
238 lines
8.5 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useMemo } from 'react';
|
|
import { useCombatStore } from '@/lib/game/stores';
|
|
import {
|
|
ACHIEVEMENTS,
|
|
ACHIEVEMENT_CATEGORY_COLORS,
|
|
getAchievementsByCategory,
|
|
isAchievementRevealed,
|
|
} from '@/lib/game/data/achievements';
|
|
import type { AchievementDef } from '@/lib/game/types';
|
|
import { Card, CardContent } from '@/components/ui/card';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Progress } from '@/components/ui/progress';
|
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
|
import { SectionHeader } from '@/components/ui/section-header';
|
|
import { DebugName } from '@/components/game/debug/debug-context';
|
|
import { fmt } from '@/lib/game/stores';
|
|
|
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
|
|
const CATEGORY_LABELS: Record<string, string> = {
|
|
combat: '⚔️ Combat',
|
|
progression: '📈 Progression',
|
|
crafting: '🔨 Crafting',
|
|
magic: '✨ Magic',
|
|
special: '🌟 Special',
|
|
};
|
|
|
|
function getProgressForAchievement(
|
|
achievement: AchievementDef,
|
|
progress: Record<string, number>,
|
|
): number {
|
|
return progress[achievement.id] ?? 0;
|
|
}
|
|
|
|
function getProgressPercent(achievement: AchievementDef, current: number): number {
|
|
if (achievement.requirement.value <= 0) return 100;
|
|
return Math.min(100, Math.round((current / achievement.requirement.value) * 100));
|
|
}
|
|
|
|
function formatReward(reward: AchievementDef['reward']): string {
|
|
const parts: string[] = [];
|
|
if (reward.insight) parts.push(`${reward.insight} insight`);
|
|
if (reward.manaBonus) parts.push(`+${reward.manaBonus} mana`);
|
|
if (reward.damageBonus) parts.push(`+${(reward.damageBonus * 100).toFixed(0)}% dmg`);
|
|
if (reward.regenBonus) parts.push(`+${reward.regenBonus} regen`);
|
|
if (reward.title) parts.push(`title: "${reward.title}"`);
|
|
if (reward.unlockEffect) parts.push(`unlock: ${reward.unlockEffect}`);
|
|
return parts.join(', ');
|
|
}
|
|
|
|
// ─── Category Section ────────────────────────────────────────────────────────
|
|
|
|
interface CategorySectionProps {
|
|
category: string;
|
|
achievements: AchievementDef[];
|
|
unlocked: string[];
|
|
progress: Record<string, number>;
|
|
collapsed: boolean;
|
|
onToggleCollapse: () => void;
|
|
}
|
|
|
|
function CategorySection({
|
|
category,
|
|
achievements,
|
|
unlocked,
|
|
progress,
|
|
collapsed,
|
|
onToggleCollapse,
|
|
}: CategorySectionProps) {
|
|
const color = ACHIEVEMENT_CATEGORY_COLORS[category] ?? '#9CA3AF';
|
|
const label = CATEGORY_LABELS[category] ?? category;
|
|
const unlockedCount = achievements.filter((a) => unlocked.includes(a.id)).length;
|
|
|
|
return (
|
|
<Card className="bg-gray-900/60 border-gray-700">
|
|
<SectionHeader
|
|
title={`${label} (${unlockedCount}/${achievements.length})`}
|
|
action={
|
|
<button
|
|
onClick={onToggleCollapse}
|
|
className="text-xs text-gray-400 hover:text-gray-200 transition-colors"
|
|
>
|
|
{collapsed ? 'Expand ▼' : 'Collapse ▲'}
|
|
</button>
|
|
}
|
|
/>
|
|
{!collapsed && (
|
|
<CardContent className="space-y-3">
|
|
{achievements.map((achievement) => {
|
|
const isUnlocked = unlocked.includes(achievement.id);
|
|
const currentProgress = getProgressForAchievement(achievement, progress);
|
|
const percent = getProgressPercent(achievement, currentProgress);
|
|
const revealed = isAchievementRevealed(achievement, currentProgress);
|
|
|
|
if (!revealed) {
|
|
return (
|
|
<div
|
|
key={achievement.id}
|
|
className="p-3 rounded border border-gray-700/50 bg-gray-800/30"
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-gray-500">???</span>
|
|
<span className="text-xs text-gray-600 italic">
|
|
Hidden achievement — keep progressing to reveal
|
|
</span>
|
|
</div>
|
|
<div className="mt-2">
|
|
<Progress value={percent} className="h-1.5" />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
key={achievement.id}
|
|
className={`p-3 rounded border ${
|
|
isUnlocked
|
|
? 'border-green-700/50 bg-green-900/20'
|
|
: 'border-gray-700/50 bg-gray-800/30'
|
|
}`}
|
|
>
|
|
<div className="flex items-center justify-between mb-1">
|
|
<div className="flex items-center gap-2">
|
|
<span className="font-medium text-sm" style={{ color }}>
|
|
{achievement.name}
|
|
</span>
|
|
{isUnlocked && (
|
|
<Badge className="bg-green-900/50 text-green-300 text-xs">
|
|
✓ Unlocked
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
<span className="text-xs text-gray-500">
|
|
{fmt(currentProgress)} / {fmt(achievement.requirement.value)}
|
|
</span>
|
|
</div>
|
|
|
|
<p className="text-xs text-gray-400 mb-2">{achievement.desc}</p>
|
|
|
|
<div className="flex items-center gap-2 mb-2">
|
|
<Progress value={percent} className="h-1.5 flex-1" />
|
|
<span className="text-xs text-gray-500 w-10 text-right">
|
|
{percent}%
|
|
</span>
|
|
</div>
|
|
|
|
<div className="text-xs text-gray-500">
|
|
<span className="text-gray-400">Reward:</span>{' '}
|
|
{formatReward(achievement.reward)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</CardContent>
|
|
)}
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
// ─── Main Component ──────────────────────────────────────────────────────────
|
|
|
|
export function AchievementsTab() {
|
|
const achievements = useCombatStore((s) => s.achievements);
|
|
const [collapsedCategories, setCollapsedCategories] = useState<Record<string, boolean>>({});
|
|
|
|
const byCategory = useMemo(() => getAchievementsByCategory(), []);
|
|
const categories = useMemo(
|
|
() => Object.keys(byCategory).sort(),
|
|
[byCategory],
|
|
);
|
|
|
|
const totalAchievements = Object.keys(ACHIEVEMENTS).length;
|
|
const unlockedCount = achievements.unlocked.length;
|
|
|
|
const toggleCollapse = (category: string) => {
|
|
setCollapsedCategories((prev) => ({
|
|
...prev,
|
|
[category]: !prev[category],
|
|
}));
|
|
};
|
|
|
|
return (
|
|
<DebugName name="AchievementsTab">
|
|
<div className="space-y-4">
|
|
{/* Summary header */}
|
|
<Card className="bg-gray-900/60 border-gray-700">
|
|
<CardContent className="py-4">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h2 className="text-lg font-semibold text-gray-100">
|
|
Achievements
|
|
</h2>
|
|
<p className="text-sm text-gray-400">
|
|
Track your progress and unlock rewards
|
|
</p>
|
|
</div>
|
|
<div className="text-right">
|
|
<div className="text-2xl font-bold text-amber-400">
|
|
{unlockedCount}
|
|
<span className="text-sm text-gray-500 font-normal">
|
|
/{totalAchievements}
|
|
</span>
|
|
</div>
|
|
<Progress
|
|
value={Math.round((unlockedCount / totalAchievements) * 100)}
|
|
className="h-2 w-32 mt-1"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Category sections */}
|
|
<ScrollArea className="h-[600px] pr-2">
|
|
<div className="space-y-4">
|
|
{categories.map((category) => (
|
|
<CategorySection
|
|
key={category}
|
|
category={category}
|
|
achievements={byCategory[category]}
|
|
unlocked={achievements.unlocked}
|
|
progress={achievements.progress}
|
|
collapsed={collapsedCategories[category] ?? false}
|
|
onToggleCollapse={() => toggleCollapse(category)}
|
|
/>
|
|
))}
|
|
</div>
|
|
</ScrollArea>
|
|
</div>
|
|
</DebugName>
|
|
);
|
|
}
|
|
|
|
AchievementsTab.displayName = 'AchievementsTab';
|