feat: recreate Achievements tab with category sections, progress tracking, and hidden achievement logic
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m19s
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m19s
This commit is contained in:
@@ -0,0 +1,250 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo, useEffect } 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 [mounted, setMounted] = useState(false);
|
||||
const [collapsedCategories, setCollapsedCategories] = useState<Record<string, boolean>>({});
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
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],
|
||||
}));
|
||||
};
|
||||
|
||||
if (!mounted) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-8 text-gray-500">
|
||||
Loading achievements…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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';
|
||||
Reference in New Issue
Block a user