47c71e6f54
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 8m47s
- Implemented complete design system with 40+ CSS custom properties - Created 9 UI primitives (GameCard, SectionHeader, StatRow, ManaBar, ElementBadge, ValueDisplay, ActionButton, SkillRow, TooltipInfo) - Redesigned all tabs: Spire, Skills, Stats, Equipment, Crafting, Attunements, Golemancy, Spells, Loot, Achievements, Lab, Debug - Added toast notification system (GameToast) with success/warning/error/info types - Added confirmation dialogs for destructive actions - Removed all dev artifacts and component name labels - Added empty states to all tabs - Replaced emoji icons with Lucide React icons - Added enchantPower placeholder to StatsTab and EquipmentTab - Mobile audit passed at 375px viewport - Build passes with 0 errors, lint passes with 0 errors Sub-tasks completed: - ST1: Design System Implementation - ST2: Global Layout & Header - ST3: Left Panel (Mana Display & Action Area) - ST4: Skills Tab - ST5: Spire Tab & Spire Mode UI - ST6: Stats Tab - ST7: Equipment & Crafting Tabs - ST8: Attunements Tab - ST9: Remaining Tabs - ST10: Toast System & Confirmation Dialogs Documentation: 15+ files in docs/task4/
206 lines
9.4 KiB
TypeScript
206 lines
9.4 KiB
TypeScript
'use client';
|
|
|
|
import { useState } from 'react';
|
|
import { GameCard } from '@/components/ui/game-card';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { ActionButton } from '@/components/ui/action-button';
|
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
|
import { ManaBar } from '@/components/ui/mana-bar';
|
|
import { Trophy, Lock, CheckCircle, ChevronDown, ChevronUp } from 'lucide-react';
|
|
import type { AchievementState } from '@/lib/game/types';
|
|
import { ACHIEVEMENTS, getAchievementsByCategory, isAchievementRevealed } from '@/lib/game/data/achievements';
|
|
import { GameState } from '@/lib/game/types';
|
|
|
|
// Map achievement categories to CSS variables for colors
|
|
const CATEGORY_COLOR_MAP: Record<string, string> = {
|
|
combat: 'var(--color-danger)',
|
|
progression: 'var(--rarity-legendary)',
|
|
crafting: 'var(--mana-dark)',
|
|
magic: 'var(--mana-water)',
|
|
special: 'var(--mana-stellar)',
|
|
};
|
|
|
|
interface AchievementsProps {
|
|
achievements: AchievementState;
|
|
gameState: Pick<GameState, 'maxFloorReached' | 'totalManaGathered' | 'signedPacts' | 'totalSpellsCast' | 'totalDamageDealt' | 'totalCraftsCompleted'>;
|
|
}
|
|
|
|
export function AchievementsDisplay({ achievements, gameState }: AchievementsProps) {
|
|
const [expandedCategory, setExpandedCategory] = useState<string | null>('combat');
|
|
|
|
const categories = getAchievementsByCategory();
|
|
const unlockedCount = achievements.unlocked.length;
|
|
const totalCount = Object.keys(ACHIEVEMENTS).length;
|
|
|
|
// Calculate progress for each achievement
|
|
const getProgress = (achievementId: string): number => {
|
|
const achievement = ACHIEVEMENTS[achievementId];
|
|
if (!achievement) return 0;
|
|
if (achievements.unlocked.includes(achievementId)) return achievement.requirement.value;
|
|
|
|
const { type, subType } = achievement.requirement;
|
|
|
|
switch (type) {
|
|
case 'floor':
|
|
if (subType === 'noPacts') {
|
|
return gameState.maxFloorReached >= achievement.requirement.value && gameState.signedPacts.length === 0
|
|
? achievement.requirement.value
|
|
: gameState.maxFloorReached;
|
|
}
|
|
return gameState.maxFloorReached;
|
|
case 'spells':
|
|
return gameState.totalSpellsCast || 0;
|
|
case 'damage':
|
|
return gameState.totalDamageDealt || 0;
|
|
case 'mana':
|
|
return gameState.totalManaGathered || 0;
|
|
case 'pact':
|
|
return gameState.signedPacts.length;
|
|
case 'craft':
|
|
return gameState.totalCraftsCompleted || 0;
|
|
default:
|
|
return achievements.progress[achievementId] || 0;
|
|
}
|
|
};
|
|
|
|
return (
|
|
<GameCard variant="default" className="w-full">
|
|
<div className="flex items-center gap-2 mb-3">
|
|
<Trophy className="w-4 h-4 text-[var(--mana-light)]" />
|
|
<h3 className="text-[var(--mana-light)] game-panel-title text-xs uppercase tracking-wider">
|
|
Achievements
|
|
</h3>
|
|
<Badge
|
|
className="ml-auto bg-[var(--bg-sunken)] text-[var(--text-secondary)] border-[var(--border-subtle)]"
|
|
aria-label={`${unlockedCount} out of ${totalCount} achievements unlocked`}
|
|
>
|
|
{unlockedCount} / {totalCount}
|
|
</Badge>
|
|
</div>
|
|
|
|
<ScrollArea className="h-64 w-full">
|
|
<div className="space-y-2 pr-2">
|
|
{Object.entries(categories).map(([category, categoryAchievements]) => (
|
|
<div key={category} className="space-y-1">
|
|
<ActionButton
|
|
variant="ghost"
|
|
size="sm"
|
|
className="w-full justify-between text-xs hover:bg-[var(--bg-sunken)]"
|
|
onClick={() => setExpandedCategory(expandedCategory === category ? null : category)}
|
|
aria-expanded={expandedCategory === category}
|
|
aria-label={`${category} category - ${categoryAchievements.filter(a => achievements.unlocked.includes(a.id)).length} of ${categoryAchievements.length} unlocked`}
|
|
>
|
|
<span style={{ color: CATEGORY_COLOR_MAP[category] || 'var(--text-primary)' }}>
|
|
{category.charAt(0).toUpperCase() + category.slice(1)}
|
|
</span>
|
|
<span className="text-[var(--text-muted)]">
|
|
{categoryAchievements.filter(a => achievements.unlocked.includes(a.id)).length} / {categoryAchievements.length}
|
|
</span>
|
|
{expandedCategory === category ? (
|
|
<ChevronUp className="w-4 h-4 text-[var(--text-muted)]" />
|
|
) : (
|
|
<ChevronDown className="w-4 h-4 text-[var(--text-muted)]" />
|
|
)}
|
|
</ActionButton>
|
|
|
|
{expandedCategory === category && (
|
|
<div className="pl-2 space-y-2">
|
|
{categoryAchievements.map((achievement) => {
|
|
const isUnlocked = achievements.unlocked.includes(achievement.id);
|
|
const progress = getProgress(achievement.id);
|
|
const isRevealed = isAchievementRevealed(achievement, progress);
|
|
const progressPercent = Math.min(100, (progress / achievement.requirement.value) * 100);
|
|
|
|
if (!isRevealed && !isUnlocked) {
|
|
return (
|
|
<div
|
|
key={achievement.id}
|
|
className="p-2 rounded bg-[var(--bg-sunken)] border border-[var(--border-subtle)]"
|
|
aria-label="Locked achievement - details hidden"
|
|
>
|
|
<div className="flex items-center gap-2 text-[var(--text-muted)]">
|
|
<Lock className="w-4 h-4" aria-hidden="true" />
|
|
<span className="text-sm">???</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
key={achievement.id}
|
|
className={`p-2 rounded border ${
|
|
isUnlocked
|
|
? 'bg-[var(--rarity-legendary-glow)] border-[var(--rarity-legendary)]/50'
|
|
: 'bg-[var(--bg-sunken)] border-[var(--border-subtle)]'
|
|
}`}
|
|
>
|
|
<div className="flex items-start justify-between mb-1">
|
|
<div className="flex items-center gap-2">
|
|
{isUnlocked ? (
|
|
<CheckCircle className="w-4 h-4 text-[var(--mana-light)]" aria-hidden="true" />
|
|
) : (
|
|
<Trophy className="w-4 h-4 text-[var(--text-muted)]" aria-hidden="true" />
|
|
)}
|
|
<span
|
|
className={`text-sm font-semibold ${
|
|
isUnlocked ? 'text-[var(--mana-light)]' : 'text-[var(--text-secondary)]'
|
|
}`}
|
|
>
|
|
{achievement.name}
|
|
</span>
|
|
</div>
|
|
{achievement.reward.title && isUnlocked && (
|
|
<Badge
|
|
className="text-xs bg-[var(--mana-dark)]/20 text-[var(--mana-dark)] border-[var(--mana-dark)]/40"
|
|
aria-label="Title reward"
|
|
>
|
|
Title
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
|
|
<div className="text-xs text-[var(--text-muted)] mb-2">
|
|
{achievement.desc}
|
|
</div>
|
|
|
|
{!isUnlocked && (
|
|
<div className="space-y-1">
|
|
<ManaBar
|
|
value={progress}
|
|
max={achievement.requirement.value}
|
|
manaType="light"
|
|
className="h-1.5"
|
|
aria-label={`Progress: ${Math.round(progressPercent)}%`}
|
|
/>
|
|
<div className="flex justify-between text-xs text-[var(--text-muted)]">
|
|
<span>{progress.toLocaleString()} / {achievement.requirement.value.toLocaleString()}</span>
|
|
<span>{progressPercent.toFixed(0)}%</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{isUnlocked && achievement.reward && (
|
|
<div className="text-xs text-[var(--mana-light)]/70">
|
|
Reward:
|
|
{achievement.reward.insight && ` +${achievement.reward.insight} Insight`}
|
|
{achievement.reward.manaBonus && ` +${achievement.reward.manaBonus} Max Mana`}
|
|
{achievement.reward.damageBonus && ` +${(achievement.reward.damageBonus * 100).toFixed(0)}% Damage`}
|
|
{achievement.reward.title && ` "${achievement.reward.title}"`}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</ScrollArea>
|
|
</GameCard>
|
|
);
|
|
}
|
|
|
|
AchievementsDisplay.displayName = "AchievementsDisplay";
|