Files
Mana-Loop/src/components/game/tabs/AchievementsTab.tsx
T
n8n-gitea b402b8f56e
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m20s
refactor: cleanup codebase — remove hydration guards, extract constants, fix bugs
2026-05-26 11:20:36 +02:00

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';