pack
This commit is contained in:
175
src/components/game/AchievementsDisplay.tsx
Normal file
175
src/components/game/AchievementsDisplay.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Trophy, Lock, CheckCircle, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import type { AchievementState } from '@/lib/game/types';
|
||||
import { ACHIEVEMENTS, ACHIEVEMENT_CATEGORY_COLORS, getAchievementsByCategory, isAchievementRevealed } from '@/lib/game/data/achievements';
|
||||
import { GameState } from '@/lib/game/types';
|
||||
|
||||
interface AchievementsProps {
|
||||
achievements: AchievementState;
|
||||
gameState: Pick<GameState, 'maxFloorReached' | 'totalManaGathered' | 'signedPacts' | 'totalSpellsCast' | 'totalDamageDealt' | 'totalCraftsCompleted' | 'combo'>;
|
||||
}
|
||||
|
||||
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 'combo':
|
||||
return gameState.combo?.maxCombo || 0;
|
||||
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 (
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-amber-400 game-panel-title text-xs flex items-center gap-2">
|
||||
<Trophy className="w-4 h-4" />
|
||||
Achievements
|
||||
<Badge className="ml-auto bg-amber-900/50 text-amber-300">
|
||||
{unlockedCount} / {totalCount}
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ScrollArea className="h-64">
|
||||
<div className="space-y-2">
|
||||
{Object.entries(categories).map(([category, categoryAchievements]) => (
|
||||
<div key={category} className="space-y-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-between text-xs"
|
||||
onClick={() => setExpandedCategory(expandedCategory === category ? null : category)}
|
||||
>
|
||||
<span style={{ color: ACHIEVEMENT_CATEGORY_COLORS[category] }}>
|
||||
{category.charAt(0).toUpperCase() + category.slice(1)}
|
||||
</span>
|
||||
<span className="text-gray-500">
|
||||
{categoryAchievements.filter(a => achievements.unlocked.includes(a.id)).length} / {categoryAchievements.length}
|
||||
</span>
|
||||
{expandedCategory === category ? (
|
||||
<ChevronUp className="w-4 h-4" />
|
||||
) : (
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{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-gray-800/30 border border-gray-700">
|
||||
<div className="flex items-center gap-2 text-gray-500">
|
||||
<Lock className="w-4 h-4" />
|
||||
<span className="text-sm">???</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={achievement.id}
|
||||
className={`p-2 rounded border ${
|
||||
isUnlocked
|
||||
? 'bg-amber-900/20 border-amber-600/50'
|
||||
: 'bg-gray-800/30 border-gray-700'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-1">
|
||||
<div className="flex items-center gap-2">
|
||||
{isUnlocked ? (
|
||||
<CheckCircle className="w-4 h-4 text-amber-400" />
|
||||
) : (
|
||||
<Trophy className="w-4 h-4 text-gray-500" />
|
||||
)}
|
||||
<span className={`text-sm font-semibold ${isUnlocked ? 'text-amber-300' : 'text-gray-300'}`}>
|
||||
{achievement.name}
|
||||
</span>
|
||||
</div>
|
||||
{achievement.reward.title && isUnlocked && (
|
||||
<Badge className="text-xs bg-purple-900/50 text-purple-300">
|
||||
Title
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-gray-400 mb-2">
|
||||
{achievement.desc}
|
||||
</div>
|
||||
|
||||
{!isUnlocked && (
|
||||
<div className="space-y-1">
|
||||
<Progress value={progressPercent} className="h-1 bg-gray-700" />
|
||||
<div className="flex justify-between text-xs text-gray-500">
|
||||
<span>{progress.toLocaleString()} / {achievement.requirement.value.toLocaleString()}</span>
|
||||
<span>{progressPercent.toFixed(0)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isUnlocked && achievement.reward && (
|
||||
<div className="text-xs text-amber-400/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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
143
src/components/game/ComboMeter.tsx
Normal file
143
src/components/game/ComboMeter.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
'use client';
|
||||
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Zap, Flame, Sparkles } from 'lucide-react';
|
||||
import type { ComboState } from '@/lib/game/types';
|
||||
import { ELEMENTS } from '@/lib/game/constants';
|
||||
|
||||
interface ComboMeterProps {
|
||||
combo: ComboState;
|
||||
isClimbing: boolean;
|
||||
}
|
||||
|
||||
export function ComboMeter({ combo, isClimbing }: ComboMeterProps) {
|
||||
const comboPercent = Math.min(100, combo.count);
|
||||
const multiplierPercent = Math.min(100, ((combo.multiplier - 1) / 2) * 100); // Max 300% = 200% bonus
|
||||
|
||||
// Combo tier names
|
||||
const getComboTier = (count: number): { name: string; color: string } => {
|
||||
if (count >= 100) return { name: 'LEGENDARY', color: 'text-amber-400' };
|
||||
if (count >= 75) return { name: 'Master', color: 'text-purple-400' };
|
||||
if (count >= 50) return { name: 'Expert', color: 'text-blue-400' };
|
||||
if (count >= 25) return { name: 'Adept', color: 'text-green-400' };
|
||||
if (count >= 10) return { name: 'Novice', color: 'text-cyan-400' };
|
||||
return { name: 'Building...', color: 'text-gray-400' };
|
||||
};
|
||||
|
||||
const tier = getComboTier(combo.count);
|
||||
const hasElementChain = combo.elementChain.length === 3 && new Set(combo.elementChain).size === 3;
|
||||
|
||||
if (!isClimbing && combo.count === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-amber-400 game-panel-title text-xs flex items-center gap-2">
|
||||
<Zap className="w-4 h-4" />
|
||||
Combo Meter
|
||||
{combo.count >= 10 && (
|
||||
<Badge className={`ml-auto ${tier.color} bg-gray-800`}>
|
||||
{tier.name}
|
||||
</Badge>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{/* Combo Count */}
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between items-center text-sm">
|
||||
<span className="text-gray-400">Hits</span>
|
||||
<span className={`font-bold ${tier.color}`}>
|
||||
{combo.count}
|
||||
{combo.maxCombo > combo.count && (
|
||||
<span className="text-gray-500 text-xs ml-2">max: {combo.maxCombo}</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={comboPercent}
|
||||
className="h-2 bg-gray-800"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Multiplier */}
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between items-center text-sm">
|
||||
<span className="text-gray-400">Multiplier</span>
|
||||
<span className="font-bold text-amber-400">
|
||||
{combo.multiplier.toFixed(2)}x
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 bg-gray-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-300"
|
||||
style={{
|
||||
width: `${multiplierPercent}%`,
|
||||
background: `linear-gradient(90deg, #F59E0B, #EF4444)`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Element Chain */}
|
||||
{combo.elementChain.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between items-center text-sm">
|
||||
<span className="text-gray-400">Element Chain</span>
|
||||
{hasElementChain && (
|
||||
<span className="text-green-400 text-xs">+25% bonus!</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
{combo.elementChain.map((elem, i) => {
|
||||
const elemDef = ELEMENTS[elem];
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="w-8 h-8 rounded border flex items-center justify-center text-xs"
|
||||
style={{
|
||||
borderColor: elemDef?.color || '#60A5FA',
|
||||
backgroundColor: `${elemDef?.color}20`,
|
||||
color: elemDef?.color || '#60A5FA',
|
||||
}}
|
||||
>
|
||||
{elemDef?.sym || '?'}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{/* Empty slots */}
|
||||
{Array.from({ length: 3 - combo.elementChain.length }).map((_, i) => (
|
||||
<div
|
||||
key={`empty-${i}`}
|
||||
className="w-8 h-8 rounded border border-gray-700 bg-gray-800/50 flex items-center justify-center text-gray-600"
|
||||
>
|
||||
?
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Decay Warning */}
|
||||
{isClimbing && combo.count > 0 && combo.decayTimer <= 3 && (
|
||||
<div className="text-xs text-red-400 flex items-center gap-1">
|
||||
<Flame className="w-3 h-3" />
|
||||
Combo decaying soon!
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Not climbing warning */}
|
||||
{!isClimbing && combo.count > 0 && (
|
||||
<div className="text-xs text-amber-400 flex items-center gap-1">
|
||||
<Sparkles className="w-3 h-3" />
|
||||
Resume climbing to maintain combo
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
117
src/components/game/LootInventory.tsx
Normal file
117
src/components/game/LootInventory.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Gem, Sparkles, Scroll, Droplet } from 'lucide-react';
|
||||
import type { LootInventory as LootInventoryType } from '@/lib/game/types';
|
||||
import { LOOT_DROPS, RARITY_COLORS } from '@/lib/game/data/loot-drops';
|
||||
|
||||
interface LootInventoryProps {
|
||||
inventory: LootInventoryType;
|
||||
}
|
||||
|
||||
export function LootInventoryDisplay({ inventory }: LootInventoryProps) {
|
||||
const materialCount = Object.values(inventory.materials).reduce((a, b) => a + b, 0);
|
||||
const blueprintCount = inventory.blueprints.length;
|
||||
|
||||
if (materialCount === 0 && blueprintCount === 0) {
|
||||
return (
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-amber-400 game-panel-title text-xs flex items-center gap-2">
|
||||
<Gem className="w-4 h-4" />
|
||||
Loot Inventory
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-gray-500 text-sm text-center py-4">
|
||||
No loot collected yet. Defeat floors and guardians to find items!
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-amber-400 game-panel-title text-xs flex items-center gap-2">
|
||||
<Gem className="w-4 h-4" />
|
||||
Loot Inventory
|
||||
<Badge className="ml-auto bg-gray-800 text-gray-300">
|
||||
{materialCount + blueprintCount} items
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ScrollArea className="h-48">
|
||||
<div className="space-y-3">
|
||||
{/* Materials */}
|
||||
{Object.entries(inventory.materials).length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs text-gray-500 mb-2 flex items-center gap-1">
|
||||
<Sparkles className="w-3 h-3" />
|
||||
Materials
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{Object.entries(inventory.materials).map(([id, count]) => {
|
||||
const drop = LOOT_DROPS[id];
|
||||
if (!drop || count <= 0) return null;
|
||||
const rarityStyle = RARITY_COLORS[drop.rarity];
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
className="p-2 rounded border bg-gray-800/50"
|
||||
style={{
|
||||
borderColor: rarityStyle?.color || '#9CA3AF',
|
||||
}}
|
||||
>
|
||||
<div className="text-xs font-semibold" style={{ color: rarityStyle?.color }}>
|
||||
{drop.name}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">
|
||||
x{count}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Blueprints */}
|
||||
{inventory.blueprints.length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs text-gray-500 mb-2 flex items-center gap-1">
|
||||
<Scroll className="w-3 h-3" />
|
||||
Blueprints Discovered
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{inventory.blueprints.map((id) => {
|
||||
const drop = LOOT_DROPS[id];
|
||||
if (!drop) return null;
|
||||
const rarityStyle = RARITY_COLORS[drop.rarity];
|
||||
return (
|
||||
<Badge
|
||||
key={id}
|
||||
className="text-xs"
|
||||
style={{
|
||||
backgroundColor: `${rarityStyle?.color}20`,
|
||||
color: rarityStyle?.color,
|
||||
borderColor: rarityStyle?.color,
|
||||
}}
|
||||
>
|
||||
{drop.name}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
582
src/components/game/tabs/FamiliarTab.tsx
Normal file
582
src/components/game/tabs/FamiliarTab.tsx
Normal file
@@ -0,0 +1,582 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Sparkles, Heart, Zap, Star, Shield, Flame, Droplet, Wind, Mountain, Sun, Moon, Leaf, Skull,
|
||||
Brain, Link, Wind as Force, Droplets, TreeDeciduous, Hourglass, Gem, CircleDot, Circle,
|
||||
Sword, Wand2, ShieldCheck, TrendingUp, Clock, Crown
|
||||
} from 'lucide-react';
|
||||
import type { GameState, FamiliarInstance, FamiliarDef, FamiliarAbilityType } from '@/lib/game/types';
|
||||
import { FAMILIARS_DEF, getFamiliarXpRequired, getFamiliarAbilityValue } from '@/lib/game/data/familiars';
|
||||
import { ELEMENTS } from '@/lib/game/constants';
|
||||
|
||||
// Element icon mapping
|
||||
const ELEMENT_ICONS: Record<string, typeof Flame> = {
|
||||
fire: Flame,
|
||||
water: Droplet,
|
||||
air: Wind,
|
||||
earth: Mountain,
|
||||
light: Sun,
|
||||
dark: Moon,
|
||||
life: Leaf,
|
||||
death: Skull,
|
||||
mental: Brain,
|
||||
transference: Link,
|
||||
force: Force,
|
||||
blood: Droplets,
|
||||
metal: Shield,
|
||||
wood: TreeDeciduous,
|
||||
sand: Hourglass,
|
||||
crystal: Gem,
|
||||
stellar: Star,
|
||||
void: CircleDot,
|
||||
raw: Circle,
|
||||
};
|
||||
|
||||
// Rarity colors
|
||||
const RARITY_COLORS: Record<string, string> = {
|
||||
common: 'text-gray-400 border-gray-600',
|
||||
uncommon: 'text-green-400 border-green-600',
|
||||
rare: 'text-blue-400 border-blue-600',
|
||||
epic: 'text-purple-400 border-purple-600',
|
||||
legendary: 'text-amber-400 border-amber-600',
|
||||
};
|
||||
|
||||
const RARITY_BG: Record<string, string> = {
|
||||
common: 'bg-gray-900/50',
|
||||
uncommon: 'bg-green-900/20',
|
||||
rare: 'bg-blue-900/20',
|
||||
epic: 'bg-purple-900/20',
|
||||
legendary: 'bg-amber-900/20',
|
||||
};
|
||||
|
||||
// Role icons
|
||||
const ROLE_ICONS: Record<string, typeof Sword> = {
|
||||
combat: Sword,
|
||||
mana: Sparkles,
|
||||
support: Heart,
|
||||
guardian: ShieldCheck,
|
||||
};
|
||||
|
||||
// Ability type icons
|
||||
const ABILITY_ICONS: Record<FamiliarAbilityType, typeof Zap> = {
|
||||
damageBonus: Sword,
|
||||
manaRegen: Sparkles,
|
||||
autoGather: Zap,
|
||||
critChance: Star,
|
||||
castSpeed: Clock,
|
||||
manaShield: Shield,
|
||||
elementalBonus: Flame,
|
||||
lifeSteal: Heart,
|
||||
bonusGold: TrendingUp,
|
||||
autoConvert: Wand2,
|
||||
thorns: ShieldCheck,
|
||||
};
|
||||
|
||||
interface FamiliarTabProps {
|
||||
store: GameState & {
|
||||
setActiveFamiliar: (index: number, active: boolean) => void;
|
||||
setFamiliarNickname: (index: number, nickname: string) => void;
|
||||
summonFamiliar: (familiarId: string) => void;
|
||||
upgradeFamiliarAbility: (index: number, abilityType: FamiliarAbilityType) => void;
|
||||
getActiveFamiliarBonuses: () => ReturnType<typeof import('@/lib/game/familiar-slice').createFamiliarSlice>['getActiveFamiliarBonuses'] extends () => infer R ? R : never;
|
||||
getAvailableFamiliars: () => string[];
|
||||
};
|
||||
}
|
||||
|
||||
export function FamiliarTab({ store }: FamiliarTabProps) {
|
||||
const [selectedFamiliar, setSelectedFamiliar] = useState<number | null>(null);
|
||||
const [nicknameInput, setNicknameInput] = useState('');
|
||||
|
||||
const familiars = store.familiars;
|
||||
const activeFamiliarSlots = store.activeFamiliarSlots;
|
||||
const activeCount = familiars.filter(f => f.active).length;
|
||||
const availableFamiliars = store.getAvailableFamiliars();
|
||||
const familiarBonuses = store.getActiveFamiliarBonuses();
|
||||
|
||||
// Format XP display
|
||||
const formatXp = (current: number, level: number) => {
|
||||
const required = getFamiliarXpRequired(level);
|
||||
return `${current}/${required}`;
|
||||
};
|
||||
|
||||
// Get familiar definition
|
||||
const getFamiliarDef = (instance: FamiliarInstance): FamiliarDef | undefined => {
|
||||
return FAMILIARS_DEF[instance.familiarId];
|
||||
};
|
||||
|
||||
// Render a single familiar card
|
||||
const renderFamiliarCard = (instance: FamiliarInstance, index: number) => {
|
||||
const def = getFamiliarDef(instance);
|
||||
if (!def) return null;
|
||||
|
||||
const ElementIcon = ELEMENT_ICONS[def.element] || Circle;
|
||||
const RoleIcon = ROLE_ICONS[def.role] || Sparkles;
|
||||
const xpRequired = getFamiliarXpRequired(instance.level);
|
||||
const xpPercent = Math.min(100, (instance.experience / xpRequired) * 100);
|
||||
const bondPercent = instance.bond;
|
||||
const isSelected = selectedFamiliar === index;
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={`${instance.familiarId}-${index}`}
|
||||
className={`cursor-pointer transition-all ${RARITY_BG[def.rarity]} ${
|
||||
isSelected ? 'ring-2 ring-amber-500' : ''
|
||||
} ${instance.active ? 'ring-1 ring-green-500/50' : ''} border ${RARITY_COLORS[def.rarity]}`}
|
||||
onClick={() => setSelectedFamiliar(isSelected ? null : index)}
|
||||
>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-10 h-10 rounded-full bg-black/30 flex items-center justify-center">
|
||||
<ElementIcon className="w-6 h-6" style={{ color: ELEMENTS[def.element]?.color || '#fff' }} />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className={`text-sm ${RARITY_COLORS[def.rarity]}`}>
|
||||
{instance.nickname || def.name}
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-1 text-xs text-gray-400">
|
||||
<RoleIcon className="w-3 h-3" />
|
||||
<span>Lv.{instance.level}</span>
|
||||
{instance.active && (
|
||||
<Badge className="ml-1 bg-green-900/50 text-green-300 text-xs py-0">Active</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="outline" className={`${RARITY_COLORS[def.rarity]} text-xs`}>
|
||||
{def.rarity}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{/* XP Bar */}
|
||||
<div className="space-y-0.5">
|
||||
<div className="flex justify-between text-xs text-gray-400">
|
||||
<span>XP</span>
|
||||
<span>{formatXp(instance.experience, instance.level)}</span>
|
||||
</div>
|
||||
<Progress value={xpPercent} className="h-1.5 bg-gray-800" />
|
||||
</div>
|
||||
|
||||
{/* Bond Bar */}
|
||||
<div className="space-y-0.5">
|
||||
<div className="flex justify-between text-xs text-gray-400">
|
||||
<span className="flex items-center gap-1">
|
||||
<Heart className="w-3 h-3" /> Bond
|
||||
</span>
|
||||
<span>{bondPercent.toFixed(0)}%</span>
|
||||
</div>
|
||||
<Progress value={bondPercent} className="h-1.5 bg-gray-800" />
|
||||
</div>
|
||||
|
||||
{/* Abilities Preview */}
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{instance.abilities.slice(0, 3).map(ability => {
|
||||
const abilityDef = def.abilities.find(a => a.type === ability.type);
|
||||
if (!abilityDef) return null;
|
||||
const AbilityIcon = ABILITY_ICONS[ability.type] || Zap;
|
||||
const value = getFamiliarAbilityValue(abilityDef, instance.level, ability.level);
|
||||
|
||||
return (
|
||||
<Tooltip key={ability.type}>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge variant="outline" className="text-xs py-0 flex items-center gap-1">
|
||||
<AbilityIcon className="w-3 h-3" />
|
||||
{ability.type === 'damageBonus' || ability.type === 'elementalBonus' ||
|
||||
ability.type === 'castSpeed' || ability.type === 'critChance' ||
|
||||
ability.type === 'lifeSteal' || ability.type === 'thorns' ||
|
||||
ability.type === 'bonusGold'
|
||||
? `+${value.toFixed(1)}%`
|
||||
: `+${value.toFixed(1)}`}
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="text-sm">{abilityDef.desc}</p>
|
||||
<p className="text-xs text-gray-400">Level {ability.level}/10</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
// Render selected familiar details
|
||||
const renderFamiliarDetails = () => {
|
||||
if (selectedFamiliar === null || selectedFamiliar >= familiars.length) return null;
|
||||
|
||||
const instance = familiars[selectedFamiliar];
|
||||
const def = getFamiliarDef(instance);
|
||||
if (!def) return null;
|
||||
|
||||
const ElementIcon = ELEMENT_ICONS[def.element] || Circle;
|
||||
|
||||
return (
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-amber-400 text-sm">Familiar Details</CardTitle>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={() => setSelectedFamiliar(null)}
|
||||
>
|
||||
✕
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Name and nickname */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-12 h-12 rounded-full bg-black/30 flex items-center justify-center">
|
||||
<ElementIcon className="w-8 h-8" style={{ color: ELEMENTS[def.element]?.color || '#fff' }} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className={`text-lg font-bold ${RARITY_COLORS[def.rarity]}`}>
|
||||
{def.name}
|
||||
</h3>
|
||||
{instance.nickname && (
|
||||
<p className="text-sm text-gray-400">"{instance.nickname}"</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Nickname input */}
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={nicknameInput}
|
||||
onChange={(e) => setNicknameInput(e.target.value)}
|
||||
placeholder="Set nickname..."
|
||||
className="h-8 text-sm bg-gray-800 border-gray-600"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
store.setFamiliarNickname(selectedFamiliar, nicknameInput);
|
||||
setNicknameInput('');
|
||||
}}
|
||||
disabled={!nicknameInput.trim()}
|
||||
>
|
||||
Set
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="text-sm text-gray-300 italic">
|
||||
{def.desc}
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-400">Level:</span>
|
||||
<span className="text-white">{instance.level}/100</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-400">Bond:</span>
|
||||
<span className="text-white">{instance.bond.toFixed(0)}%</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-400">Role:</span>
|
||||
<span className="text-white capitalize">{def.role}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-400">Element:</span>
|
||||
<span style={{ color: ELEMENTS[def.element]?.color }}>{def.element}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator className="bg-gray-700" />
|
||||
|
||||
{/* Abilities */}
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-semibold text-gray-300">Abilities</h4>
|
||||
{instance.abilities.map(ability => {
|
||||
const abilityDef = def.abilities.find(a => a.type === ability.type);
|
||||
if (!abilityDef) return null;
|
||||
const AbilityIcon = ABILITY_ICONS[ability.type] || Zap;
|
||||
const value = getFamiliarAbilityValue(abilityDef, instance.level, ability.level);
|
||||
const upgradeCost = ability.level * 100;
|
||||
const canUpgrade = instance.experience >= upgradeCost && ability.level < 10;
|
||||
|
||||
return (
|
||||
<div key={ability.type} className="p-2 rounded bg-gray-800/50 border border-gray-700">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<AbilityIcon className="w-4 h-4 text-amber-400" />
|
||||
<span className="text-sm font-medium capitalize">
|
||||
{ability.type.replace(/([A-Z])/g, ' $1').trim()}
|
||||
</span>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-xs">Lv.{ability.level}/10</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mb-2">{abilityDef.desc}</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-green-400">
|
||||
Current: +{value.toFixed(2)}
|
||||
{ability.type === 'damageBonus' || ability.type === 'elementalBonus' ||
|
||||
ability.type === 'castSpeed' || ability.type === 'critChance' ||
|
||||
ability.type === 'lifeSteal' || ability.type === 'thorns' ||
|
||||
ability.type === 'bonusGold' ? '%' : ''}
|
||||
</span>
|
||||
{ability.level < 10 && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-6 text-xs"
|
||||
disabled={!canUpgrade}
|
||||
onClick={() => store.upgradeFamiliarAbility(selectedFamiliar, ability.type)}
|
||||
>
|
||||
Upgrade ({upgradeCost} XP)
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Activate/Deactivate */}
|
||||
<Button
|
||||
className={`w-full ${instance.active ? 'bg-red-900/50 hover:bg-red-800/50' : 'bg-green-900/50 hover:bg-green-800/50'}`}
|
||||
onClick={() => store.setActiveFamiliar(selectedFamiliar, !instance.active)}
|
||||
disabled={!instance.active && activeCount >= activeFamiliarSlots}
|
||||
>
|
||||
{instance.active ? 'Deactivate' : 'Activate'}
|
||||
</Button>
|
||||
|
||||
{/* Flavor text */}
|
||||
{def.flavorText && (
|
||||
<p className="text-xs text-gray-500 italic text-center">
|
||||
"{def.flavorText}"
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
// Render summonable familiars
|
||||
const renderSummonableFamiliars = () => {
|
||||
if (availableFamiliars.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-cyan-400 text-sm flex items-center gap-2">
|
||||
<Sparkles className="w-4 h-4" />
|
||||
Available to Summon ({availableFamiliars.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ScrollArea className="h-48">
|
||||
<div className="space-y-2">
|
||||
{availableFamiliars.map(familiarId => {
|
||||
const def = FAMILIARS_DEF[familiarId];
|
||||
if (!def) return null;
|
||||
|
||||
const ElementIcon = ELEMENT_ICONS[def.element] || Circle;
|
||||
const RoleIcon = ROLE_ICONS[def.role] || Sparkles;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={familiarId}
|
||||
className={`p-2 rounded border ${RARITY_COLORS[def.rarity]} ${RARITY_BG[def.rarity]} flex items-center justify-between`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<ElementIcon className="w-5 h-5" style={{ color: ELEMENTS[def.element]?.color || '#fff' }} />
|
||||
<div>
|
||||
<div className={`text-sm font-medium ${RARITY_COLORS[def.rarity]}`}>
|
||||
{def.name}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 flex items-center gap-1">
|
||||
<RoleIcon className="w-3 h-3" />
|
||||
{def.role}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7"
|
||||
onClick={() => store.summonFamiliar(familiarId)}
|
||||
>
|
||||
Summon
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
// Render active bonuses
|
||||
const renderActiveBonuses = () => {
|
||||
const hasBonuses = Object.entries(familiarBonuses).some(([key, value]) => {
|
||||
if (key === 'damageMultiplier' || key === 'castSpeedMultiplier' ||
|
||||
key === 'elementalDamageMultiplier' || key === 'insightMultiplier') {
|
||||
return value > 1;
|
||||
}
|
||||
return value > 0;
|
||||
});
|
||||
|
||||
if (!hasBonuses) return null;
|
||||
|
||||
return (
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-green-400 text-sm flex items-center gap-2">
|
||||
<TrendingUp className="w-4 h-4" />
|
||||
Active Familiar Bonuses
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 text-sm">
|
||||
{familiarBonuses.damageMultiplier > 1 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Sword className="w-4 h-4 text-red-400" />
|
||||
<span>+{((familiarBonuses.damageMultiplier - 1) * 100).toFixed(0)}% DMG</span>
|
||||
</div>
|
||||
)}
|
||||
{familiarBonuses.manaRegenBonus > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="w-4 h-4 text-blue-400" />
|
||||
<span>+{familiarBonuses.manaRegenBonus.toFixed(1)} regen</span>
|
||||
</div>
|
||||
)}
|
||||
{familiarBonuses.autoGatherRate > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap className="w-4 h-4 text-yellow-400" />
|
||||
<span>+{familiarBonuses.autoGatherRate.toFixed(1)}/hr gather</span>
|
||||
</div>
|
||||
)}
|
||||
{familiarBonuses.critChanceBonus > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Star className="w-4 h-4 text-amber-400" />
|
||||
<span>+{familiarBonuses.critChanceBonus.toFixed(1)}% crit</span>
|
||||
</div>
|
||||
)}
|
||||
{familiarBonuses.castSpeedMultiplier > 1 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="w-4 h-4 text-cyan-400" />
|
||||
<span>+{((familiarBonuses.castSpeedMultiplier - 1) * 100).toFixed(0)}% speed</span>
|
||||
</div>
|
||||
)}
|
||||
{familiarBonuses.elementalDamageMultiplier > 1 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Flame className="w-4 h-4 text-orange-400" />
|
||||
<span>+{((familiarBonuses.elementalDamageMultiplier - 1) * 100).toFixed(0)}% elem</span>
|
||||
</div>
|
||||
)}
|
||||
{familiarBonuses.lifeStealPercent > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Heart className="w-4 h-4 text-red-400" />
|
||||
<span>+{familiarBonuses.lifeStealPercent.toFixed(0)}% lifesteal</span>
|
||||
</div>
|
||||
)}
|
||||
{familiarBonuses.insightMultiplier > 1 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<TrendingUp className="w-4 h-4 text-purple-400" />
|
||||
<span>+{((familiarBonuses.insightMultiplier - 1) * 100).toFixed(0)}% insight</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* Owned Familiars */}
|
||||
<Card className="bg-gray-900/80 border-gray-700 lg:col-span-2">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-amber-400 text-sm flex items-center gap-2">
|
||||
<Heart className="w-4 h-4" />
|
||||
Your Familiars ({familiars.length})
|
||||
</CardTitle>
|
||||
<div className="text-xs text-gray-400">
|
||||
Active Slots: {activeCount}/{activeFamiliarSlots}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{familiars.length > 0 ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{familiars.map((instance, index) => renderFamiliarCard(instance, index))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center p-8 text-gray-500">
|
||||
No familiars yet. Progress through the game to summon companions!
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Active Bonuses */}
|
||||
{renderActiveBonuses()}
|
||||
|
||||
{/* Selected Familiar Details */}
|
||||
{renderFamiliarDetails()}
|
||||
|
||||
{/* Summonable Familiars */}
|
||||
{renderSummonableFamiliars()}
|
||||
|
||||
{/* Familiar Guide */}
|
||||
<Card className="bg-gray-900/80 border-gray-700 lg:col-span-2">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-purple-400 text-sm flex items-center gap-2">
|
||||
<Crown className="w-4 h-4" />
|
||||
Familiar Guide
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm text-gray-300">
|
||||
<div>
|
||||
<h4 className="font-semibold text-amber-400 mb-1">Acquiring Familiars</h4>
|
||||
<p>Familiars become available to summon as you progress through floors, gather mana, and sign pacts with guardians. Higher rarity familiars are unlocked later.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-amber-400 mb-1">Leveling & Bond</h4>
|
||||
<p>Active familiars gain XP from combat, gathering, and time. Higher bond increases their power and XP gain. Upgrade abilities using XP to boost their effects.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-amber-400 mb-1">Roles</h4>
|
||||
<p>
|
||||
<span className="text-red-400">Combat</span> - Damage and crit bonuses<br/>
|
||||
<span className="text-blue-400">Mana</span> - Regeneration and auto-gathering<br/>
|
||||
<span className="text-green-400">Support</span> - Speed and utility<br/>
|
||||
<span className="text-amber-400">Guardian</span> - Defense and shields
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-amber-400 mb-1">Active Slots</h4>
|
||||
<p>You can have 1 familiar active by default. Upgrade through prestige to unlock more active slots for stacking bonuses.</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user