pack
This commit is contained in:
153
src/app/page.tsx
153
src/app/page.tsx
@@ -18,9 +18,14 @@ import { ScrollArea } from '@/components/ui/scroll-area';
|
|||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
||||||
import { Sparkles, Swords, BookOpen, FlaskConical, Trophy, RotateCcw, Pause, Play, Zap, Clock, Target, Flame, Droplet, Wind, Mountain, Sun, Moon, Leaf, Skull, Brain, Link, Wind as Force, Droplets, TreeDeciduous, Hourglass, Gem, Star, CircleDot, X, Circle, BarChart3 } from 'lucide-react';
|
import { Sparkles, Swords, BookOpen, FlaskConical, Trophy, RotateCcw, Pause, Play, Zap, Clock, Target, Flame, Droplet, Wind, Mountain, Sun, Moon, Leaf, Skull, Brain, Link, Wind as Force, Droplets, TreeDeciduous, Hourglass, Gem, Star, CircleDot, X, Circle, BarChart3, Award, Package, Heart } from 'lucide-react';
|
||||||
import type { GameAction } from '@/lib/game/types';
|
import type { GameAction } from '@/lib/game/types';
|
||||||
import { CraftingTab } from '@/components/game/tabs/CraftingTab';
|
import { CraftingTab } from '@/components/game/tabs/CraftingTab';
|
||||||
|
import { FamiliarTab } from '@/components/game/tabs/FamiliarTab';
|
||||||
|
import { ComboMeter } from '@/components/game/ComboMeter';
|
||||||
|
import { LootInventoryDisplay } from '@/components/game/LootInventory';
|
||||||
|
import { AchievementsDisplay } from '@/components/game/AchievementsDisplay';
|
||||||
|
import { ACHIEVEMENTS } from '@/lib/game/data/achievements';
|
||||||
|
|
||||||
// Helper to get active spells from equipped caster weapons
|
// Helper to get active spells from equipped caster weapons
|
||||||
function getActiveEquipmentSpells(
|
function getActiveEquipmentSpells(
|
||||||
@@ -647,6 +652,9 @@ export default function ManaLoopGame() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Combo Meter - Shows when climbing or has active combo */}
|
||||||
|
<ComboMeter combo={store.combo} isClimbing={store.currentAction === 'climb'} />
|
||||||
|
|
||||||
{/* Current Study (if any) */}
|
{/* Current Study (if any) */}
|
||||||
{store.currentStudyTarget && (
|
{store.currentStudyTarget && (
|
||||||
<Card className="bg-gray-900/80 border-purple-600/50 lg:col-span-2">
|
<Card className="bg-gray-900/80 border-purple-600/50 lg:col-span-2">
|
||||||
@@ -2246,7 +2254,10 @@ export default function ManaLoopGame() {
|
|||||||
<TabsTrigger value="spells" className="data-[state=active]:bg-gray-700 shrink-0">📖 Spells</TabsTrigger>
|
<TabsTrigger value="spells" className="data-[state=active]:bg-gray-700 shrink-0">📖 Spells</TabsTrigger>
|
||||||
<TabsTrigger value="lab" className="data-[state=active]:bg-gray-700 shrink-0">🧪 Lab</TabsTrigger>
|
<TabsTrigger value="lab" className="data-[state=active]:bg-gray-700 shrink-0">🧪 Lab</TabsTrigger>
|
||||||
<TabsTrigger value="crafting" className="data-[state=active]:bg-gray-700 shrink-0">✨ Crafting</TabsTrigger>
|
<TabsTrigger value="crafting" className="data-[state=active]:bg-gray-700 shrink-0">✨ Crafting</TabsTrigger>
|
||||||
|
<TabsTrigger value="familiars" className="data-[state=active]:bg-gray-700 shrink-0">🔮 Familiars</TabsTrigger>
|
||||||
<TabsTrigger value="skills" className="data-[state=active]:bg-gray-700 shrink-0">📚 Skills</TabsTrigger>
|
<TabsTrigger value="skills" className="data-[state=active]:bg-gray-700 shrink-0">📚 Skills</TabsTrigger>
|
||||||
|
<TabsTrigger value="loot" className="data-[state=active]:bg-gray-700 shrink-0">💎 Loot</TabsTrigger>
|
||||||
|
<TabsTrigger value="achievements" className="data-[state=active]:bg-gray-700 shrink-0">🏆 Achievements</TabsTrigger>
|
||||||
<TabsTrigger value="grimoire" className="data-[state=active]:bg-gray-700 shrink-0">⭐ Grimoire</TabsTrigger>
|
<TabsTrigger value="grimoire" className="data-[state=active]:bg-gray-700 shrink-0">⭐ Grimoire</TabsTrigger>
|
||||||
<TabsTrigger value="stats" className="data-[state=active]:bg-gray-700 shrink-0">📊 Stats</TabsTrigger>
|
<TabsTrigger value="stats" className="data-[state=active]:bg-gray-700 shrink-0">📊 Stats</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
@@ -2289,10 +2300,150 @@ export default function ManaLoopGame() {
|
|||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="familiars">
|
||||||
|
<FamiliarTab store={store} />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="skills">
|
<TabsContent value="skills">
|
||||||
{renderSkillsTab()}
|
{renderSkillsTab()}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="loot">
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
|
<LootInventoryDisplay inventory={store.lootInventory} />
|
||||||
|
|
||||||
|
<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">
|
||||||
|
<Package className="w-4 h-4" />
|
||||||
|
Loot Information
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-3 text-sm">
|
||||||
|
<p className="text-gray-400">
|
||||||
|
As you climb the Spire, you'll discover valuable loot from defeated floors and guardians.
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-gray-300 font-semibold">Loot Types:</div>
|
||||||
|
<ul className="text-gray-400 space-y-1 list-disc list-inside">
|
||||||
|
<li><span className="text-gray-300">Materials</span> - Used for advanced crafting</li>
|
||||||
|
<li><span className="text-gray-300">Essence</span> - Grants elemental mana</li>
|
||||||
|
<li><span className="text-gray-300">Mana Orbs</span> - Direct mana boost</li>
|
||||||
|
<li><span className="text-gray-300">Blueprints</span> - New equipment designs</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-gray-300 font-semibold">Drop Rates:</div>
|
||||||
|
<ul className="text-gray-400 space-y-1 list-disc list-inside">
|
||||||
|
<li>Higher floors = better loot</li>
|
||||||
|
<li>Guardians have 2x drop rate</li>
|
||||||
|
<li>Rare drops from floor 50+</li>
|
||||||
|
<li>Legendary items from floor 75+</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="bg-gray-900/80 border-gray-700 lg:col-span-2">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-amber-400 game-panel-title text-xs">Loot Statistics</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<div className="p-3 bg-gray-800/50 rounded text-center">
|
||||||
|
<div className="text-xl font-bold text-amber-400 game-mono">
|
||||||
|
{Object.values(store.lootInventory.materials).reduce((a, b) => a + b, 0)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-400">Materials Found</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-gray-800/50 rounded text-center">
|
||||||
|
<div className="text-xl font-bold text-purple-400 game-mono">
|
||||||
|
{store.lootInventory.blueprints.length}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-400">Blueprints</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-gray-800/50 rounded text-center">
|
||||||
|
<div className="text-xl font-bold text-blue-400 game-mono">
|
||||||
|
{store.totalSpellsCast || 0}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-400">Spells Cast</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-gray-800/50 rounded text-center">
|
||||||
|
<div className="text-xl font-bold text-green-400 game-mono">
|
||||||
|
{store.combo?.maxCombo || 0}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-400">Max Combo</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="achievements">
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
|
<AchievementsDisplay
|
||||||
|
achievements={store.achievements}
|
||||||
|
gameState={{
|
||||||
|
maxFloorReached: store.maxFloorReached,
|
||||||
|
totalManaGathered: store.totalManaGathered,
|
||||||
|
signedPacts: store.signedPacts,
|
||||||
|
totalSpellsCast: store.totalSpellsCast || 0,
|
||||||
|
totalDamageDealt: store.totalDamageDealt || 0,
|
||||||
|
totalCraftsCompleted: store.totalCraftsCompleted || 0,
|
||||||
|
combo: store.combo,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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">
|
||||||
|
<Award className="w-4 h-4" />
|
||||||
|
Achievement Progress
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="p-3 bg-gray-800/50 rounded">
|
||||||
|
<div className="text-xl font-bold text-amber-400 game-mono">
|
||||||
|
{store.achievements?.unlocked?.length || 0}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-400">Unlocked</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-gray-800/50 rounded">
|
||||||
|
<div className="text-xl font-bold text-purple-400 game-mono">
|
||||||
|
{(store.achievements?.unlocked || [])
|
||||||
|
.reduce((sum: number, id: string) => sum + (ACHIEVEMENTS[id]?.reward?.insight || 0), 0)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-400">Insight Earned</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator className="bg-gray-700" />
|
||||||
|
|
||||||
|
<div className="text-sm text-gray-400">
|
||||||
|
Achievements provide permanent bonuses and insight rewards. Track your progress and unlock special titles!
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-gray-300 font-semibold text-sm">Categories:</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Badge className="bg-red-900/50 text-red-300">Combat</Badge>
|
||||||
|
<Badge className="bg-amber-900/50 text-amber-300">Progression</Badge>
|
||||||
|
<Badge className="bg-purple-900/50 text-purple-300">Crafting</Badge>
|
||||||
|
<Badge className="bg-blue-900/50 text-blue-300">Magic</Badge>
|
||||||
|
<Badge className="bg-pink-900/50 text-pink-300">Special</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="grimoire">
|
<TabsContent value="grimoire">
|
||||||
{renderGrimoireTab()}
|
{renderGrimoireTab()}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
272
src/lib/game/data/achievements.ts
Normal file
272
src/lib/game/data/achievements.ts
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
// ─── Achievement Definitions ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
import type { AchievementDef } from '../types';
|
||||||
|
|
||||||
|
export const ACHIEVEMENTS: Record<string, AchievementDef> = {
|
||||||
|
// ─── Combat Achievements ───
|
||||||
|
firstBlood: {
|
||||||
|
id: 'firstBlood',
|
||||||
|
name: 'First Blood',
|
||||||
|
desc: 'Clear your first floor',
|
||||||
|
category: 'combat',
|
||||||
|
requirement: { type: 'floor', value: 2 },
|
||||||
|
reward: { insight: 10 },
|
||||||
|
},
|
||||||
|
floorClimber: {
|
||||||
|
id: 'floorClimber',
|
||||||
|
name: 'Floor Climber',
|
||||||
|
desc: 'Reach floor 10',
|
||||||
|
category: 'combat',
|
||||||
|
requirement: { type: 'floor', value: 10 },
|
||||||
|
reward: { insight: 25, manaBonus: 10 },
|
||||||
|
},
|
||||||
|
spireAssault: {
|
||||||
|
id: 'spireAssault',
|
||||||
|
name: 'Spire Assault',
|
||||||
|
desc: 'Reach floor 25',
|
||||||
|
category: 'combat',
|
||||||
|
requirement: { type: 'floor', value: 25 },
|
||||||
|
reward: { insight: 50, damageBonus: 0.05 },
|
||||||
|
},
|
||||||
|
towerConqueror: {
|
||||||
|
id: 'towerConqueror',
|
||||||
|
name: 'Tower Conqueror',
|
||||||
|
desc: 'Reach floor 50',
|
||||||
|
category: 'combat',
|
||||||
|
requirement: { type: 'floor', value: 50 },
|
||||||
|
reward: { insight: 100, manaBonus: 50, damageBonus: 0.1 },
|
||||||
|
},
|
||||||
|
spireMaster: {
|
||||||
|
id: 'spireMaster',
|
||||||
|
name: 'Spire Master',
|
||||||
|
desc: 'Reach floor 75',
|
||||||
|
category: 'combat',
|
||||||
|
requirement: { type: 'floor', value: 75 },
|
||||||
|
reward: { insight: 200, damageBonus: 0.15, title: 'Spire Master' },
|
||||||
|
},
|
||||||
|
apexReached: {
|
||||||
|
id: 'apexReached',
|
||||||
|
name: 'Apex Reached',
|
||||||
|
desc: 'Reach floor 100',
|
||||||
|
category: 'combat',
|
||||||
|
requirement: { type: 'floor', value: 100 },
|
||||||
|
reward: { insight: 500, manaBonus: 200, damageBonus: 0.25, title: 'Apex Climber' },
|
||||||
|
},
|
||||||
|
|
||||||
|
// ─── Combo Achievements ───
|
||||||
|
comboStarter: {
|
||||||
|
id: 'comboStarter',
|
||||||
|
name: 'Combo Starter',
|
||||||
|
desc: 'Reach a 10-hit combo',
|
||||||
|
category: 'combat',
|
||||||
|
requirement: { type: 'combo', value: 10 },
|
||||||
|
reward: { insight: 15 },
|
||||||
|
},
|
||||||
|
comboMaster: {
|
||||||
|
id: 'comboMaster',
|
||||||
|
name: 'Combo Master',
|
||||||
|
desc: 'Reach a 50-hit combo',
|
||||||
|
category: 'combat',
|
||||||
|
requirement: { type: 'combo', value: 50 },
|
||||||
|
reward: { insight: 50, damageBonus: 0.05 },
|
||||||
|
},
|
||||||
|
comboLegend: {
|
||||||
|
id: 'comboLegend',
|
||||||
|
name: 'Combo Legend',
|
||||||
|
desc: 'Reach a 100-hit combo',
|
||||||
|
category: 'combat',
|
||||||
|
requirement: { type: 'combo', value: 100 },
|
||||||
|
reward: { insight: 150, damageBonus: 0.1, title: 'Combo Legend' },
|
||||||
|
},
|
||||||
|
|
||||||
|
// ─── Damage Achievements ───
|
||||||
|
hundredDamage: {
|
||||||
|
id: 'hundredDamage',
|
||||||
|
name: 'Heavy Hitter',
|
||||||
|
desc: 'Deal 100 damage in a single hit',
|
||||||
|
category: 'combat',
|
||||||
|
requirement: { type: 'damage', value: 100 },
|
||||||
|
reward: { insight: 20 },
|
||||||
|
},
|
||||||
|
thousandDamage: {
|
||||||
|
id: 'thousandDamage',
|
||||||
|
name: 'Devastating Blow',
|
||||||
|
desc: 'Deal 1,000 damage in a single hit',
|
||||||
|
category: 'combat',
|
||||||
|
requirement: { type: 'damage', value: 1000 },
|
||||||
|
reward: { insight: 75, damageBonus: 0.03 },
|
||||||
|
},
|
||||||
|
tenThousandDamage: {
|
||||||
|
id: 'tenThousandDamage',
|
||||||
|
name: 'Apocalypse Now',
|
||||||
|
desc: 'Deal 10,000 damage in a single hit',
|
||||||
|
category: 'combat',
|
||||||
|
requirement: { type: 'damage', value: 10000 },
|
||||||
|
reward: { insight: 200, damageBonus: 0.05, title: 'Apocalypse Bringer' },
|
||||||
|
},
|
||||||
|
|
||||||
|
// ─── Pact Achievements ───
|
||||||
|
pactSeeker: {
|
||||||
|
id: 'pactSeeker',
|
||||||
|
name: 'Pact Seeker',
|
||||||
|
desc: 'Sign your first guardian pact',
|
||||||
|
category: 'progression',
|
||||||
|
requirement: { type: 'pact', value: 1 },
|
||||||
|
reward: { insight: 30 },
|
||||||
|
},
|
||||||
|
pactCollector: {
|
||||||
|
id: 'pactCollector',
|
||||||
|
name: 'Pact Collector',
|
||||||
|
desc: 'Sign 5 guardian pacts',
|
||||||
|
category: 'progression',
|
||||||
|
requirement: { type: 'pact', value: 5 },
|
||||||
|
reward: { insight: 100, manaBonus: 25 },
|
||||||
|
},
|
||||||
|
pactMaster: {
|
||||||
|
id: 'pactMaster',
|
||||||
|
name: 'Pact Master',
|
||||||
|
desc: 'Sign all guardian pacts',
|
||||||
|
category: 'progression',
|
||||||
|
requirement: { type: 'pact', value: 12 },
|
||||||
|
reward: { insight: 500, damageBonus: 0.2, title: 'Pact Master' },
|
||||||
|
},
|
||||||
|
|
||||||
|
// ─── Magic Achievements ───
|
||||||
|
spellCaster: {
|
||||||
|
id: 'spellCaster',
|
||||||
|
name: 'Spell Caster',
|
||||||
|
desc: 'Cast 100 spells',
|
||||||
|
category: 'magic',
|
||||||
|
requirement: { type: 'spells', value: 100 },
|
||||||
|
reward: { insight: 25 },
|
||||||
|
},
|
||||||
|
spellWeaver: {
|
||||||
|
id: 'spellWeaver',
|
||||||
|
name: 'Spell Weaver',
|
||||||
|
desc: 'Cast 1,000 spells',
|
||||||
|
category: 'magic',
|
||||||
|
requirement: { type: 'spells', value: 1000 },
|
||||||
|
reward: { insight: 75, regenBonus: 0.5 },
|
||||||
|
},
|
||||||
|
spellStorm: {
|
||||||
|
id: 'spellStorm',
|
||||||
|
name: 'Spell Storm',
|
||||||
|
desc: 'Cast 10,000 spells',
|
||||||
|
category: 'magic',
|
||||||
|
requirement: { type: 'spells', value: 10000 },
|
||||||
|
reward: { insight: 200, regenBonus: 1, title: 'Storm Caller' },
|
||||||
|
},
|
||||||
|
|
||||||
|
// ─── Mana Achievements ───
|
||||||
|
manaPool: {
|
||||||
|
id: 'manaPool',
|
||||||
|
name: 'Mana Pool',
|
||||||
|
desc: 'Accumulate 1,000 total mana',
|
||||||
|
category: 'magic',
|
||||||
|
requirement: { type: 'mana', value: 1000 },
|
||||||
|
reward: { insight: 20 },
|
||||||
|
},
|
||||||
|
manaLake: {
|
||||||
|
id: 'manaLake',
|
||||||
|
name: 'Mana Lake',
|
||||||
|
desc: 'Accumulate 100,000 total mana',
|
||||||
|
category: 'magic',
|
||||||
|
requirement: { type: 'mana', value: 100000 },
|
||||||
|
reward: { insight: 100, manaBonus: 50 },
|
||||||
|
},
|
||||||
|
manaOcean: {
|
||||||
|
id: 'manaOcean',
|
||||||
|
name: 'Mana Ocean',
|
||||||
|
desc: 'Accumulate 10,000,000 total mana',
|
||||||
|
category: 'magic',
|
||||||
|
requirement: { type: 'mana', value: 10000000 },
|
||||||
|
reward: { insight: 500, manaBonus: 200, title: 'Mana Ocean' },
|
||||||
|
},
|
||||||
|
|
||||||
|
// ─── Crafting Achievements ───
|
||||||
|
enchanter: {
|
||||||
|
id: 'enchanter',
|
||||||
|
name: 'Enchanter',
|
||||||
|
desc: 'Complete your first enchantment',
|
||||||
|
category: 'crafting',
|
||||||
|
requirement: { type: 'craft', value: 1 },
|
||||||
|
reward: { insight: 30 },
|
||||||
|
},
|
||||||
|
masterEnchanter: {
|
||||||
|
id: 'masterEnchanter',
|
||||||
|
name: 'Master Enchanter',
|
||||||
|
desc: 'Complete 10 enchantments',
|
||||||
|
category: 'crafting',
|
||||||
|
requirement: { type: 'craft', value: 10 },
|
||||||
|
reward: { insight: 100, unlockEffect: 'efficiencyBoost' },
|
||||||
|
},
|
||||||
|
legendaryEnchanter: {
|
||||||
|
id: 'legendaryEnchanter',
|
||||||
|
name: 'Legendary Enchanter',
|
||||||
|
desc: 'Complete 50 enchantments',
|
||||||
|
category: 'crafting',
|
||||||
|
requirement: { type: 'craft', value: 50 },
|
||||||
|
reward: { insight: 300, title: 'Legendary Enchanter' },
|
||||||
|
},
|
||||||
|
|
||||||
|
// ─── Special Achievements ───
|
||||||
|
speedRunner: {
|
||||||
|
id: 'speedRunner',
|
||||||
|
name: 'Speed Runner',
|
||||||
|
desc: 'Reach floor 50 in under 5 days',
|
||||||
|
category: 'special',
|
||||||
|
requirement: { type: 'time', value: 5, subType: 'floor50' },
|
||||||
|
reward: { insight: 200, title: 'Speed Demon' },
|
||||||
|
hidden: true,
|
||||||
|
},
|
||||||
|
perfectionist: {
|
||||||
|
id: 'perfectionist',
|
||||||
|
name: 'Perfectionist',
|
||||||
|
desc: 'Reach floor 100 without any guardian pacts',
|
||||||
|
category: 'special',
|
||||||
|
requirement: { type: 'floor', value: 100, subType: 'noPacts' },
|
||||||
|
reward: { insight: 1000, title: 'Perfect Climber' },
|
||||||
|
hidden: true,
|
||||||
|
},
|
||||||
|
survivor: {
|
||||||
|
id: 'survivor',
|
||||||
|
name: 'Survivor',
|
||||||
|
desc: 'Complete a loop during full incursion (day 30+)',
|
||||||
|
category: 'special',
|
||||||
|
requirement: { type: 'time', value: 30 },
|
||||||
|
reward: { insight: 300, manaBonus: 100, title: 'Survivor' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Category colors for UI
|
||||||
|
export const ACHIEVEMENT_CATEGORY_COLORS: Record<string, string> = {
|
||||||
|
combat: '#EF4444', // Red
|
||||||
|
progression: '#F59E0B', // Amber
|
||||||
|
crafting: '#8B5CF6', // Purple
|
||||||
|
magic: '#3B82F6', // Blue
|
||||||
|
special: '#EC4899', // Pink
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get achievements by category
|
||||||
|
export function getAchievementsByCategory(): Record<string, AchievementDef[]> {
|
||||||
|
const result: Record<string, AchievementDef[]> = {};
|
||||||
|
|
||||||
|
for (const achievement of Object.values(ACHIEVEMENTS)) {
|
||||||
|
if (!result[achievement.category]) {
|
||||||
|
result[achievement.category] = [];
|
||||||
|
}
|
||||||
|
result[achievement.category].push(achievement);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if an achievement should be revealed
|
||||||
|
export function isAchievementRevealed(
|
||||||
|
achievement: AchievementDef,
|
||||||
|
progress: number
|
||||||
|
): boolean {
|
||||||
|
if (!achievement.hidden) return true;
|
||||||
|
// Reveal hidden achievements when at 50% progress
|
||||||
|
return progress >= achievement.requirement.value * 0.5;
|
||||||
|
}
|
||||||
498
src/lib/game/data/familiars.ts
Normal file
498
src/lib/game/data/familiars.ts
Normal file
@@ -0,0 +1,498 @@
|
|||||||
|
// ─── Familiar Definitions ───────────────────────────────────────────────────────
|
||||||
|
// Magical companions that provide passive bonuses and active assistance
|
||||||
|
|
||||||
|
import type { FamiliarDef, FamiliarAbility } from '../types';
|
||||||
|
|
||||||
|
// ─── Familiar Abilities ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const ABILITIES = {
|
||||||
|
// Combat abilities
|
||||||
|
damageBonus: (base: number, scaling: number): FamiliarAbility => ({
|
||||||
|
type: 'damageBonus',
|
||||||
|
baseValue: base,
|
||||||
|
scalingPerLevel: scaling,
|
||||||
|
desc: `+${base}% damage (+${scaling}% per level)`,
|
||||||
|
}),
|
||||||
|
|
||||||
|
critChance: (base: number, scaling: number): FamiliarAbility => ({
|
||||||
|
type: 'critChance',
|
||||||
|
baseValue: base,
|
||||||
|
scalingPerLevel: scaling,
|
||||||
|
desc: `+${base}% crit chance (+${scaling}% per level)`,
|
||||||
|
}),
|
||||||
|
|
||||||
|
castSpeed: (base: number, scaling: number): FamiliarAbility => ({
|
||||||
|
type: 'castSpeed',
|
||||||
|
baseValue: base,
|
||||||
|
scalingPerLevel: scaling,
|
||||||
|
desc: `+${base}% cast speed (+${scaling}% per level)`,
|
||||||
|
}),
|
||||||
|
|
||||||
|
elementalBonus: (base: number, scaling: number): FamiliarAbility => ({
|
||||||
|
type: 'elementalBonus',
|
||||||
|
baseValue: base,
|
||||||
|
scalingPerLevel: scaling,
|
||||||
|
desc: `+${base}% elemental damage (+${scaling}% per level)`,
|
||||||
|
}),
|
||||||
|
|
||||||
|
lifeSteal: (base: number, scaling: number): FamiliarAbility => ({
|
||||||
|
type: 'lifeSteal',
|
||||||
|
baseValue: base,
|
||||||
|
scalingPerLevel: scaling,
|
||||||
|
desc: `+${base}% life steal (+${scaling}% per level)`,
|
||||||
|
}),
|
||||||
|
|
||||||
|
thorns: (base: number, scaling: number): FamiliarAbility => ({
|
||||||
|
type: 'thorns',
|
||||||
|
baseValue: base,
|
||||||
|
scalingPerLevel: scaling,
|
||||||
|
desc: `Reflect ${base}% damage taken (+${scaling}% per level)`,
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Mana abilities
|
||||||
|
manaRegen: (base: number, scaling: number): FamiliarAbility => ({
|
||||||
|
type: 'manaRegen',
|
||||||
|
baseValue: base,
|
||||||
|
scalingPerLevel: scaling,
|
||||||
|
desc: `+${base} mana regen (+${scaling} per level)`,
|
||||||
|
}),
|
||||||
|
|
||||||
|
autoGather: (base: number, scaling: number): FamiliarAbility => ({
|
||||||
|
type: 'autoGather',
|
||||||
|
baseValue: base,
|
||||||
|
scalingPerLevel: scaling,
|
||||||
|
desc: `Auto-gather ${base} mana/hour (+${scaling} per level)`,
|
||||||
|
}),
|
||||||
|
|
||||||
|
autoConvert: (base: number, scaling: number): FamiliarAbility => ({
|
||||||
|
type: 'autoConvert',
|
||||||
|
baseValue: base,
|
||||||
|
scalingPerLevel: scaling,
|
||||||
|
desc: `Auto-convert ${base} mana/hour (+${scaling} per level)`,
|
||||||
|
}),
|
||||||
|
|
||||||
|
manaShield: (base: number, scaling: number): FamiliarAbility => ({
|
||||||
|
type: 'manaShield',
|
||||||
|
baseValue: base,
|
||||||
|
scalingPerLevel: scaling,
|
||||||
|
desc: `Shield absorbs ${base} damage, costs 1 mana per ${base} damage`,
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Support abilities
|
||||||
|
bonusGold: (base: number, scaling: number): FamiliarAbility => ({
|
||||||
|
type: 'bonusGold',
|
||||||
|
baseValue: base,
|
||||||
|
scalingPerLevel: scaling,
|
||||||
|
desc: `+${base}% insight gain (+${scaling}% per level)`,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Familiar Definitions ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const FAMILIARS_DEF: Record<string, FamiliarDef> = {
|
||||||
|
// === COMMON FAMILIARS (Tier 1) ===
|
||||||
|
|
||||||
|
// Mana Wisps - Basic mana helpers
|
||||||
|
manaWisp: {
|
||||||
|
id: 'manaWisp',
|
||||||
|
name: 'Mana Wisp',
|
||||||
|
desc: 'A gentle spirit of pure mana that drifts lazily through the air.',
|
||||||
|
role: 'mana',
|
||||||
|
element: 'raw',
|
||||||
|
rarity: 'common',
|
||||||
|
abilities: [
|
||||||
|
ABILITIES.manaRegen(0.5, 0.1),
|
||||||
|
],
|
||||||
|
baseStats: { power: 10, bond: 15 },
|
||||||
|
unlockCondition: { type: 'mana', value: 100 },
|
||||||
|
flavorText: 'It hums with quiet contentment, barely visible in dim light.',
|
||||||
|
},
|
||||||
|
|
||||||
|
fireSpark: {
|
||||||
|
id: 'fireSpark',
|
||||||
|
name: 'Fire Spark',
|
||||||
|
desc: 'A tiny ember given life, crackling with barely contained energy.',
|
||||||
|
role: 'combat',
|
||||||
|
element: 'fire',
|
||||||
|
rarity: 'common',
|
||||||
|
abilities: [
|
||||||
|
ABILITIES.damageBonus(2, 0.5),
|
||||||
|
],
|
||||||
|
baseStats: { power: 12, bond: 10 },
|
||||||
|
unlockCondition: { type: 'floor', value: 5 },
|
||||||
|
flavorText: 'It bounces excitedly, leaving scorch marks on everything it touches.',
|
||||||
|
},
|
||||||
|
|
||||||
|
waterDroplet: {
|
||||||
|
id: 'waterDroplet',
|
||||||
|
name: 'Water Droplet',
|
||||||
|
desc: 'A perfect sphere of living water that never seems to evaporate.',
|
||||||
|
role: 'support',
|
||||||
|
element: 'water',
|
||||||
|
rarity: 'common',
|
||||||
|
abilities: [
|
||||||
|
ABILITIES.manaRegen(0.3, 0.1),
|
||||||
|
ABILITIES.lifeSteal(1, 0.2),
|
||||||
|
],
|
||||||
|
baseStats: { power: 8, bond: 12 },
|
||||||
|
unlockCondition: { type: 'floor', value: 3 },
|
||||||
|
flavorText: 'Ripples spread across its surface with each spell you cast.',
|
||||||
|
},
|
||||||
|
|
||||||
|
earthPebble: {
|
||||||
|
id: 'earthPebble',
|
||||||
|
name: 'Earth Pebble',
|
||||||
|
desc: 'A small stone with a surprisingly friendly personality.',
|
||||||
|
role: 'guardian',
|
||||||
|
element: 'earth',
|
||||||
|
rarity: 'common',
|
||||||
|
abilities: [
|
||||||
|
ABILITIES.thorns(2, 0.5),
|
||||||
|
],
|
||||||
|
baseStats: { power: 15, bond: 8 },
|
||||||
|
unlockCondition: { type: 'floor', value: 8 },
|
||||||
|
flavorText: 'It occasionally rolls itself to a new position when bored.',
|
||||||
|
},
|
||||||
|
|
||||||
|
// === UNCOMMON FAMILIARS (Tier 2) ===
|
||||||
|
|
||||||
|
flameImp: {
|
||||||
|
id: 'flameImp',
|
||||||
|
name: 'Flame Imp',
|
||||||
|
desc: 'A mischievous fire spirit that delights in destruction.',
|
||||||
|
role: 'combat',
|
||||||
|
element: 'fire',
|
||||||
|
rarity: 'uncommon',
|
||||||
|
abilities: [
|
||||||
|
ABILITIES.damageBonus(4, 0.8),
|
||||||
|
ABILITIES.elementalBonus(3, 0.6),
|
||||||
|
],
|
||||||
|
baseStats: { power: 25, bond: 12 },
|
||||||
|
unlockCondition: { type: 'floor', value: 15 },
|
||||||
|
flavorText: 'It cackles with glee whenever you defeat an enemy.',
|
||||||
|
},
|
||||||
|
|
||||||
|
windSylph: {
|
||||||
|
id: 'windSylph',
|
||||||
|
name: 'Wind Sylph',
|
||||||
|
desc: 'An airy spirit that moves like a gentle breeze.',
|
||||||
|
role: 'support',
|
||||||
|
element: 'air',
|
||||||
|
rarity: 'uncommon',
|
||||||
|
abilities: [
|
||||||
|
ABILITIES.castSpeed(3, 0.6),
|
||||||
|
],
|
||||||
|
baseStats: { power: 20, bond: 15 },
|
||||||
|
unlockCondition: { type: 'floor', value: 12 },
|
||||||
|
flavorText: 'Its laughter sounds like wind chimes in a storm.',
|
||||||
|
},
|
||||||
|
|
||||||
|
manaSprite: {
|
||||||
|
id: 'manaSprite',
|
||||||
|
name: 'Mana Sprite',
|
||||||
|
desc: 'A more evolved mana spirit with a playful nature.',
|
||||||
|
role: 'mana',
|
||||||
|
element: 'raw',
|
||||||
|
rarity: 'uncommon',
|
||||||
|
abilities: [
|
||||||
|
ABILITIES.manaRegen(1, 0.2),
|
||||||
|
ABILITIES.autoGather(2, 0.5),
|
||||||
|
],
|
||||||
|
baseStats: { power: 18, bond: 18 },
|
||||||
|
unlockCondition: { type: 'mana', value: 1000 },
|
||||||
|
flavorText: 'It sometimes tickles your ear with invisible hands.',
|
||||||
|
},
|
||||||
|
|
||||||
|
crystalGolem: {
|
||||||
|
id: 'crystalGolem',
|
||||||
|
name: 'Crystal Golem',
|
||||||
|
desc: 'A small construct made of crystallized mana.',
|
||||||
|
role: 'guardian',
|
||||||
|
element: 'crystal',
|
||||||
|
rarity: 'uncommon',
|
||||||
|
abilities: [
|
||||||
|
ABILITIES.thorns(5, 1),
|
||||||
|
ABILITIES.manaShield(10, 2),
|
||||||
|
],
|
||||||
|
baseStats: { power: 30, bond: 10 },
|
||||||
|
unlockCondition: { type: 'floor', value: 20 },
|
||||||
|
flavorText: 'Light refracts through its body in mesmerizing patterns.',
|
||||||
|
},
|
||||||
|
|
||||||
|
// === RARE FAMILIARS (Tier 3) ===
|
||||||
|
|
||||||
|
phoenixHatchling: {
|
||||||
|
id: 'phoenixHatchling',
|
||||||
|
name: 'Phoenix Hatchling',
|
||||||
|
desc: 'A young phoenix, still learning to control its flames.',
|
||||||
|
role: 'combat',
|
||||||
|
element: 'fire',
|
||||||
|
rarity: 'rare',
|
||||||
|
abilities: [
|
||||||
|
ABILITIES.damageBonus(6, 1.2),
|
||||||
|
ABILITIES.lifeSteal(3, 0.5),
|
||||||
|
],
|
||||||
|
baseStats: { power: 40, bond: 15 },
|
||||||
|
unlockCondition: { type: 'floor', value: 30 },
|
||||||
|
flavorText: 'Tiny flames dance around its feathers as it practices flying.',
|
||||||
|
},
|
||||||
|
|
||||||
|
frostWisp: {
|
||||||
|
id: 'frostWisp',
|
||||||
|
name: 'Frost Wisp',
|
||||||
|
desc: 'A spirit of eternal winter, beautiful and deadly.',
|
||||||
|
role: 'combat',
|
||||||
|
element: 'water',
|
||||||
|
rarity: 'rare',
|
||||||
|
abilities: [
|
||||||
|
ABILITIES.elementalBonus(8, 1.5),
|
||||||
|
ABILITIES.castSpeed(4, 0.8),
|
||||||
|
],
|
||||||
|
baseStats: { power: 35, bond: 12 },
|
||||||
|
unlockCondition: { type: 'floor', value: 25 },
|
||||||
|
flavorText: 'Frost patterns appear on surfaces wherever it lingers.',
|
||||||
|
},
|
||||||
|
|
||||||
|
manaElemental: {
|
||||||
|
id: 'manaElemental',
|
||||||
|
name: 'Mana Elemental',
|
||||||
|
desc: 'A concentrated form of pure magical energy.',
|
||||||
|
role: 'mana',
|
||||||
|
element: 'raw',
|
||||||
|
rarity: 'rare',
|
||||||
|
abilities: [
|
||||||
|
ABILITIES.manaRegen(2, 0.4),
|
||||||
|
ABILITIES.autoGather(5, 1),
|
||||||
|
ABILITIES.autoConvert(2, 0.5),
|
||||||
|
],
|
||||||
|
baseStats: { power: 30, bond: 20 },
|
||||||
|
unlockCondition: { type: 'mana', value: 5000 },
|
||||||
|
flavorText: 'Reality seems to bend slightly around its fluctuating form.',
|
||||||
|
},
|
||||||
|
|
||||||
|
shieldGuardian: {
|
||||||
|
id: 'shieldGuardian',
|
||||||
|
name: 'Shield Guardian',
|
||||||
|
desc: 'A loyal protector carved from enchanted stone.',
|
||||||
|
role: 'guardian',
|
||||||
|
element: 'earth',
|
||||||
|
rarity: 'rare',
|
||||||
|
abilities: [
|
||||||
|
ABILITIES.thorns(8, 1.5),
|
||||||
|
ABILITIES.manaShield(20, 4),
|
||||||
|
],
|
||||||
|
baseStats: { power: 50, bond: 8 },
|
||||||
|
unlockCondition: { type: 'floor', value: 35 },
|
||||||
|
flavorText: 'It stands motionless for hours, then suddenly moves to block danger.',
|
||||||
|
},
|
||||||
|
|
||||||
|
// === EPIC FAMILIARS (Tier 4) ===
|
||||||
|
|
||||||
|
infernoDrake: {
|
||||||
|
id: 'infernoDrake',
|
||||||
|
name: 'Inferno Drake',
|
||||||
|
desc: 'A small dragon wreathed in eternal flames.',
|
||||||
|
role: 'combat',
|
||||||
|
element: 'fire',
|
||||||
|
rarity: 'epic',
|
||||||
|
abilities: [
|
||||||
|
ABILITIES.damageBonus(10, 2),
|
||||||
|
ABILITIES.elementalBonus(12, 2),
|
||||||
|
ABILITIES.critChance(3, 0.6),
|
||||||
|
],
|
||||||
|
baseStats: { power: 60, bond: 12 },
|
||||||
|
unlockCondition: { type: 'floor', value: 50 },
|
||||||
|
flavorText: 'Smoke occasionally drifts from its nostrils as it dreams of conquest.',
|
||||||
|
},
|
||||||
|
|
||||||
|
starlightSerpent: {
|
||||||
|
id: 'starlightSerpent',
|
||||||
|
name: 'Starlight Serpent',
|
||||||
|
desc: 'A serpentine creature formed from captured starlight.',
|
||||||
|
role: 'support',
|
||||||
|
element: 'stellar',
|
||||||
|
rarity: 'epic',
|
||||||
|
abilities: [
|
||||||
|
ABILITIES.castSpeed(8, 1.5),
|
||||||
|
ABILITIES.bonusGold(5, 1),
|
||||||
|
ABILITIES.manaRegen(1.5, 0.3),
|
||||||
|
],
|
||||||
|
baseStats: { power: 45, bond: 25 },
|
||||||
|
unlockCondition: { type: 'floor', value: 45 },
|
||||||
|
flavorText: 'It traces constellations in the air with its glowing body.',
|
||||||
|
},
|
||||||
|
|
||||||
|
voidWalker: {
|
||||||
|
id: 'voidWalker',
|
||||||
|
name: 'Void Walker',
|
||||||
|
desc: 'A being that exists partially outside normal reality.',
|
||||||
|
role: 'mana',
|
||||||
|
element: 'void',
|
||||||
|
rarity: 'epic',
|
||||||
|
abilities: [
|
||||||
|
ABILITIES.manaRegen(3, 0.6),
|
||||||
|
ABILITIES.autoGather(10, 2),
|
||||||
|
ABILITIES.manaShield(15, 3),
|
||||||
|
],
|
||||||
|
baseStats: { power: 55, bond: 15 },
|
||||||
|
unlockCondition: { type: 'floor', value: 55 },
|
||||||
|
flavorText: 'It sometimes disappears entirely, only to reappear moments later.',
|
||||||
|
},
|
||||||
|
|
||||||
|
ancientGolem: {
|
||||||
|
id: 'ancientGolem',
|
||||||
|
name: 'Ancient Golem',
|
||||||
|
desc: 'A construct from a forgotten age, still following its prime directive.',
|
||||||
|
role: 'guardian',
|
||||||
|
element: 'earth',
|
||||||
|
rarity: 'epic',
|
||||||
|
abilities: [
|
||||||
|
ABILITIES.thorns(15, 3),
|
||||||
|
ABILITIES.manaShield(30, 5),
|
||||||
|
ABILITIES.damageBonus(5, 1),
|
||||||
|
],
|
||||||
|
baseStats: { power: 80, bond: 6 },
|
||||||
|
unlockCondition: { type: 'floor', value: 60 },
|
||||||
|
flavorText: 'Ancient runes glow faintly across its weathered surface.',
|
||||||
|
},
|
||||||
|
|
||||||
|
// === LEGENDARY FAMILIARS (Tier 5) ===
|
||||||
|
|
||||||
|
primordialPhoenix: {
|
||||||
|
id: 'primordialPhoenix',
|
||||||
|
name: 'Primordial Phoenix',
|
||||||
|
desc: 'An ancient fire bird, reborn countless times through the ages.',
|
||||||
|
role: 'combat',
|
||||||
|
element: 'fire',
|
||||||
|
rarity: 'legendary',
|
||||||
|
abilities: [
|
||||||
|
ABILITIES.damageBonus(15, 3),
|
||||||
|
ABILITIES.elementalBonus(20, 4),
|
||||||
|
ABILITIES.lifeSteal(8, 1.5),
|
||||||
|
ABILITIES.critChance(5, 1),
|
||||||
|
],
|
||||||
|
baseStats: { power: 100, bond: 20 },
|
||||||
|
unlockCondition: { type: 'pact', value: 25 }, // Guardian floor 25
|
||||||
|
flavorText: 'Its eyes hold the wisdom of a thousand lifetimes.',
|
||||||
|
},
|
||||||
|
|
||||||
|
leviathanSpawn: {
|
||||||
|
id: 'leviathanSpawn',
|
||||||
|
name: 'Leviathan Spawn',
|
||||||
|
desc: 'The offspring of an ancient sea god, still growing into its power.',
|
||||||
|
role: 'mana',
|
||||||
|
element: 'water',
|
||||||
|
rarity: 'legendary',
|
||||||
|
abilities: [
|
||||||
|
ABILITIES.manaRegen(5, 1),
|
||||||
|
ABILITIES.autoGather(20, 4),
|
||||||
|
ABILITIES.autoConvert(8, 1.5),
|
||||||
|
ABILITIES.manaShield(25, 5),
|
||||||
|
],
|
||||||
|
baseStats: { power: 90, bond: 18 },
|
||||||
|
unlockCondition: { type: 'pact', value: 50 },
|
||||||
|
flavorText: 'The air around it always smells of salt and deep ocean.',
|
||||||
|
},
|
||||||
|
|
||||||
|
celestialGuardian: {
|
||||||
|
id: 'celestialGuardian',
|
||||||
|
name: 'Celestial Guardian',
|
||||||
|
desc: 'A divine protector sent by powers beyond mortal comprehension.',
|
||||||
|
role: 'guardian',
|
||||||
|
element: 'light',
|
||||||
|
rarity: 'legendary',
|
||||||
|
abilities: [
|
||||||
|
ABILITIES.thorns(25, 5),
|
||||||
|
ABILITIES.manaShield(50, 10),
|
||||||
|
ABILITIES.damageBonus(10, 2),
|
||||||
|
ABILITIES.lifeSteal(5, 1),
|
||||||
|
],
|
||||||
|
baseStats: { power: 120, bond: 12 },
|
||||||
|
unlockCondition: { type: 'pact', value: 75 },
|
||||||
|
flavorText: 'It radiates an aura of absolute protection and quiet judgment.',
|
||||||
|
},
|
||||||
|
|
||||||
|
voidEmperor: {
|
||||||
|
id: 'voidEmperor',
|
||||||
|
name: 'Void Emperor',
|
||||||
|
desc: 'A ruler from the spaces between dimensions, bound to your service.',
|
||||||
|
role: 'support',
|
||||||
|
element: 'void',
|
||||||
|
rarity: 'legendary',
|
||||||
|
abilities: [
|
||||||
|
ABILITIES.castSpeed(15, 3),
|
||||||
|
ABILITIES.bonusGold(15, 3),
|
||||||
|
ABILITIES.manaRegen(4, 0.8),
|
||||||
|
ABILITIES.critChance(8, 1.5),
|
||||||
|
],
|
||||||
|
baseStats: { power: 85, bond: 25 },
|
||||||
|
unlockCondition: { type: 'floor', value: 90 },
|
||||||
|
flavorText: 'It regards reality with the detached interest of a god.',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Helper Functions ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Get XP required for next familiar level
|
||||||
|
export function getFamiliarXpRequired(level: number): number {
|
||||||
|
// Exponential scaling: 100 * 1.5^(level-1)
|
||||||
|
return Math.floor(100 * Math.pow(1.5, level - 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get bond required for next bond level (1-100)
|
||||||
|
export function getBondRequired(currentBond: number): number {
|
||||||
|
// Linear scaling, every 10 bond requires more time
|
||||||
|
const bondTier = Math.floor(currentBond / 10);
|
||||||
|
return 100 + bondTier * 50; // Base 100, +50 per tier
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate familiar's ability value at given level and ability level
|
||||||
|
export function getFamiliarAbilityValue(
|
||||||
|
ability: FamiliarAbility,
|
||||||
|
familiarLevel: number,
|
||||||
|
abilityLevel: number
|
||||||
|
): number {
|
||||||
|
// Base value + (familiar level bonus) + (ability level bonus)
|
||||||
|
const familiarBonus = Math.floor(familiarLevel / 10) * ability.scalingPerLevel;
|
||||||
|
const abilityBonus = (abilityLevel - 1) * ability.scalingPerLevel * 2;
|
||||||
|
return ability.baseValue + familiarBonus + abilityBonus;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all familiars of a specific rarity
|
||||||
|
export function getFamiliarsByRarity(rarity: FamiliarDef['rarity']): FamiliarDef[] {
|
||||||
|
return Object.values(FAMILIARS_DEF).filter(f => f.rarity === rarity);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all familiars of a specific role
|
||||||
|
export function getFamiliarsByRole(role: FamiliarRole): FamiliarDef[] {
|
||||||
|
return Object.values(FAMILIARS_DEF).filter(f => f.role === role);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if player meets unlock condition for a familiar
|
||||||
|
export function canUnlockFamiliar(
|
||||||
|
familiar: FamiliarDef,
|
||||||
|
maxFloor: number,
|
||||||
|
signedPacts: number[],
|
||||||
|
totalManaGathered: number,
|
||||||
|
skillsLearned: number
|
||||||
|
): boolean {
|
||||||
|
if (!familiar.unlockCondition) return true;
|
||||||
|
|
||||||
|
const { type, value } = familiar.unlockCondition;
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'floor':
|
||||||
|
return maxFloor >= value;
|
||||||
|
case 'pact':
|
||||||
|
return signedPacts.includes(value);
|
||||||
|
case 'mana':
|
||||||
|
return totalManaGathered >= value;
|
||||||
|
case 'study':
|
||||||
|
return skillsLearned >= value;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Starting familiar (given to new players)
|
||||||
|
export const STARTING_FAMILIAR = 'manaWisp';
|
||||||
242
src/lib/game/data/loot-drops.ts
Normal file
242
src/lib/game/data/loot-drops.ts
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
// ─── Loot Drop Definitions ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
import type { LootDrop } from '../types';
|
||||||
|
|
||||||
|
export const LOOT_DROPS: Record<string, LootDrop> = {
|
||||||
|
// ─── Materials (used for crafting) ───
|
||||||
|
manaCrystalDust: {
|
||||||
|
id: 'manaCrystalDust',
|
||||||
|
name: 'Mana Crystal Dust',
|
||||||
|
rarity: 'common',
|
||||||
|
type: 'material',
|
||||||
|
minFloor: 1,
|
||||||
|
dropChance: 0.15,
|
||||||
|
},
|
||||||
|
arcaneShard: {
|
||||||
|
id: 'arcaneShard',
|
||||||
|
name: 'Arcane Shard',
|
||||||
|
rarity: 'uncommon',
|
||||||
|
type: 'material',
|
||||||
|
minFloor: 10,
|
||||||
|
dropChance: 0.10,
|
||||||
|
},
|
||||||
|
elementalCore: {
|
||||||
|
id: 'elementalCore',
|
||||||
|
name: 'Elemental Core',
|
||||||
|
rarity: 'rare',
|
||||||
|
type: 'material',
|
||||||
|
minFloor: 25,
|
||||||
|
dropChance: 0.08,
|
||||||
|
},
|
||||||
|
voidEssence: {
|
||||||
|
id: 'voidEssence',
|
||||||
|
name: 'Void Essence',
|
||||||
|
rarity: 'epic',
|
||||||
|
type: 'material',
|
||||||
|
minFloor: 50,
|
||||||
|
dropChance: 0.05,
|
||||||
|
guardianOnly: true,
|
||||||
|
},
|
||||||
|
celestialFragment: {
|
||||||
|
id: 'celestialFragment',
|
||||||
|
name: 'Celestial Fragment',
|
||||||
|
rarity: 'legendary',
|
||||||
|
type: 'material',
|
||||||
|
minFloor: 75,
|
||||||
|
dropChance: 0.02,
|
||||||
|
guardianOnly: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
// ─── Elemental Essence (grants elemental mana) ───
|
||||||
|
fireEssenceDrop: {
|
||||||
|
id: 'fireEssenceDrop',
|
||||||
|
name: 'Fire Essence',
|
||||||
|
rarity: 'uncommon',
|
||||||
|
type: 'essence',
|
||||||
|
minFloor: 5,
|
||||||
|
dropChance: 0.12,
|
||||||
|
amount: { min: 5, max: 15 },
|
||||||
|
},
|
||||||
|
waterEssenceDrop: {
|
||||||
|
id: 'waterEssenceDrop',
|
||||||
|
name: 'Water Essence',
|
||||||
|
rarity: 'uncommon',
|
||||||
|
type: 'essence',
|
||||||
|
minFloor: 5,
|
||||||
|
dropChance: 0.12,
|
||||||
|
amount: { min: 5, max: 15 },
|
||||||
|
},
|
||||||
|
airEssenceDrop: {
|
||||||
|
id: 'airEssenceDrop',
|
||||||
|
name: 'Air Essence',
|
||||||
|
rarity: 'uncommon',
|
||||||
|
type: 'essence',
|
||||||
|
minFloor: 5,
|
||||||
|
dropChance: 0.12,
|
||||||
|
amount: { min: 5, max: 15 },
|
||||||
|
},
|
||||||
|
earthEssenceDrop: {
|
||||||
|
id: 'earthEssenceDrop',
|
||||||
|
name: 'Earth Essence',
|
||||||
|
rarity: 'uncommon',
|
||||||
|
type: 'essence',
|
||||||
|
minFloor: 5,
|
||||||
|
dropChance: 0.12,
|
||||||
|
amount: { min: 5, max: 15 },
|
||||||
|
},
|
||||||
|
lightEssenceDrop: {
|
||||||
|
id: 'lightEssenceDrop',
|
||||||
|
name: 'Light Essence',
|
||||||
|
rarity: 'rare',
|
||||||
|
type: 'essence',
|
||||||
|
minFloor: 20,
|
||||||
|
dropChance: 0.08,
|
||||||
|
amount: { min: 3, max: 10 },
|
||||||
|
},
|
||||||
|
darkEssenceDrop: {
|
||||||
|
id: 'darkEssenceDrop',
|
||||||
|
name: 'Dark Essence',
|
||||||
|
rarity: 'rare',
|
||||||
|
type: 'essence',
|
||||||
|
minFloor: 20,
|
||||||
|
dropChance: 0.08,
|
||||||
|
amount: { min: 3, max: 10 },
|
||||||
|
},
|
||||||
|
lifeEssenceDrop: {
|
||||||
|
id: 'lifeEssenceDrop',
|
||||||
|
name: 'Life Essence',
|
||||||
|
rarity: 'epic',
|
||||||
|
type: 'essence',
|
||||||
|
minFloor: 40,
|
||||||
|
dropChance: 0.05,
|
||||||
|
amount: { min: 2, max: 8 },
|
||||||
|
},
|
||||||
|
deathEssenceDrop: {
|
||||||
|
id: 'deathEssenceDrop',
|
||||||
|
name: 'Death Essence',
|
||||||
|
rarity: 'epic',
|
||||||
|
type: 'essence',
|
||||||
|
minFloor: 40,
|
||||||
|
dropChance: 0.05,
|
||||||
|
amount: { min: 2, max: 8 },
|
||||||
|
},
|
||||||
|
|
||||||
|
// ─── Raw Mana Drops ───
|
||||||
|
manaOrb: {
|
||||||
|
id: 'manaOrb',
|
||||||
|
name: 'Mana Orb',
|
||||||
|
rarity: 'common',
|
||||||
|
type: 'gold', // Uses gold type but gives raw mana
|
||||||
|
minFloor: 1,
|
||||||
|
dropChance: 0.20,
|
||||||
|
amount: { min: 10, max: 50 },
|
||||||
|
},
|
||||||
|
greaterManaOrb: {
|
||||||
|
id: 'greaterManaOrb',
|
||||||
|
name: 'Greater Mana Orb',
|
||||||
|
rarity: 'uncommon',
|
||||||
|
type: 'gold',
|
||||||
|
minFloor: 15,
|
||||||
|
dropChance: 0.10,
|
||||||
|
amount: { min: 50, max: 150 },
|
||||||
|
},
|
||||||
|
supremeManaOrb: {
|
||||||
|
id: 'supremeManaOrb',
|
||||||
|
name: 'Supreme Mana Orb',
|
||||||
|
rarity: 'rare',
|
||||||
|
type: 'gold',
|
||||||
|
minFloor: 35,
|
||||||
|
dropChance: 0.05,
|
||||||
|
amount: { min: 100, max: 500 },
|
||||||
|
},
|
||||||
|
|
||||||
|
// ─── Equipment Blueprints ───
|
||||||
|
staffBlueprint: {
|
||||||
|
id: 'staffBlueprint',
|
||||||
|
name: 'Staff Blueprint',
|
||||||
|
rarity: 'uncommon',
|
||||||
|
type: 'blueprint',
|
||||||
|
minFloor: 10,
|
||||||
|
dropChance: 0.03,
|
||||||
|
},
|
||||||
|
wandBlueprint: {
|
||||||
|
id: 'wandBlueprint',
|
||||||
|
name: 'Wand Blueprint',
|
||||||
|
rarity: 'rare',
|
||||||
|
type: 'blueprint',
|
||||||
|
minFloor: 20,
|
||||||
|
dropChance: 0.02,
|
||||||
|
},
|
||||||
|
robeBlueprint: {
|
||||||
|
id: 'robeBlueprint',
|
||||||
|
name: 'Mage Robe Blueprint',
|
||||||
|
rarity: 'rare',
|
||||||
|
type: 'blueprint',
|
||||||
|
minFloor: 25,
|
||||||
|
dropChance: 0.02,
|
||||||
|
},
|
||||||
|
artifactBlueprint: {
|
||||||
|
id: 'artifactBlueprint',
|
||||||
|
name: 'Artifact Blueprint',
|
||||||
|
rarity: 'legendary',
|
||||||
|
type: 'blueprint',
|
||||||
|
minFloor: 60,
|
||||||
|
dropChance: 0.01,
|
||||||
|
guardianOnly: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Rarity colors for UI
|
||||||
|
export const RARITY_COLORS: Record<string, { color: string; glow: string }> = {
|
||||||
|
common: { color: '#9CA3AF', glow: '#9CA3AF40' },
|
||||||
|
uncommon: { color: '#22C55E', glow: '#22C55E40' },
|
||||||
|
rare: { color: '#3B82F6', glow: '#3B82F640' },
|
||||||
|
epic: { color: '#A855F7', glow: '#A855F740' },
|
||||||
|
legendary: { color: '#F59E0B', glow: '#F59E0B60' },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get loot drops available at a given floor
|
||||||
|
export function getAvailableDrops(floor: number, isGuardian: boolean): LootDrop[] {
|
||||||
|
return Object.values(LOOT_DROPS).filter(drop => {
|
||||||
|
if (drop.minFloor > floor) return false;
|
||||||
|
if (drop.guardianOnly && !isGuardian) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Roll for loot drops
|
||||||
|
export function rollLootDrops(
|
||||||
|
floor: number,
|
||||||
|
isGuardian: boolean,
|
||||||
|
luckBonus: number = 0
|
||||||
|
): Array<{ drop: LootDrop; amount: number }> {
|
||||||
|
const available = getAvailableDrops(floor, isGuardian);
|
||||||
|
const drops: Array<{ drop: LootDrop; amount: number }> = [];
|
||||||
|
|
||||||
|
for (const drop of available) {
|
||||||
|
// Calculate adjusted drop chance
|
||||||
|
let chance = drop.dropChance;
|
||||||
|
chance *= (1 + luckBonus); // Apply luck bonus
|
||||||
|
|
||||||
|
// Guardian floors have 2x drop rate
|
||||||
|
if (isGuardian) chance *= 2;
|
||||||
|
|
||||||
|
// Cap at 50% for any single drop
|
||||||
|
chance = Math.min(0.5, chance);
|
||||||
|
|
||||||
|
if (Math.random() < chance) {
|
||||||
|
let amount = 1;
|
||||||
|
|
||||||
|
// For gold/essence types, roll amount
|
||||||
|
if (drop.amount) {
|
||||||
|
amount = Math.floor(
|
||||||
|
Math.random() * (drop.amount.max - drop.amount.min + 1) + drop.amount.min
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
drops.push({ drop, amount });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return drops;
|
||||||
|
}
|
||||||
367
src/lib/game/familiar-slice.ts
Normal file
367
src/lib/game/familiar-slice.ts
Normal file
@@ -0,0 +1,367 @@
|
|||||||
|
// ─── Familiar Slice ─────────────────────────────────────────────────────────────
|
||||||
|
// Actions and computations for the familiar system
|
||||||
|
|
||||||
|
import type { GameState, FamiliarInstance, FamiliarAbilityType } from './types';
|
||||||
|
import { FAMILIARS_DEF, getFamiliarXpRequired, getFamiliarAbilityValue, canUnlockFamiliar, STARTING_FAMILIAR } from './data/familiars';
|
||||||
|
import { HOURS_PER_TICK } from './constants';
|
||||||
|
|
||||||
|
// ─── Familiar Actions Interface ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface FamiliarActions {
|
||||||
|
// Summoning and management
|
||||||
|
summonFamiliar: (familiarId: string) => void;
|
||||||
|
setActiveFamiliar: (instanceIndex: number, active: boolean) => void;
|
||||||
|
setFamiliarNickname: (instanceIndex: number, nickname: string) => void;
|
||||||
|
|
||||||
|
// Progression
|
||||||
|
gainFamiliarXp: (amount: number, source: 'combat' | 'gather' | 'meditate' | 'study' | 'time') => void;
|
||||||
|
upgradeFamiliarAbility: (instanceIndex: number, abilityType: FamiliarAbilityType) => void;
|
||||||
|
|
||||||
|
// Computation
|
||||||
|
getActiveFamiliarBonuses: () => FamiliarBonuses;
|
||||||
|
getAvailableFamiliars: () => string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Computed Bonuses ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface FamiliarBonuses {
|
||||||
|
damageMultiplier: number;
|
||||||
|
manaRegenBonus: number;
|
||||||
|
autoGatherRate: number;
|
||||||
|
autoConvertRate: number;
|
||||||
|
critChanceBonus: number;
|
||||||
|
castSpeedMultiplier: number;
|
||||||
|
elementalDamageMultiplier: number;
|
||||||
|
lifeStealPercent: number;
|
||||||
|
thornsPercent: number;
|
||||||
|
insightMultiplier: number;
|
||||||
|
manaShieldAmount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_FAMILIAR_BONUSES: FamiliarBonuses = {
|
||||||
|
damageMultiplier: 1,
|
||||||
|
manaRegenBonus: 0,
|
||||||
|
autoGatherRate: 0,
|
||||||
|
autoConvertRate: 0,
|
||||||
|
critChanceBonus: 0,
|
||||||
|
castSpeedMultiplier: 1,
|
||||||
|
elementalDamageMultiplier: 1,
|
||||||
|
lifeStealPercent: 0,
|
||||||
|
thornsPercent: 0,
|
||||||
|
insightMultiplier: 1,
|
||||||
|
manaShieldAmount: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Familiar Slice Factory ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function createFamiliarSlice(
|
||||||
|
set: (fn: (state: GameState) => Partial<GameState>) => void,
|
||||||
|
get: () => GameState
|
||||||
|
): FamiliarActions {
|
||||||
|
return {
|
||||||
|
// Summon a new familiar
|
||||||
|
summonFamiliar: (familiarId: string) => {
|
||||||
|
const state = get();
|
||||||
|
const familiarDef = FAMILIARS_DEF[familiarId];
|
||||||
|
if (!familiarDef) return;
|
||||||
|
|
||||||
|
// Check if already owned
|
||||||
|
if (state.familiars.some(f => f.familiarId === familiarId)) return;
|
||||||
|
|
||||||
|
// Check unlock condition
|
||||||
|
if (!canUnlockFamiliar(
|
||||||
|
familiarDef,
|
||||||
|
state.maxFloorReached,
|
||||||
|
state.signedPacts,
|
||||||
|
state.totalManaGathered,
|
||||||
|
Object.keys(state.skills).length
|
||||||
|
)) return;
|
||||||
|
|
||||||
|
// Create new familiar instance
|
||||||
|
const newInstance: FamiliarInstance = {
|
||||||
|
familiarId,
|
||||||
|
level: 1,
|
||||||
|
bond: 0,
|
||||||
|
experience: 0,
|
||||||
|
abilities: familiarDef.abilities.map(a => ({
|
||||||
|
type: a.type,
|
||||||
|
level: 1,
|
||||||
|
})),
|
||||||
|
active: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add to familiars list
|
||||||
|
set((s) => ({
|
||||||
|
familiars: [...s.familiars, newInstance],
|
||||||
|
log: [`🌟 ${familiarDef.name} has answered your call!`, ...s.log.slice(0, 49)],
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
// Set a familiar as active/inactive
|
||||||
|
setActiveFamiliar: (instanceIndex: number, active: boolean) => {
|
||||||
|
const state = get();
|
||||||
|
if (instanceIndex < 0 || instanceIndex >= state.familiars.length) return;
|
||||||
|
|
||||||
|
const activeCount = state.familiars.filter(f => f.active).length;
|
||||||
|
|
||||||
|
// Check if we have slots available
|
||||||
|
if (active && activeCount >= state.activeFamiliarSlots) {
|
||||||
|
// Deactivate another familiar first
|
||||||
|
const newFamiliars = [...state.familiars];
|
||||||
|
const activeIndex = newFamiliars.findIndex(f => f.active);
|
||||||
|
if (activeIndex >= 0) {
|
||||||
|
newFamiliars[activeIndex] = { ...newFamiliars[activeIndex], active: false };
|
||||||
|
}
|
||||||
|
newFamiliars[instanceIndex] = { ...newFamiliars[instanceIndex], active };
|
||||||
|
set({ familiars: newFamiliars });
|
||||||
|
} else {
|
||||||
|
// Just toggle the familiar
|
||||||
|
const newFamiliars = [...state.familiars];
|
||||||
|
newFamiliars[instanceIndex] = { ...newFamiliars[instanceIndex], active };
|
||||||
|
set({ familiars: newFamiliars });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Set a familiar's nickname
|
||||||
|
setFamiliarNickname: (instanceIndex: number, nickname: string) => {
|
||||||
|
const state = get();
|
||||||
|
if (instanceIndex < 0 || instanceIndex >= state.familiars.length) return;
|
||||||
|
|
||||||
|
const newFamiliars = [...state.familiars];
|
||||||
|
newFamiliars[instanceIndex] = {
|
||||||
|
...newFamiliars[instanceIndex],
|
||||||
|
nickname: nickname || undefined
|
||||||
|
};
|
||||||
|
set({ familiars: newFamiliars });
|
||||||
|
},
|
||||||
|
|
||||||
|
// Grant XP to all active familiars
|
||||||
|
gainFamiliarXp: (amount: number, _source: 'combat' | 'gather' | 'meditate' | 'study' | 'time') => {
|
||||||
|
const state = get();
|
||||||
|
if (state.familiars.length === 0) return;
|
||||||
|
|
||||||
|
const newFamiliars = [...state.familiars];
|
||||||
|
let leveled = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < newFamiliars.length; i++) {
|
||||||
|
const familiar = newFamiliars[i];
|
||||||
|
if (!familiar.active) continue;
|
||||||
|
|
||||||
|
const def = FAMILIARS_DEF[familiar.familiarId];
|
||||||
|
if (!def) continue;
|
||||||
|
|
||||||
|
// Apply bond multiplier to XP gain
|
||||||
|
const bondMultiplier = 1 + (familiar.bond / 100);
|
||||||
|
const xpGain = Math.floor(amount * bondMultiplier);
|
||||||
|
|
||||||
|
let newExp = familiar.experience + xpGain;
|
||||||
|
let newLevel = familiar.level;
|
||||||
|
|
||||||
|
// Check for level ups
|
||||||
|
while (newLevel < 100 && newExp >= getFamiliarXpRequired(newLevel)) {
|
||||||
|
newExp -= getFamiliarXpRequired(newLevel);
|
||||||
|
newLevel++;
|
||||||
|
leveled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gain bond passively
|
||||||
|
const newBond = Math.min(100, familiar.bond + 0.01);
|
||||||
|
|
||||||
|
newFamiliars[i] = {
|
||||||
|
...familiar,
|
||||||
|
level: newLevel,
|
||||||
|
experience: newExp,
|
||||||
|
bond: newBond,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
set({
|
||||||
|
familiars: newFamiliars,
|
||||||
|
totalFamiliarXpEarned: state.totalFamiliarXpEarned + amount,
|
||||||
|
...(leveled ? { log: ['📈 Your familiar has grown stronger!', ...state.log.slice(0, 49)] } : {}),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Upgrade a familiar's ability
|
||||||
|
upgradeFamiliarAbility: (instanceIndex: number, abilityType: FamiliarAbilityType) => {
|
||||||
|
const state = get();
|
||||||
|
if (instanceIndex < 0 || instanceIndex >= state.familiars.length) return;
|
||||||
|
|
||||||
|
const familiar = state.familiars[instanceIndex];
|
||||||
|
const def = FAMILIARS_DEF[familiar.familiarId];
|
||||||
|
if (!def) return;
|
||||||
|
|
||||||
|
// Find the ability
|
||||||
|
const abilityIndex = familiar.abilities.findIndex(a => a.type === abilityType);
|
||||||
|
if (abilityIndex < 0) return;
|
||||||
|
|
||||||
|
const ability = familiar.abilities[abilityIndex];
|
||||||
|
if (ability.level >= 10) return; // Max level
|
||||||
|
|
||||||
|
// Cost: level * 100 XP
|
||||||
|
const cost = ability.level * 100;
|
||||||
|
if (familiar.experience < cost) return;
|
||||||
|
|
||||||
|
// Upgrade
|
||||||
|
const newAbilities = [...familiar.abilities];
|
||||||
|
newAbilities[abilityIndex] = { ...ability, level: ability.level + 1 };
|
||||||
|
|
||||||
|
const newFamiliars = [...state.familiars];
|
||||||
|
newFamiliars[instanceIndex] = {
|
||||||
|
...familiar,
|
||||||
|
abilities: newAbilities,
|
||||||
|
experience: familiar.experience - cost,
|
||||||
|
};
|
||||||
|
|
||||||
|
set({ familiars: newFamiliars });
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get total bonuses from active familiars
|
||||||
|
getActiveFamiliarBonuses: (): FamiliarBonuses => {
|
||||||
|
const state = get();
|
||||||
|
const bonuses = { ...DEFAULT_FAMILIAR_BONUSES };
|
||||||
|
|
||||||
|
for (const familiar of state.familiars) {
|
||||||
|
if (!familiar.active) continue;
|
||||||
|
|
||||||
|
const def = FAMILIARS_DEF[familiar.familiarId];
|
||||||
|
if (!def) continue;
|
||||||
|
|
||||||
|
// Bond multiplier: up to 50% bonus at max bond
|
||||||
|
const bondMultiplier = 1 + (familiar.bond / 200);
|
||||||
|
|
||||||
|
for (const abilityInst of familiar.abilities) {
|
||||||
|
const abilityDef = def.abilities.find(a => a.type === abilityInst.type);
|
||||||
|
if (!abilityDef) continue;
|
||||||
|
|
||||||
|
const value = getFamiliarAbilityValue(abilityDef, familiar.level, abilityInst.level) * bondMultiplier;
|
||||||
|
|
||||||
|
switch (abilityInst.type) {
|
||||||
|
case 'damageBonus':
|
||||||
|
bonuses.damageMultiplier += value / 100;
|
||||||
|
break;
|
||||||
|
case 'manaRegen':
|
||||||
|
bonuses.manaRegenBonus += value;
|
||||||
|
break;
|
||||||
|
case 'autoGather':
|
||||||
|
bonuses.autoGatherRate += value;
|
||||||
|
break;
|
||||||
|
case 'autoConvert':
|
||||||
|
bonuses.autoConvertRate += value;
|
||||||
|
break;
|
||||||
|
case 'critChance':
|
||||||
|
bonuses.critChanceBonus += value;
|
||||||
|
break;
|
||||||
|
case 'castSpeed':
|
||||||
|
bonuses.castSpeedMultiplier += value / 100;
|
||||||
|
break;
|
||||||
|
case 'elementalBonus':
|
||||||
|
bonuses.elementalDamageMultiplier += value / 100;
|
||||||
|
break;
|
||||||
|
case 'lifeSteal':
|
||||||
|
bonuses.lifeStealPercent += value;
|
||||||
|
break;
|
||||||
|
case 'thorns':
|
||||||
|
bonuses.thornsPercent += value;
|
||||||
|
break;
|
||||||
|
case 'bonusGold':
|
||||||
|
bonuses.insightMultiplier += value / 100;
|
||||||
|
break;
|
||||||
|
case 'manaShield':
|
||||||
|
bonuses.manaShieldAmount += value;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bonuses;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get list of available (unlocked but not owned) familiars
|
||||||
|
getAvailableFamiliars: (): string[] => {
|
||||||
|
const state = get();
|
||||||
|
const owned = new Set(state.familiars.map(f => f.familiarId));
|
||||||
|
|
||||||
|
return Object.values(FAMILIARS_DEF)
|
||||||
|
.filter(f =>
|
||||||
|
!owned.has(f.id) &&
|
||||||
|
canUnlockFamiliar(
|
||||||
|
f,
|
||||||
|
state.maxFloorReached,
|
||||||
|
state.signedPacts,
|
||||||
|
state.totalManaGathered,
|
||||||
|
Object.keys(state.skills).length
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.map(f => f.id);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Familiar Tick Processing ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Process familiar-related tick effects (called from main tick)
|
||||||
|
export function processFamiliarTick(
|
||||||
|
state: Pick<GameState, 'familiars' | 'rawMana' | 'elements' | 'totalManaGathered' | 'activeFamiliarSlots'>,
|
||||||
|
familiarBonuses: FamiliarBonuses
|
||||||
|
): { rawMana: number; elements: GameState['elements']; totalManaGathered: number; gatherLog?: string } {
|
||||||
|
let rawMana = state.rawMana;
|
||||||
|
let elements = state.elements;
|
||||||
|
let totalManaGathered = state.totalManaGathered;
|
||||||
|
let gatherLog: string | undefined;
|
||||||
|
|
||||||
|
// Auto-gather from familiars
|
||||||
|
if (familiarBonuses.autoGatherRate > 0) {
|
||||||
|
const gathered = familiarBonuses.autoGatherRate * HOURS_PER_TICK;
|
||||||
|
rawMana += gathered;
|
||||||
|
totalManaGathered += gathered;
|
||||||
|
if (gathered >= 1) {
|
||||||
|
gatherLog = `✨ Familiars gathered ${Math.floor(gathered)} mana`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-convert from familiars
|
||||||
|
if (familiarBonuses.autoConvertRate > 0) {
|
||||||
|
const convertAmount = Math.min(
|
||||||
|
familiarBonuses.autoConvertRate * HOURS_PER_TICK,
|
||||||
|
Math.floor(rawMana / 5) // 5 raw mana per element
|
||||||
|
);
|
||||||
|
|
||||||
|
if (convertAmount > 0) {
|
||||||
|
// Find unlocked elements with space
|
||||||
|
const unlockedElements = Object.entries(elements)
|
||||||
|
.filter(([, e]) => e.unlocked && e.current < e.max)
|
||||||
|
.sort((a, b) => (b[1].max - b[1].current) - (a[1].max - a[1].current));
|
||||||
|
|
||||||
|
if (unlockedElements.length > 0) {
|
||||||
|
const [targetId, targetState] = unlockedElements[0];
|
||||||
|
const canConvert = Math.min(convertAmount, targetState.max - targetState.current);
|
||||||
|
rawMana -= canConvert * 5;
|
||||||
|
elements = {
|
||||||
|
...elements,
|
||||||
|
[targetId]: { ...targetState, current: targetState.current + canConvert },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { rawMana, elements, totalManaGathered, gatherLog };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grant starting familiar to new players
|
||||||
|
export function grantStartingFamiliar(): FamiliarInstance[] {
|
||||||
|
const starterDef = FAMILIARS_DEF[STARTING_FAMILIAR];
|
||||||
|
if (!starterDef) return [];
|
||||||
|
|
||||||
|
return [{
|
||||||
|
familiarId: STARTING_FAMILIAR,
|
||||||
|
level: 1,
|
||||||
|
bond: 0,
|
||||||
|
experience: 0,
|
||||||
|
abilities: starterDef.abilities.map(a => ({
|
||||||
|
type: a.type,
|
||||||
|
level: 1,
|
||||||
|
})),
|
||||||
|
active: true, // Start with familiar active
|
||||||
|
}];
|
||||||
|
}
|
||||||
@@ -39,6 +39,15 @@ import {
|
|||||||
} from './crafting-slice';
|
} from './crafting-slice';
|
||||||
import { EQUIPMENT_TYPES } from './data/equipment';
|
import { EQUIPMENT_TYPES } from './data/equipment';
|
||||||
import { ENCHANTMENT_EFFECTS, calculateEffectCapacityCost } from './data/enchantment-effects';
|
import { ENCHANTMENT_EFFECTS, calculateEffectCapacityCost } from './data/enchantment-effects';
|
||||||
|
import {
|
||||||
|
createFamiliarSlice,
|
||||||
|
processFamiliarTick,
|
||||||
|
grantStartingFamiliar,
|
||||||
|
type FamiliarActions,
|
||||||
|
type FamiliarBonuses,
|
||||||
|
DEFAULT_FAMILIAR_BONUSES,
|
||||||
|
} from './familiar-slice';
|
||||||
|
import { rollLootDrops } from './data/loot-drops';
|
||||||
|
|
||||||
// Default empty effects for when effects aren't provided
|
// Default empty effects for when effects aren't provided
|
||||||
const DEFAULT_EFFECTS: ComputedEffects = {
|
const DEFAULT_EFFECTS: ComputedEffects = {
|
||||||
@@ -528,14 +537,48 @@ function makeInitial(overrides: Partial<GameState> = {}): GameState {
|
|||||||
incursionStrength: 0,
|
incursionStrength: 0,
|
||||||
containmentWards: 0,
|
containmentWards: 0,
|
||||||
|
|
||||||
log: ['✨ The loop begins. You start with a Basic Staff (Mana Bolt) and civilian clothes. Gather your strength, mage.'],
|
// Combo System
|
||||||
|
combo: {
|
||||||
|
count: 0,
|
||||||
|
multiplier: 1,
|
||||||
|
lastCastTime: 0,
|
||||||
|
decayTimer: 0,
|
||||||
|
maxCombo: 0,
|
||||||
|
elementChain: [],
|
||||||
|
},
|
||||||
|
totalTicks: 0,
|
||||||
|
|
||||||
|
// Loot System
|
||||||
|
lootInventory: {
|
||||||
|
materials: {},
|
||||||
|
essence: {},
|
||||||
|
blueprints: [],
|
||||||
|
},
|
||||||
|
lootDropsToday: 0,
|
||||||
|
|
||||||
|
// Achievements
|
||||||
|
achievements: {
|
||||||
|
unlocked: [],
|
||||||
|
progress: {},
|
||||||
|
},
|
||||||
|
totalDamageDealt: 0,
|
||||||
|
totalSpellsCast: 0,
|
||||||
|
totalCraftsCompleted: 0,
|
||||||
|
|
||||||
|
// Familiars
|
||||||
|
familiars: grantStartingFamiliar(),
|
||||||
|
activeFamiliarSlots: 1,
|
||||||
|
familiarSummonProgress: 0,
|
||||||
|
totalFamiliarXpEarned: 0,
|
||||||
|
|
||||||
|
log: ['✨ The loop begins. You start with a Basic Staff (Mana Bolt) and civilian clothes. A friendly Mana Wisp floats nearby. Gather your strength, mage.'],
|
||||||
loopInsight: 0,
|
loopInsight: 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Game Store ───────────────────────────────────────────────────────────────
|
// ─── Game Store ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface GameStore extends GameState, CraftingActions {
|
interface GameStore extends GameState, CraftingActions, FamiliarActions {
|
||||||
// Actions
|
// Actions
|
||||||
tick: () => void;
|
tick: () => void;
|
||||||
gatherMana: () => void;
|
gatherMana: () => void;
|
||||||
@@ -573,6 +616,7 @@ export const useGameStore = create<GameStore>()(
|
|||||||
persist(
|
persist(
|
||||||
(set, get) => ({
|
(set, get) => ({
|
||||||
...makeInitial(),
|
...makeInitial(),
|
||||||
|
...createFamiliarSlice(set, get),
|
||||||
|
|
||||||
getMaxMana: () => computeMaxMana(get()),
|
getMaxMana: () => computeMaxMana(get()),
|
||||||
getRegen: () => computeRegen(get()),
|
getRegen: () => computeRegen(get()),
|
||||||
@@ -600,8 +644,16 @@ export const useGameStore = create<GameStore>()(
|
|||||||
// Compute unified effects (includes skill upgrades AND equipment enchantments)
|
// Compute unified effects (includes skill upgrades AND equipment enchantments)
|
||||||
const effects = getUnifiedEffects(state);
|
const effects = getUnifiedEffects(state);
|
||||||
|
|
||||||
|
// Compute familiar bonuses
|
||||||
|
const familiarBonuses = state.familiars.length > 0
|
||||||
|
? (() => {
|
||||||
|
const slice = createFamiliarSlice(set, get);
|
||||||
|
return slice.getActiveFamiliarBonuses();
|
||||||
|
})()
|
||||||
|
: DEFAULT_FAMILIAR_BONUSES;
|
||||||
|
|
||||||
const maxMana = computeMaxMana(state, effects);
|
const maxMana = computeMaxMana(state, effects);
|
||||||
const baseRegen = computeRegen(state, effects);
|
const baseRegen = computeRegen(state, effects) + familiarBonuses.manaRegenBonus;
|
||||||
|
|
||||||
// Time progression
|
// Time progression
|
||||||
let hour = state.hour + HOURS_PER_TICK;
|
let hour = state.hour + HOURS_PER_TICK;
|
||||||
@@ -658,13 +710,24 @@ export const useGameStore = create<GameStore>()(
|
|||||||
let rawMana = Math.min(state.rawMana + effectiveRegen * HOURS_PER_TICK, maxMana);
|
let rawMana = Math.min(state.rawMana + effectiveRegen * HOURS_PER_TICK, maxMana);
|
||||||
let totalManaGathered = state.totalManaGathered;
|
let totalManaGathered = state.totalManaGathered;
|
||||||
|
|
||||||
|
// Familiar auto-gather and auto-convert
|
||||||
|
let elements = state.elements;
|
||||||
|
if (familiarBonuses.autoGatherRate > 0 || familiarBonuses.autoConvertRate > 0) {
|
||||||
|
const familiarUpdates = processFamiliarTick(
|
||||||
|
{ rawMana, elements, totalManaGathered, familiars: state.familiars, activeFamiliarSlots: state.activeFamiliarSlots },
|
||||||
|
familiarBonuses
|
||||||
|
);
|
||||||
|
rawMana = Math.min(familiarUpdates.rawMana, maxMana);
|
||||||
|
elements = familiarUpdates.elements;
|
||||||
|
totalManaGathered = familiarUpdates.totalManaGathered;
|
||||||
|
}
|
||||||
|
|
||||||
// Study progress
|
// Study progress
|
||||||
let currentStudyTarget = state.currentStudyTarget;
|
let currentStudyTarget = state.currentStudyTarget;
|
||||||
let skills = state.skills;
|
let skills = state.skills;
|
||||||
let skillProgress = state.skillProgress;
|
let skillProgress = state.skillProgress;
|
||||||
let spells = state.spells;
|
let spells = state.spells;
|
||||||
let log = state.log;
|
let log = state.log;
|
||||||
let elements = state.elements;
|
|
||||||
let unlockedEffects = state.unlockedEffects;
|
let unlockedEffects = state.unlockedEffects;
|
||||||
|
|
||||||
if (state.currentAction === 'study' && currentStudyTarget) {
|
if (state.currentAction === 'study' && currentStudyTarget) {
|
||||||
@@ -738,9 +801,28 @@ export const useGameStore = create<GameStore>()(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Combat - MULTI-SPELL casting from all equipped weapons
|
// Combat - MULTI-SPELL casting from all equipped weapons
|
||||||
let { currentFloor, floorHP, floorMaxHP, maxFloorReached, signedPacts, equipmentSpellStates } = state;
|
let { currentFloor, floorHP, floorMaxHP, maxFloorReached, signedPacts, equipmentSpellStates, combo, totalTicks, lootInventory, achievements, totalDamageDealt, totalSpellsCast } = state;
|
||||||
const floorElement = getFloorElement(currentFloor);
|
const floorElement = getFloorElement(currentFloor);
|
||||||
|
|
||||||
|
// Increment total ticks
|
||||||
|
const newTotalTicks = totalTicks + 1;
|
||||||
|
|
||||||
|
// Combo decay - decay combo when not climbing or when decay timer expires
|
||||||
|
let newCombo = { ...combo };
|
||||||
|
if (state.currentAction !== 'climb') {
|
||||||
|
// Rapidly decay combo when not climbing
|
||||||
|
newCombo.count = Math.max(0, newCombo.count - 5);
|
||||||
|
newCombo.multiplier = 1 + newCombo.count * 0.02;
|
||||||
|
} else if (newCombo.count > 0) {
|
||||||
|
// Slow decay while climbing but not casting
|
||||||
|
newCombo.decayTimer--;
|
||||||
|
if (newCombo.decayTimer <= 0) {
|
||||||
|
newCombo.count = Math.max(0, newCombo.count - 2);
|
||||||
|
newCombo.multiplier = 1 + newCombo.count * 0.02;
|
||||||
|
newCombo.decayTimer = 10;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (state.currentAction === 'climb') {
|
if (state.currentAction === 'climb') {
|
||||||
// Get all spells from equipped caster weapons
|
// Get all spells from equipped caster weapons
|
||||||
const activeSpells = getActiveEquipmentSpells(state.equippedInstances, state.equipmentInstances);
|
const activeSpells = getActiveEquipmentSpells(state.equippedInstances, state.equipmentInstances);
|
||||||
@@ -768,7 +850,7 @@ export const useGameStore = create<GameStore>()(
|
|||||||
|
|
||||||
// Compute attack speed from quickCast skill and upgrades
|
// Compute attack speed from quickCast skill and upgrades
|
||||||
const baseAttackSpeed = 1 + (state.skills.quickCast || 0) * 0.05;
|
const baseAttackSpeed = 1 + (state.skills.quickCast || 0) * 0.05;
|
||||||
const totalAttackSpeed = baseAttackSpeed * effects.attackSpeedMultiplier;
|
const totalAttackSpeed = baseAttackSpeed * effects.attackSpeedMultiplier * familiarBonuses.castSpeedMultiplier;
|
||||||
|
|
||||||
// Process each active spell
|
// Process each active spell
|
||||||
for (const { spellId, equipmentId } of activeSpells) {
|
for (const { spellId, equipmentId } of activeSpells) {
|
||||||
@@ -796,9 +878,41 @@ export const useGameStore = create<GameStore>()(
|
|||||||
elements = afterCost.elements;
|
elements = afterCost.elements;
|
||||||
totalManaGathered += spellDef.cost.amount;
|
totalManaGathered += spellDef.cost.amount;
|
||||||
|
|
||||||
|
// Increment spell cast counter
|
||||||
|
totalSpellsCast++;
|
||||||
|
|
||||||
|
// ─── Combo System ───
|
||||||
|
// Build combo on each cast
|
||||||
|
newCombo.count = Math.min(100, newCombo.count + 1);
|
||||||
|
newCombo.lastCastTime = newTotalTicks;
|
||||||
|
newCombo.decayTimer = 10; // Reset decay timer
|
||||||
|
newCombo.maxCombo = Math.max(newCombo.maxCombo, newCombo.count);
|
||||||
|
|
||||||
|
// Track element chain
|
||||||
|
const spellElement = spellDef.elem;
|
||||||
|
newCombo.elementChain = [...newCombo.elementChain.slice(-2), spellElement];
|
||||||
|
|
||||||
|
// Calculate combo multiplier
|
||||||
|
let comboMult = 1 + newCombo.count * 0.02; // +2% per combo
|
||||||
|
|
||||||
|
// Element chain bonus: +25% if last 3 spells were different elements
|
||||||
|
const uniqueElements = new Set(newCombo.elementChain);
|
||||||
|
if (newCombo.elementChain.length === 3 && uniqueElements.size === 3) {
|
||||||
|
comboMult += 0.25;
|
||||||
|
// Log elemental chain occasionally
|
||||||
|
if (newCombo.count % 10 === 0) {
|
||||||
|
log = [`🌈 Elemental Chain! (${newCombo.elementChain.join(' → ')})`, ...log.slice(0, 49)];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
newCombo.multiplier = Math.min(3.0, comboMult);
|
||||||
|
|
||||||
// Calculate damage
|
// Calculate damage
|
||||||
let dmg = calcDamage(state, spellId, floorElement);
|
let dmg = calcDamage(state, spellId, floorElement);
|
||||||
|
|
||||||
|
// Apply combo multiplier FIRST
|
||||||
|
dmg *= newCombo.multiplier;
|
||||||
|
|
||||||
// Apply upgrade damage multipliers and bonuses
|
// Apply upgrade damage multipliers and bonuses
|
||||||
dmg = dmg * effects.baseDamageMultiplier + effects.baseDamageBonus;
|
dmg = dmg * effects.baseDamageMultiplier + effects.baseDamageBonus;
|
||||||
|
|
||||||
@@ -812,6 +926,15 @@ export const useGameStore = create<GameStore>()(
|
|||||||
dmg *= 1.5;
|
dmg *= 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Familiar bonuses
|
||||||
|
dmg *= familiarBonuses.damageMultiplier;
|
||||||
|
dmg *= familiarBonuses.elementalDamageMultiplier;
|
||||||
|
|
||||||
|
// Familiar crit chance bonus
|
||||||
|
if (Math.random() < familiarBonuses.critChanceBonus / 100) {
|
||||||
|
dmg *= 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
// Spell echo - chance to cast again
|
// Spell echo - chance to cast again
|
||||||
const echoChance = (skills.spellEcho || 0) * 0.1;
|
const echoChance = (skills.spellEcho || 0) * 0.1;
|
||||||
if (Math.random() < echoChance) {
|
if (Math.random() < echoChance) {
|
||||||
@@ -826,6 +949,15 @@ export const useGameStore = create<GameStore>()(
|
|||||||
rawMana = Math.min(rawMana + healAmount, maxMana);
|
rawMana = Math.min(rawMana + healAmount, maxMana);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Familiar lifesteal
|
||||||
|
if (familiarBonuses.lifeStealPercent > 0) {
|
||||||
|
const healAmount = dmg * (familiarBonuses.lifeStealPercent / 100);
|
||||||
|
rawMana = Math.min(rawMana + healAmount, maxMana);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track total damage for achievements
|
||||||
|
totalDamageDealt += dmg;
|
||||||
|
|
||||||
// Apply damage
|
// Apply damage
|
||||||
floorHP = Math.max(0, floorHP - dmg);
|
floorHP = Math.max(0, floorHP - dmg);
|
||||||
|
|
||||||
@@ -835,6 +967,33 @@ export const useGameStore = create<GameStore>()(
|
|||||||
if (floorHP <= 0) {
|
if (floorHP <= 0) {
|
||||||
// Floor cleared
|
// Floor cleared
|
||||||
const wasGuardian = GUARDIANS[currentFloor];
|
const wasGuardian = GUARDIANS[currentFloor];
|
||||||
|
|
||||||
|
// ─── Loot Drop System ───
|
||||||
|
const lootDrops = rollLootDrops(currentFloor, !!wasGuardian, 0);
|
||||||
|
|
||||||
|
for (const { drop, amount } of lootDrops) {
|
||||||
|
if (drop.type === 'material') {
|
||||||
|
lootInventory.materials[drop.id] = (lootInventory.materials[drop.id] || 0) + amount;
|
||||||
|
log = [`💎 Found: ${drop.name}!`, ...log.slice(0, 49)];
|
||||||
|
} else if (drop.type === 'essence' && drop.id) {
|
||||||
|
// Extract element from essence drop id (e.g., 'fireEssenceDrop' -> 'fire')
|
||||||
|
const element = drop.id.replace('EssenceDrop', '');
|
||||||
|
if (elements[element]) {
|
||||||
|
const gain = Math.min(amount, elements[element].max - elements[element].current);
|
||||||
|
elements[element] = { ...elements[element], current: elements[element].current + gain };
|
||||||
|
log = [`✨ Gained ${gain} ${element} essence!`, ...log.slice(0, 49)];
|
||||||
|
}
|
||||||
|
} else if (drop.type === 'gold') {
|
||||||
|
rawMana += amount;
|
||||||
|
log = [`💫 Gained ${amount} mana from ${drop.name}!`, ...log.slice(0, 49)];
|
||||||
|
} else if (drop.type === 'blueprint') {
|
||||||
|
if (!lootInventory.blueprints.includes(drop.id)) {
|
||||||
|
lootInventory.blueprints.push(drop.id);
|
||||||
|
log = [`📜 Discovered: ${drop.name}!`, ...log.slice(0, 49)];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (wasGuardian && !signedPacts.includes(currentFloor)) {
|
if (wasGuardian && !signedPacts.includes(currentFloor)) {
|
||||||
signedPacts = [...signedPacts, currentFloor];
|
signedPacts = [...signedPacts, currentFloor];
|
||||||
log = [`⚔️ ${wasGuardian.name} defeated! Pact signed! (${wasGuardian.pact}x)`, ...log.slice(0, 49)];
|
log = [`⚔️ ${wasGuardian.name} defeated! Pact signed! (${wasGuardian.pact}x)`, ...log.slice(0, 49)];
|
||||||
@@ -852,6 +1011,11 @@ export const useGameStore = create<GameStore>()(
|
|||||||
floorHP = floorMaxHP;
|
floorHP = floorMaxHP;
|
||||||
maxFloorReached = Math.max(maxFloorReached, currentFloor);
|
maxFloorReached = Math.max(maxFloorReached, currentFloor);
|
||||||
|
|
||||||
|
// Reset combo on floor change (partial reset - keep 50%)
|
||||||
|
newCombo.count = Math.floor(newCombo.count * 0.5);
|
||||||
|
newCombo.multiplier = 1 + newCombo.count * 0.02;
|
||||||
|
newCombo.elementChain = [];
|
||||||
|
|
||||||
// Reset ALL spell progress on floor change
|
// Reset ALL spell progress on floor change
|
||||||
equipmentSpellStates = equipmentSpellStates.map(s => ({ ...s, castProgress: 0 }));
|
equipmentSpellStates = equipmentSpellStates.map(s => ({ ...s, castProgress: 0 }));
|
||||||
spellState = { ...spellState, castProgress: 0 };
|
spellState = { ...spellState, castProgress: 0 };
|
||||||
@@ -866,6 +1030,9 @@ export const useGameStore = create<GameStore>()(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update combo state
|
||||||
|
combo = newCombo;
|
||||||
|
|
||||||
// Process crafting actions (design, prepare, enchant)
|
// Process crafting actions (design, prepare, enchant)
|
||||||
const craftingUpdates = processCraftingTick(
|
const craftingUpdates = processCraftingTick(
|
||||||
{
|
{
|
||||||
@@ -912,11 +1079,47 @@ export const useGameStore = create<GameStore>()(
|
|||||||
spells,
|
spells,
|
||||||
elements,
|
elements,
|
||||||
log,
|
log,
|
||||||
equipmentSpellStates,
|
equipmentSpellStates,
|
||||||
|
combo,
|
||||||
|
totalTicks: newTotalTicks,
|
||||||
|
lootInventory,
|
||||||
|
achievements,
|
||||||
|
totalDamageDealt,
|
||||||
|
totalSpellsCast,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Grant XP to active familiars based on activity
|
||||||
|
let familiars = state.familiars;
|
||||||
|
if (familiars.some(f => f.active)) {
|
||||||
|
let xpGain = 0;
|
||||||
|
let xpSource: 'combat' | 'gather' | 'meditate' | 'study' | 'time' = 'time';
|
||||||
|
|
||||||
|
if (state.currentAction === 'climb') {
|
||||||
|
xpGain = 2 * HOURS_PER_TICK; // 2 XP per hour in combat
|
||||||
|
xpSource = 'combat';
|
||||||
|
} else if (state.currentAction === 'meditate') {
|
||||||
|
xpGain = 1 * HOURS_PER_TICK;
|
||||||
|
xpSource = 'meditate';
|
||||||
|
} else if (state.currentAction === 'study') {
|
||||||
|
xpGain = 1.5 * HOURS_PER_TICK;
|
||||||
|
xpSource = 'study';
|
||||||
|
} else {
|
||||||
|
xpGain = 0.5 * HOURS_PER_TICK; // Passive XP
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update familiar XP and bond
|
||||||
|
familiars = familiars.map(f => {
|
||||||
|
if (!f.active) return f;
|
||||||
|
const bondMultiplier = 1 + (f.bond / 100);
|
||||||
|
const xpGained = Math.floor(xpGain * bondMultiplier);
|
||||||
|
const newXp = f.experience + xpGained;
|
||||||
|
const newBond = Math.min(100, f.bond + 0.02); // Slow bond gain
|
||||||
|
return { ...f, experience: newXp, bond: newBond };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
set({
|
set({
|
||||||
day,
|
day,
|
||||||
hour,
|
hour,
|
||||||
@@ -937,6 +1140,13 @@ export const useGameStore = create<GameStore>()(
|
|||||||
unlockedEffects,
|
unlockedEffects,
|
||||||
log,
|
log,
|
||||||
equipmentSpellStates,
|
equipmentSpellStates,
|
||||||
|
combo,
|
||||||
|
totalTicks: newTotalTicks,
|
||||||
|
lootInventory,
|
||||||
|
achievements,
|
||||||
|
totalDamageDealt,
|
||||||
|
totalSpellsCast,
|
||||||
|
familiars,
|
||||||
...craftingUpdates,
|
...craftingUpdates,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -213,6 +213,85 @@ export interface EquipmentSpellState {
|
|||||||
castProgress: number; // 0-1 progress toward next cast
|
castProgress: number; // 0-1 progress toward next cast
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Combo System ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface ComboState {
|
||||||
|
count: number; // Number of consecutive spell casts
|
||||||
|
multiplier: number; // Current damage multiplier (1.0 = base)
|
||||||
|
lastCastTime: number; // Game tick when last spell was cast
|
||||||
|
decayTimer: number; // Ticks until combo decays
|
||||||
|
maxCombo: number; // Highest combo achieved this loop
|
||||||
|
elementChain: string[]; // Last 3 elements cast (for elemental chain bonus)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const COMBO_CONFIG = {
|
||||||
|
maxCombo: 100, // Maximum combo count
|
||||||
|
baseDecayTicks: 10, // Ticks before combo starts decaying
|
||||||
|
decayRate: 2, // Combo lost per decay tick
|
||||||
|
baseMultiplier: 0.02, // +2% damage per combo
|
||||||
|
maxMultiplier: 3.0, // 300% max multiplier
|
||||||
|
elementalChainBonus: 0.25, // +25% for 3 different elements
|
||||||
|
perfectChainBonus: 0.5, // +50% for perfect element wheel
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ─── Loot Drop System ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface LootDrop {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
rarity: 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary';
|
||||||
|
type: 'equipment' | 'material' | 'gold' | 'essence' | 'blueprint';
|
||||||
|
minFloor: number; // Minimum floor for this drop
|
||||||
|
dropChance: number; // Base drop chance (0-1)
|
||||||
|
guardianOnly?: boolean; // Only drops from guardians
|
||||||
|
effect?: LootEffect;
|
||||||
|
amount?: { min: number; max: number }; // For gold/essence
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LootEffect {
|
||||||
|
type: 'manaBonus' | 'damageBonus' | 'regenBonus' | 'castSpeed' | 'critChance' | 'special';
|
||||||
|
value: number;
|
||||||
|
desc: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LootInventory {
|
||||||
|
materials: Record<string, number>; // materialId -> count
|
||||||
|
essence: Record<string, number>; // element -> count
|
||||||
|
blueprints: string[]; // unlocked blueprint IDs
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Achievement System ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface AchievementDef {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
desc: string;
|
||||||
|
category: 'combat' | 'progression' | 'crafting' | 'magic' | 'special';
|
||||||
|
requirement: AchievementRequirement;
|
||||||
|
reward: AchievementReward;
|
||||||
|
hidden?: boolean; // Hidden until unlocked
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AchievementRequirement {
|
||||||
|
type: 'floor' | 'combo' | 'spells' | 'damage' | 'mana' | 'craft' | 'pact' | 'time';
|
||||||
|
value: number;
|
||||||
|
subType?: string; // e.g., specific element, spell type
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AchievementReward {
|
||||||
|
insight?: number;
|
||||||
|
manaBonus?: number;
|
||||||
|
regenBonus?: number;
|
||||||
|
damageBonus?: number;
|
||||||
|
unlockEffect?: string; // Unlocks an enchantment effect
|
||||||
|
title?: string; // Display title
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AchievementState {
|
||||||
|
unlocked: string[]; // Achievement IDs
|
||||||
|
progress: Record<string, number>; // achievementId -> progress
|
||||||
|
}
|
||||||
|
|
||||||
export interface BlueprintDef {
|
export interface BlueprintDef {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -226,6 +305,69 @@ export interface BlueprintDef {
|
|||||||
learned: boolean;
|
learned: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Familiar System ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Familiar role determines their primary function
|
||||||
|
export type FamiliarRole = 'combat' | 'mana' | 'support' | 'guardian';
|
||||||
|
|
||||||
|
// Familiar ability types
|
||||||
|
export type FamiliarAbilityType =
|
||||||
|
| 'damageBonus' // +X% damage
|
||||||
|
| 'manaRegen' // +X mana regen
|
||||||
|
| 'autoGather' // Gathers mana automatically
|
||||||
|
| 'critChance' // +X% crit chance
|
||||||
|
| 'castSpeed' // +X% cast speed
|
||||||
|
| 'manaShield' // Absorbs damage, costs mana
|
||||||
|
| 'elementalBonus' // +X% elemental damage
|
||||||
|
| 'lifeSteal' // Heal on hit
|
||||||
|
| 'bonusGold' // +X% insight gain
|
||||||
|
| 'autoConvert' // Auto-converts mana to elements
|
||||||
|
| 'thorns'; // Reflects damage
|
||||||
|
|
||||||
|
export interface FamiliarAbility {
|
||||||
|
type: FamiliarAbilityType;
|
||||||
|
baseValue: number; // Base effect value
|
||||||
|
scalingPerLevel: number; // How much it increases per familiar level
|
||||||
|
desc: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Familiar definition (static data)
|
||||||
|
export interface FamiliarDef {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
desc: string;
|
||||||
|
role: FamiliarRole;
|
||||||
|
element: string; // Associated element
|
||||||
|
rarity: 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary';
|
||||||
|
abilities: FamiliarAbility[];
|
||||||
|
baseStats: {
|
||||||
|
power: number; // Affects ability strength
|
||||||
|
bond: number; // How fast bond grows
|
||||||
|
};
|
||||||
|
unlockCondition?: {
|
||||||
|
type: 'floor' | 'pact' | 'mana' | 'study';
|
||||||
|
value: number;
|
||||||
|
};
|
||||||
|
flavorText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Familiar instance (player's owned familiar)
|
||||||
|
export interface FamiliarInstance {
|
||||||
|
familiarId: string; // Reference to FamiliarDef
|
||||||
|
level: number; // 1-100
|
||||||
|
bond: number; // 0-100, affects power multiplier
|
||||||
|
experience: number; // XP towards next level
|
||||||
|
abilities: Array<{
|
||||||
|
type: FamiliarAbilityType;
|
||||||
|
level: number; // Ability level (1-10)
|
||||||
|
}>;
|
||||||
|
active: boolean; // Is this familiar currently summoned?
|
||||||
|
nickname?: string; // Optional custom name
|
||||||
|
}
|
||||||
|
|
||||||
|
// Familiar experience gain sources
|
||||||
|
export type FamiliarXpSource = 'combat' | 'gather' | 'meditate' | 'study' | 'time';
|
||||||
|
|
||||||
export type GameAction = 'meditate' | 'climb' | 'study' | 'craft' | 'repair' | 'convert' | 'design' | 'prepare' | 'enchant';
|
export type GameAction = 'meditate' | 'climb' | 'study' | 'craft' | 'repair' | 'convert' | 'design' | 'prepare' | 'enchant';
|
||||||
|
|
||||||
export interface ScheduleBlock {
|
export interface ScheduleBlock {
|
||||||
@@ -326,6 +468,26 @@ export interface GameState {
|
|||||||
incursionStrength: number;
|
incursionStrength: number;
|
||||||
containmentWards: number;
|
containmentWards: number;
|
||||||
|
|
||||||
|
// Combo System
|
||||||
|
combo: ComboState;
|
||||||
|
totalTicks: number; // Total ticks this loop (for combo timing)
|
||||||
|
|
||||||
|
// Loot System
|
||||||
|
lootInventory: LootInventory;
|
||||||
|
lootDropsToday: number; // Track drops for diminishing returns
|
||||||
|
|
||||||
|
// Achievements
|
||||||
|
achievements: AchievementState;
|
||||||
|
totalDamageDealt: number; // For damage achievements
|
||||||
|
totalSpellsCast: number; // For spell achievements
|
||||||
|
totalCraftsCompleted: number; // For craft achievements
|
||||||
|
|
||||||
|
// Familiars
|
||||||
|
familiars: FamiliarInstance[]; // Owned familiars
|
||||||
|
activeFamiliarSlots: number; // How many familiars can be active (default 1)
|
||||||
|
familiarSummonProgress: number; // Progress toward summoning new familiar (0-100)
|
||||||
|
totalFamiliarXpEarned: number; // Lifetime XP earned for familiars
|
||||||
|
|
||||||
// Log
|
// Log
|
||||||
log: string[];
|
log: string[];
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user