Initial commit

This commit is contained in:
Z User
2026-04-03 17:23:15 +00:00
commit 4f474dbcf3
192 changed files with 47527 additions and 0 deletions
+173
View File
@@ -0,0 +1,173 @@
'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'>;
}
export function AchievementsDisplay({ achievements, gameState }: AchievementsProps) {
const [expandedCategory, setExpandedCategory] = useState<string | null>('combat');
const categories = getAchievementsByCategory();
const unlockedCount = achievements.unlocked.length;
const totalCount = Object.keys(ACHIEVEMENTS).length;
// Calculate progress for each achievement
const getProgress = (achievementId: string): number => {
const achievement = ACHIEVEMENTS[achievementId];
if (!achievement) return 0;
if (achievements.unlocked.includes(achievementId)) return achievement.requirement.value;
const { type, subType } = achievement.requirement;
switch (type) {
case 'floor':
if (subType === 'noPacts') {
return gameState.maxFloorReached >= achievement.requirement.value && gameState.signedPacts.length === 0
? achievement.requirement.value
: gameState.maxFloorReached;
}
return gameState.maxFloorReached;
case 'spells':
return gameState.totalSpellsCast || 0;
case 'damage':
return gameState.totalDamageDealt || 0;
case 'mana':
return gameState.totalManaGathered || 0;
case 'pact':
return gameState.signedPacts.length;
case 'craft':
return gameState.totalCraftsCompleted || 0;
default:
return achievements.progress[achievementId] || 0;
}
};
return (
<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>
);
}
+86
View File
@@ -0,0 +1,86 @@
'use client';
import { Button } from '@/components/ui/button';
import { Sparkles, Swords, BookOpen, Target, FlaskConical } from 'lucide-react';
import type { GameAction } from '@/lib/game/types';
interface ActionButtonsProps {
currentAction: GameAction;
designProgress: { progress: number; required: number } | null;
preparationProgress: { progress: number; required: number } | null;
applicationProgress: { progress: number; required: number } | null;
setAction: (action: GameAction) => void;
}
export function ActionButtons({
currentAction,
designProgress,
preparationProgress,
applicationProgress,
setAction,
}: ActionButtonsProps) {
const actions: { id: GameAction; label: string; icon: typeof Swords }[] = [
{ id: 'meditate', label: 'Meditate', icon: Sparkles },
{ id: 'climb', label: 'Climb', icon: Swords },
{ id: 'study', label: 'Study', icon: BookOpen },
];
const hasDesignProgress = designProgress !== null;
const hasPrepProgress = preparationProgress !== null;
const hasAppProgress = applicationProgress !== null;
return (
<div className="space-y-2">
<div className="grid grid-cols-3 gap-2">
{actions.map(({ id, label, icon: Icon }) => (
<Button
key={id}
variant={currentAction === id ? 'default' : 'outline'}
size="sm"
className={`h-9 ${currentAction === id ? 'bg-blue-600 hover:bg-blue-700' : 'bg-gray-800/50 hover:bg-gray-700/50 border-gray-600'}`}
onClick={() => setAction(id)}
>
<Icon className="w-4 h-4 mr-1" />
{label}
</Button>
))}
</div>
{/* Crafting actions row - shown when there's active crafting progress */}
{(hasDesignProgress || hasPrepProgress || hasAppProgress) && (
<div className="grid grid-cols-3 gap-2">
<Button
variant={currentAction === 'design' ? 'default' : 'outline'}
size="sm"
disabled={!hasDesignProgress}
className={`h-9 ${currentAction === 'design' ? 'bg-purple-600 hover:bg-purple-700' : 'bg-gray-800/50 hover:bg-gray-700/50 border-gray-600'}`}
onClick={() => hasDesignProgress && setAction('design')}
>
<Target className="w-4 h-4 mr-1" />
Design
</Button>
<Button
variant={currentAction === 'prepare' ? 'default' : 'outline'}
size="sm"
disabled={!hasPrepProgress}
className={`h-9 ${currentAction === 'prepare' ? 'bg-purple-600 hover:bg-purple-700' : 'bg-gray-800/50 hover:bg-gray-700/50 border-gray-600'}`}
onClick={() => hasPrepProgress && setAction('prepare')}
>
<FlaskConical className="w-4 h-4 mr-1" />
Prepare
</Button>
<Button
variant={currentAction === 'enchant' ? 'default' : 'outline'}
size="sm"
disabled={!hasAppProgress}
className={`h-9 ${currentAction === 'enchant' ? 'bg-purple-600 hover:bg-purple-700' : 'bg-gray-800/50 hover:bg-gray-700/50 border-gray-600'}`}
onClick={() => hasAppProgress && setAction('enchant')}
>
<Sparkles className="w-4 h-4 mr-1" />
Enchant
</Button>
</div>
)}
</div>
);
}
+50
View File
@@ -0,0 +1,50 @@
'use client';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { MAX_DAY, INCURSION_START_DAY } from '@/lib/game/constants';
interface CalendarDisplayProps {
day: number;
hour: number;
incursionStrength?: number;
}
export function CalendarDisplay({ day }: CalendarDisplayProps) {
const days: React.ReactElement[] = [];
for (let d = 1; d <= MAX_DAY; d++) {
let dayClass = 'w-6 h-6 sm:w-7 sm:h-7 rounded text-xs flex items-center justify-center font-mono border transition-all ';
if (d < day) {
dayClass += 'bg-blue-900/30 border-blue-800/50 text-blue-400';
} else if (d === day) {
dayClass += 'bg-blue-600/40 border-blue-500 text-blue-300 shadow-lg shadow-blue-500/30';
} else {
dayClass += 'bg-gray-800/30 border-gray-700/50 text-gray-500';
}
if (d >= INCURSION_START_DAY) {
dayClass += ' border-red-600/50';
}
days.push(
<Tooltip key={d}>
<TooltipTrigger asChild>
<div className={dayClass}>
{d}
</div>
</TooltipTrigger>
<TooltipContent>
<p>Day {d}</p>
{d >= INCURSION_START_DAY && <p className="text-red-400">Incursion Active</p>}
</TooltipContent>
</Tooltip>
);
}
return (
<div className="grid grid-cols-7 sm:grid-cols-7 md:grid-cols-14 gap-1">
{days}
</div>
);
}
+143
View 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>
);
}
+161
View File
@@ -0,0 +1,161 @@
'use client';
import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';
import { Target, FlaskConical, Sparkles, Play, Pause, X } from 'lucide-react';
import { fmt } from '@/lib/game/store';
import { formatStudyTime } from '@/lib/game/formatting';
import type { EquipmentInstance, EnchantmentDesign } from '@/lib/game/types';
interface CraftingProgressProps {
designProgress: { designId: string; progress: number; required: number } | null;
preparationProgress: { equipmentInstanceId: string; progress: number; required: number; manaCostPaid: number } | null;
applicationProgress: { equipmentInstanceId: string; designId: string; progress: number; required: number; manaPerHour: number; paused: boolean } | null;
equipmentInstances: Record<string, EquipmentInstance>;
enchantmentDesigns: EnchantmentDesign[];
cancelDesign: () => void;
cancelPreparation: () => void;
pauseApplication: () => void;
resumeApplication: () => void;
cancelApplication: () => void;
}
export function CraftingProgress({
designProgress,
preparationProgress,
applicationProgress,
equipmentInstances,
enchantmentDesigns,
cancelDesign,
cancelPreparation,
pauseApplication,
resumeApplication,
cancelApplication,
}: CraftingProgressProps) {
const progressSections: React.ReactNode[] = [];
// Design progress
if (designProgress) {
const progressPct = Math.min(100, (designProgress.progress / designProgress.required) * 100);
progressSections.push(
<div key="design" className="p-3 rounded border border-cyan-600/50 bg-cyan-900/20">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Target className="w-4 h-4 text-cyan-400" />
<span className="text-sm font-semibold text-cyan-300">
Designing Enchantment
</span>
</div>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-gray-400 hover:text-white"
onClick={cancelDesign}
>
<X className="w-4 h-4" />
</Button>
</div>
<Progress value={progressPct} className="h-2 bg-gray-800" />
<div className="flex justify-between text-xs text-gray-400 mt-1">
<span>{formatStudyTime(designProgress.progress)} / {formatStudyTime(designProgress.required)}</span>
<span>Design Time</span>
</div>
</div>
);
}
// Preparation progress
if (preparationProgress) {
const progressPct = Math.min(100, (preparationProgress.progress / preparationProgress.required) * 100);
const instance = equipmentInstances[preparationProgress.equipmentInstanceId];
progressSections.push(
<div key="prepare" className="p-3 rounded border border-green-600/50 bg-green-900/20">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<FlaskConical className="w-4 h-4 text-green-400" />
<span className="text-sm font-semibold text-green-300">
Preparing {instance?.name || 'Equipment'}
</span>
</div>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-gray-400 hover:text-white"
onClick={cancelPreparation}
>
<X className="w-4 h-4" />
</Button>
</div>
<Progress value={progressPct} className="h-2 bg-gray-800" />
<div className="flex justify-between text-xs text-gray-400 mt-1">
<span>{formatStudyTime(preparationProgress.progress)} / {formatStudyTime(preparationProgress.required)}</span>
<span>Mana spent: {fmt(preparationProgress.manaCostPaid)}</span>
</div>
</div>
);
}
// Application progress
if (applicationProgress) {
const progressPct = Math.min(100, (applicationProgress.progress / applicationProgress.required) * 100);
const instance = equipmentInstances[applicationProgress.equipmentInstanceId];
const design = enchantmentDesigns.find(d => d.id === applicationProgress.designId);
progressSections.push(
<div key="enchant" className="p-3 rounded border border-amber-600/50 bg-amber-900/20">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Sparkles className="w-4 h-4 text-amber-400" />
<span className="text-sm font-semibold text-amber-300">
Enchanting {instance?.name || 'Equipment'}
</span>
</div>
<div className="flex gap-1">
{applicationProgress.paused ? (
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-green-400 hover:text-green-300"
onClick={resumeApplication}
>
<Play className="w-4 h-4" />
</Button>
) : (
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-yellow-400 hover:text-yellow-300"
onClick={pauseApplication}
>
<Pause className="w-4 h-4" />
</Button>
)}
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-gray-400 hover:text-white"
onClick={cancelApplication}
>
<X className="w-4 h-4" />
</Button>
</div>
</div>
<Progress value={progressPct} className="h-2 bg-gray-800" />
<div className="flex justify-between text-xs text-gray-400 mt-1">
<span>{formatStudyTime(applicationProgress.progress)} / {formatStudyTime(applicationProgress.required)}</span>
<span>Mana/hr: {fmt(applicationProgress.manaPerHour)}</span>
</div>
{design && (
<div className="text-xs text-amber-400/70 mt-1">
Applying: {design.name}
</div>
)}
</div>
);
}
return progressSections.length > 0 ? (
<div className="space-y-2">
{progressSections}
</div>
) : null;
}
+405
View File
@@ -0,0 +1,405 @@
'use client';
import { createContext, useContext, useMemo, type ReactNode } from 'react';
import { useSkillStore } from '@/lib/game/stores/skillStore';
import { useManaStore } from '@/lib/game/stores/manaStore';
import { usePrestigeStore } from '@/lib/game/stores/prestigeStore';
import { useUIStore } from '@/lib/game/stores/uiStore';
import { useCombatStore } from '@/lib/game/stores/combatStore';
import { useGameStore, useGameLoop } from '@/lib/game/stores/gameStore';
import { computeEffects, hasSpecial, SPECIAL_EFFECTS } from '@/lib/game/upgrade-effects';
import { getStudySpeedMultiplier, getStudyCostMultiplier } from '@/lib/game/constants';
import { getTierMultiplier } from '@/lib/game/skill-evolution';
import {
computeMaxMana,
computeRegen,
computeClickMana,
getMeditationBonus,
canAffordSpellCost,
calcDamage,
getFloorElement,
getBoonBonuses,
getIncursionStrength,
} from '@/lib/game/utils';
import {
ELEMENTS,
GUARDIANS,
SPELLS_DEF,
HOURS_PER_TICK,
TICK_MS,
} from '@/lib/game/constants';
import type { ElementDef, GuardianDef, SpellDef, GameAction } from '@/lib/game/types';
// Define a unified store type that combines all stores
interface UnifiedStore {
// From gameStore (coordinator)
day: number;
hour: number;
incursionStrength: number;
containmentWards: number;
initialized: boolean;
tick: () => void;
resetGame: () => void;
gatherMana: () => void;
startNewLoop: () => void;
// From manaStore
rawMana: number;
meditateTicks: number;
totalManaGathered: number;
elements: Record<string, { current: number; max: number; unlocked: boolean }>;
setRawMana: (amount: number) => void;
addRawMana: (amount: number, max: number) => void;
spendRawMana: (amount: number) => boolean;
convertMana: (element: string, amount: number) => boolean;
unlockElement: (element: string, cost: number) => boolean;
craftComposite: (target: string, recipe: string[]) => boolean;
// From skillStore
skills: Record<string, number>;
skillProgress: Record<string, number>;
skillUpgrades: Record<string, string[]>;
skillTiers: Record<string, number>;
paidStudySkills: Record<string, number>;
currentStudyTarget: { type: 'skill' | 'spell' | 'blueprint'; id: string; progress: number; required: number } | null;
parallelStudyTarget: { type: 'skill' | 'spell' | 'blueprint'; id: string; progress: number; required: number } | null;
setSkillLevel: (skillId: string, level: number) => void;
startStudyingSkill: (skillId: string, rawMana: number) => { started: boolean; cost: number };
startStudyingSpell: (spellId: string, rawMana: number, studyTime: number) => { started: boolean; cost: number };
cancelStudy: (retentionBonus: number) => void;
selectSkillUpgrade: (skillId: string, upgradeId: string) => void;
deselectSkillUpgrade: (skillId: string, upgradeId: string) => void;
commitSkillUpgrades: (skillId: string, upgradeIds: string[]) => void;
tierUpSkill: (skillId: string) => void;
getSkillUpgradeChoices: (skillId: string, milestone: 5 | 10) => { available: Array<{ id: string; name: string; desc: string; milestone: 5 | 10; effect: { type: string; stat?: string; value?: number; specialId?: string } }>; selected: string[] };
// From prestigeStore
loopCount: number;
insight: number;
totalInsight: number;
loopInsight: number;
prestigeUpgrades: Record<string, number>;
memorySlots: number;
pactSlots: number;
memories: Array<{ skillId: string; level: number; tier: number; upgrades: string[] }>;
defeatedGuardians: number[];
signedPacts: number[];
pactRitualFloor: number | null;
pactRitualProgress: number;
doPrestige: (id: string) => void;
addMemory: (memory: { skillId: string; level: number; tier: number; upgrades: string[] }) => void;
removeMemory: (skillId: string) => void;
clearMemories: () => void;
startPactRitual: (floor: number, rawMana: number) => boolean;
cancelPactRitual: () => void;
removePact: (floor: number) => void;
defeatGuardian: (floor: number) => void;
// From combatStore
currentFloor: number;
floorHP: number;
floorMaxHP: number;
maxFloorReached: number;
activeSpell: string;
currentAction: GameAction;
castProgress: number;
spells: Record<string, { learned: boolean; level: number; studyProgress?: number }>;
setAction: (action: GameAction) => void;
setSpell: (spellId: string) => void;
learnSpell: (spellId: string) => void;
advanceFloor: () => void;
// From uiStore
log: string[];
paused: boolean;
gameOver: boolean;
victory: boolean;
addLog: (message: string) => void;
togglePause: () => void;
setPaused: (paused: boolean) => void;
setGameOver: (gameOver: boolean, victory?: boolean) => void;
}
interface GameContextValue {
// Unified store for backward compatibility
store: UnifiedStore;
// Individual stores for direct access if needed
skillStore: ReturnType<typeof useSkillStore.getState>;
manaStore: ReturnType<typeof useManaStore.getState>;
prestigeStore: ReturnType<typeof usePrestigeStore.getState>;
uiStore: ReturnType<typeof useUIStore.getState>;
combatStore: ReturnType<typeof useCombatStore.getState>;
// Computed effects from upgrades
upgradeEffects: ReturnType<typeof computeEffects>;
// Derived stats
maxMana: number;
baseRegen: number;
clickMana: number;
floorElem: string;
floorElemDef: ElementDef | undefined;
isGuardianFloor: boolean;
currentGuardian: GuardianDef | undefined;
activeSpellDef: SpellDef | undefined;
meditationMultiplier: number;
incursionStrength: number;
studySpeedMult: number;
studyCostMult: number;
// Effective regen calculations
effectiveRegenWithSpecials: number;
manaCascadeBonus: number;
effectiveRegen: number;
// DPS calculation
dps: number;
// Boons
activeBoons: ReturnType<typeof getBoonBonuses>;
// Helpers
canCastSpell: (spellId: string) => boolean;
hasSpecial: (effects: ReturnType<typeof computeEffects>, specialId: string) => boolean;
SPECIAL_EFFECTS: typeof SPECIAL_EFFECTS;
}
const GameContext = createContext<GameContextValue | null>(null);
export function GameProvider({ children }: { children: ReactNode }) {
// Get all individual stores
const gameStore = useGameStore();
const skillState = useSkillStore();
const manaState = useManaStore();
const prestigeState = usePrestigeStore();
const uiState = useUIStore();
const combatState = useCombatStore();
// Create unified store object for backward compatibility
const unifiedStore = useMemo<UnifiedStore>(() => ({
// From gameStore
day: gameStore.day,
hour: gameStore.hour,
incursionStrength: gameStore.incursionStrength,
containmentWards: gameStore.containmentWards,
initialized: gameStore.initialized,
tick: gameStore.tick,
resetGame: gameStore.resetGame,
gatherMana: gameStore.gatherMana,
startNewLoop: gameStore.startNewLoop,
// From manaStore
rawMana: manaState.rawMana,
meditateTicks: manaState.meditateTicks,
totalManaGathered: manaState.totalManaGathered,
elements: manaState.elements,
setRawMana: manaState.setRawMana,
addRawMana: manaState.addRawMana,
spendRawMana: manaState.spendRawMana,
convertMana: manaState.convertMana,
unlockElement: manaState.unlockElement,
craftComposite: manaState.craftComposite,
// From skillStore
skills: skillState.skills,
skillProgress: skillState.skillProgress,
skillUpgrades: skillState.skillUpgrades,
skillTiers: skillState.skillTiers,
paidStudySkills: skillState.paidStudySkills,
currentStudyTarget: skillState.currentStudyTarget,
parallelStudyTarget: skillState.parallelStudyTarget,
setSkillLevel: skillState.setSkillLevel,
startStudyingSkill: skillState.startStudyingSkill,
startStudyingSpell: skillState.startStudyingSpell,
cancelStudy: skillState.cancelStudy,
selectSkillUpgrade: skillState.selectSkillUpgrade,
deselectSkillUpgrade: skillState.deselectSkillUpgrade,
commitSkillUpgrades: skillState.commitSkillUpgrades,
tierUpSkill: skillState.tierUpSkill,
getSkillUpgradeChoices: skillState.getSkillUpgradeChoices,
// From prestigeStore
loopCount: prestigeState.loopCount,
insight: prestigeState.insight,
totalInsight: prestigeState.totalInsight,
loopInsight: prestigeState.loopInsight,
prestigeUpgrades: prestigeState.prestigeUpgrades,
memorySlots: prestigeState.memorySlots,
pactSlots: prestigeState.pactSlots,
memories: prestigeState.memories,
defeatedGuardians: prestigeState.defeatedGuardians,
signedPacts: prestigeState.signedPacts,
pactRitualFloor: prestigeState.pactRitualFloor,
pactRitualProgress: prestigeState.pactRitualProgress,
doPrestige: prestigeState.doPrestige,
addMemory: prestigeState.addMemory,
removeMemory: prestigeState.removeMemory,
clearMemories: prestigeState.clearMemories,
startPactRitual: prestigeState.startPactRitual,
cancelPactRitual: prestigeState.cancelPactRitual,
removePact: prestigeState.removePact,
defeatGuardian: prestigeState.defeatGuardian,
// From combatStore
currentFloor: combatState.currentFloor,
floorHP: combatState.floorHP,
floorMaxHP: combatState.floorMaxHP,
maxFloorReached: combatState.maxFloorReached,
activeSpell: combatState.activeSpell,
currentAction: combatState.currentAction,
castProgress: combatState.castProgress,
spells: combatState.spells,
setAction: combatState.setAction,
setSpell: combatState.setSpell,
learnSpell: combatState.learnSpell,
advanceFloor: combatState.advanceFloor,
// From uiStore
log: uiState.logs,
paused: uiState.paused,
gameOver: uiState.gameOver,
victory: uiState.victory,
addLog: uiState.addLog,
togglePause: uiState.togglePause,
setPaused: uiState.setPaused,
setGameOver: uiState.setGameOver,
}), [gameStore, skillState, manaState, prestigeState, uiState, combatState]);
// Computed effects from upgrades
const upgradeEffects = useMemo(
() => computeEffects(skillState.skillUpgrades || {}, skillState.skillTiers || {}),
[skillState.skillUpgrades, skillState.skillTiers]
);
// Create a minimal state object for compute functions
const stateForCompute = useMemo(() => ({
skills: skillState.skills,
prestigeUpgrades: prestigeState.prestigeUpgrades,
skillUpgrades: skillState.skillUpgrades,
skillTiers: skillState.skillTiers,
signedPacts: prestigeState.signedPacts,
rawMana: manaState.rawMana,
meditateTicks: manaState.meditateTicks,
incursionStrength: gameStore.incursionStrength,
}), [skillState, prestigeState, manaState, gameStore.incursionStrength]);
// Derived stats
const maxMana = useMemo(
() => computeMaxMana(stateForCompute, upgradeEffects),
[stateForCompute, upgradeEffects]
);
const baseRegen = useMemo(
() => computeRegen(stateForCompute, upgradeEffects),
[stateForCompute, upgradeEffects]
);
const clickMana = useMemo(() => computeClickMana(stateForCompute), [stateForCompute]);
// Floor element from combat store
const floorElem = useMemo(() => getFloorElement(combatState.currentFloor), [combatState.currentFloor]);
const floorElemDef = ELEMENTS[floorElem];
const isGuardianFloor = !!GUARDIANS[combatState.currentFloor];
const currentGuardian = GUARDIANS[combatState.currentFloor];
const activeSpellDef = SPELLS_DEF[combatState.activeSpell];
const meditationMultiplier = useMemo(
() => getMeditationBonus(manaState.meditateTicks, skillState.skills, upgradeEffects.meditationEfficiency),
[manaState.meditateTicks, skillState.skills, upgradeEffects.meditationEfficiency]
);
const incursionStrength = useMemo(
() => getIncursionStrength(gameStore.day, gameStore.hour),
[gameStore.day, gameStore.hour]
);
const studySpeedMult = useMemo(
() => getStudySpeedMultiplier(skillState.skills),
[skillState.skills]
);
const studyCostMult = useMemo(
() => getStudyCostMultiplier(skillState.skills),
[skillState.skills]
);
// Effective regen calculations
const effectiveRegenWithSpecials = baseRegen * (1 - incursionStrength);
const manaCascadeBonus = hasSpecial(upgradeEffects, SPECIAL_EFFECTS.MANA_CASCADE)
? Math.floor(maxMana / 100) * 0.1
: 0;
const effectiveRegen = (effectiveRegenWithSpecials + manaCascadeBonus) * meditationMultiplier;
// Active boons
const activeBoons = useMemo(
() => getBoonBonuses(prestigeState.signedPacts),
[prestigeState.signedPacts]
);
// DPS calculation - based on active spell, attack speed, and damage
const dps = useMemo(() => {
if (!activeSpellDef) return 0;
const baseDmg = calcDamage(
{ skills: skillState.skills, signedPacts: prestigeState.signedPacts },
combatState.activeSpell,
floorElem
);
const dmgWithEffects = baseDmg * upgradeEffects.baseDamageMultiplier + upgradeEffects.baseDamageBonus;
const attackSpeed = (1 + (skillState.skills.quickCast || 0) * 0.05) * upgradeEffects.attackSpeedMultiplier;
const castSpeed = activeSpellDef.castSpeed || 1;
return dmgWithEffects * attackSpeed * castSpeed;
}, [activeSpellDef, skillState.skills, prestigeState.signedPacts, floorElem, upgradeEffects, combatState.activeSpell]);
// Helper functions
const canCastSpell = (spellId: string): boolean => {
const spell = SPELLS_DEF[spellId];
if (!spell) return false;
return canAffordSpellCost(spell.cost, manaState.rawMana, manaState.elements);
};
const value: GameContextValue = {
store: unifiedStore,
skillStore: skillState,
manaStore: manaState,
prestigeStore: prestigeState,
uiStore: uiState,
combatStore: combatState,
upgradeEffects,
maxMana,
baseRegen,
clickMana,
floorElem,
floorElemDef,
isGuardianFloor,
currentGuardian,
activeSpellDef,
meditationMultiplier,
incursionStrength,
studySpeedMult,
studyCostMult,
effectiveRegenWithSpecials,
manaCascadeBonus,
effectiveRegen,
dps,
activeBoons,
canCastSpell,
hasSpecial,
SPECIAL_EFFECTS,
};
return <GameContext.Provider value={value}>{children}</GameContext.Provider>;
}
export function useGameContext() {
const context = useContext(GameContext);
if (!context) {
throw new Error('useGameContext must be used within a GameProvider');
}
return context;
}
// Re-export useGameLoop for convenience
export { useGameLoop };
+193
View File
@@ -0,0 +1,193 @@
'use client';
import { useGameStore, fmt, fmtDec, computePactMultiplier } from '@/lib/game/store';
import { ELEMENTS, GUARDIANS, PRESTIGE_DEF } from '@/lib/game/constants';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { RotateCcw } from 'lucide-react';
export function GrimoireTab() {
const store = useGameStore();
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Current Status */}
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 game-panel-title text-xs">Loop Status</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div className="p-3 bg-gray-800/50 rounded">
<div className="text-2xl font-bold text-amber-400 game-mono">{store.loopCount}</div>
<div className="text-xs text-gray-400">Loops Completed</div>
</div>
<div className="p-3 bg-gray-800/50 rounded">
<div className="text-2xl font-bold text-purple-400 game-mono">{fmt(store.insight)}</div>
<div className="text-xs text-gray-400">Current Insight</div>
</div>
<div className="p-3 bg-gray-800/50 rounded">
<div className="text-2xl font-bold text-blue-400 game-mono">{fmt(store.totalInsight)}</div>
<div className="text-xs text-gray-400">Total Insight</div>
</div>
<div className="p-3 bg-gray-800/50 rounded">
<div className="text-2xl font-bold text-green-400 game-mono">{store.memorySlots}</div>
<div className="text-xs text-gray-400">Memory Slots</div>
</div>
</div>
</CardContent>
</Card>
{/* Signed Pacts */}
<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 game-panel-title text-xs">Signed Pacts ({store.signedPacts.length}/{1 + (store.prestigeUpgrades.pactCapacity || 0)})</CardTitle>
{store.signedPacts.length > 1 && (
<div className="text-xs text-gray-400">
Combined: ×{fmtDec(computePactMultiplier(store), 2)} damage
</div>
)}
</div>
</CardHeader>
<CardContent>
{store.signedPacts.length === 0 ? (
<div className="text-gray-500 text-sm">No pacts signed yet. Defeat guardians to earn pacts.</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{store.signedPacts.map((floor) => {
const guardian = GUARDIANS[floor];
if (!guardian) return null;
return (
<div
key={floor}
className="p-3 rounded border"
style={{ borderColor: guardian.color, backgroundColor: `${guardian.color}10` }}
>
<div className="flex items-center justify-between mb-2">
<div>
<div className="font-semibold text-sm" style={{ color: guardian.color }}>
{guardian.name}
</div>
<div className="text-xs text-gray-400">{guardian.theme} Floor {floor}</div>
</div>
<Badge style={{ backgroundColor: `${guardian.color}30`, color: guardian.color }}>
{guardian.damageMultiplier}x dmg / {guardian.insightMultiplier}x insight
</Badge>
</div>
{/* Unique Boon */}
{guardian.uniqueBoon && (
<div className="mb-2 p-2 bg-cyan-900/20 rounded border border-cyan-800/30">
<div className="text-xs font-semibold text-cyan-300"> {guardian.uniqueBoon.name}</div>
<div className="text-xs text-cyan-200/70">{guardian.uniqueBoon.desc}</div>
</div>
)}
{/* Perks & Costs */}
<div className="grid grid-cols-2 gap-2 text-xs">
{guardian.perks.length > 0 && (
<div>
<div className="text-green-400 font-semibold mb-1">Perks</div>
{guardian.perks.map(perk => (
<div key={perk.id} className="text-green-300/70"> {perk.desc}</div>
))}
</div>
)}
{guardian.costs.length > 0 && (
<div>
<div className="text-red-400 font-semibold mb-1">Costs</div>
{guardian.costs.map(cost => (
<div key={cost.id} className="text-red-300/70"> {cost.desc}</div>
))}
</div>
)}
</div>
{/* Unlocked Mana */}
{guardian.unlocksMana.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{guardian.unlocksMana.map(elemId => {
const elem = ELEMENTS[elemId];
return (
<Badge key={elemId} variant="outline" className="text-xs" style={{ borderColor: elem?.color, color: elem?.color }}>
{elem?.sym}
</Badge>
);
})}
</div>
)}
</div>
);
})}
</div>
)}
</CardContent>
</Card>
{/* Prestige Upgrades */}
<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">Insight Upgrades (Permanent)</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{Object.entries(PRESTIGE_DEF).map(([id, def]) => {
const level = store.prestigeUpgrades[id] || 0;
const maxed = level >= def.max;
const canBuy = !maxed && store.insight >= def.cost;
return (
<div
key={id}
className="p-3 rounded border border-gray-700 bg-gray-800/50"
>
<div className="flex items-center justify-between mb-2">
<div className="font-semibold text-amber-400 text-sm">{def.name}</div>
<Badge variant="outline" className="text-xs">
{level}/{def.max}
</Badge>
</div>
<div className="text-xs text-gray-400 italic mb-2">{def.desc}</div>
<Button
size="sm"
variant={canBuy ? 'default' : 'outline'}
className="w-full"
disabled={!canBuy}
onClick={() => store.doPrestige(id)}
>
{maxed ? 'Maxed' : `Upgrade (${fmt(def.cost)} insight)`}
</Button>
</div>
);
})}
</div>
{/* Reset Game Button */}
<div className="mt-4 pt-4 border-t border-gray-700">
<div className="flex items-center justify-between">
<div>
<div className="text-sm text-gray-400">Reset All Progress</div>
<div className="text-xs text-gray-500">Clear all data and start fresh</div>
</div>
<Button
size="sm"
variant="outline"
className="border-red-600/50 text-red-400 hover:bg-red-900/20"
onClick={() => {
if (confirm('Are you sure you want to reset ALL progress? This cannot be undone!')) {
store.resetGame();
}
}}
>
<RotateCcw className="w-4 h-4 mr-1" />
Reset
</Button>
</div>
</div>
</CardContent>
</Card>
</div>
);
}
+171
View File
@@ -0,0 +1,171 @@
'use client';
import { useState } from 'react';
import { useGameStore } from '@/lib/game/store';
import { ELEMENTS, MANA_PER_ELEMENT } from '@/lib/game/constants';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
export function LabTab() {
const store = useGameStore();
const [convertTarget, setConvertTarget] = useState('fire');
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Elemental Mana Display */}
<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">Elemental Mana</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 lg:grid-cols-8 gap-2">
{Object.entries(store.elements)
.filter(([, state]) => state.unlocked && state.current >= 1)
.map(([id, state]) => {
const def = ELEMENTS[id];
const isSelected = convertTarget === id;
return (
<div
key={id}
className={`p-2 rounded border cursor-pointer transition-all ${isSelected ? 'border-blue-500 bg-blue-900/20' : 'border-gray-700 bg-gray-800/50 hover:border-gray-600'}`}
style={{ borderColor: isSelected ? def?.color : undefined }}
onClick={() => setConvertTarget(id)}
>
<div className="text-lg text-center">{def?.sym}</div>
<div className="text-xs font-semibold text-center" style={{ color: def?.color }}>{def?.name}</div>
<div className="text-xs text-gray-400 game-mono text-center">{state.current}/{state.max}</div>
</div>
);
})}
</div>
</CardContent>
</Card>
{/* Element Conversion */}
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 game-panel-title text-xs">Element Conversion</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-gray-400 mb-3">
Convert raw mana to elemental mana (100:1 ratio)
</p>
<div className="flex gap-2 flex-wrap">
<Button
size="sm"
variant="outline"
onClick={() => store.convertMana(convertTarget, 1)}
disabled={!store.elements[convertTarget]?.unlocked || store.rawMana < MANA_PER_ELEMENT}
>
+1 ({MANA_PER_ELEMENT})
</Button>
<Button
size="sm"
variant="outline"
onClick={() => store.convertMana(convertTarget, 10)}
disabled={!store.elements[convertTarget]?.unlocked || store.rawMana < MANA_PER_ELEMENT * 10}
>
+10 ({MANA_PER_ELEMENT * 10})
</Button>
<Button
size="sm"
variant="outline"
onClick={() => store.convertMana(convertTarget, 100)}
disabled={!store.elements[convertTarget]?.unlocked || store.rawMana < MANA_PER_ELEMENT * 100}
>
+100 ({MANA_PER_ELEMENT * 100})
</Button>
</div>
</CardContent>
</Card>
{/* Unlock Elements */}
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 game-panel-title text-xs">Unlock Elements</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-gray-400 mb-3">
Unlock new elemental affinities (500 mana each)
</p>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{Object.entries(store.elements)
.filter(([id, state]) => !state.unlocked && ELEMENTS[id]?.cat !== 'exotic')
.map(([id]) => {
const def = ELEMENTS[id];
return (
<div
key={id}
className="p-2 rounded border border-gray-700 bg-gray-800/50"
>
<div className="text-lg opacity-50">{def?.sym}</div>
<div className="text-xs font-semibold text-gray-500">{def?.name}</div>
<Button
size="sm"
variant="outline"
className="mt-1 w-full"
disabled={store.rawMana < 500}
onClick={() => store.unlockElement(id)}
>
Unlock
</Button>
</div>
);
})}
</div>
</CardContent>
</Card>
{/* Composite Crafting */}
<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">Composite & Exotic Crafting</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
{Object.entries(ELEMENTS)
.filter(([, def]) => def.recipe)
.map(([id, def]) => {
const state = store.elements[id];
const recipe = def.recipe!;
const canCraft = recipe.every(
(r) => (store.elements[r]?.current || 0) >= recipe.filter((x) => x === r).length
);
return (
<div
key={id}
className={`p-3 rounded border ${canCraft ? 'border-gray-600 bg-gray-800/50' : 'border-gray-700 bg-gray-800/30 opacity-50'}`}
>
<div className="flex items-center gap-2 mb-2">
<span className="text-2xl">{def.sym}</span>
<div>
<div className="text-sm font-semibold" style={{ color: def.color }}>
{def.name}
</div>
<div className="text-xs text-gray-500">{def.cat}</div>
</div>
</div>
<div className="text-xs text-gray-400 mb-2">
{recipe.map((r) => ELEMENTS[r]?.sym).join(' + ')}
</div>
<Button
size="sm"
variant={canCraft ? 'default' : 'outline'}
className="w-full"
disabled={!canCraft}
onClick={() => store.craftComposite(id)}
>
Craft
</Button>
</div>
);
})}
</div>
</CardContent>
</Card>
</div>
);
}
+460
View File
@@ -0,0 +1,460 @@
'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 { Separator } from '@/components/ui/separator';
import { Input } from '@/components/ui/input';
import {
Gem, Sparkles, Scroll, Droplet, Trash2, Search,
Package, Sword, Shield, Shirt, Crown, ArrowUpDown,
Wrench, AlertTriangle
} from 'lucide-react';
import type { LootInventory as LootInventoryType, EquipmentInstance, ElementState } from '@/lib/game/types';
import { LOOT_DROPS, RARITY_COLORS } from '@/lib/game/data/loot-drops';
import { EQUIPMENT_TYPES } from '@/lib/game/data/equipment';
import { ELEMENTS } from '@/lib/game/constants';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
interface LootInventoryProps {
inventory: LootInventoryType;
elements?: Record<string, ElementState>;
equipmentInstances?: Record<string, EquipmentInstance>;
onDeleteMaterial?: (materialId: string, amount: number) => void;
onDeleteEquipment?: (instanceId: string) => void;
}
type SortMode = 'name' | 'rarity' | 'count';
type FilterMode = 'all' | 'materials' | 'essence' | 'blueprints' | 'equipment';
const RARITY_ORDER = {
common: 0,
uncommon: 1,
rare: 2,
epic: 3,
legendary: 4,
mythic: 5,
};
const CATEGORY_ICONS: Record<string, typeof Sword> = {
caster: Sword,
shield: Shield,
catalyst: Sparkles,
head: Crown,
body: Shirt,
hands: Wrench,
feet: Package,
accessory: Gem,
};
export function LootInventoryDisplay({
inventory,
elements,
equipmentInstances = {},
onDeleteMaterial,
onDeleteEquipment,
}: LootInventoryProps) {
const [searchTerm, setSearchTerm] = useState('');
const [sortMode, setSortMode] = useState<SortMode>('rarity');
const [filterMode, setFilterMode] = useState<FilterMode>('all');
const [deleteConfirm, setDeleteConfirm] = useState<{ type: 'material' | 'equipment'; id: string; name: string } | null>(null);
// Count items
const materialCount = Object.values(inventory.materials).reduce((a, b) => a + b, 0);
const essenceCount = elements ? Object.values(elements).reduce((a, e) => a + e.current, 0) : 0;
const blueprintCount = inventory.blueprints.length;
const equipmentCount = Object.keys(equipmentInstances).length;
const totalItems = materialCount + blueprintCount + equipmentCount;
// Filter and sort materials
const filteredMaterials = Object.entries(inventory.materials)
.filter(([id, count]) => {
if (count <= 0) return false;
const drop = LOOT_DROPS[id];
if (!drop) return false;
if (searchTerm && !drop.name.toLowerCase().includes(searchTerm.toLowerCase())) return false;
return true;
})
.sort(([aId, aCount], [bId, bCount]) => {
const aDrop = LOOT_DROPS[aId];
const bDrop = LOOT_DROPS[bId];
if (!aDrop || !bDrop) return 0;
switch (sortMode) {
case 'name':
return aDrop.name.localeCompare(bDrop.name);
case 'rarity':
return RARITY_ORDER[bDrop.rarity] - RARITY_ORDER[aDrop.rarity];
case 'count':
return bCount - aCount;
default:
return 0;
}
});
// Filter and sort essence
const filteredEssence = elements
? Object.entries(elements)
.filter(([id, state]) => {
if (!state.unlocked || state.current <= 0) return false;
if (searchTerm && !ELEMENTS[id]?.name.toLowerCase().includes(searchTerm.toLowerCase())) return false;
return true;
})
.sort(([aId, aState], [bId, bState]) => {
switch (sortMode) {
case 'name':
return (ELEMENTS[aId]?.name || aId).localeCompare(ELEMENTS[bId]?.name || bId);
case 'count':
return bState.current - aState.current;
default:
return 0;
}
})
: [];
// Filter and sort equipment
const filteredEquipment = Object.entries(equipmentInstances)
.filter(([id, instance]) => {
if (searchTerm && !instance.name.toLowerCase().includes(searchTerm.toLowerCase())) return false;
return true;
})
.sort(([aId, aInst], [bId, bInst]) => {
switch (sortMode) {
case 'name':
return aInst.name.localeCompare(bInst.name);
case 'rarity':
return RARITY_ORDER[bInst.rarity] - RARITY_ORDER[aInst.rarity];
default:
return 0;
}
});
// Check if we have anything to show
const hasItems = totalItems > 0 || essenceCount > 0;
if (!hasItems) {
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" />
Inventory
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-gray-500 text-sm text-center py-4">
No items collected yet. Defeat floors and guardians to find loot!
</div>
</CardContent>
</Card>
);
}
const handleDeleteMaterial = (materialId: string) => {
const drop = LOOT_DROPS[materialId];
if (drop) {
setDeleteConfirm({ type: 'material', id: materialId, name: drop.name });
}
};
const handleDeleteEquipment = (instanceId: string) => {
const instance = equipmentInstances[instanceId];
if (instance) {
setDeleteConfirm({ type: 'equipment', id: instanceId, name: instance.name });
}
};
const confirmDelete = () => {
if (!deleteConfirm) return;
if (deleteConfirm.type === 'material' && onDeleteMaterial) {
onDeleteMaterial(deleteConfirm.id, inventory.materials[deleteConfirm.id] || 0);
} else if (deleteConfirm.type === 'equipment' && onDeleteEquipment) {
onDeleteEquipment(deleteConfirm.id);
}
setDeleteConfirm(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">
<Gem className="w-4 h-4" />
Inventory
<Badge className="ml-auto bg-gray-800 text-gray-300 text-xs">
{totalItems} items
</Badge>
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{/* Search and Filter Controls */}
<div className="flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3 h-3 text-gray-500" />
<Input
placeholder="Search..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="h-7 pl-7 bg-gray-800/50 border-gray-700 text-xs"
/>
</div>
<Button
variant="outline"
size="sm"
className="h-7 px-2 bg-gray-800/50"
onClick={() => setSortMode(sortMode === 'rarity' ? 'name' : sortMode === 'name' ? 'count' : 'rarity')}
>
<ArrowUpDown className="w-3 h-3" />
</Button>
</div>
{/* Filter Tabs */}
<div className="flex gap-1 flex-wrap">
{[
{ mode: 'all' as FilterMode, label: 'All' },
{ mode: 'materials' as FilterMode, label: `Materials (${materialCount})` },
{ mode: 'essence' as FilterMode, label: `Essence (${essenceCount})` },
{ mode: 'blueprints' as FilterMode, label: `Blueprints (${blueprintCount})` },
{ mode: 'equipment' as FilterMode, label: `Equipment (${equipmentCount})` },
].map(({ mode, label }) => (
<Button
key={mode}
variant={filterMode === mode ? 'default' : 'outline'}
size="sm"
className={`h-6 px-2 text-xs ${filterMode === mode ? 'bg-amber-600 hover:bg-amber-700' : 'bg-gray-800/50'}`}
onClick={() => setFilterMode(mode)}
>
{label}
</Button>
))}
</div>
<Separator className="bg-gray-700" />
<ScrollArea className="h-64">
<div className="space-y-3">
{/* Materials */}
{(filterMode === 'all' || filterMode === 'materials') && filteredMaterials.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">
{filteredMaterials.map(([id, count]) => {
const drop = LOOT_DROPS[id];
if (!drop) return null;
const rarityStyle = RARITY_COLORS[drop.rarity];
return (
<div
key={id}
className="p-2 rounded border bg-gray-800/50 group relative"
style={{
borderColor: rarityStyle?.color || '#9CA3AF',
}}
>
<div className="flex items-start justify-between">
<div>
<div className="text-xs font-semibold" style={{ color: rarityStyle?.color }}>
{drop.name}
</div>
<div className="text-xs text-gray-400">
x{count}
</div>
<div className="text-xs text-gray-500 capitalize">
{drop.rarity}
</div>
</div>
{onDeleteMaterial && (
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0 opacity-0 group-hover:opacity-100 text-red-400 hover:text-red-300 hover:bg-red-900/20"
onClick={() => handleDeleteMaterial(id)}
>
<Trash2 className="w-3 h-3" />
</Button>
)}
</div>
</div>
);
})}
</div>
</div>
)}
{/* Essence */}
{(filterMode === 'all' || filterMode === 'essence') && filteredEssence.length > 0 && (
<div>
<div className="text-xs text-gray-500 mb-2 flex items-center gap-1">
<Droplet className="w-3 h-3" />
Elemental Essence
</div>
<div className="grid grid-cols-2 gap-2">
{filteredEssence.map(([id, state]) => {
const elem = ELEMENTS[id];
if (!elem) return null;
return (
<div
key={id}
className="p-2 rounded border bg-gray-800/50"
style={{
borderColor: elem.color,
}}
>
<div className="flex items-center gap-1">
<span style={{ color: elem.color }}>{elem.sym}</span>
<span className="text-xs font-semibold" style={{ color: elem.color }}>
{elem.name}
</span>
</div>
<div className="text-xs text-gray-400">
{state.current} / {state.max}
</div>
</div>
);
})}
</div>
</div>
)}
{/* Blueprints */}
{(filterMode === 'all' || filterMode === '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 (permanent)
</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 className="text-xs text-gray-500 mt-1 italic">
Blueprints are permanent unlocks - use them to craft equipment
</div>
</div>
)}
{/* Equipment */}
{(filterMode === 'all' || filterMode === 'equipment') && filteredEquipment.length > 0 && (
<div>
<div className="text-xs text-gray-500 mb-2 flex items-center gap-1">
<Package className="w-3 h-3" />
Equipment
</div>
<div className="space-y-2">
{filteredEquipment.map(([id, instance]) => {
const type = EQUIPMENT_TYPES[instance.typeId];
const Icon = type ? CATEGORY_ICONS[type.category] || Package : Package;
const rarityStyle = RARITY_COLORS[instance.rarity];
return (
<div
key={id}
className="p-2 rounded border bg-gray-800/50 group"
style={{
borderColor: rarityStyle?.color || '#9CA3AF',
}}
>
<div className="flex items-start justify-between">
<div className="flex items-start gap-2">
<Icon className="w-4 h-4 mt-0.5" style={{ color: rarityStyle?.color }} />
<div>
<div className="text-xs font-semibold" style={{ color: rarityStyle?.color }}>
{instance.name}
</div>
<div className="text-xs text-gray-400">
{type?.name} {instance.usedCapacity}/{instance.totalCapacity} cap
</div>
<div className="text-xs text-gray-500 capitalize">
{instance.rarity} {instance.enchantments.length} enchants
</div>
</div>
</div>
{onDeleteEquipment && (
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0 opacity-0 group-hover:opacity-100 text-red-400 hover:text-red-300 hover:bg-red-900/20"
onClick={() => handleDeleteEquipment(id)}
>
<Trash2 className="w-3 h-3" />
</Button>
)}
</div>
</div>
);
})}
</div>
</div>
)}
</div>
</ScrollArea>
</CardContent>
</Card>
{/* Delete Confirmation Dialog */}
<AlertDialog open={!!deleteConfirm} onOpenChange={() => setDeleteConfirm(null)}>
<AlertDialogContent className="bg-gray-900 border-gray-700">
<AlertDialogHeader>
<AlertDialogTitle className="text-amber-400 flex items-center gap-2">
<AlertTriangle className="w-5 h-5" />
Delete Item
</AlertDialogTitle>
<AlertDialogDescription className="text-gray-300">
Are you sure you want to delete <strong>{deleteConfirm?.name}</strong>?
{deleteConfirm?.type === 'material' && (
<span className="block mt-2 text-red-400">
This will delete ALL {inventory.materials[deleteConfirm?.id || ''] || 0} of this material!
</span>
)}
{deleteConfirm?.type === 'equipment' && (
<span className="block mt-2 text-red-400">
This equipment and all its enchantments will be permanently lost!
</span>
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="bg-gray-800 border-gray-700">Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-red-600 hover:bg-red-700"
onClick={confirmDelete}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
+123
View File
@@ -0,0 +1,123 @@
'use client';
import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';
import { Card, CardContent } from '@/components/ui/card';
import { Zap, ChevronDown, ChevronUp } from 'lucide-react';
import { fmt, fmtDec } from '@/lib/game/store';
import { ELEMENTS } from '@/lib/game/constants';
import { useState } from 'react';
interface ManaDisplayProps {
rawMana: number;
maxMana: number;
effectiveRegen: number;
meditationMultiplier: number;
clickMana: number;
isGathering: boolean;
onGatherStart: () => void;
onGatherEnd: () => void;
elements: Record<string, { current: number; max: number; unlocked: boolean }>;
}
export function ManaDisplay({
rawMana,
maxMana,
effectiveRegen,
meditationMultiplier,
clickMana,
isGathering,
onGatherStart,
onGatherEnd,
elements,
}: ManaDisplayProps) {
const [expanded, setExpanded] = useState(true);
// Get unlocked elements with current > 0, sorted by current amount
const unlockedElements = Object.entries(elements)
.filter(([, state]) => state.unlocked && state.current > 0)
.sort((a, b) => b[1].current - a[1].current);
return (
<Card className="bg-gray-900/80 border-gray-700">
<CardContent className="pt-4 space-y-3">
{/* Raw Mana - Main Display */}
<div>
<div className="flex items-baseline gap-1">
<span className="text-3xl font-bold game-mono text-blue-400">{fmt(rawMana)}</span>
<span className="text-sm text-gray-400">/ {fmt(maxMana)}</span>
</div>
<div className="text-xs text-gray-400">
+{fmtDec(effectiveRegen)} mana/hr {meditationMultiplier > 1.01 && <span className="text-purple-400">({fmtDec(meditationMultiplier, 1)}x med)</span>}
</div>
</div>
<Progress
value={(rawMana / maxMana) * 100}
className="h-2 bg-gray-800"
/>
<Button
className={`w-full bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 ${isGathering ? 'animate-pulse' : ''}`}
onMouseDown={onGatherStart}
onMouseUp={onGatherEnd}
onMouseLeave={onGatherEnd}
onTouchStart={onGatherStart}
onTouchEnd={onGatherEnd}
>
<Zap className="w-4 h-4 mr-2" />
Gather +{clickMana} Mana
{isGathering && <span className="ml-2 text-xs">(Holding...)</span>}
</Button>
{/* Elemental Mana Pools */}
{unlockedElements.length > 0 && (
<div className="border-t border-gray-700 pt-3 mt-3">
<button
onClick={() => setExpanded(!expanded)}
className="flex items-center justify-between w-full text-xs text-gray-400 hover:text-gray-300 mb-2"
>
<span>Elemental Mana ({unlockedElements.length})</span>
{expanded ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
</button>
{expanded && (
<div className="grid grid-cols-2 gap-2">
{unlockedElements.map(([id, state]) => {
const elem = ELEMENTS[id];
if (!elem) return null;
return (
<div
key={id}
className="p-2 rounded bg-gray-800/50 border border-gray-700"
>
<div className="flex items-center gap-1 mb-1">
<span style={{ color: elem.color }}>{elem.sym}</span>
<span className="text-xs font-medium" style={{ color: elem.color }}>
{elem.name}
</span>
</div>
<div className="h-1.5 bg-gray-700 rounded-full overflow-hidden mb-1">
<div
className="h-full rounded-full transition-all"
style={{
width: `${Math.min(100, (state.current / state.max) * 100)}%`,
backgroundColor: elem.color
}}
/>
</div>
<div className="text-xs text-gray-400 game-mono">
{fmt(state.current)}/{fmt(state.max)}
</div>
</div>
);
})}
</div>
)}
</div>
)}
</CardContent>
</Card>
);
}
+418
View File
@@ -0,0 +1,418 @@
'use client';
import { useState } from 'react';
import { useGameStore, fmt, fmtDec } from '@/lib/game/store';
import { SKILLS_DEF, SKILL_CATEGORIES, getStudySpeedMultiplier, getStudyCostMultiplier } from '@/lib/game/constants';
import { SKILL_EVOLUTION_PATHS, getUpgradesForSkillAtMilestone, getNextTierSkill, getTierMultiplier } from '@/lib/game/skill-evolution';
import { computeEffects, hasSpecial, SPECIAL_EFFECTS } from '@/lib/game/upgrade-effects';
import { useStudyStats } from '@/lib/game/hooks/useGameDerived';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { BookOpen, X } from 'lucide-react';
import type { SkillUpgradeChoice } from '@/lib/game/types';
// Format study time
function formatStudyTime(hours: number): string {
if (hours < 1) return `${Math.round(hours * 60)}m`;
return `${hours.toFixed(1)}h`;
}
export function SkillsTab() {
const store = useGameStore();
const { studySpeedMult, studyCostMult, hasParallelStudy } = useStudyStats();
const [upgradeDialogSkill, setUpgradeDialogSkill] = useState<string | null>(null);
const [upgradeDialogMilestone, setUpgradeDialogMilestone] = useState<5 | 10>(5);
const [pendingUpgradeSelections, setPendingUpgradeSelections] = useState<string[]>([]);
const upgradeEffects = computeEffects(store.skillUpgrades || {}, store.skillTiers || {});
// Check if skill has milestone available
const hasMilestoneUpgrade = (skillId: string, level: number): { milestone: 5 | 10; hasUpgrades: boolean; selectedCount: number } | null => {
const baseSkillId = skillId.includes('_t') ? skillId.split('_t')[0] : skillId;
const path = SKILL_EVOLUTION_PATHS[baseSkillId];
if (!path) return null;
if (level >= 5) {
const upgrades5 = getUpgradesForSkillAtMilestone(skillId, 5, store.skillTiers);
const selected5 = (store.skillUpgrades[skillId] || []).filter(id => id.includes('_l5'));
if (upgrades5.length > 0 && selected5.length < 2) {
return { milestone: 5, hasUpgrades: true, selectedCount: selected5.length };
}
}
if (level >= 10) {
const upgrades10 = getUpgradesForSkillAtMilestone(skillId, 10, store.skillTiers);
const selected10 = (store.skillUpgrades[skillId] || []).filter(id => id.includes('_l10'));
if (upgrades10.length > 0 && selected10.length < 2) {
return { milestone: 10, hasUpgrades: true, selectedCount: selected10.length };
}
}
return null;
};
// Render upgrade selection dialog
const renderUpgradeDialog = () => {
if (!upgradeDialogSkill) return null;
const skillDef = SKILLS_DEF[upgradeDialogSkill];
const level = store.skills[upgradeDialogSkill] || 0;
const { available, selected: alreadySelected } = store.getSkillUpgradeChoices(upgradeDialogSkill, upgradeDialogMilestone);
const currentSelections = pendingUpgradeSelections.length > 0 ? pendingUpgradeSelections : alreadySelected;
const toggleUpgrade = (upgradeId: string) => {
if (currentSelections.includes(upgradeId)) {
setPendingUpgradeSelections(currentSelections.filter(id => id !== upgradeId));
} else if (currentSelections.length < 2) {
setPendingUpgradeSelections([...currentSelections, upgradeId]);
}
};
const handleDone = () => {
if (currentSelections.length === 2 && upgradeDialogSkill) {
store.commitSkillUpgrades(upgradeDialogSkill, currentSelections, upgradeDialogMilestone);
}
setPendingUpgradeSelections([]);
setUpgradeDialogSkill(null);
};
const handleCancel = () => {
setPendingUpgradeSelections([]);
setUpgradeDialogSkill(null);
};
return (
<Dialog open={!!upgradeDialogSkill} onOpenChange={(open) => {
if (!open) {
setPendingUpgradeSelections([]);
setUpgradeDialogSkill(null);
}
}}>
<DialogContent className="bg-gray-900 border-gray-700 max-w-lg">
<DialogHeader>
<DialogTitle className="text-amber-400">
Choose Upgrade - {skillDef?.name || upgradeDialogSkill}
</DialogTitle>
<DialogDescription className="text-gray-400">
Level {upgradeDialogMilestone} Milestone - Select 2 upgrades ({currentSelections.length}/2 chosen)
</DialogDescription>
</DialogHeader>
<div className="space-y-2 mt-4">
{available.map((upgrade) => {
const isSelected = currentSelections.includes(upgrade.id);
const canToggle = currentSelections.length < 2 || isSelected;
return (
<div
key={upgrade.id}
className={`p-3 rounded border cursor-pointer transition-all ${
isSelected
? 'border-amber-500 bg-amber-900/30'
: canToggle
? 'border-gray-600 bg-gray-800/50 hover:border-amber-500/50 hover:bg-gray-800'
: 'border-gray-700 bg-gray-800/30 opacity-50 cursor-not-allowed'
}`}
onClick={() => {
if (canToggle) {
toggleUpgrade(upgrade.id);
}
}}
>
<div className="flex items-center justify-between">
<div className="font-semibold text-sm text-amber-300">{upgrade.name}</div>
{isSelected && <Badge className="bg-amber-600 text-amber-100">Selected</Badge>}
</div>
<div className="text-xs text-gray-400 mt-1">{upgrade.desc}</div>
{upgrade.effect.type === 'multiplier' && (
<div className="text-xs text-green-400 mt-1">
+{Math.round((upgrade.effect.value! - 1) * 100)}% {upgrade.effect.stat}
</div>
)}
{upgrade.effect.type === 'bonus' && (
<div className="text-xs text-blue-400 mt-1">
+{upgrade.effect.value} {upgrade.effect.stat}
</div>
)}
{upgrade.effect.type === 'special' && (
<div className="text-xs text-cyan-400 mt-1">
{upgrade.effect.specialDesc || 'Special effect'}
</div>
)}
</div>
);
})}
</div>
<div className="flex justify-end gap-2 mt-4">
<Button
variant="outline"
onClick={handleCancel}
>
Cancel
</Button>
<Button
variant="default"
onClick={handleDone}
disabled={currentSelections.length !== 2}
>
{currentSelections.length < 2 ? `Select ${2 - currentSelections.length} more` : 'Confirm'}
</Button>
</div>
</DialogContent>
</Dialog>
);
};
// Render study progress
const renderStudyProgress = () => {
if (!store.currentStudyTarget) return null;
const target = store.currentStudyTarget;
const progressPct = Math.min(100, (target.progress / target.required) * 100);
const def = SKILLS_DEF[target.id] || SKILLS_DEF[target.id.split('_t')[0]];
return (
<div className="p-3 rounded border border-purple-600/50 bg-purple-900/20">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<BookOpen className="w-4 h-4 text-purple-400" />
<span className="text-sm font-semibold text-purple-300">
{def?.name}
</span>
</div>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-gray-400 hover:text-white"
onClick={() => store.cancelStudy()}
>
<X className="w-4 h-4" />
</Button>
</div>
<Progress value={progressPct} className="h-2 bg-gray-800" />
<div className="flex justify-between text-xs text-gray-400 mt-1">
<span>{formatStudyTime(target.progress)} / {formatStudyTime(target.required)}</span>
<span>{studySpeedMult.toFixed(1)}x speed</span>
</div>
</div>
);
};
return (
<div className="space-y-4">
{/* Upgrade Selection Dialog */}
{renderUpgradeDialog()}
{/* Current Study Progress */}
{store.currentStudyTarget && store.currentStudyTarget.type === 'skill' && (
<Card className="bg-gray-900/80 border-purple-600/50">
<CardContent className="pt-4">
{renderStudyProgress()}
</CardContent>
</Card>
)}
{SKILL_CATEGORIES.map((cat) => {
const skillsInCat = Object.entries(SKILLS_DEF).filter(([, def]) => def.cat === cat.id);
if (skillsInCat.length === 0) return null;
return (
<Card key={cat.id} className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 game-panel-title text-xs">
{cat.icon} {cat.name}
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{skillsInCat.map(([id, def]) => {
const currentTier = store.skillTiers?.[id] || 1;
const tieredSkillId = currentTier > 1 ? `${id}_t${currentTier}` : id;
const tierMultiplier = getTierMultiplier(tieredSkillId);
const level = store.skills[tieredSkillId] || store.skills[id] || 0;
const maxed = level >= def.max;
const isStudying = (store.currentStudyTarget?.id === id || store.currentStudyTarget?.id === tieredSkillId) && store.currentStudyTarget?.type === 'skill';
const savedProgress = store.skillProgress[tieredSkillId] || store.skillProgress[id] || 0;
const tierDef = SKILL_EVOLUTION_PATHS[id]?.tiers.find(t => t.tier === currentTier);
const skillDisplayName = tierDef?.name || def.name;
// Check prerequisites
let prereqMet = true;
if (def.req) {
for (const [r, rl] of Object.entries(def.req)) {
if ((store.skills[r] || 0) < rl) {
prereqMet = false;
break;
}
}
}
// Apply skill modifiers
const studyEffects = computeEffects(store.skillUpgrades || {}, store.skillTiers || {});
const effectiveSpeedMult = studySpeedMult * studyEffects.studySpeedMultiplier;
const tierStudyTime = def.studyTime * currentTier;
const effectiveStudyTime = tierStudyTime / effectiveSpeedMult;
const baseCost = def.base * (level + 1) * currentTier;
const cost = Math.floor(baseCost * studyCostMult);
const canStudy = !maxed && prereqMet && store.rawMana >= cost && !isStudying;
const milestoneInfo = hasMilestoneUpgrade(tieredSkillId, level);
const nextTierSkill = getNextTierSkill(tieredSkillId);
const canTierUp = maxed && nextTierSkill;
const selectedUpgrades = store.skillUpgrades[tieredSkillId] || [];
const selectedL5 = selectedUpgrades.filter(u => u.includes('_l5'));
const selectedL10 = selectedUpgrades.filter(u => u.includes('_l10'));
return (
<div
key={id}
className={`flex flex-col sm:flex-row sm:items-center justify-between p-3 rounded border gap-2 ${
isStudying ? 'border-purple-500 bg-purple-900/20' :
milestoneInfo ? 'border-amber-500/50 bg-amber-900/10' :
'border-gray-700 bg-gray-800/30'
}`}
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-semibold text-sm">{skillDisplayName}</span>
{currentTier > 1 && (
<Badge className="bg-purple-600/50 text-purple-200 text-xs">Tier {currentTier} ({fmtDec(tierMultiplier, 0)}x)</Badge>
)}
{level > 0 && <span className="text-purple-400 text-sm">Lv.{level}</span>}
{selectedUpgrades.length > 0 && (
<div className="flex gap-1">
{selectedL5.length > 0 && (
<Badge className="bg-amber-700/50 text-amber-200 text-xs">L5: {selectedL5.length}</Badge>
)}
{selectedL10.length > 0 && (
<Badge className="bg-purple-700/50 text-purple-200 text-xs">L10: {selectedL10.length}</Badge>
)}
</div>
)}
</div>
<div className="text-xs text-gray-400 italic">{def.desc}{currentTier > 1 && ` (Tier ${currentTier}: ${fmtDec(tierMultiplier, 0)}x effect)`}</div>
{!prereqMet && def.req && (
<div className="text-xs text-red-400 mt-1">
Requires: {Object.entries(def.req).map(([r, rl]) => `${SKILLS_DEF[r]?.name} Lv.${rl}`).join(', ')}
</div>
)}
<div className="text-xs text-gray-500 mt-1">
<span className={effectiveSpeedMult > 1 ? 'text-green-400' : ''}>
Study: {formatStudyTime(effectiveStudyTime)}{effectiveSpeedMult > 1 && <span className="text-xs ml-1">({Math.round(effectiveSpeedMult * 100)}% speed)</span>}
</span>
{' • '}
<span className={studyCostMult < 1 ? 'text-green-400' : ''}>
Cost: {fmt(cost)} mana{studyCostMult < 1 && <span className="text-xs ml-1">({Math.round(studyCostMult * 100)}% cost)</span>}
</span>
</div>
{milestoneInfo && (
<div className="text-xs text-amber-400 mt-1 flex items-center gap-1">
Level {milestoneInfo.milestone} milestone: {milestoneInfo.selectedCount}/2 upgrades selected
</div>
)}
</div>
<div className="flex items-center gap-3 flex-wrap sm:flex-nowrap">
{/* Level dots */}
<div className="flex gap-1 shrink-0">
{Array.from({ length: def.max }).map((_, i) => (
<div
key={i}
className={`w-2 h-2 rounded-full border ${
i < level ? 'bg-purple-500 border-purple-400' :
i === 4 || i === 9 ? 'border-amber-500' :
'border-gray-600'
}`}
/>
))}
</div>
{isStudying ? (
<div className="text-xs text-purple-400">
{formatStudyTime(store.currentStudyTarget?.progress || 0)}/{formatStudyTime(tierStudyTime)}
</div>
) : milestoneInfo ? (
<Button
size="sm"
className="bg-amber-600 hover:bg-amber-700"
onClick={() => {
setUpgradeDialogSkill(tieredSkillId);
setUpgradeDialogMilestone(milestoneInfo.milestone);
}}
>
Choose Upgrades
</Button>
) : canTierUp ? (
<Button
size="sm"
className="bg-purple-600 hover:bg-purple-700"
onClick={() => store.tierUpSkill(tieredSkillId)}
>
Tier Up
</Button>
) : maxed ? (
<Badge className="bg-green-900/50 text-green-300">Maxed</Badge>
) : (
<div className="flex gap-1">
<Button
size="sm"
variant={canStudy ? 'default' : 'outline'}
disabled={!canStudy}
className={canStudy ? 'bg-purple-600 hover:bg-purple-700' : 'opacity-50'}
onClick={() => store.startStudyingSkill(tieredSkillId)}
>
Study ({fmt(cost)})
</Button>
{/* Parallel Study button */}
{hasParallelStudy &&
store.currentStudyTarget &&
!store.parallelStudyTarget &&
store.currentStudyTarget.id !== tieredSkillId &&
canStudy && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
className="border-cyan-500 text-cyan-400 hover:bg-cyan-900/30"
onClick={() => store.startParallelStudySkill(tieredSkillId)}
>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Study in parallel (50% speed)</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
)}
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
);
})}
</div>
);
}
+166
View File
@@ -0,0 +1,166 @@
'use client';
import { useGameStore, canAffordSpellCost, fmt, fmtDec, calcDamage } from '@/lib/game/store';
import { ELEMENTS, SPELLS_DEF, getStudySpeedMultiplier, getStudyCostMultiplier } from '@/lib/game/constants';
import { useStudyStats } from '@/lib/game/hooks/useGameDerived';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';
// Format spell cost for display
function formatSpellCost(cost: { type: 'raw' | 'element'; element?: string; amount: number }): string {
if (cost.type === 'raw') {
return `${cost.amount} raw`;
}
const elemDef = ELEMENTS[cost.element || ''];
return `${cost.amount} ${elemDef?.sym || '?'}`;
}
// Get cost color
function getSpellCostColor(cost: { type: 'raw' | 'element'; element?: string; amount: number }): string {
if (cost.type === 'raw') {
return '#60A5FA';
}
return ELEMENTS[cost.element || '']?.color || '#9CA3AF';
}
// Format study time
function formatStudyTime(hours: number): string {
if (hours < 1) return `${Math.round(hours * 60)}m`;
return `${hours.toFixed(1)}h`;
}
export function SpellsTab() {
const store = useGameStore();
const { studySpeedMult, studyCostMult } = useStudyStats();
const spellTiers = [0, 1, 2, 3, 4];
return (
<div className="space-y-6">
{spellTiers.map(tier => {
const spellsInTier = Object.entries(SPELLS_DEF).filter(([, def]) => def.tier === tier);
if (spellsInTier.length === 0) return null;
const tierNames = ['Basic Spells (Raw Mana)', 'Tier 1 - Elemental', 'Tier 2 - Advanced', 'Tier 3 - Master', 'Tier 4 - Legendary'];
const tierColors = ['text-gray-400', 'text-green-400', 'text-blue-400', 'text-purple-400', 'text-amber-400'];
return (
<div key={tier}>
<h3 className={`text-lg font-semibold mb-3 ${tierColors[tier]}`}>{tierNames[tier]}</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{spellsInTier.map(([id, def]) => {
const state = store.spells[id];
const learned = state?.learned;
const isStudying = store.currentStudyTarget?.id === id;
const elemDef = def.elem === 'raw' ? null : ELEMENTS[def.elem];
const baseStudyTime = def.studyTime || (def.tier * 4);
const isActive = store.activeSpell === id;
const canCast = learned && canAffordSpellCost(def.cost, store.rawMana, store.elements);
// Apply skill modifiers
const studyTime = baseStudyTime / studySpeedMult;
const unlockCost = Math.floor(def.unlock * studyCostMult);
// Can start studying?
const canStudy = !learned && !isStudying && store.rawMana >= unlockCost;
return (
<Card
key={id}
className={`bg-gray-900/80 border-gray-700 ${learned ? '' : 'opacity-75'} ${isStudying ? 'border-purple-500' : ''} ${canCast ? 'ring-1 ring-green-500/30' : ''}`}
>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-sm game-panel-title" style={{ color: def.elem === 'raw' ? '#60A5FA' : elemDef?.color }}>
{def.name}
</CardTitle>
{def.tier > 0 && (
<Badge variant="outline" className="text-xs">
T{def.tier}
</Badge>
)}
</div>
</CardHeader>
<CardContent className="space-y-2">
<div className="text-xs text-gray-400">
{def.elem !== 'raw' && <span className="mr-2">{elemDef?.sym} {elemDef?.name}</span>}
<span className="mr-2"> {def.dmg} dmg</span>
</div>
{/* Cost display */}
<div className="text-xs game-mono" style={{ color: getSpellCostColor(def.cost) }}>
Cost: {formatSpellCost(def.cost)}
</div>
{def.desc && (
<div className="text-xs text-gray-500 italic">{def.desc}</div>
)}
{def.effects && def.effects.length > 0 && (
<div className="flex gap-1 flex-wrap">
{def.effects.map((eff, i) => (
<Badge key={i} variant="outline" className="text-xs">
{eff.type === 'burn' && `🔥 Burn`}
{eff.type === 'stun' && `⚡ Stun`}
{eff.type === 'pierce' && `🎯 Pierce`}
{eff.type === 'multicast' && `✨ Multicast`}
</Badge>
))}
</div>
)}
{learned ? (
<div className="flex gap-2">
<Badge className="bg-green-900/50 text-green-300">Learned</Badge>
{isActive && <Badge className="bg-amber-900/50 text-amber-300">Active</Badge>}
{!isActive && (
<Button size="sm" variant="outline" onClick={() => store.setSpell(id)}>
Set Active
</Button>
)}
</div>
) : isStudying ? (
<div className="space-y-1">
<Progress
value={Math.min(100, ((state?.studyProgress || 0) / studyTime) * 100)}
className="h-2 bg-gray-800"
/>
<div className="text-xs text-purple-400">
Studying... {formatStudyTime(state?.studyProgress || 0)}/{formatStudyTime(studyTime)}
</div>
</div>
) : (
<div className="space-y-2">
<div className="text-xs text-gray-500">
<span className={studySpeedMult > 1 ? 'text-green-400' : ''}>
Study: {formatStudyTime(studyTime)}{studySpeedMult > 1 && <span className="text-xs ml-1">({Math.round(studySpeedMult * 100)}% speed)</span>}
</span>
{' • '}
<span className={studyCostMult < 1 ? 'text-green-400' : ''}>
Cost: {fmt(unlockCost)} mana{studyCostMult < 1 && <span className="text-xs ml-1">({Math.round(studyCostMult * 100)}% cost)</span>}
</span>
</div>
<Button
size="sm"
variant={canStudy ? 'default' : 'outline'}
disabled={!canStudy}
className={canStudy ? 'bg-purple-600 hover:bg-purple-700' : 'opacity-50'}
onClick={() => store.startStudyingSpell(id)}
>
Start Study ({fmt(unlockCost)} mana)
</Button>
</div>
)}
</CardContent>
</Card>
);
})}
</div>
</div>
);
})}
</div>
);
}
+320
View File
@@ -0,0 +1,320 @@
'use client';
import { useGameStore, canAffordSpellCost, fmt, fmtDec, calcDamage, computePactMultiplier } from '@/lib/game/store';
import { ELEMENTS, GUARDIANS, SPELLS_DEF, MANA_PER_ELEMENT } from '@/lib/game/constants';
import { useManaStats, useCombatStats, useStudyStats } from '@/lib/game/hooks/useGameDerived';
import { formatSpellCost, getSpellCostColor, formatStudyTime } from '@/lib/game/formatting';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Separator } from '@/components/ui/separator';
import { X, BookOpen } from 'lucide-react';
export function SpireTab() {
const store = useGameStore();
const { effectiveRegen, meditationMultiplier, incursionStrength } = useManaStats();
const {
floorElem, floorElemDef, isGuardianFloor, currentGuardian,
activeSpellDef, dps, damageBreakdown
} = useCombatStats();
const { effectiveStudySpeedMult } = useStudyStats();
// Check if spell can be cast
const canCastSpell = (spellId: string): boolean => {
const spell = SPELLS_DEF[spellId];
if (!spell) return false;
return canAffordSpellCost(spell.cost, store.rawMana, store.elements);
};
// Render study progress
const renderStudyProgress = () => {
if (!store.currentStudyTarget) return null;
const target = store.currentStudyTarget;
const progressPct = Math.min(100, (target.progress / target.required) * 100);
const isSkill = target.type === 'skill';
const def = isSkill ? SPELLS_DEF[target.id] : SPELLS_DEF[target.id];
return (
<div className="p-3 rounded border border-purple-600/50 bg-purple-900/20">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<BookOpen className="w-4 h-4 text-purple-400" />
<span className="text-sm font-semibold text-purple-300">
{def?.name}
</span>
</div>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-gray-400 hover:text-white"
onClick={() => store.cancelStudy()}
>
<X className="w-4 h-4" />
</Button>
</div>
<Progress value={progressPct} className="h-2 bg-gray-800" />
<div className="flex justify-between text-xs text-gray-400 mt-1">
<span>{formatStudyTime(target.progress)} / {formatStudyTime(target.required)}</span>
<span>{effectiveStudySpeedMult.toFixed(1)}x speed</span>
</div>
</div>
);
};
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Current Floor Card */}
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 game-panel-title text-xs">Current Floor</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-baseline gap-2">
<span className="text-4xl font-bold game-title" style={{ color: floorElemDef?.color }}>
{store.currentFloor}
</span>
<span className="text-gray-400 text-sm">/ 100</span>
<span className="ml-auto text-sm" style={{ color: floorElemDef?.color }}>
{floorElemDef?.sym} {floorElemDef?.name}
</span>
{isGuardianFloor && (
<Badge className="bg-red-900/50 text-red-300 border-red-600">GUARDIAN</Badge>
)}
</div>
{isGuardianFloor && currentGuardian && (
<div className="text-sm font-semibold game-panel-title" style={{ color: floorElemDef?.color }}>
{currentGuardian.name}
</div>
)}
{/* HP Bar */}
<div className="space-y-1">
<div className="h-3 bg-gray-800 rounded-full overflow-hidden">
<div
className="h-full rounded-full transition-all duration-300"
style={{
width: `${Math.max(0, (store.floorHP / store.floorMaxHP) * 100)}%`,
background: `linear-gradient(90deg, ${floorElemDef?.color}99, ${floorElemDef?.color})`,
boxShadow: `0 0 10px ${floorElemDef?.glow}`,
}}
/>
</div>
<div className="flex justify-between text-xs text-gray-400 game-mono">
<span>{fmt(store.floorHP)} / {fmt(store.floorMaxHP)} HP</span>
<span>DPS: {store.currentAction === 'climb' && canCastSpell(store.activeSpell) ? fmtDec(dps) : '—'}</span>
</div>
</div>
<Separator className="bg-gray-700" />
<div className="text-sm text-gray-400">
Best: Floor <strong className="text-gray-200">{store.maxFloorReached}</strong>
Pacts: <strong className="text-amber-400">{store.signedPacts.length}</strong>
</div>
</CardContent>
</Card>
{/* Active Spell Card */}
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 game-panel-title text-xs">Active Spell</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{activeSpellDef ? (
<>
<div className="text-lg font-semibold game-panel-title" style={{ color: activeSpellDef.elem === 'raw' ? '#60A5FA' : ELEMENTS[activeSpellDef.elem]?.color }}>
{activeSpellDef.name}
{activeSpellDef.tier === 0 && <Badge className="ml-2 bg-gray-600 text-gray-200">Basic</Badge>}
{activeSpellDef.tier >= 4 && <Badge className="ml-2 bg-amber-600 text-amber-100">Legendary</Badge>}
</div>
<div className="text-sm text-gray-400 game-mono">
{fmt(calcDamage(store, store.activeSpell))} dmg
<span style={{ color: getSpellCostColor(activeSpellDef.cost) }}>
{' '}{formatSpellCost(activeSpellDef.cost)}
</span>
{' '} {(activeSpellDef.castSpeed || 1).toFixed(1)} casts/hr
</div>
{/* Cast progress bar when climbing */}
{store.currentAction === 'climb' && (
<div className="space-y-1">
<div className="flex justify-between text-xs text-gray-400">
<span>Cast Progress</span>
<span>{((store.castProgress || 0) * 100).toFixed(0)}%</span>
</div>
<Progress value={Math.min(100, (store.castProgress || 0) * 100)} className="h-2 bg-gray-800" />
</div>
)}
{activeSpellDef.desc && (
<div className="text-xs text-gray-500 italic">{activeSpellDef.desc}</div>
)}
{activeSpellDef.effects && activeSpellDef.effects.length > 0 && (
<div className="flex gap-1 flex-wrap">
{activeSpellDef.effects.map((eff, i) => (
<Badge key={i} variant="outline" className="text-xs">
{eff.type === 'burn' && `🔥 Burn ${eff.value}/hr`}
{eff.type === 'stun' && `⚡ Stun ${eff.value}s`}
{eff.type === 'pierce' && `🗡️ Pierce ${Math.round(eff.value * 100)}%`}
{eff.type === 'multicast' && `${Math.round(eff.value * 100)}% Multicast`}
{eff.type === 'buff' && `💪 Buff`}
</Badge>
))}
</div>
)}
</>
) : (
<div className="text-gray-500">No spell selected</div>
)}
{/* Can cast indicator */}
{activeSpellDef && (
<div className={`text-xs ${canCastSpell(store.activeSpell) ? 'text-green-400' : 'text-red-400'}`}>
{canCastSpell(store.activeSpell) ? '✓ Can cast' : '✗ Insufficient mana'}
</div>
)}
{incursionStrength > 0 && (
<div className="p-2 bg-red-900/20 border border-red-800/50 rounded">
<div className="text-xs text-red-400 game-panel-title mb-1">LABYRINTH INCURSION</div>
<div className="text-sm text-gray-300">
-{Math.round(incursionStrength * 100)}% mana regen
</div>
</div>
)}
</CardContent>
</Card>
{/* Current Study (if any) */}
{store.currentStudyTarget && (
<Card className="bg-gray-900/80 border-purple-600/50 lg:col-span-2">
<CardContent className="pt-4 space-y-3">
{renderStudyProgress()}
{/* Parallel Study Progress */}
{store.parallelStudyTarget && (
<div className="p-3 rounded border border-cyan-600/50 bg-cyan-900/20">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<BookOpen className="w-4 h-4 text-cyan-400" />
<span className="text-sm font-semibold text-cyan-300">
Parallel: {store.parallelStudyTarget.type === 'skill' ? store.parallelStudyTarget.id : store.parallelStudyTarget.id}
</span>
</div>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-gray-400 hover:text-white"
onClick={() => store.cancelParallelStudy()}
>
<X className="w-4 h-4" />
</Button>
</div>
<Progress value={Math.min(100, (store.parallelStudyTarget.progress / store.parallelStudyTarget.required) * 100)} className="h-2 bg-gray-800" />
<div className="flex justify-between text-xs text-gray-400 mt-1">
<span>{formatStudyTime(store.parallelStudyTarget.progress)} / {formatStudyTime(store.parallelStudyTarget.required)}</span>
<span>50% speed (Parallel Study)</span>
</div>
</div>
)}
</CardContent>
</Card>
)}
{/* Pact Signing Progress */}
{store.pactSigningProgress && (
<Card className="bg-gray-900/80 border-amber-600/50 lg:col-span-2">
<CardContent className="pt-4 space-y-3">
<div className="p-3 rounded border border-amber-500/30 bg-amber-900/20">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className="text-2xl">📜</span>
<div>
<div className="text-sm font-semibold text-amber-300">
Signing Pact: {GUARDIANS[store.pactSigningProgress.floor]?.name}
</div>
<div className="text-xs text-amber-400">
Floor {store.pactSigningProgress.floor}
</div>
</div>
</div>
</div>
<Progress
value={Math.min(100, (store.pactSigningProgress.progress / store.pactSigningProgress.required) * 100)}
className="h-2 bg-gray-800"
/>
<div className="flex justify-between text-xs text-amber-400 mt-1">
<span>{formatStudyTime(store.pactSigningProgress.progress)} / {formatStudyTime(store.pactSigningProgress.required)}</span>
<span>Cost: {fmt(store.pactSigningProgress.manaCost)} mana</span>
</div>
</div>
</CardContent>
</Card>
)}
{/* Spells Available */}
<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">Known Spells</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2">
{Object.entries(store.spells)
.filter(([, state]) => state.learned)
.map(([id, state]) => {
const def = SPELLS_DEF[id];
if (!def) return null;
const elemDef = def.elem === 'raw' ? null : ELEMENTS[def.elem];
const isActive = store.activeSpell === id;
const canCast = canCastSpell(id);
return (
<Button
key={id}
variant="outline"
className={`h-auto py-2 px-3 flex flex-col items-start ${isActive ? 'border-amber-500 bg-amber-900/20' : canCast ? 'border-gray-600 bg-gray-800/50 hover:bg-gray-700/50' : 'border-gray-700 bg-gray-800/30 opacity-60'}`}
onClick={() => store.setSpell(id)}
>
<div className="text-sm font-semibold" style={{ color: def.elem === 'raw' ? '#60A5FA' : elemDef?.color }}>
{def.name}
</div>
<div className="text-xs text-gray-400 game-mono">
{fmt(calcDamage(store, id))} dmg
</div>
<div className="text-xs game-mono" style={{ color: getSpellCostColor(def.cost) }}>
{formatSpellCost(def.cost)}
</div>
</Button>
);
})}
</div>
</CardContent>
</Card>
{/* Activity Log */}
<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">Activity Log</CardTitle>
</CardHeader>
<CardContent>
<ScrollArea className="h-32">
<div className="space-y-1">
{store.log.slice(0, 20).map((entry, i) => (
<div
key={i}
className={`text-sm ${i === 0 ? 'text-gray-200' : 'text-gray-500'} italic`}
>
{entry}
</div>
))}
</div>
</ScrollArea>
</CardContent>
</Card>
</div>
);
}
+551
View File
@@ -0,0 +1,551 @@
'use client';
import { useGameStore, fmt, fmtDec, calcDamage, computePactMultiplier, computePactInsightMultiplier } from '@/lib/game/store';
import { ELEMENTS, GUARDIANS, SPELLS_DEF } from '@/lib/game/constants';
import { SKILL_EVOLUTION_PATHS, getTierMultiplier } from '@/lib/game/skill-evolution';
import { useManaStats, useCombatStats, useStudyStats } from '@/lib/game/hooks/useGameDerived';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import { Droplet, Swords, BookOpen, FlaskConical, RotateCcw, Trophy, Star } from 'lucide-react';
import type { SkillUpgradeChoice } from '@/lib/game/types';
export function StatsTab() {
const store = useGameStore();
const {
upgradeEffects, maxMana, baseRegen, clickMana,
meditationMultiplier, incursionStrength, manaCascadeBonus, effectiveRegen,
hasSteadyStream, hasManaTorrent, hasDesperateWells
} = useManaStats();
const { activeSpellDef, pactMultiplier, pactInsightMultiplier } = useCombatStats();
const { studySpeedMult, studyCostMult } = useStudyStats();
// Compute element max
const elemMax = (() => {
const ea = store.skillTiers?.elemAttune || 1;
const tieredSkillId = ea > 1 ? `elemAttune_t${ea}` : 'elemAttune';
const level = store.skills[tieredSkillId] || store.skills.elemAttune || 0;
const tierMult = getTierMultiplier(tieredSkillId);
return 10 + level * 50 * tierMult + (store.prestigeUpgrades.elementalAttune || 0) * 25;
})();
// Get all selected skill upgrades
const getAllSelectedUpgrades = () => {
const upgrades: { skillId: string; upgrade: SkillUpgradeChoice }[] = [];
for (const [skillId, selectedIds] of Object.entries(store.skillUpgrades)) {
const baseSkillId = skillId.includes('_t') ? skillId.split('_t')[0] : skillId;
const path = SKILL_EVOLUTION_PATHS[baseSkillId];
if (!path) continue;
for (const tier of path.tiers) {
if (tier.skillId === skillId) {
for (const upgradeId of selectedIds) {
const upgrade = tier.upgrades.find(u => u.id === upgradeId);
if (upgrade) {
upgrades.push({ skillId, upgrade });
}
}
}
}
}
return upgrades;
};
const selectedUpgrades = getAllSelectedUpgrades();
return (
<div className="space-y-4">
{/* Mana Stats */}
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-blue-400 game-panel-title text-xs flex items-center gap-2">
<Droplet className="w-4 h-4" />
Mana Stats
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-gray-400">Base Max Mana:</span>
<span className="text-gray-200">100</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Mana Well Bonus:</span>
<span className="text-blue-300">
{(() => {
const mw = store.skillTiers?.manaWell || 1;
const tieredSkillId = mw > 1 ? `manaWell_t${mw}` : 'manaWell';
const level = store.skills[tieredSkillId] || store.skills.manaWell || 0;
const tierMult = getTierMultiplier(tieredSkillId);
return `+${fmt(level * 100 * tierMult)} (${level} lvl × 100 × ${tierMult}x tier)`;
})()}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Prestige Mana Well:</span>
<span className="text-blue-300">+{fmt((store.prestigeUpgrades.manaWell || 0) * 500)}</span>
</div>
{upgradeEffects.maxManaBonus > 0 && (
<div className="flex justify-between text-sm">
<span className="text-amber-400">Upgrade Mana Bonus:</span>
<span className="text-amber-300">+{fmt(upgradeEffects.maxManaBonus)}</span>
</div>
)}
{upgradeEffects.maxManaMultiplier > 1 && (
<div className="flex justify-between text-sm">
<span className="text-amber-400">Upgrade Mana Multiplier:</span>
<span className="text-amber-300">×{fmtDec(upgradeEffects.maxManaMultiplier, 2)}</span>
</div>
)}
<div className="flex justify-between text-sm font-semibold border-t border-gray-700 pt-2">
<span className="text-gray-300">Total Max Mana:</span>
<span className="text-blue-400">{fmt(maxMana)}</span>
</div>
</div>
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-gray-400">Base Regen:</span>
<span className="text-gray-200">2/hr</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Mana Flow Bonus:</span>
<span className="text-blue-300">
{(() => {
const mf = store.skillTiers?.manaFlow || 1;
const tieredSkillId = mf > 1 ? `manaFlow_t${mf}` : 'manaFlow';
const level = store.skills[tieredSkillId] || store.skills.manaFlow || 0;
const tierMult = getTierMultiplier(tieredSkillId);
return `+${fmtDec(level * 1 * tierMult)}/hr (${level} lvl × 1 × ${tierMult}x tier)`;
})()}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Mana Spring Bonus:</span>
<span className="text-blue-300">+{(store.skills.manaSpring || 0) * 2}/hr</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Prestige Mana Flow:</span>
<span className="text-blue-300">+{fmtDec((store.prestigeUpgrades.manaFlow || 0) * 0.5)}/hr</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Temporal Echo:</span>
<span className="text-blue-300">×{fmtDec(1 + (store.prestigeUpgrades.temporalEcho || 0) * 0.1, 2)}</span>
</div>
<div className="flex justify-between text-sm font-semibold border-t border-gray-700 pt-2">
<span className="text-gray-300">Base Regen:</span>
<span className="text-blue-400">{fmtDec(baseRegen, 2)}/hr</span>
</div>
{upgradeEffects.regenBonus > 0 && (
<div className="flex justify-between text-sm">
<span className="text-amber-400">Upgrade Regen Bonus:</span>
<span className="text-amber-300">+{fmtDec(upgradeEffects.regenBonus, 2)}/hr</span>
</div>
)}
{upgradeEffects.permanentRegenBonus > 0 && (
<div className="flex justify-between text-sm">
<span className="text-amber-400">Permanent Regen Bonus:</span>
<span className="text-amber-300">+{fmtDec(upgradeEffects.permanentRegenBonus, 2)}/hr</span>
</div>
)}
{upgradeEffects.regenMultiplier > 1 && (
<div className="flex justify-between text-sm">
<span className="text-amber-400">Upgrade Regen Multiplier:</span>
<span className="text-amber-300">×{fmtDec(upgradeEffects.regenMultiplier, 2)}</span>
</div>
)}
</div>
</div>
<Separator className="bg-gray-700 my-3" />
{/* Skill Upgrade Effects Summary */}
{upgradeEffects.activeUpgrades.length > 0 && (
<>
<div className="mb-2">
<span className="text-xs text-amber-400 game-panel-title">Active Skill Upgrades</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 mb-3">
{upgradeEffects.activeUpgrades.map((upgrade, idx) => (
<div key={idx} className="flex justify-between text-xs bg-gray-800/50 rounded px-2 py-1">
<span className="text-gray-300">{upgrade.name}</span>
<span className="text-gray-400">{upgrade.desc}</span>
</div>
))}
</div>
<Separator className="bg-gray-700 my-3" />
</>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-gray-400">Click Mana Value:</span>
<span className="text-purple-300">+{clickMana}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Mana Tap Bonus:</span>
<span className="text-purple-300">+{store.skills.manaTap || 0}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Mana Surge Bonus:</span>
<span className="text-purple-300">+{(store.skills.manaSurge || 0) * 3}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Mana Overflow:</span>
<span className="text-purple-300">×{fmtDec(1 + (store.skills.manaOverflow || 0) * 0.25, 2)}</span>
</div>
</div>
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-gray-400">Meditation Multiplier:</span>
<span className={`font-semibold ${meditationMultiplier > 1.5 ? 'text-purple-400' : 'text-gray-300'}`}>
{fmtDec(meditationMultiplier, 2)}x
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Effective Regen:</span>
<span className="text-green-400 font-semibold">{fmtDec(effectiveRegen, 2)}/hr</span>
</div>
{incursionStrength > 0 && !hasSteadyStream && (
<div className="flex justify-between text-sm">
<span className="text-red-400">Incursion Penalty:</span>
<span className="text-red-400">-{Math.round(incursionStrength * 100)}%</span>
</div>
)}
{hasSteadyStream && incursionStrength > 0 && (
<div className="flex justify-between text-sm">
<span className="text-green-400">Steady Stream:</span>
<span className="text-green-400">Immune to incursion</span>
</div>
)}
{manaCascadeBonus > 0 && (
<div className="flex justify-between text-sm">
<span className="text-cyan-400">Mana Cascade Bonus:</span>
<span className="text-cyan-400">+{fmtDec(manaCascadeBonus, 2)}/hr</span>
</div>
)}
{hasManaTorrent && store.rawMana > maxMana * 0.75 && (
<div className="flex justify-between text-sm">
<span className="text-cyan-400">Mana Torrent:</span>
<span className="text-cyan-400">+50% regen (high mana)</span>
</div>
)}
{hasDesperateWells && store.rawMana < maxMana * 0.25 && (
<div className="flex justify-between text-sm">
<span className="text-cyan-400">Desperate Wells:</span>
<span className="text-cyan-400">+50% regen (low mana)</span>
</div>
)}
</div>
</div>
</CardContent>
</Card>
{/* Combat Stats */}
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-red-400 game-panel-title text-xs flex items-center gap-2">
<Swords className="w-4 h-4" />
Combat Stats
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-gray-400">Active Spell Base Damage:</span>
<span className="text-gray-200">{activeSpellDef?.dmg || 5}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Combat Training Bonus:</span>
<span className="text-red-300">+{(store.skills.combatTrain || 0) * 5}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Arcane Fury Multiplier:</span>
<span className="text-red-300">×{fmtDec(1 + (store.skills.arcaneFury || 0) * 0.1, 2)}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Elemental Mastery:</span>
<span className="text-red-300">×{fmtDec(1 + (store.skills.elementalMastery || 0) * 0.15, 2)}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Guardian Bane:</span>
<span className="text-red-300">×{fmtDec(1 + (store.skills.guardianBane || 0) * 0.2, 2)} (vs guardians)</span>
</div>
</div>
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-gray-400">Critical Hit Chance:</span>
<span className="text-amber-300">{((store.skills.precision || 0) * 5)}%</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Critical Multiplier:</span>
<span className="text-amber-300">1.5x</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Spell Echo Chance:</span>
<span className="text-amber-300">{((store.skills.spellEcho || 0) * 10)}%</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Pact Multiplier:</span>
<span className="text-amber-300">×{fmtDec(pactMultiplier, 2)}</span>
</div>
<div className="flex justify-between text-sm font-semibold border-t border-gray-700 pt-2">
<span className="text-gray-300">Total Damage:</span>
<span className="text-red-400">{fmt(calcDamage(store, store.activeSpell))}</span>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Pact Status */}
<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" />
Pact Status
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-gray-400">Pact Slots:</span>
<span className="text-amber-300">{store.signedPacts.length} / {1 + (store.prestigeUpgrades.pactCapacity || 0)}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Damage Multiplier:</span>
<span className="text-amber-300">×{fmtDec(pactMultiplier, 2)}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Insight Multiplier:</span>
<span className="text-purple-300">×{fmtDec(pactInsightMultiplier, 2)}</span>
</div>
{store.signedPacts.length > 1 && (
<>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Interference Mitigation:</span>
<span className="text-green-300">{Math.min(store.pactInterferenceMitigation || 0, 5) * 10}%</span>
</div>
{(store.pactInterferenceMitigation || 0) >= 5 && (
<div className="flex justify-between text-sm">
<span className="text-cyan-400">Synergy Bonus:</span>
<span className="text-cyan-300">+{((store.pactInterferenceMitigation || 0) - 5) * 10}%</span>
</div>
)}
</>
)}
</div>
<div className="space-y-2">
<div className="text-sm text-gray-400 mb-2">Unlocked Mana Types:</div>
<div className="flex flex-wrap gap-1">
{Object.entries(store.elements)
.filter(([, state]) => state.unlocked)
.map(([id]) => {
const elem = ELEMENTS[id];
return (
<Badge key={id} variant="outline" className="text-xs" style={{ borderColor: elem?.color, color: elem?.color }}>
{elem?.sym} {elem?.name}
</Badge>
);
})}
</div>
</div>
</div>
</CardContent>
</Card>
{/* Study Stats */}
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-purple-400 game-panel-title text-xs flex items-center gap-2">
<BookOpen className="w-4 h-4" />
Study Stats
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-gray-400">Study Speed:</span>
<span className="text-purple-300">×{fmtDec(studySpeedMult, 2)}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Quick Learner Bonus:</span>
<span className="text-purple-300">+{((store.skills.quickLearner || 0) * 10)}%</span>
</div>
</div>
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-gray-400">Study Cost:</span>
<span className="text-purple-300">{Math.round(studyCostMult * 100)}%</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Focused Mind Bonus:</span>
<span className="text-purple-300">-{((store.skills.focusedMind || 0) * 5)}%</span>
</div>
</div>
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-gray-400">Progress Retention:</span>
<span className="text-purple-300">{Math.round((1 + (store.skills.knowledgeRetention || 0) * 0.2) * 100)}%</span>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Element Stats */}
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-green-400 game-panel-title text-xs flex items-center gap-2">
<FlaskConical className="w-4 h-4" />
Element Stats
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-gray-400">Element Capacity:</span>
<span className="text-green-300">{elemMax}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Elem. Attunement Bonus:</span>
<span className="text-green-300">
{(() => {
const ea = store.skillTiers?.elemAttune || 1;
const tieredSkillId = ea > 1 ? `elemAttune_t${ea}` : 'elemAttune';
const level = store.skills[tieredSkillId] || store.skills.elemAttune || 0;
const tierMult = getTierMultiplier(tieredSkillId);
return `+${level * 50 * tierMult}`;
})()}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Prestige Attunement:</span>
<span className="text-green-300">+{(store.prestigeUpgrades.elementalAttune || 0) * 25}</span>
</div>
</div>
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-gray-400">Unlocked Elements:</span>
<span className="text-green-300">{Object.values(store.elements).filter(e => e.unlocked).length} / {Object.keys(ELEMENTS).length}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Elem. Crafting Bonus:</span>
<span className="text-green-300">×{fmtDec(1 + (store.skills.elemCrafting || 0) * 0.25, 2)}</span>
</div>
</div>
</div>
<Separator className="bg-gray-700 my-3" />
<div className="text-xs text-gray-400 mb-2">Elemental Mana Pools:</div>
<div className="grid grid-cols-4 sm:grid-cols-6 md:grid-cols-8 gap-2">
{Object.entries(store.elements)
.filter(([, state]) => state.unlocked)
.map(([id, state]) => {
const def = ELEMENTS[id];
return (
<div key={id} className="p-2 rounded border border-gray-700 bg-gray-800/50 text-center">
<div className="text-lg">{def?.sym}</div>
<div className="text-xs text-gray-400">{state.current}/{state.max}</div>
</div>
);
})}
</div>
</CardContent>
</Card>
{/* Active Upgrades */}
<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">
<Star className="w-4 h-4" />
Active Skill Upgrades ({selectedUpgrades.length})
</CardTitle>
</CardHeader>
<CardContent>
{selectedUpgrades.length === 0 ? (
<div className="text-gray-500 text-sm">No skill upgrades selected yet. Level skills to 5 or 10 to choose upgrades.</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{selectedUpgrades.map(({ skillId, upgrade }) => (
<div key={upgrade.id} className="p-2 rounded border border-amber-600/30 bg-amber-900/10">
<div className="flex items-center justify-between">
<span className="text-amber-300 text-sm font-semibold">{upgrade.name}</span>
<Badge variant="outline" className="text-xs text-gray-400">
{SKILLS_DEF[skillId]?.name || skillId}
</Badge>
</div>
<div className="text-xs text-gray-400 mt-1">{upgrade.desc}</div>
{upgrade.effect.type === 'multiplier' && (
<div className="text-xs text-green-400 mt-1">
+{Math.round((upgrade.effect.value! - 1) * 100)}% {upgrade.effect.stat}
</div>
)}
{upgrade.effect.type === 'bonus' && (
<div className="text-xs text-blue-400 mt-1">
+{upgrade.effect.value} {upgrade.effect.stat}
</div>
)}
{upgrade.effect.type === 'special' && (
<div className="text-xs text-cyan-400 mt-1">
{upgrade.effect.specialDesc || 'Special effect active'}
</div>
)}
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Loop Stats */}
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-purple-400 game-panel-title text-xs flex items-center gap-2">
<RotateCcw className="w-4 h-4" />
Loop Stats
</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-2xl font-bold text-amber-400 game-mono">{store.loopCount}</div>
<div className="text-xs text-gray-400">Loops Completed</div>
</div>
<div className="p-3 bg-gray-800/50 rounded text-center">
<div className="text-2xl font-bold text-purple-400 game-mono">{fmt(store.insight)}</div>
<div className="text-xs text-gray-400">Current Insight</div>
</div>
<div className="p-3 bg-gray-800/50 rounded text-center">
<div className="text-2xl font-bold text-blue-400 game-mono">{fmt(store.totalInsight)}</div>
<div className="text-xs text-gray-400">Total Insight</div>
</div>
<div className="p-3 bg-gray-800/50 rounded text-center">
<div className="text-2xl font-bold text-green-400 game-mono">{store.maxFloorReached}</div>
<div className="text-xs text-gray-400">Max Floor</div>
</div>
</div>
<Separator className="bg-gray-700 my-3" />
<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-gray-300 game-mono">{Object.values(store.spells).filter(s => s.learned).length}</div>
<div className="text-xs text-gray-400">Spells Learned</div>
</div>
<div className="p-3 bg-gray-800/50 rounded text-center">
<div className="text-xl font-bold text-gray-300 game-mono">{Object.values(store.skills).reduce((a, b) => a + b, 0)}</div>
<div className="text-xs text-gray-400">Total Skill Levels</div>
</div>
<div className="p-3 bg-gray-800/50 rounded text-center">
<div className="text-xl font-bold text-gray-300 game-mono">{fmt(store.totalManaGathered)}</div>
<div className="text-xs text-gray-400">Total Mana Gathered</div>
</div>
<div className="p-3 bg-gray-800/50 rounded text-center">
<div className="text-xl font-bold text-gray-300 game-mono">{store.memorySlots}</div>
<div className="text-xs text-gray-400">Memory Slots</div>
</div>
</div>
</CardContent>
</Card>
</div>
);
}
+57
View File
@@ -0,0 +1,57 @@
'use client';
import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';
import { BookOpen, X } from 'lucide-react';
import { SKILLS_DEF, SPELLS_DEF } from '@/lib/game/constants';
import { formatStudyTime } from '@/lib/game/formatting';
import type { StudyTarget } from '@/lib/game/types';
interface StudyProgressProps {
currentStudyTarget: StudyTarget | null;
skills: Record<string, number>;
studySpeedMult: number;
cancelStudy: () => void;
}
export function StudyProgress({
currentStudyTarget,
skills,
studySpeedMult,
cancelStudy,
}: StudyProgressProps) {
if (!currentStudyTarget) return null;
const target = currentStudyTarget;
const progressPct = Math.min(100, (target.progress / target.required) * 100);
const isSkill = target.type === 'skill';
const def = isSkill ? SKILLS_DEF[target.id] : SPELLS_DEF[target.id];
const currentLevel = isSkill ? (skills[target.id] || 0) : 0;
return (
<div className="p-3 rounded border border-purple-600/50 bg-purple-900/20">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<BookOpen className="w-4 h-4 text-purple-400" />
<span className="text-sm font-semibold text-purple-300">
{def?.name}
{isSkill && ` Lv.${currentLevel + 1}`}
</span>
</div>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-gray-400 hover:text-white"
onClick={cancelStudy}
>
<X className="w-4 h-4" />
</Button>
</div>
<Progress value={progressPct} className="h-2 bg-gray-800" />
<div className="flex justify-between text-xs text-gray-400 mt-1">
<span>{formatStudyTime(target.progress)} / {formatStudyTime(target.required)}</span>
<span>{studySpeedMult.toFixed(1)}x speed</span>
</div>
</div>
);
}
+51
View File
@@ -0,0 +1,51 @@
'use client';
import { Play, Pause } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { fmt } from '@/lib/game/store';
import { formatHour } from '@/lib/game/formatting';
interface TimeDisplayProps {
day: number;
hour: number;
insight: number;
paused: boolean;
onTogglePause: () => void;
}
export function TimeDisplay({
day,
hour,
insight,
paused,
onTogglePause,
}: TimeDisplayProps) {
return (
<div className="flex items-center gap-4">
<div className="text-center">
<div className="text-lg font-bold game-mono text-amber-400">
Day {day}
</div>
<div className="text-xs text-gray-400">
{formatHour(hour)}
</div>
</div>
<div className="text-center">
<div className="text-lg font-bold game-mono text-purple-400">
{fmt(insight)}
</div>
<div className="text-xs text-gray-400">Insight</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={onTogglePause}
className="text-gray-400 hover:text-white"
>
{paused ? <Play className="w-4 h-4" /> : <Pause className="w-4 h-4" />}
</Button>
</div>
);
}
+115
View File
@@ -0,0 +1,115 @@
'use client';
import { SKILLS_DEF } from '@/lib/game/constants';
import type { SkillUpgradeChoice } from '@/lib/game/types';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
export interface UpgradeDialogProps {
open: boolean;
skillId: string | null;
milestone: 5 | 10;
pendingSelections: string[];
available: SkillUpgradeChoice[];
alreadySelected: string[];
onToggle: (upgradeId: string) => void;
onConfirm: () => void;
onCancel: () => void;
onOpenChange: (open: boolean) => void;
}
export function UpgradeDialog({
open,
skillId,
milestone,
pendingSelections,
available,
alreadySelected,
onToggle,
onConfirm,
onCancel,
onOpenChange,
}: UpgradeDialogProps) {
if (!skillId) return null;
const skillDef = SKILLS_DEF[skillId];
const currentSelections = pendingSelections.length > 0 ? pendingSelections : alreadySelected;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="bg-gray-900 border-gray-700 max-w-lg">
<DialogHeader>
<DialogTitle className="text-amber-400">
Choose Upgrade - {skillDef?.name || skillId}
</DialogTitle>
<DialogDescription className="text-gray-400">
Level {milestone} Milestone - Select 2 upgrades ({currentSelections.length}/2 chosen)
</DialogDescription>
</DialogHeader>
<div className="space-y-2 mt-4">
{available.map((upgrade) => {
const isSelected = currentSelections.includes(upgrade.id);
const canToggle = currentSelections.length < 2 || isSelected;
return (
<div
key={upgrade.id}
className={`p-3 rounded border cursor-pointer transition-all ${
isSelected
? 'border-amber-500 bg-amber-900/30'
: canToggle
? 'border-gray-600 bg-gray-800/50 hover:border-amber-500/50 hover:bg-gray-800'
: 'border-gray-700 bg-gray-800/30 opacity-50 cursor-not-allowed'
}`}
onClick={() => {
if (canToggle) {
onToggle(upgrade.id);
}
}}
>
<div className="flex items-center justify-between">
<div className="font-semibold text-sm text-amber-300">{upgrade.name}</div>
{isSelected && <Badge className="bg-amber-600 text-amber-100">Selected</Badge>}
</div>
<div className="text-xs text-gray-400 mt-1">{upgrade.desc}</div>
{upgrade.effect.type === 'multiplier' && (
<div className="text-xs text-green-400 mt-1">
+{Math.round((upgrade.effect.value! - 1) * 100)}% {upgrade.effect.stat}
</div>
)}
{upgrade.effect.type === 'bonus' && (
<div className="text-xs text-blue-400 mt-1">
+{upgrade.effect.value} {upgrade.effect.stat}
</div>
)}
{upgrade.effect.type === 'special' && (
<div className="text-xs text-cyan-400 mt-1">
{upgrade.effect.specialDesc || 'Special effect'}
</div>
)}
</div>
);
})}
</div>
<div className="flex justify-end gap-2 mt-4">
<Button
variant="outline"
onClick={onCancel}
>
Cancel
</Button>
<Button
variant="default"
onClick={onConfirm}
disabled={currentSelections.length !== 2}
>
{currentSelections.length < 2 ? `Select ${2 - currentSelections.length} more` : 'Confirm'}
</Button>
</div>
</DialogContent>
</Dialog>
);
}
+19
View File
@@ -0,0 +1,19 @@
// ─── Game Components Index ──────────────────────────────────────────────────────
// Re-exports all game tab components for cleaner imports
// Tab components
export { CraftingTab } from './tabs/CraftingTab';
export { SpireTab } from './tabs/SpireTab';
export { SpellsTab } from './tabs/SpellsTab';
export { LabTab } from './tabs/LabTab';
export { SkillsTab } from './tabs/SkillsTab';
export { StatsTab } from './tabs/StatsTab';
// UI components
export { ActionButtons } from './ActionButtons';
export { CalendarDisplay } from './CalendarDisplay';
export { CraftingProgress } from './CraftingProgress';
export { StudyProgress } from './StudyProgress';
export { ManaDisplay } from './ManaDisplay';
export { TimeDisplay } from './TimeDisplay';
export { UpgradeDialog } from './UpgradeDialog';
+19
View File
@@ -0,0 +1,19 @@
'use client';
import { useGameContext } from '../GameContext';
export function GameFooter() {
const { store } = useGameContext();
return (
<footer className="sticky bottom-0 bg-gray-900/80 border-t border-gray-700 px-4 py-2 text-center text-xs text-gray-500">
<span className="text-gray-400">Loop {store.loopCount + 1}</span>
{' • '}
<span>Pacts: {store.signedPacts.length}/{store.pactSlots}</span>
{' • '}
<span>Spells: {Object.values(store.spells).filter((s) => s.learned).length}</span>
{' • '}
<span>Skills: {Object.values(store.skills).reduce((a, b) => a + b, 0)}</span>
</footer>
);
}
+79
View File
@@ -0,0 +1,79 @@
'use client';
import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { Pause, Play } from 'lucide-react';
import { useGameContext } from '../GameContext';
import { formatTime } from '../types';
import { MAX_DAY, INCURSION_START_DAY } from '@/lib/game/constants';
import { fmt } from '@/lib/game/stores';
export function GameHeader() {
const { store } = useGameContext();
// Calendar rendering
const renderCalendar = () => {
const days: React.ReactElement[] = [];
for (let d = 1; d <= MAX_DAY; d++) {
let dayClass = 'w-7 h-7 rounded text-xs flex items-center justify-center font-mono border transition-all ';
if (d < store.day) {
dayClass += 'bg-blue-900/30 border-blue-800/50 text-blue-400';
} else if (d === store.day) {
dayClass += 'bg-blue-600/40 border-blue-500 text-blue-300 shadow-lg shadow-blue-500/30';
} else {
dayClass += 'bg-gray-800/30 border-gray-700/50 text-gray-500';
}
if (d >= INCURSION_START_DAY) {
dayClass += ' border-red-600/50';
}
days.push(
<Tooltip key={d}>
<TooltipTrigger asChild>
<div className={dayClass}>{d}</div>
</TooltipTrigger>
<TooltipContent>
<p>Day {d}</p>
{d >= INCURSION_START_DAY && <p className="text-red-400">Incursion Active</p>}
</TooltipContent>
</Tooltip>
);
}
return days;
};
return (
<header className="sticky top-0 z-50 bg-gradient-to-b from-gray-900 to-gray-900/80 border-b border-gray-700 px-4 py-2">
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold game-title tracking-wider">MANA LOOP</h1>
<div className="flex items-center gap-4">
<div className="text-center">
<div className="text-lg font-bold game-mono text-amber-400">Day {store.day}</div>
<div className="text-xs text-gray-400">{formatTime(store.hour)}</div>
</div>
<div className="text-center">
<div className="text-lg font-bold game-mono text-purple-400">{fmt(store.insight)}</div>
<div className="text-xs text-gray-400">Insight</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => store.togglePause()}
className="text-gray-400 hover:text-white"
>
{store.paused ? <Play className="w-4 h-4" /> : <Pause className="w-4 h-4" />}
</Button>
</div>
</div>
{/* Calendar */}
<div className="mt-2 flex gap-1 overflow-x-auto pb-1">{renderCalendar()}</div>
</header>
);
}
+141
View File
@@ -0,0 +1,141 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';
import { Card, CardContent } from '@/components/ui/card';
import { Zap, Sparkles, Swords, BookOpen, FlaskConical, type LucideIcon } from 'lucide-react';
import { useGameContext } from '../GameContext';
import { fmt, fmtDec } from '@/lib/game/stores';
import type { GameAction } from '@/lib/game/types';
export function GameSidebar() {
const {
store,
maxMana,
clickMana,
effectiveRegen,
meditationMultiplier,
floorElemDef,
} = useGameContext();
const [isGathering, setIsGathering] = useState(false);
// Auto-gather while holding
useEffect(() => {
if (!isGathering) return;
let lastGatherTime = 0;
const minGatherInterval = 100;
let animationFrameId: number;
const gatherLoop = (timestamp: number) => {
if (timestamp - lastGatherTime >= minGatherInterval) {
store.gatherMana();
lastGatherTime = timestamp;
}
animationFrameId = requestAnimationFrame(gatherLoop);
};
animationFrameId = requestAnimationFrame(gatherLoop);
return () => cancelAnimationFrame(animationFrameId);
}, [isGathering, store]);
const handleGatherStart = useCallback(() => {
setIsGathering(true);
store.gatherMana();
}, [store]);
const handleGatherEnd = useCallback(() => {
setIsGathering(false);
}, []);
// Action buttons
const actions: { id: GameAction; label: string; icon: LucideIcon }[] = [
{ id: 'meditate', label: 'Meditate', icon: Sparkles },
{ id: 'climb', label: 'Climb', icon: Swords },
{ id: 'study', label: 'Study', icon: BookOpen },
{ id: 'convert', label: 'Convert', icon: FlaskConical },
];
return (
<aside className="w-full md:w-64 bg-gray-900/50 border-b md:border-b-0 md:border-r border-gray-700 p-4 space-y-4">
{/* Mana Panel */}
<Card className="bg-gray-900/80 border-gray-700">
<CardContent className="pt-4 space-y-3">
<div>
<div className="flex items-baseline gap-1">
<span className="text-3xl font-bold game-mono text-blue-400">{fmt(store.rawMana)}</span>
<span className="text-sm text-gray-400">/ {fmt(maxMana)}</span>
</div>
<div className="text-xs text-gray-400">
+{fmtDec(effectiveRegen)} mana/hr{' '}
{meditationMultiplier > 1.01 && (
<span className="text-purple-400">({fmtDec(meditationMultiplier, 1)}x med)</span>
)}
</div>
</div>
<Progress value={(store.rawMana / maxMana) * 100} className="h-2 bg-gray-800" />
<Button
className={`w-full bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 ${
isGathering ? 'animate-pulse' : ''
}`}
onMouseDown={handleGatherStart}
onMouseUp={handleGatherEnd}
onMouseLeave={handleGatherEnd}
onTouchStart={handleGatherStart}
onTouchEnd={handleGatherEnd}
>
<Zap className="w-4 h-4 mr-2" />
Gather +{clickMana} Mana
{isGathering && <span className="ml-2 text-xs">(Holding...)</span>}
</Button>
</CardContent>
</Card>
{/* Actions */}
<Card className="bg-gray-900/80 border-gray-700">
<CardContent className="pt-4">
<div className="text-xs text-amber-400 game-panel-title mb-2">Current Action</div>
<div className="grid grid-cols-2 gap-2">
{actions.map(({ id, label, icon: Icon }) => (
<Button
key={id}
variant={store.currentAction === id ? 'default' : 'outline'}
size="sm"
className={`h-9 ${
store.currentAction === id
? 'bg-blue-600 hover:bg-blue-700'
: 'bg-gray-800/50 hover:bg-gray-700/50 border-gray-600'
}`}
onClick={() => store.setAction(id)}
>
<Icon className="w-4 h-4 mr-1" />
{label}
</Button>
))}
</div>
</CardContent>
</Card>
{/* Floor Status */}
<Card className="bg-gray-900/80 border-gray-700">
<CardContent className="pt-4 space-y-2">
<div className="text-xs text-amber-400 game-panel-title mb-2">Floor Status</div>
<div className="flex items-center justify-between">
<span className="text-sm text-gray-400">Current</span>
<span className="text-lg font-bold" style={{ color: floorElemDef?.color }}>
{store.currentFloor}
</span>
</div>
<Progress value={(store.floorHP / store.floorMaxHP) * 100} className="h-2 bg-gray-800" />
<div className="text-xs text-gray-400 game-mono">
{fmt(store.floorHP)} / {fmt(store.floorMaxHP)} HP
</div>
</CardContent>
</Card>
</aside>
);
}
+76
View File
@@ -0,0 +1,76 @@
'use client';
import { useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { useGameContext } from '../GameContext';
import { fmt } from '@/lib/game/stores';
import { MemorySlotPicker } from './MemorySlotPicker';
export function GameOverScreen() {
const { store } = useGameContext();
const [memoriesConfirmed, setMemoriesConfirmed] = useState(false);
if (!store.gameOver) return null;
const handleStartNewLoop = () => {
store.startNewLoop();
};
return (
<div className="fixed inset-0 game-overlay flex items-center justify-center z-50 overflow-auto py-4">
<div className="max-w-lg w-full mx-4 space-y-4">
<Card className="bg-gray-900 border-gray-600 shadow-2xl">
<CardHeader>
<CardTitle
className={`text-3xl text-center game-title ${store.victory ? 'text-amber-400' : 'text-red-400'}`}
>
{store.victory ? '🏆 VICTORY!' : '⏰ LOOP ENDS'}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-center text-gray-400">
{store.victory
? 'The Awakened One falls! Your power echoes through eternity.'
: 'The time loop resets... but you remember.'}
</p>
<div className="grid grid-cols-2 gap-3">
<div className="p-3 bg-gray-800 rounded">
<div className="text-xl font-bold text-amber-400 game-mono">{fmt(store.loopInsight)}</div>
<div className="text-xs text-gray-400">Insight Gained</div>
</div>
<div className="p-3 bg-gray-800 rounded">
<div className="text-xl font-bold text-blue-400 game-mono">{store.maxFloorReached}</div>
<div className="text-xs text-gray-400">Best Floor</div>
</div>
<div className="p-3 bg-gray-800 rounded">
<div className="text-xl font-bold text-purple-400 game-mono">{store.signedPacts.length}</div>
<div className="text-xs text-gray-400">Pacts Signed</div>
</div>
<div className="p-3 bg-gray-800 rounded">
<div className="text-xl font-bold text-green-400 game-mono">{store.loopCount + 1}</div>
<div className="text-xs text-gray-400">Total Loops</div>
</div>
</div>
</CardContent>
</Card>
{/* Memory Slot Picker */}
{store.memorySlots > 0 && !memoriesConfirmed && (
<MemorySlotPicker onConfirm={() => setMemoriesConfirmed(true)} />
)}
<Button
className="w-full bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700"
size="lg"
onClick={handleStartNewLoop}
disabled={store.memorySlots > 0 && !memoriesConfirmed}
>
Begin New Loop
{store.memorySlots > 0 && !memoriesConfirmed && ' (Confirm Memories First)'}
</Button>
</div>
</div>
);
}
+206
View File
@@ -0,0 +1,206 @@
'use client';
import { useState, useMemo } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Save, Trash2, Star, ChevronUp } from 'lucide-react';
import { useGameContext } from '../GameContext';
import { SKILLS_DEF } from '@/lib/game/constants';
import { getTierMultiplier, getBaseSkillId } from '@/lib/game/skill-evolution';
import type { Memory } from '@/lib/game/types';
interface MemorySlotPickerProps {
onConfirm?: () => void;
}
export function MemorySlotPicker({ onConfirm }: MemorySlotPickerProps) {
const { store } = useGameContext();
const [selectedSkills, setSelectedSkills] = useState<Memory[]>(store.memories || []);
// Get all skills that have progress and can be saved
const saveableSkills = useMemo(() => {
const skills: { skillId: string; level: number; tier: number; upgrades: string[]; name: string }[] = [];
for (const [skillId, level] of Object.entries(store.skills)) {
if (level && level > 0) {
const baseSkillId = getBaseSkillId(skillId);
const tier = store.skillTiers?.[baseSkillId] || 1;
const tieredSkillId = tier > 1 ? `${baseSkillId}_t${tier}` : baseSkillId;
const upgrades = store.skillUpgrades?.[tieredSkillId] || [];
const skillDef = SKILLS_DEF[baseSkillId];
// Only include if it's a base skill (not a tiered variant in the skills object)
if (skillId === baseSkillId || skillId.includes('_t')) {
// Get the actual skill ID and level
const actualLevel = store.skills[tieredSkillId] || store.skills[baseSkillId] || 0;
if (actualLevel > 0) {
skills.push({
skillId: baseSkillId,
level: actualLevel,
tier,
upgrades,
name: skillDef?.name || baseSkillId,
});
}
}
}
}
// Remove duplicates and keep highest tier/level
const uniqueSkills = new Map<string, typeof skills[0]>();
for (const skill of skills) {
const existing = uniqueSkills.get(skill.skillId);
if (!existing || skill.tier > existing.tier || (skill.tier === existing.tier && skill.level > existing.level)) {
uniqueSkills.set(skill.skillId, skill);
}
}
return Array.from(uniqueSkills.values()).sort((a, b) => {
// Sort by tier then level then name
if (a.tier !== b.tier) return b.tier - a.tier;
if (a.level !== b.level) return b.level - a.level;
return a.name.localeCompare(b.name);
});
}, [store.skills, store.skillTiers, store.skillUpgrades]);
const isSkillSelected = (skillId: string) => selectedSkills.some(m => m.skillId === skillId);
const canAddMore = selectedSkills.length < store.memorySlots;
const toggleSkill = (skillId: string) => {
const existingIndex = selectedSkills.findIndex(m => m.skillId === skillId);
if (existingIndex >= 0) {
// Remove it
setSelectedSkills(selectedSkills.filter((_, i) => i !== existingIndex));
} else if (canAddMore) {
// Add it
const skill = saveableSkills.find(s => s.skillId === skillId);
if (skill) {
setSelectedSkills([...selectedSkills, {
skillId: skill.skillId,
level: skill.level,
tier: skill.tier,
upgrades: skill.upgrades,
}]);
}
}
};
const handleConfirm = () => {
// Clear and re-add selected memories
store.clearMemories();
for (const memory of selectedSkills) {
store.addMemory(memory);
}
onConfirm?.();
};
return (
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 game-panel-title text-sm flex items-center gap-2">
<Save className="w-4 h-4" />
Memory Slots ({selectedSkills.length}/{store.memorySlots})
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-xs text-gray-400">
Select skills to preserve in your memory. Saved skills will retain their level, tier, and upgrades in the next loop.
</p>
{/* Selected Skills */}
{selectedSkills.length > 0 && (
<div className="space-y-1">
<div className="text-xs text-green-400 game-panel-title">Saved to Memory:</div>
<div className="flex flex-wrap gap-1">
{selectedSkills.map((memory) => {
const skillDef = SKILLS_DEF[memory.skillId];
return (
<Badge
key={memory.skillId}
className="bg-amber-900/50 text-amber-200 cursor-pointer hover:bg-red-900/50"
onClick={() => toggleSkill(memory.skillId)}
>
{skillDef?.name || memory.skillId}
{' '}Lv.{memory.level}
{memory.tier > 1 && ` T${memory.tier}`}
{memory.upgrades.length > 0 && ` (${memory.upgrades.length}⭐)`}
<Trash2 className="w-3 h-3 ml-1" />
</Badge>
);
})}
</div>
</div>
)}
{/* Available Skills */}
<div className="text-xs text-gray-400 game-panel-title">Skills to Save:</div>
<ScrollArea className="h-48">
<div className="space-y-1 pr-2">
{saveableSkills.length === 0 ? (
<div className="text-gray-500 text-xs text-center py-4">
No skills with progress to save
</div>
) : (
saveableSkills.map((skill) => {
const isSelected = isSkillSelected(skill.skillId);
const tierMult = getTierMultiplier(skill.tier > 1 ? `${skill.skillId}_t${skill.tier}` : skill.skillId);
return (
<div
key={skill.skillId}
className={`p-2 rounded border cursor-pointer transition-all ${
isSelected
? 'border-amber-500 bg-amber-900/30'
: canAddMore
? 'border-gray-700 bg-gray-800/50 hover:border-gray-600'
: 'border-gray-800 bg-gray-900/30 opacity-50'
}`}
onClick={() => toggleSkill(skill.skillId)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="font-semibold text-sm">{skill.name}</span>
{skill.tier > 1 && (
<Badge className="bg-purple-600/50 text-purple-200 text-xs">
Tier {skill.tier} ({tierMult}x)
</Badge>
)}
</div>
<div className="flex items-center gap-2">
<span className="text-purple-400 text-sm">Lv.{skill.level}</span>
{skill.upgrades.length > 0 && (
<Badge className="bg-amber-700/50 text-amber-200 text-xs flex items-center gap-1">
<Star className="w-3 h-3" />
{skill.upgrades.length}
</Badge>
)}
</div>
</div>
{skill.upgrades.length > 0 && (
<div className="text-xs text-gray-500 mt-1">
Upgrades: {skill.upgrades.length} selected
</div>
)}
</div>
);
})
)}
</div>
</ScrollArea>
{/* Confirm Button */}
<Button
className="w-full bg-amber-600 hover:bg-amber-700"
onClick={handleConfirm}
>
<Save className="w-4 h-4 mr-2" />
Confirm Memories
</Button>
</CardContent>
</Card>
);
}
+60
View File
@@ -0,0 +1,60 @@
'use client';
import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';
import { BookOpen, X } from 'lucide-react';
import { useGameContext } from '../GameContext';
import { formatStudyTime } from '../types';
import { SKILLS_DEF, SPELLS_DEF } from '@/lib/game/constants';
interface StudyProgressProps {
target: NonNullable<ReturnType<typeof useGameContext>['store']['currentStudyTarget']>;
showCancel?: boolean;
speedLabel?: string;
}
export function StudyProgress({ target, showCancel = true, speedLabel }: StudyProgressProps) {
const { store, studySpeedMult } = useGameContext();
const progressPct = Math.min(100, (target.progress / target.required) * 100);
const isSkill = target.type === 'skill';
const def = isSkill ? SKILLS_DEF[target.id] : SPELLS_DEF[target.id];
const currentLevel = isSkill ? store.skills[target.id] || 0 : 0;
const handleCancel = () => {
// Calculate retention bonus from knowledge retention skill
const retentionBonus = 0.2 * (store.skills.knowledgeRetention || 0);
store.cancelStudy(retentionBonus);
};
return (
<div className="p-3 rounded border border-purple-600/50 bg-purple-900/20">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<BookOpen className="w-4 h-4 text-purple-400" />
<span className="text-sm font-semibold text-purple-300">
{def?.name}
{isSkill && ` Lv.${currentLevel + 1}`}
</span>
</div>
{showCancel && (
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-gray-400 hover:text-white"
onClick={handleCancel}
>
<X className="w-4 h-4" />
</Button>
)}
</div>
<Progress value={progressPct} className="h-2 bg-gray-800" />
<div className="flex justify-between text-xs text-gray-400 mt-1">
<span>
{formatStudyTime(target.progress)} / {formatStudyTime(target.required)}
</span>
<span>{speedLabel ?? `${studySpeedMult.toFixed(1)}x speed`}</span>
</div>
</div>
);
}
+126
View File
@@ -0,0 +1,126 @@
'use client';
import { useState } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { useGameContext } from '../GameContext';
import { SKILLS_DEF } from '@/lib/game/constants';
interface UpgradeDialogProps {
skillId: string | null;
milestone: 5 | 10;
onClose: () => void;
}
export function UpgradeDialog({ skillId, milestone, onClose }: UpgradeDialogProps) {
const { store } = useGameContext();
const skillDef = skillId ? SKILLS_DEF[skillId] : null;
const { available, selected: alreadySelected } = skillId
? store.getSkillUpgradeChoices(skillId, milestone)
: { available: [], selected: [] };
// Use local state for selections within this dialog session
const [pendingSelections, setPendingSelections] = useState<string[]>(() => [...alreadySelected]);
const toggleUpgrade = (upgradeId: string) => {
setPendingSelections((prev) => {
if (prev.includes(upgradeId)) {
return prev.filter((id) => id !== upgradeId);
} else if (prev.length < 2) {
return [...prev, upgradeId];
}
return prev;
});
};
const handleDone = () => {
if (pendingSelections.length === 2 && skillId) {
store.commitSkillUpgrades(skillId, pendingSelections);
}
onClose();
};
const handleOpenChange = (open: boolean) => {
if (!open) {
setPendingSelections([...alreadySelected]);
onClose();
}
};
// Don't render if no skill selected
if (!skillId) return null;
return (
<Dialog open={!!skillId} onOpenChange={handleOpenChange}>
<DialogContent className="bg-gray-900 border-gray-700 max-w-lg">
<DialogHeader>
<DialogTitle className="text-amber-400">Choose Upgrade - {skillDef?.name || skillId}</DialogTitle>
<DialogDescription className="text-gray-400">
Level {milestone} Milestone - Select 2 upgrades ({pendingSelections.length}/2 chosen)
</DialogDescription>
</DialogHeader>
<div className="space-y-2 mt-4">
{available.map((upgrade) => {
const isSelected = pendingSelections.includes(upgrade.id);
const canToggle = pendingSelections.length < 2 || isSelected;
return (
<div
key={upgrade.id}
className={`p-3 rounded border cursor-pointer transition-all ${
isSelected
? 'border-amber-500 bg-amber-900/30'
: canToggle
? 'border-gray-600 bg-gray-800/50 hover:border-amber-500/50 hover:bg-gray-800'
: 'border-gray-700 bg-gray-800/30 opacity-50 cursor-not-allowed'
}`}
onClick={() => {
if (canToggle) {
toggleUpgrade(upgrade.id);
}
}}
>
<div className="flex items-center justify-between">
<div className="font-semibold text-sm text-amber-300">{upgrade.name}</div>
{isSelected && <Badge className="bg-amber-600 text-amber-100">Selected</Badge>}
</div>
<div className="text-xs text-gray-400 mt-1">{upgrade.desc}</div>
{upgrade.effect.type === 'multiplier' && (
<div className="text-xs text-green-400 mt-1">
+{Math.round((upgrade.effect.value! - 1) * 100)}% {upgrade.effect.stat}
</div>
)}
{upgrade.effect.type === 'bonus' && (
<div className="text-xs text-blue-400 mt-1">
+{upgrade.effect.value} {upgrade.effect.stat}
</div>
)}
{upgrade.effect.type === 'special' && (
<div className="text-xs text-cyan-400 mt-1"> {upgrade.desc || 'Special effect'}</div>
)}
</div>
);
})}
</div>
<div className="flex justify-end gap-2 mt-4">
<Button
variant="outline"
onClick={() => {
setPendingSelections([...alreadySelected]);
onClose();
}}
>
Cancel
</Button>
<Button variant="default" onClick={handleDone} disabled={pendingSelections.length !== 2}>
{pendingSelections.length < 2 ? `Select ${2 - pendingSelections.length} more` : 'Confirm'}
</Button>
</div>
</DialogContent>
</Dialog>
);
}
+43
View File
@@ -0,0 +1,43 @@
'use client';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import type { GameStore } from '@/lib/game/store';
import { AchievementsDisplay } from '@/components/game/AchievementsDisplay';
export interface AchievementsTabProps {
store: GameStore;
}
export function AchievementsTab({ store }: AchievementsTabProps) {
const achievements = store.achievements;
const unlockedCount = achievements.unlocked.length;
return (
<div className="space-y-4">
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 text-sm flex items-center gap-2">
🏆 Achievements
<Badge className="ml-auto bg-amber-900/50 text-amber-300">
{unlockedCount} unlocked
</Badge>
</CardTitle>
</CardHeader>
<CardContent>
<AchievementsDisplay
achievements={achievements}
gameState={{
maxFloorReached: store.maxFloorReached,
totalManaGathered: store.totalManaGathered,
signedPacts: store.signedPacts,
totalSpellsCast: store.totalSpellsCast,
totalDamageDealt: store.totalDamageDealt,
totalCraftsCompleted: store.totalCraftsCompleted,
}}
/>
</CardContent>
</Card>
</div>
);
}
+268
View File
@@ -0,0 +1,268 @@
'use client';
import { ATTUNEMENTS_DEF, ATTUNEMENT_SLOT_NAMES, getTotalAttunementRegen, getAvailableSkillCategories, getAttunementXPForLevel, MAX_ATTUNEMENT_LEVEL, getAttunementConversionRate } from '@/lib/game/data/attunements';
import { ELEMENTS } from '@/lib/game/constants';
import type { GameStore, AttunementState } from '@/lib/game/types';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';
import { Lock, Sparkles, TrendingUp } from 'lucide-react';
export interface AttunementsTabProps {
store: GameStore;
}
export function AttunementsTab({ store }: AttunementsTabProps) {
const attunements = store.attunements || {};
// Get active attunements
const activeAttunements = Object.entries(attunements)
.filter(([, state]) => state.active)
.map(([id]) => ATTUNEMENTS_DEF[id])
.filter(Boolean);
// Calculate total regen from attunements
const totalAttunementRegen = getTotalAttunementRegen(attunements);
// Get available skill categories
const availableCategories = getAvailableSkillCategories(attunements);
return (
<div className="space-y-4">
{/* Overview Card */}
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 text-sm">Your Attunements</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-gray-400 mb-3">
Attunements are magical bonds tied to specific body locations. Each attunement grants unique capabilities,
mana regeneration, and access to specialized skills. Level them up to increase their power.
</p>
<div className="flex flex-wrap gap-2">
<Badge className="bg-teal-900/50 text-teal-300">
+{totalAttunementRegen.toFixed(1)} raw mana/hr
</Badge>
<Badge className="bg-purple-900/50 text-purple-300">
{activeAttunements.length} active attunement{activeAttunements.length !== 1 ? 's' : ''}
</Badge>
</div>
</CardContent>
</Card>
{/* Attunement Slots */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{Object.entries(ATTUNEMENTS_DEF).map(([id, def]) => {
const state = attunements[id];
const isActive = state?.active;
const isUnlocked = state?.active || def.unlocked;
const level = state?.level || 1;
const xp = state?.experience || 0;
const xpNeeded = getAttunementXPForLevel(level + 1);
const xpProgress = xpNeeded > 0 ? (xp / xpNeeded) * 100 : 100;
const isMaxLevel = level >= MAX_ATTUNEMENT_LEVEL;
// Get primary mana element info
const primaryElem = def.primaryManaType ? ELEMENTS[def.primaryManaType] : null;
// Get current mana for this attunement's type
const currentMana = def.primaryManaType ? store.elements[def.primaryManaType]?.current || 0 : 0;
const maxMana = def.primaryManaType ? store.elements[def.primaryManaType]?.max || 50 : 50;
// Calculate level-scaled stats
const levelMult = Math.pow(1.5, level - 1);
const scaledRegen = def.rawManaRegen * levelMult;
const scaledConversion = getAttunementConversionRate(id, level);
return (
<Card
key={id}
className={`bg-gray-900/80 transition-all ${
isActive
? 'border-2 shadow-lg'
: isUnlocked
? 'border-gray-600'
: 'border-gray-800 opacity-70'
}`}
style={{
borderColor: isActive ? def.color : undefined,
boxShadow: isActive ? `0 0 20px ${def.color}30` : undefined
}}
>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-2xl">{def.icon}</span>
<div>
<CardTitle className="text-sm" style={{ color: isActive ? def.color : '#9CA3AF' }}>
{def.name}
</CardTitle>
<div className="text-xs text-gray-500">
{ATTUNEMENT_SLOT_NAMES[def.slot]}
</div>
</div>
</div>
{!isUnlocked && (
<Lock className="w-4 h-4 text-gray-600" />
)}
{isActive && (
<Badge className="text-xs" style={{ backgroundColor: `${def.color}30`, color: def.color }}>
Lv.{level}
</Badge>
)}
</div>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-xs text-gray-400">{def.desc}</p>
{/* Mana Type */}
<div className="space-y-1">
<div className="flex items-center justify-between text-xs">
<span className="text-gray-500">Primary Mana</span>
{primaryElem ? (
<span style={{ color: primaryElem.color }}>
{primaryElem.sym} {primaryElem.name}
</span>
) : (
<span className="text-purple-400">From Pacts</span>
)}
</div>
{/* Mana bar (only for attunements with primary type) */}
{primaryElem && isActive && (
<div className="space-y-1">
<Progress
value={(currentMana / maxMana) * 100}
className="h-2 bg-gray-800"
/>
<div className="flex justify-between text-xs text-gray-500">
<span>{currentMana.toFixed(1)}</span>
<span>/{maxMana}</span>
</div>
</div>
)}
</div>
{/* Stats with level scaling */}
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="p-2 bg-gray-800/50 rounded">
<div className="text-gray-500">Raw Regen</div>
<div className="text-green-400 font-semibold">
+{scaledRegen.toFixed(2)}/hr
{level > 1 && <span className="text-xs ml-1">({((levelMult - 1) * 100).toFixed(0)}% bonus)</span>}
</div>
</div>
<div className="p-2 bg-gray-800/50 rounded">
<div className="text-gray-500">Conversion</div>
<div className="text-cyan-400 font-semibold">
{scaledConversion > 0 ? `${scaledConversion.toFixed(2)}/hr` : '—'}
{level > 1 && scaledConversion > 0 && <span className="text-xs ml-1">({((levelMult - 1) * 100).toFixed(0)}% bonus)</span>}
</div>
</div>
</div>
{/* XP Progress Bar */}
{isUnlocked && state && !isMaxLevel && (
<div className="space-y-1">
<div className="flex items-center justify-between text-xs">
<span className="text-gray-500 flex items-center gap-1">
<TrendingUp className="w-3 h-3" />
XP Progress
</span>
<span className="text-amber-400">{xp} / {xpNeeded}</span>
</div>
<Progress
value={xpProgress}
className="h-2 bg-gray-800"
/>
<div className="text-xs text-gray-500">
{isMaxLevel ? 'Max Level' : `${xpNeeded - xp} XP to Level ${level + 1}`}
</div>
</div>
)}
{/* Max Level Indicator */}
{isMaxLevel && (
<div className="text-xs text-amber-400 text-center font-semibold">
MAX LEVEL
</div>
)}
{/* Capabilities */}
<div className="space-y-1">
<div className="text-xs text-gray-500">Capabilities</div>
<div className="flex flex-wrap gap-1">
{def.capabilities.map(cap => (
<Badge key={cap} variant="outline" className="text-xs">
{cap === 'enchanting' && '✨ Enchanting'}
{cap === 'disenchanting' && '🔄 Disenchant'}
{cap === 'pacts' && '🤝 Pacts'}
{cap === 'guardianPowers' && '💜 Guardian Powers'}
{cap === 'elementalMastery' && '🌟 Elem. Mastery'}
{cap === 'golemCrafting' && '🗿 Golems'}
{cap === 'gearCrafting' && '⚒️ Gear'}
{cap === 'earthShaping' && '⛰️ Earth Shaping'}
{!['enchanting', 'disenchanting', 'pacts', 'guardianPowers',
'elementalMastery', 'golemCrafting', 'gearCrafting', 'earthShaping'].includes(cap) && cap}
</Badge>
))}
</div>
</div>
{/* Unlock condition for locked attunements */}
{!isUnlocked && def.unlockCondition && (
<div className="text-xs text-amber-400 italic">
🔒 {def.unlockCondition}
</div>
)}
</CardContent>
</Card>
);
})}
</div>
{/* Available Skills Summary */}
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 text-sm">Available Skill Categories</CardTitle>
</CardHeader>
<CardContent>
<p className="text-xs text-gray-400 mb-2">
Your attunements grant access to specialized skill categories:
</p>
<div className="flex flex-wrap gap-2">
{availableCategories.map(cat => {
const attunement = Object.values(ATTUNEMENTS_DEF).find(a =>
a.skillCategories.includes(cat) && attunements[a.id]?.active
);
return (
<Badge
key={cat}
className={attunement ? '' : 'bg-gray-700/50 text-gray-400'}
style={attunement ? {
backgroundColor: `${attunement.color}30`,
color: attunement.color
} : undefined}
>
{cat === 'mana' && '💧 Mana'}
{cat === 'study' && '📚 Study'}
{cat === 'research' && '🔮 Research'}
{cat === 'ascension' && '⭐ Ascension'}
{cat === 'enchant' && '✨ Enchanting'}
{cat === 'effectResearch' && '🔬 Effect Research'}
{cat === 'invocation' && '💜 Invocation'}
{cat === 'pact' && '🤝 Pact Mastery'}
{cat === 'fabrication' && '⚒️ Fabrication'}
{cat === 'golemancy' && '🗿 Golemancy'}
{!['mana', 'study', 'research', 'ascension', 'enchant', 'effectResearch',
'invocation', 'pact', 'fabrication', 'golemancy'].includes(cat) && cat}
</Badge>
);
})}
</div>
</CardContent>
</Card>
</div>
);
}
+965
View File
@@ -0,0 +1,965 @@
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Separator } from '@/components/ui/separator';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
Wand2, Scroll, Hammer, Sparkles, Trash2, Plus, Minus,
Package, Zap, Clock, ChevronRight, Circle, Anvil
} from 'lucide-react';
import { EQUIPMENT_TYPES, type EquipmentType, type EquipmentSlot } from '@/lib/game/data/equipment';
import { ENCHANTMENT_EFFECTS, type EnchantmentEffectDef, calculateEffectCapacityCost } from '@/lib/game/data/enchantment-effects';
import { CRAFTING_RECIPES, canCraftRecipe } from '@/lib/game/data/crafting-recipes';
import { LOOT_DROPS, RARITY_COLORS } from '@/lib/game/data/loot-drops';
import type { EquipmentInstance, EnchantmentDesign, DesignEffect, AppliedEnchantment, LootInventory, EquipmentCraftingProgress } from '@/lib/game/types';
import { fmt, type GameStore } from '@/lib/game/store';
// Slot display names
const SLOT_NAMES: Record<EquipmentSlot, string> = {
mainHand: 'Main Hand',
offHand: 'Off Hand',
head: 'Head',
body: 'Body',
hands: 'Hands',
feet: 'Feet',
accessory1: 'Accessory 1',
accessory2: 'Accessory 2',
};
export interface CraftingTabProps {
store: GameStore;
}
export function CraftingTab({ store }: CraftingTabProps) {
const equippedInstances = store.equippedInstances;
const equipmentInstances = store.equipmentInstances;
const enchantmentDesigns = store.enchantmentDesigns;
const designProgress = store.designProgress;
const preparationProgress = store.preparationProgress;
const applicationProgress = store.applicationProgress;
const equipmentCraftingProgress = store.equipmentCraftingProgress;
const rawMana = store.rawMana;
const skills = store.skills;
const currentAction = store.currentAction;
const unlockedEffects = store.unlockedEffects;
const lootInventory = store.lootInventory;
const startDesigningEnchantment = store.startDesigningEnchantment;
const cancelDesign = store.cancelDesign;
const saveDesign = store.saveDesign;
const deleteDesign = store.deleteDesign;
const startPreparing = store.startPreparing;
const cancelPreparation = store.cancelPreparation;
const startApplying = store.startApplying;
const pauseApplication = store.pauseApplication;
const resumeApplication = store.resumeApplication;
const cancelApplication = store.cancelApplication;
const disenchantEquipment = store.disenchantEquipment;
const getAvailableCapacity = store.getAvailableCapacity;
const startCraftingEquipment = store.startCraftingEquipment;
const cancelEquipmentCrafting = store.cancelEquipmentCrafting;
const deleteMaterial = store.deleteMaterial;
const [craftingStage, setCraftingStage] = useState<'design' | 'prepare' | 'apply' | 'craft'>('craft');
const [selectedEquipmentType, setSelectedEquipmentType] = useState<string | null>(null);
const [selectedEquipmentInstance, setSelectedEquipmentInstance] = useState<string | null>(null);
const [selectedDesign, setSelectedDesign] = useState<string | null>(null);
// Design creation state
const [designName, setDesignName] = useState('');
const [selectedEffects, setSelectedEffects] = useState<DesignEffect[]>([]);
const enchantingLevel = skills.enchanting || 0;
const efficiencyBonus = (skills.efficientEnchant || 0) * 0.05;
// Get equipped items as array
const equippedItems = Object.entries(equippedInstances)
.filter(([, instanceId]) => instanceId && equipmentInstances[instanceId])
.map(([slot, instanceId]) => ({
slot: slot as EquipmentSlot,
instance: equipmentInstances[instanceId!],
}));
// Calculate total capacity cost for current design
const designCapacityCost = selectedEffects.reduce(
(total, eff) => total + calculateEffectCapacityCost(eff.effectId, eff.stacks, efficiencyBonus),
0
);
// Get capacity limit for selected equipment type
const selectedEquipmentCapacity = selectedEquipmentType ? EQUIPMENT_TYPES[selectedEquipmentType]?.baseCapacity || 0 : 0;
const isOverCapacity = selectedEquipmentType ? designCapacityCost > selectedEquipmentCapacity : false;
// Calculate design time
const designTime = selectedEffects.reduce((total, eff) => total + 0.5 * eff.stacks, 1);
// Add effect to design
const addEffect = (effectId: string) => {
const existing = selectedEffects.find(e => e.effectId === effectId);
const effectDef = ENCHANTMENT_EFFECTS[effectId];
if (!effectDef) return;
if (existing) {
if (existing.stacks < effectDef.maxStacks) {
setSelectedEffects(selectedEffects.map(e =>
e.effectId === effectId
? { ...e, stacks: e.stacks + 1 }
: e
));
}
} else {
setSelectedEffects([...selectedEffects, {
effectId,
stacks: 1,
capacityCost: calculateEffectCapacityCost(effectId, 1, efficiencyBonus),
}]);
}
};
// Remove effect from design
const removeEffect = (effectId: string) => {
const existing = selectedEffects.find(e => e.effectId === effectId);
if (!existing) return;
if (existing.stacks > 1) {
setSelectedEffects(selectedEffects.map(e =>
e.effectId === effectId
? { ...e, stacks: e.stacks - 1 }
: e
));
} else {
setSelectedEffects(selectedEffects.filter(e => e.effectId !== effectId));
}
};
// Create design
const handleCreateDesign = () => {
if (!designName || !selectedEquipmentType || selectedEffects.length === 0) return;
const success = startDesigningEnchantment(designName, selectedEquipmentType, selectedEffects);
if (success) {
// Reset form
setDesignName('');
setSelectedEquipmentType(null);
setSelectedEffects([]);
}
};
// Get available effects for selected equipment type (only unlocked ones)
const getAvailableEffects = () => {
if (!selectedEquipmentType) return [];
const type = EQUIPMENT_TYPES[selectedEquipmentType];
if (!type) return [];
return Object.values(ENCHANTMENT_EFFECTS).filter(
effect =>
effect.allowedEquipmentCategories.includes(type.category) &&
unlockedEffects.includes(effect.id)
);
};
// Render design stage
const renderDesignStage = () => (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Equipment Type Selection */}
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 text-sm">1. Select Equipment Type</CardTitle>
</CardHeader>
<CardContent>
{designProgress ? (
<div className="space-y-3">
<div className="text-sm text-gray-400">
Designing for: {EQUIPMENT_TYPES[designProgress.equipmentType]?.name}
</div>
<div className="text-sm font-semibold text-amber-300">{designProgress.name}</div>
<Progress value={(designProgress.progress / designProgress.required) * 100} className="h-3" />
<div className="flex justify-between text-xs text-gray-400">
<span>{designProgress.progress.toFixed(1)}h / {designProgress.required.toFixed(1)}h</span>
<Button size="sm" variant="outline" onClick={cancelDesign}>Cancel</Button>
</div>
</div>
) : (
<ScrollArea className="h-64">
<div className="grid grid-cols-2 gap-2">
{Object.values(EQUIPMENT_TYPES).map(type => (
<div
key={type.id}
className={`p-2 rounded border cursor-pointer transition-all ${
selectedEquipmentType === type.id
? 'border-amber-500 bg-amber-900/20'
: 'border-gray-700 bg-gray-800/50 hover:border-gray-600'
}`}
onClick={() => setSelectedEquipmentType(type.id)}
>
<div className="text-sm font-semibold">{type.name}</div>
<div className="text-xs text-gray-400">Cap: {type.baseCapacity}</div>
</div>
))}
</div>
</ScrollArea>
)}
</CardContent>
</Card>
{/* Effect Selection */}
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 text-sm">2. Select Effects</CardTitle>
</CardHeader>
<CardContent>
{enchantingLevel < 1 ? (
<div className="text-center text-gray-400 py-8">
<Wand2 className="w-12 h-12 mx-auto mb-2 opacity-50" />
<p>Learn Enchanting skill to design enchantments</p>
</div>
) : designProgress ? (
<div className="space-y-2">
<div className="text-sm text-gray-400">Design in progress...</div>
{designProgress.effects.map(eff => {
const def = ENCHANTMENT_EFFECTS[eff.effectId];
return (
<div key={eff.effectId} className="flex justify-between text-sm">
<span>{def?.name} x{eff.stacks}</span>
<span className="text-gray-400">{eff.capacityCost} cap</span>
</div>
);
})}
</div>
) : !selectedEquipmentType ? (
<div className="text-center text-gray-400 py-8">
Select an equipment type first
</div>
) : (
<>
<ScrollArea className="h-48 mb-4">
<div className="space-y-2">
{getAvailableEffects().map(effect => {
const selected = selectedEffects.find(e => e.effectId === effect.id);
const cost = calculateEffectCapacityCost(effect.id, (selected?.stacks || 0) + 1, efficiencyBonus);
return (
<div
key={effect.id}
className={`p-2 rounded border transition-all ${
selected
? 'border-purple-500 bg-purple-900/20'
: 'border-gray-700 bg-gray-800/50'
}`}
>
<div className="flex justify-between items-start">
<div className="flex-1">
<div className="text-sm font-semibold">{effect.name}</div>
<div className="text-xs text-gray-400">{effect.description}</div>
<div className="text-xs text-gray-500 mt-1">
Cost: {effect.baseCapacityCost} cap | Max: {effect.maxStacks}
</div>
</div>
<div className="flex gap-1">
{selected && (
<Button
size="sm"
variant="outline"
className="h-6 w-6 p-0"
onClick={() => removeEffect(effect.id)}
>
<Minus className="w-3 h-3" />
</Button>
)}
<Button
size="sm"
variant="outline"
className="h-6 w-6 p-0"
onClick={() => addEffect(effect.id)}
disabled={!selected && selectedEffects.length >= 5}
>
<Plus className="w-3 h-3" />
</Button>
</div>
</div>
{selected && (
<Badge variant="outline" className="mt-1 text-xs">
{selected.stacks}/{effect.maxStacks}
</Badge>
)}
</div>
);
})}
</div>
</ScrollArea>
{/* Selected effects summary */}
<Separator className="bg-gray-700 my-2" />
<div className="space-y-2">
<input
type="text"
placeholder="Design name..."
value={designName}
onChange={(e) => setDesignName(e.target.value)}
className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm"
/>
<div className="flex justify-between text-sm">
<span>Total Capacity:</span>
<span className={isOverCapacity ? 'text-red-400' : 'text-green-400'}>
{designCapacityCost.toFixed(0)} / {selectedEquipmentCapacity}
</span>
</div>
<div className="flex justify-between text-sm text-gray-400">
<span>Design Time:</span>
<span>{designTime.toFixed(1)}h</span>
</div>
<Button
className="w-full"
disabled={!designName || selectedEffects.length === 0 || isOverCapacity}
onClick={handleCreateDesign}
>
{isOverCapacity ? 'Over Capacity!' : `Start Design (${designTime.toFixed(1)}h)`}
</Button>
</div>
</>
)}
</CardContent>
</Card>
{/* Saved Designs */}
<Card className="bg-gray-900/80 border-gray-700 lg:col-span-2">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 text-sm">Saved Designs ({enchantmentDesigns.length})</CardTitle>
</CardHeader>
<CardContent>
{enchantmentDesigns.length === 0 ? (
<div className="text-center text-gray-400 py-4">
No saved designs yet
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{enchantmentDesigns.map(design => (
<div
key={design.id}
className={`p-3 rounded border ${
selectedDesign === design.id
? 'border-amber-500 bg-amber-900/20'
: 'border-gray-700 bg-gray-800/50'
}`}
onClick={() => setSelectedDesign(design.id)}
>
<div className="flex justify-between items-start">
<div>
<div className="font-semibold">{design.name}</div>
<div className="text-xs text-gray-400">
{EQUIPMENT_TYPES[design.equipmentType]?.name}
</div>
</div>
<Button
size="sm"
variant="ghost"
className="h-6 w-6 p-0 text-gray-400 hover:text-red-400"
onClick={(e) => {
e.stopPropagation();
deleteDesign(design.id);
}}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
<div className="mt-2 text-xs text-gray-400">
{design.effects.length} effects | {design.totalCapacityUsed} cap
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
);
// Render prepare stage
const renderPrepareStage = () => (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Equipment Selection */}
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 text-sm">Select Equipment to Prepare or Disenchant</CardTitle>
</CardHeader>
<CardContent>
{preparationProgress ? (
<div className="space-y-3">
<div className="text-sm text-gray-400">
Preparing: {equipmentInstances[preparationProgress.equipmentInstanceId]?.name}
</div>
<Progress value={(preparationProgress.progress / preparationProgress.required) * 100} className="h-3" />
<div className="flex justify-between text-xs text-gray-400">
<span>{preparationProgress.progress.toFixed(1)}h / {preparationProgress.required.toFixed(1)}h</span>
<span>Mana paid: {fmt(preparationProgress.manaCostPaid)}</span>
</div>
<Button size="sm" variant="outline" onClick={cancelPreparation}>Cancel</Button>
</div>
) : (
<ScrollArea className="h-64">
<div className="space-y-2">
{equippedItems.map(({ slot, instance }) => {
const hasEnchantments = instance.enchantments.length > 0;
return (
<div
key={instance.instanceId}
className={`p-3 rounded border cursor-pointer transition-all ${
selectedEquipmentInstance === instance.instanceId
? 'border-amber-500 bg-amber-900/20'
: 'border-gray-700 bg-gray-800/50 hover:border-gray-600'
} ${hasEnchantments ? 'border-l-4 border-l-red-600' : ''}`}
onClick={() => setSelectedEquipmentInstance(instance.instanceId)}
>
<div className="flex justify-between">
<div>
<div className="font-semibold">{instance.name}</div>
<div className="text-xs text-gray-400">{SLOT_NAMES[slot]}</div>
{hasEnchantments && (
<div className="text-xs text-red-400 mt-1">
{instance.enchantments.length} enchantments - Disenchant to apply new
</div>
)}
</div>
<div className="text-right text-sm">
<div className="text-green-400">{instance.usedCapacity}/{instance.totalCapacity} cap</div>
<div className="text-xs text-gray-400">{instance.enchantments.length} enchants</div>
</div>
</div>
</div>
);
})}
{equippedItems.length === 0 && (
<div className="text-center text-gray-400 py-4">No equipped items</div>
)}
</div>
</ScrollArea>
)}
</CardContent>
</Card>
{/* Preparation Details */}
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 text-sm">Preparation Details</CardTitle>
</CardHeader>
<CardContent>
{!selectedEquipmentInstance ? (
<div className="text-center text-gray-400 py-8">
Select equipment to prepare or disenchant
</div>
) : preparationProgress ? (
<div className="text-gray-400">Preparation in progress...</div>
) : (
(() => {
const instance = equipmentInstances[selectedEquipmentInstance];
const hasEnchantments = instance.enchantments.length > 0;
const prepTime = 2 + Math.floor(instance.totalCapacity / 50);
const manaCost = instance.totalCapacity * 10;
// Calculate disenchant recovery
const disenchantLevel = skills.disenchanting || 0;
const recoveryRate = 0.1 + disenchantLevel * 0.2;
const totalRecoverable = instance.enchantments.reduce(
(sum, e) => sum + Math.floor(e.actualCost * recoveryRate),
0
);
return (
<div className="space-y-4">
<div className="text-lg font-semibold">{instance.name}</div>
<Separator className="bg-gray-700" />
{/* Disenchant option for enchanted gear */}
{hasEnchantments && (
<div className="p-3 rounded border border-red-600/50 bg-red-900/20 space-y-3">
<div className="text-sm font-semibold text-red-400"> Equipment has enchantments</div>
<div className="text-xs text-gray-400">
You must disenchant before applying new enchantments.
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Recoverable Mana:</span>
<span className="text-green-400">{fmt(totalRecoverable)}</span>
</div>
<Button
className="w-full bg-red-600 hover:bg-red-700"
onClick={() => disenchantEquipment(instance.instanceId)}
>
<Trash2 className="w-4 h-4 mr-2" />
Disenchant & Recover {fmt(totalRecoverable)} Mana
</Button>
</div>
)}
{/* Prepare option for non-enchanted gear */}
{!hasEnchantments && (
<>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-400">Capacity:</span>
<span>{instance.usedCapacity}/{instance.totalCapacity}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">Prep Time:</span>
<span>{prepTime}h</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">Mana Cost:</span>
<span className={rawMana < manaCost ? 'text-red-400' : 'text-green-400'}>
{fmt(manaCost)}
</span>
</div>
</div>
<Button
className="w-full"
disabled={rawMana < manaCost}
onClick={() => startPreparing(selectedEquipmentInstance)}
>
Start Preparation ({prepTime}h, {fmt(manaCost)} mana)
</Button>
</>
)}
</div>
);
})()
)}
</CardContent>
</Card>
</div>
);
// Render apply stage
const renderApplyStage = () => (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Equipment & Design Selection */}
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 text-sm">Select Equipment & Design</CardTitle>
</CardHeader>
<CardContent>
{applicationProgress ? (
<div className="space-y-3">
<div className="text-sm text-gray-400">
Applying to: {equipmentInstances[applicationProgress.equipmentInstanceId]?.name}
</div>
<Progress value={(applicationProgress.progress / applicationProgress.required) * 100} className="h-3" />
<div className="flex justify-between text-xs text-gray-400">
<span>{applicationProgress.progress.toFixed(1)}h / {applicationProgress.required.toFixed(1)}h</span>
<span>Mana spent: {fmt(applicationProgress.manaSpent)}</span>
</div>
<div className="flex gap-2">
{applicationProgress.paused ? (
<Button size="sm" onClick={resumeApplication}>Resume</Button>
) : (
<Button size="sm" variant="outline" onClick={pauseApplication}>Pause</Button>
)}
<Button size="sm" variant="outline" onClick={cancelApplication}>Cancel</Button>
</div>
</div>
) : (
<div className="space-y-4">
<div>
<div className="text-sm text-gray-400 mb-2">Equipment (without enchantments):</div>
<ScrollArea className="h-32">
<div className="space-y-1">
{equippedItems
.filter(({ instance }) => instance.enchantments.length === 0)
.map(({ slot, instance }) => (
<div
key={instance.instanceId}
className={`p-2 rounded border cursor-pointer text-sm ${
selectedEquipmentInstance === instance.instanceId
? 'border-amber-500 bg-amber-900/20'
: 'border-gray-700 bg-gray-800/50'
}`}
onClick={() => setSelectedEquipmentInstance(instance.instanceId)}
>
{instance.name} ({instance.usedCapacity}/{instance.totalCapacity} cap)
</div>
))}
{equippedItems.filter(({ instance }) => instance.enchantments.length === 0).length === 0 && (
<div className="text-center text-gray-500 text-xs py-2">
No unenchanted equipment available. Disenchant in Prepare stage first.
</div>
)}
</div>
</ScrollArea>
</div>
<div>
<div className="text-sm text-gray-400 mb-2">Design:</div>
<ScrollArea className="h-32">
<div className="space-y-1">
{enchantmentDesigns.map(design => (
<div
key={design.id}
className={`p-2 rounded border cursor-pointer text-sm ${
selectedDesign === design.id
? 'border-purple-500 bg-purple-900/20'
: 'border-gray-700 bg-gray-800/50'
}`}
onClick={() => setSelectedDesign(design.id)}
>
{design.name} ({design.totalCapacityUsed} cap)
</div>
))}
</div>
</ScrollArea>
</div>
</div>
)}
</CardContent>
</Card>
{/* Application Details */}
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 text-sm">Apply Enchantment</CardTitle>
</CardHeader>
<CardContent>
{!selectedEquipmentInstance || !selectedDesign ? (
<div className="text-center text-gray-400 py-8">
Select equipment and a design
</div>
) : applicationProgress ? (
<div className="text-gray-400">Application in progress...</div>
) : (
(() => {
const instance = equipmentInstances[selectedEquipmentInstance];
const design = enchantmentDesigns.find(d => d.id === selectedDesign);
if (!design) return null;
const availableCap = instance.totalCapacity - instance.usedCapacity;
const canFit = availableCap >= design.totalCapacityUsed;
const applicationTime = 2 + design.effects.reduce((t, e) => t + e.stacks, 0);
const manaPerHour = 20 + design.effects.reduce((t, e) => t + e.stacks * 5, 0);
return (
<div className="space-y-4">
<div className="text-lg font-semibold">{design.name}</div>
<div className="text-sm text-gray-400"> {instance.name}</div>
<Separator className="bg-gray-700" />
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-400">Required Capacity:</span>
<span className={canFit ? 'text-green-400' : 'text-red-400'}>
{design.totalCapacityUsed} / {availableCap} available
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">Application Time:</span>
<span>{applicationTime}h</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">Mana per Hour:</span>
<span>{manaPerHour}</span>
</div>
</div>
<div className="text-sm text-gray-400">
Effects:
<ul className="list-disc list-inside">
{design.effects.map(eff => (
<li key={eff.effectId}>
{ENCHANTMENT_EFFECTS[eff.effectId]?.name} x{eff.stacks}
</li>
))}
</ul>
</div>
<Button
className="w-full"
disabled={!canFit}
onClick={() => startApplying(selectedEquipmentInstance, selectedDesign)}
>
Apply Enchantment
</Button>
</div>
);
})()
)}
</CardContent>
</Card>
</div>
);
// Render equipment crafting stage
const renderCraftStage = () => (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Blueprint Selection */}
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 text-sm flex items-center gap-2">
<Anvil className="w-4 h-4" />
Available Blueprints
</CardTitle>
</CardHeader>
<CardContent>
{equipmentCraftingProgress ? (
<div className="space-y-3">
<div className="text-sm text-gray-400">
Crafting: {CRAFTING_RECIPES[equipmentCraftingProgress.blueprintId]?.name}
</div>
<Progress value={(equipmentCraftingProgress.progress / equipmentCraftingProgress.required) * 100} className="h-3" />
<div className="flex justify-between text-xs text-gray-400">
<span>{equipmentCraftingProgress.progress.toFixed(1)}h / {equipmentCraftingProgress.required.toFixed(1)}h</span>
<span>Mana spent: {fmt(equipmentCraftingProgress.manaSpent)}</span>
</div>
<Button size="sm" variant="outline" onClick={cancelEquipmentCrafting}>Cancel</Button>
</div>
) : (
<ScrollArea className="h-64">
<div className="space-y-2">
{lootInventory.blueprints.length === 0 ? (
<div className="text-center text-gray-400 py-4">
<Package className="w-12 h-12 mx-auto mb-2 opacity-50" />
<p>No blueprints discovered yet.</p>
<p className="text-xs mt-1">Defeat guardians to find blueprints!</p>
</div>
) : (
lootInventory.blueprints.map(bpId => {
const recipe = CRAFTING_RECIPES[bpId];
if (!recipe) return null;
const { canCraft, missingMaterials, missingMana } = canCraftRecipe(
recipe,
lootInventory.materials,
rawMana
);
const rarityStyle = RARITY_COLORS[recipe.rarity];
return (
<div
key={bpId}
className="p-3 rounded border bg-gray-800/50"
style={{ borderColor: rarityStyle?.color }}
>
<div className="flex justify-between items-start mb-2">
<div>
<div className="font-semibold" style={{ color: rarityStyle?.color }}>
{recipe.name}
</div>
<div className="text-xs text-gray-400 capitalize">{recipe.rarity}</div>
</div>
<Badge variant="outline" className="text-xs">
{EQUIPMENT_TYPES[recipe.equipmentTypeId]?.category}
</Badge>
</div>
<div className="text-xs text-gray-400 mb-2">{recipe.description}</div>
<Separator className="bg-gray-700 my-2" />
<div className="text-xs space-y-1">
<div className="text-gray-500">Materials:</div>
{Object.entries(recipe.materials).map(([matId, amount]) => {
const available = lootInventory.materials[matId] || 0;
const matDrop = LOOT_DROPS[matId];
const hasEnough = available >= amount;
return (
<div key={matId} className="flex justify-between">
<span>{matDrop?.name || matId}</span>
<span className={hasEnough ? 'text-green-400' : 'text-red-400'}>
{available} / {amount}
</span>
</div>
);
})}
<div className="flex justify-between mt-2">
<span>Mana Cost:</span>
<span className={rawMana >= recipe.manaCost ? 'text-green-400' : 'text-red-400'}>
{fmt(recipe.manaCost)}
</span>
</div>
<div className="flex justify-between">
<span>Craft Time:</span>
<span>{recipe.craftTime}h</span>
</div>
</div>
<Button
className="w-full mt-3"
size="sm"
disabled={!canCraft || currentAction === 'craft'}
onClick={() => startCraftingEquipment(bpId)}
>
{canCraft ? 'Craft Equipment' : 'Missing Resources'}
</Button>
</div>
);
})
)}
</div>
</ScrollArea>
)}
</CardContent>
</Card>
{/* Materials Inventory */}
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 text-sm flex items-center gap-2">
<Package className="w-4 h-4" />
Materials ({Object.values(lootInventory.materials).reduce((a, b) => a + b, 0)})
</CardTitle>
</CardHeader>
<CardContent>
<ScrollArea className="h-64">
{Object.keys(lootInventory.materials).length === 0 ? (
<div className="text-center text-gray-400 py-4">
<Sparkles className="w-12 h-12 mx-auto mb-2 opacity-50" />
<p>No materials collected yet.</p>
<p className="text-xs mt-1">Defeat floors to gather materials!</p>
</div>
) : (
<div className="grid grid-cols-2 gap-2">
{Object.entries(lootInventory.materials).map(([matId, count]) => {
if (count <= 0) return null;
const drop = LOOT_DROPS[matId];
if (!drop) return null;
const rarityStyle = RARITY_COLORS[drop.rarity];
return (
<div
key={matId}
className="p-2 rounded border bg-gray-800/50 group relative"
style={{ borderColor: rarityStyle?.color }}
>
<div className="flex items-start justify-between">
<div>
<div className="text-sm font-semibold" style={{ color: rarityStyle?.color }}>
{drop.name}
</div>
<div className="text-xs text-gray-400">x{count}</div>
</div>
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0 opacity-0 group-hover:opacity-100 text-red-400 hover:text-red-300 hover:bg-red-900/20"
onClick={() => deleteMaterial(matId, count)}
>
<Trash2 className="w-3 h-3" />
</Button>
</div>
</div>
);
})}
</div>
)}
</ScrollArea>
</CardContent>
</Card>
</div>
);
return (
<div className="space-y-4">
{/* Stage Tabs */}
<Tabs value={craftingStage} onValueChange={(v) => setCraftingStage(v as typeof craftingStage)}>
<TabsList className="bg-gray-800/50">
<TabsTrigger value="craft" className="data-[state=active]:bg-cyan-600">
<Anvil className="w-4 h-4 mr-1" />
Craft
</TabsTrigger>
<TabsTrigger value="design" className="data-[state=active]:bg-amber-600">
<Scroll className="w-4 h-4 mr-1" />
Design
</TabsTrigger>
<TabsTrigger value="prepare" className="data-[state=active]:bg-amber-600">
<Hammer className="w-4 h-4 mr-1" />
Prepare
</TabsTrigger>
<TabsTrigger value="apply" className="data-[state=active]:bg-amber-600">
<Sparkles className="w-4 h-4 mr-1" />
Apply
</TabsTrigger>
</TabsList>
<TabsContent value="craft" className="mt-4">
{renderCraftStage()}
</TabsContent>
<TabsContent value="design" className="mt-4">
{renderDesignStage()}
</TabsContent>
<TabsContent value="prepare" className="mt-4">
{renderPrepareStage()}
</TabsContent>
<TabsContent value="apply" className="mt-4">
{renderApplyStage()}
</TabsContent>
</Tabs>
{/* Current Activity Indicator */}
{currentAction === 'craft' && equipmentCraftingProgress && (
<Card className="bg-cyan-900/30 border-cyan-600">
<CardContent className="py-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<Anvil className="w-5 h-5 text-cyan-400" />
<span>Crafting equipment...</span>
</div>
<div className="text-sm text-gray-400">
{((equipmentCraftingProgress.progress / equipmentCraftingProgress.required) * 100).toFixed(0)}%
</div>
</CardContent>
</Card>
)}
{currentAction === 'design' && designProgress && (
<Card className="bg-purple-900/30 border-purple-600">
<CardContent className="py-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<Scroll className="w-5 h-5 text-purple-400" />
<span>Designing enchantment...</span>
</div>
<div className="text-sm text-gray-400">
{((designProgress.progress / designProgress.required) * 100).toFixed(0)}%
</div>
</CardContent>
</Card>
)}
{currentAction === 'prepare' && preparationProgress && (
<Card className="bg-blue-900/30 border-blue-600">
<CardContent className="py-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<Hammer className="w-5 h-5 text-blue-400" />
<span>Preparing equipment...</span>
</div>
<div className="text-sm text-gray-400">
{((preparationProgress.progress / preparationProgress.required) * 100).toFixed(0)}%
</div>
</CardContent>
</Card>
)}
{currentAction === 'enchant' && applicationProgress && (
<Card className="bg-amber-900/30 border-amber-600">
<CardContent className="py-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<Sparkles className="w-5 h-5 text-amber-400" />
<span>{applicationProgress.paused ? 'Enchantment paused' : 'Applying enchantment...'}</span>
</div>
<div className="flex items-center gap-2">
<div className="text-sm text-gray-400">
{((applicationProgress.progress / applicationProgress.required) * 100).toFixed(0)}%
</div>
{applicationProgress.paused ? (
<Button size="sm" onClick={resumeApplication}>Resume</Button>
) : (
<Button size="sm" variant="outline" onClick={pauseApplication}>Pause</Button>
)}
</div>
</CardContent>
</Card>
)}
</div>
);
}
+700
View File
@@ -0,0 +1,700 @@
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import {
RotateCcw, Bug, Plus, Minus, Lock, Unlock, Zap,
Clock, Star, AlertTriangle, Sparkles, Settings, Eye, BookOpen
} from 'lucide-react';
import type { GameStore } from '@/lib/game/types';
import { ATTUNEMENTS_DEF } from '@/lib/game/data/attunements';
import { ELEMENTS } from '@/lib/game/constants';
import { fmt } from '@/lib/game/store';
import { useDebug } from '@/lib/game/debug-context';
interface DebugTabProps {
store: GameStore;
}
export function DebugTab({ store }: DebugTabProps) {
const [confirmReset, setConfirmReset] = useState(false);
const { showComponentNames, toggleComponentNames } = useDebug();
const handleReset = () => {
if (confirmReset) {
store.resetGame();
setConfirmReset(false);
} else {
setConfirmReset(true);
setTimeout(() => setConfirmReset(false), 3000);
}
};
const handleAddMana = (amount: number) => {
// Use gatherMana multiple times to add mana
for (let i = 0; i < amount; i++) {
store.gatherMana();
}
};
const handleUnlockAttunement = (id: string) => {
// Debug action to unlock attunements
if (store.debugUnlockAttunement) {
store.debugUnlockAttunement(id);
}
};
const handleUnlockElement = (element: string) => {
store.unlockElement(element);
};
const handleAddElementalMana = (element: string, amount: number) => {
const elem = store.elements[element];
if (elem?.unlocked) {
// Add directly to element pool - need to implement in store
if (store.debugAddElementalMana) {
store.debugAddElementalMana(element, amount);
}
}
};
const handleSetTime = (day: number, hour: number) => {
if (store.debugSetTime) {
store.debugSetTime(day, hour);
}
};
const handleAddAttunementXP = (id: string, amount: number) => {
if (store.debugAddAttunementXP) {
store.debugAddAttunementXP(id, amount);
}
};
return (
<div className="space-y-4">
{/* Warning Banner */}
<Card className="bg-amber-900/20 border-amber-600/50">
<CardContent className="pt-4">
<div className="flex items-center gap-2 text-amber-400">
<AlertTriangle className="w-5 h-5" />
<span className="font-semibold">Debug Mode</span>
</div>
<p className="text-sm text-amber-300/70 mt-1">
These tools are for development and testing. Using them may break game balance or save data.
</p>
</CardContent>
</Card>
{/* Display Options */}
<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">
<Eye className="w-4 h-4" />
Display Options
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="show-component-names" className="text-sm">Show Component Names</Label>
<p className="text-xs text-gray-400">
Display component names at the top of each component for debugging
</p>
</div>
<Switch
id="show-component-names"
checked={showComponentNames}
onCheckedChange={toggleComponentNames}
/>
</div>
</CardContent>
</Card>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Game Reset */}
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-red-400 text-sm flex items-center gap-2">
<RotateCcw className="w-4 h-4" />
Game Reset
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-xs text-gray-400">
Reset all game progress and start fresh. This cannot be undone.
</p>
<Button
className={`w-full ${confirmReset ? 'bg-red-600 hover:bg-red-700' : 'bg-gray-700 hover:bg-gray-600'}`}
onClick={handleReset}
>
{confirmReset ? (
<>
<AlertTriangle className="w-4 h-4 mr-2" />
Click Again to Confirm Reset
</>
) : (
<>
<RotateCcw className="w-4 h-4 mr-2" />
Reset Game
</>
)}
</Button>
</CardContent>
</Card>
{/* Mana Debug */}
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-blue-400 text-sm flex items-center gap-2">
<Zap className="w-4 h-4" />
Mana Debug
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="text-xs text-gray-400 mb-2">
Current: {fmt(store.rawMana)} / {fmt(store.getMaxMana())}
</div>
<div className="flex gap-2 flex-wrap">
<Button size="sm" variant="outline" onClick={() => handleAddMana(10)}>
<Plus className="w-3 h-3 mr-1" /> +10
</Button>
<Button size="sm" variant="outline" onClick={() => handleAddMana(100)}>
<Plus className="w-3 h-3 mr-1" /> +100
</Button>
<Button size="sm" variant="outline" onClick={() => handleAddMana(1000)}>
<Plus className="w-3 h-3 mr-1" /> +1K
</Button>
<Button size="sm" variant="outline" onClick={() => handleAddMana(10000)}>
<Plus className="w-3 h-3 mr-1" /> +10K
</Button>
</div>
<Separator className="bg-gray-700" />
<div className="text-xs text-gray-400">Fill to max:</div>
<Button
size="sm"
className="w-full bg-blue-600 hover:bg-blue-700"
onClick={() => {
const max = store.getMaxMana();
const current = store.rawMana;
for (let i = 0; i < Math.floor(max - current); i++) {
store.gatherMana();
}
}}
>
Fill Mana
</Button>
</CardContent>
</Card>
{/* Time Control */}
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 text-sm flex items-center gap-2">
<Clock className="w-4 h-4" />
Time Control
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="text-xs text-gray-400">
Current: Day {store.day}, Hour {store.hour}
</div>
<div className="flex gap-2 flex-wrap">
<Button size="sm" variant="outline" onClick={() => handleSetTime(1, 0)}>
Day 1
</Button>
<Button size="sm" variant="outline" onClick={() => handleSetTime(10, 0)}>
Day 10
</Button>
<Button size="sm" variant="outline" onClick={() => handleSetTime(20, 0)}>
Day 20
</Button>
<Button size="sm" variant="outline" onClick={() => handleSetTime(30, 0)}>
Day 30
</Button>
</div>
<Separator className="bg-gray-700" />
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
onClick={() => store.togglePause()}
>
{store.paused ? '▶ Resume' : '⏸ Pause'}
</Button>
</div>
</CardContent>
</Card>
{/* Attunement Unlock */}
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-purple-400 text-sm flex items-center gap-2">
<Sparkles className="w-4 h-4" />
Attunements
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{Object.entries(ATTUNEMENTS_DEF).map(([id, def]) => {
const isActive = store.attunements?.[id]?.active;
const level = store.attunements?.[id]?.level || 1;
const xp = store.attunements?.[id]?.experience || 0;
return (
<div key={id} className="flex items-center justify-between p-2 bg-gray-800/50 rounded">
<div className="flex items-center gap-2">
<span>{def.icon}</span>
<div>
<div className="text-sm font-medium">{def.name}</div>
{isActive && (
<div className="text-xs text-gray-400">Lv.{level} {xp} XP</div>
)}
</div>
</div>
<div className="flex gap-1">
{!isActive && (
<Button
size="sm"
variant="outline"
onClick={() => handleUnlockAttunement(id)}
>
<Unlock className="w-3 h-3" />
</Button>
)}
{isActive && (
<>
<Button
size="sm"
variant="outline"
onClick={() => handleAddAttunementXP(id, 50)}
>
+50 XP
</Button>
<Button
size="sm"
variant="outline"
onClick={() => handleAddAttunementXP(id, 500)}
>
+500 XP
</Button>
</>
)}
</div>
</div>
);
})}
</CardContent>
</Card>
{/* Element Unlock */}
<Card className="bg-gray-900/80 border-gray-700 md:col-span-2">
<CardHeader className="pb-2">
<CardTitle className="text-green-400 text-sm flex items-center gap-2">
<Star className="w-4 h-4" />
Elemental Mana
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-2">
{Object.entries(ELEMENTS).map(([id, def]) => {
const elem = store.elements[id];
const isUnlocked = elem?.unlocked;
return (
<div
key={id}
className={`p-2 rounded border ${
isUnlocked ? 'border-gray-600' : 'border-gray-800 opacity-60'
}`}
style={{
borderColor: isUnlocked ? def.color : undefined
}}
>
<div className="flex items-center justify-between mb-1">
<span style={{ color: def.color }}>{def.sym}</span>
{!isUnlocked && (
<Button
size="sm"
variant="ghost"
className="h-5 w-5 p-0"
onClick={() => handleUnlockElement(id)}
>
<Lock className="w-3 h-3" />
</Button>
)}
</div>
<div className="text-xs" style={{ color: def.color }}>{def.name}</div>
{isUnlocked && (
<div className="text-xs text-gray-400 mt-1">
{elem.current.toFixed(0)}/{elem.max}
</div>
)}
{isUnlocked && (
<Button
size="sm"
variant="ghost"
className="h-5 w-full mt-1 text-xs"
onClick={() => handleAddElementalMana(id, 100)}
>
+100
</Button>
)}
</div>
);
})}
</div>
</CardContent>
</Card>
{/* Skills Debug */}
<Card className="bg-gray-900/80 border-gray-700 md:col-span-2">
<CardHeader className="pb-2">
<CardTitle className="text-cyan-400 text-sm flex items-center gap-2">
<Settings className="w-4 h-4" />
Quick Actions
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex gap-2 flex-wrap">
<Button
size="sm"
variant="outline"
onClick={() => {
// Unlock all base elements
['fire', 'water', 'air', 'earth', 'light', 'dark', 'death'].forEach(e => {
if (!store.elements[e]?.unlocked) {
store.unlockElement(e);
}
});
}}
>
Unlock All Base Elements
</Button>
<Button
size="sm"
variant="outline"
onClick={() => {
// Unlock utility elements (only transference remains)
['transference'].forEach(e => {
if (!store.elements[e]?.unlocked) {
store.unlockElement(e);
}
});
}}
>
Unlock Utility Elements
</Button>
<Button
size="sm"
variant="outline"
onClick={() => {
// Max floor
if (store.debugSetFloor) {
store.debugSetFloor(100);
}
}}
>
Skip to Floor 100
</Button>
<Button
size="sm"
variant="outline"
onClick={() => {
// Reset floor HP
if (store.resetFloorHP) {
store.resetFloorHP();
}
}}
>
Reset Floor HP
</Button>
</div>
</CardContent>
</Card>
{/* Skill Research Debug */}
<Card className="bg-gray-900/80 border-gray-700 md:col-span-2">
<CardHeader className="pb-2">
<CardTitle className="text-purple-400 text-sm flex items-center gap-2">
<BookOpen className="w-4 h-4" />
Skill Research Debug
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{/* Enchanting Skills */}
<div>
<div className="text-xs text-gray-400 mb-2">Enchanting Skills:</div>
<div className="flex gap-2 flex-wrap">
<Button
size="sm"
variant="outline"
onClick={() => {
// Level up all enchanting skills by 1
const enchantSkills = ['enchanting', 'efficientEnchant', 'disenchanting', 'enchantSpeed', 'scrollCrafting', 'essenceRefining'];
enchantSkills.forEach(skillId => {
if (store.skills[skillId] !== undefined) {
store.skills[skillId] = Math.min((store.skills[skillId] || 0) + 1, 10);
} else {
store.skills[skillId] = 1;
}
});
}}
>
+1 All Enchanting
</Button>
<Button
size="sm"
variant="outline"
onClick={() => {
// Max all enchanting skills
const enchantSkills = ['enchanting', 'efficientEnchant', 'disenchanting', 'enchantSpeed', 'scrollCrafting', 'essenceRefining'];
enchantSkills.forEach(skillId => {
store.skills[skillId] = 10;
});
}}
>
Max All Enchanting
</Button>
</div>
</div>
{/* Mana Skills */}
<div>
<div className="text-xs text-gray-400 mb-2">Mana Skills:</div>
<div className="flex gap-2 flex-wrap">
<Button
size="sm"
variant="outline"
onClick={() => {
const manaSkills = ['manaWell', 'manaFlow', 'elemAttune', 'manaOverflow'];
manaSkills.forEach(skillId => {
if (store.skills[skillId] !== undefined) {
store.skills[skillId] = Math.min((store.skills[skillId] || 0) + 1, 10);
} else {
store.skills[skillId] = 1;
}
});
}}
>
+1 All Mana
</Button>
<Button
size="sm"
variant="outline"
onClick={() => {
const manaSkills = ['manaWell', 'manaFlow', 'elemAttune', 'manaOverflow'];
manaSkills.forEach(skillId => {
store.skills[skillId] = 10;
});
}}
>
Max All Mana
</Button>
</div>
</div>
{/* Study Skills */}
<div>
<div className="text-xs text-gray-400 mb-2">Study Skills:</div>
<div className="flex gap-2 flex-wrap">
<Button
size="sm"
variant="outline"
onClick={() => {
const studySkills = ['quickLearner', 'focusedMind', 'meditation', 'knowledgeRetention'];
studySkills.forEach(skillId => {
if (store.skills[skillId] !== undefined) {
store.skills[skillId] = Math.min((store.skills[skillId] || 0) + 1, 10);
} else {
store.skills[skillId] = 1;
}
});
}}
>
+1 All Study
</Button>
<Button
size="sm"
variant="outline"
onClick={() => {
const studySkills = ['quickLearner', 'focusedMind', 'meditation', 'knowledgeRetention'];
studySkills.forEach(skillId => {
store.skills[skillId] = 10;
});
}}
>
Max All Study
</Button>
</div>
</div>
{/* Crafting Skills */}
<div>
<div className="text-xs text-gray-400 mb-2">Crafting Skills:</div>
<div className="flex gap-2 flex-wrap">
<Button
size="sm"
variant="outline"
onClick={() => {
const craftSkills = ['effCrafting', 'fieldRepair', 'elemCrafting'];
craftSkills.forEach(skillId => {
if (store.skills[skillId] !== undefined) {
store.skills[skillId] = Math.min((store.skills[skillId] || 0) + 1, 10);
} else {
store.skills[skillId] = 1;
}
});
}}
>
+1 All Crafting
</Button>
<Button
size="sm"
variant="outline"
onClick={() => {
const craftSkills = ['effCrafting', 'fieldRepair', 'elemCrafting'];
craftSkills.forEach(skillId => {
store.skills[skillId] = 10;
});
}}
>
Max All Crafting
</Button>
</div>
</div>
{/* Research Effects */}
<div>
<div className="text-xs text-gray-400 mb-2">Research Effects:</div>
<div className="flex gap-2 flex-wrap">
<Button
size="sm"
variant="outline"
onClick={() => {
// Unlock all spell research
const researchSkills = [
'researchManaSpells', 'researchFireSpells', 'researchWaterSpells',
'researchAirSpells', 'researchEarthSpells', 'researchLightSpells',
'researchDarkSpells', 'researchLifeDeathSpells',
'researchAdvancedFire', 'researchAdvancedWater', 'researchAdvancedAir',
'researchAdvancedEarth', 'researchAdvancedLight', 'researchAdvancedDark',
'researchMasterFire', 'researchMasterWater', 'researchMasterEarth',
'researchDamageEffects', 'researchCombatEffects',
'researchManaEffects', 'researchAdvancedManaEffects', 'researchUtilityEffects'
];
researchSkills.forEach(skillId => {
store.skills[skillId] = 1;
});
}}
>
Unlock All Research
</Button>
<Button
size="sm"
variant="outline"
onClick={() => {
// Add all unlocked effects to unlockedEffects
const effectIds = Object.keys(store.unlockedEffects || {});
// Add spell effects, mana effects, combat effects, utility effects
const allEffectIds = [
// Spell effects
'spell_manaBolt', 'spell_manaStrike', 'spell_fireball', 'spell_emberShot',
'spell_waterJet', 'spell_iceShard', 'spell_gust', 'spell_windSlash',
'spell_stoneBullet', 'spell_rockSpike', 'spell_lightLance', 'spell_radiance',
'spell_shadowBolt', 'spell_darkPulse', 'spell_drain',
// Tier 2 spells
'spell_inferno', 'spell_flameWave', 'spell_tidalWave', 'spell_iceStorm',
'spell_hurricane', 'spell_windBlade', 'spell_earthquake', 'spell_stoneBarrage',
'spell_solarFlare', 'spell_divineSmite', 'spell_voidRift', 'spell_shadowStorm',
// Tier 3 spells
'spell_pyroclasm', 'spell_tsunami', 'spell_meteorStrike',
// Lightning
'spell_spark', 'spell_lightningBolt', 'spell_chainLightning',
'spell_stormCall', 'spell_thunderStrike',
// Metal and Sand
'spell_metalShard', 'spell_ironFist', 'spell_steelTempest', 'spell_furnaceBlast',
'spell_sandBlast', 'spell_sandstorm', 'spell_desertWind', 'spell_duneCollapse',
// Mana effects
'mana_cap_50', 'mana_cap_100', 'mana_regen_1', 'mana_regen_2', 'mana_regen_5',
'click_mana_1', 'click_mana_3',
// Combat effects
'damage_5', 'damage_10', 'damage_pct_10', 'crit_5', 'attack_speed_10',
// Utility effects
'meditate_10', 'study_10', 'insight_5',
// Special
'spell_echo_10', 'guardian_dmg_10', 'overpower_80',
// Weapon mana
'weapon_mana_cap_20', 'weapon_mana_cap_50', 'weapon_mana_cap_100',
'weapon_mana_regen_1', 'weapon_mana_regen_2', 'weapon_mana_regen_5',
// Sword enchants
'sword_fire', 'sword_frost', 'sword_lightning', 'sword_void'
];
allEffectIds.forEach(id => {
if (!store.unlockedEffects.includes(id)) {
store.unlockedEffects.push(id);
}
});
}}
>
Unlock All Effects
</Button>
</div>
</div>
{/* Max All */}
<Separator className="bg-gray-700" />
<div className="flex gap-2">
<Button
size="sm"
className="bg-purple-600 hover:bg-purple-700"
onClick={() => {
// Max all skills
Object.keys(store.skills).forEach(skillId => {
const current = store.skills[skillId] || 0;
if (current < 10) {
store.skills[skillId] = 10;
}
});
// Unlock all effects
const allEffectIds = [
'spell_manaBolt', 'spell_manaStrike', 'spell_fireball', 'spell_emberShot',
'spell_waterJet', 'spell_iceShard', 'spell_gust', 'spell_windSlash',
'spell_stoneBullet', 'spell_rockSpike', 'spell_lightLance', 'spell_radiance',
'spell_shadowBolt', 'spell_darkPulse', 'spell_drain', 'spell_inferno',
'spell_flameWave', 'spell_tidalWave', 'spell_iceStorm', 'spell_hurricane',
'spell_windBlade', 'spell_earthquake', 'spell_stoneBarrage', 'spell_solarFlare',
'spell_divineSmite', 'spell_voidRift', 'spell_shadowStorm', 'spell_pyroclasm',
'spell_tsunami', 'spell_meteorStrike', 'spell_spark', 'spell_lightningBolt',
'spell_chainLightning', 'spell_stormCall', 'spell_thunderStrike', 'spell_metalShard',
'spell_ironFist', 'spell_steelTempest', 'spell_furnaceBlast', 'spell_sandBlast',
'spell_sandstorm', 'spell_desertWind', 'spell_duneCollapse',
'mana_cap_50', 'mana_cap_100', 'mana_regen_1', 'mana_regen_2', 'mana_regen_5',
'click_mana_1', 'click_mana_3', 'damage_5', 'damage_10', 'damage_pct_10',
'crit_5', 'attack_speed_10', 'meditate_10', 'study_10', 'insight_5',
'spell_echo_10', 'guardian_dmg_10', 'overpower_80',
'weapon_mana_cap_20', 'weapon_mana_cap_50', 'weapon_mana_cap_100',
'weapon_mana_regen_1', 'weapon_mana_regen_2', 'weapon_mana_regen_5',
'sword_fire', 'sword_frost', 'sword_lightning', 'sword_void'
];
allEffectIds.forEach(id => {
if (!store.unlockedEffects.includes(id)) {
store.unlockedEffects.push(id);
}
});
}}
>
🚀 Max Everything
</Button>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
);
}
+393
View File
@@ -0,0 +1,393 @@
'use client';
import { useState } from 'react';
import {
EQUIPMENT_TYPES,
EQUIPMENT_SLOTS,
getEquipmentBySlot,
type EquipmentSlot,
type EquipmentType,
} from '@/lib/game/data/equipment';
import { ENCHANTMENT_EFFECTS } from '@/lib/game/data/enchantment-effects';
import { fmt } from '@/lib/game/store';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
} from '@/components/ui/tooltip';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select';
import type { GameStore, EquipmentInstance } from '@/lib/game/types';
export interface EquipmentTabProps {
store: GameStore;
}
// Slot display names
const SLOT_NAMES: Record<EquipmentSlot, string> = {
mainHand: 'Main Hand',
offHand: 'Off Hand',
head: 'Head',
body: 'Body',
hands: 'Hands',
feet: 'Feet',
accessory1: 'Accessory 1',
accessory2: 'Accessory 2',
};
// Slot icons
const SLOT_ICONS: Record<EquipmentSlot, string> = {
mainHand: '⚔️',
offHand: '🛡️',
head: '🎩',
body: '👕',
hands: '🧤',
feet: '👢',
accessory1: '💍',
accessory2: '📿',
};
// Rarity colors
const RARITY_COLORS: Record<string, string> = {
common: 'border-gray-500 bg-gray-800/30',
uncommon: 'border-green-500 bg-green-900/20',
rare: 'border-blue-500 bg-blue-900/20',
epic: 'border-purple-500 bg-purple-900/20',
legendary: 'border-amber-500 bg-amber-900/20',
mythic: 'border-red-500 bg-red-900/20',
};
const RARITY_TEXT_COLORS: Record<string, string> = {
common: 'text-gray-300',
uncommon: 'text-green-400',
rare: 'text-blue-400',
epic: 'text-purple-400',
legendary: 'text-amber-400',
mythic: 'text-red-400',
};
export function EquipmentTab({ store }: EquipmentTabProps) {
const [selectedSlot, setSelectedSlot] = useState<EquipmentSlot | null>(null);
// Get unequipped items
const equippedIds = new Set(Object.values(store.equippedInstances).filter(Boolean));
const unequippedItems = Object.values(store.equipmentInstances).filter(
(inst) => !equippedIds.has(inst.instanceId)
);
// Equip an item to a slot
const handleEquip = (instanceId: string, slot: EquipmentSlot) => {
store.equipItem(instanceId, slot);
setSelectedSlot(null);
};
// Unequip from a slot
const handleUnequip = (slot: EquipmentSlot) => {
store.unequipItem(slot);
};
// Get items that can be equipped in a slot
const getEquippableItems = (slot: EquipmentSlot): EquipmentInstance[] => {
const equipmentTypes = getEquipmentBySlot(slot);
const typeIds = new Set(equipmentTypes.map((t) => t.id));
return unequippedItems.filter((inst) => typeIds.has(inst.typeId));
};
// Get all items that can go in a slot (including accessories that can go in either accessory slot)
const getItemsForSlot = (slot: EquipmentSlot): EquipmentInstance[] => {
if (slot === 'accessory1' || slot === 'accessory2') {
// Accessories can go in either slot
const accessoryTypes = EQUIPMENT_TYPES;
const accessoryTypeIds = Object.values(accessoryTypes)
.filter((t) => t.category === 'accessory')
.map((t) => t.id);
return unequippedItems.filter((inst) => accessoryTypeIds.includes(inst.typeId));
}
return getEquippableItems(slot);
};
return (
<div className="space-y-4">
{/* Equipment Slots */}
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 game-panel-title text-xs">
Equipped Gear
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
{EQUIPMENT_SLOTS.map((slot) => {
const instanceId = store.equippedInstances[slot];
const instance = instanceId ? store.equipmentInstances[instanceId] : null;
const equipmentType = instance ? EQUIPMENT_TYPES[instance.typeId] : null;
return (
<div
key={slot}
className={`p-3 rounded border ${
instance
? RARITY_COLORS[instance.rarity]
: 'border-gray-700 bg-gray-800/30'
}`}
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span>{SLOT_ICONS[slot]}</span>
<span className="text-sm font-semibold text-gray-300">
{SLOT_NAMES[slot]}
</span>
</div>
{instance && (
<Button
size="sm"
variant="ghost"
className="h-6 text-xs text-red-400 hover:text-red-300 hover:bg-red-900/20"
onClick={() => handleUnequip(slot)}
>
</Button>
)}
</div>
{instance ? (
<div className="space-y-1">
<div className={`font-semibold text-sm ${RARITY_TEXT_COLORS[instance.rarity]}`}>
{instance.name}
</div>
<div className="text-xs text-gray-400">
Capacity: {instance.usedCapacity}/{instance.totalCapacity}
</div>
{instance.enchantments.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{instance.enchantments.map((ench, i) => {
const effect = ENCHANTMENT_EFFECTS[ench.effectId];
return (
<TooltipProvider key={i}>
<Tooltip>
<TooltipTrigger asChild>
<Badge
variant="outline"
className="text-xs cursor-help"
>
{effect?.name || ench.effectId}
{ench.stacks > 1 && ` x${ench.stacks}`}
</Badge>
</TooltipTrigger>
<TooltipContent>
<p>{effect?.description || 'Unknown effect'}</p>
<p className="text-gray-400 text-xs">
Category: {effect?.category || 'unknown'}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
})}
</div>
)}
</div>
) : (
<div className="text-sm text-gray-500 italic">
Empty
</div>
)}
</div>
);
})}
</div>
</CardContent>
</Card>
{/* Inventory */}
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 game-panel-title text-xs">
Equipment Inventory ({unequippedItems.length} items)
</CardTitle>
</CardHeader>
<CardContent>
{unequippedItems.length === 0 ? (
<div className="text-gray-500 text-sm text-center py-4">
No unequipped items. Craft new gear in the Crafting tab.
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 max-h-96 overflow-y-auto">
{unequippedItems.map((instance) => {
const equipmentType = EQUIPMENT_TYPES[instance.typeId];
const validSlots = equipmentType
? (equipmentType.category === 'accessory'
? ['accessory1', 'accessory2'] as EquipmentSlot[]
: [equipmentType.slot])
: [];
return (
<div
key={instance.instanceId}
className={`p-3 rounded border ${RARITY_COLORS[instance.rarity]}`}
>
<div className="flex items-start justify-between mb-2">
<div>
<div className={`font-semibold text-sm ${RARITY_TEXT_COLORS[instance.rarity]}`}>
{instance.name}
</div>
<div className="text-xs text-gray-400">
{equipmentType?.description}
</div>
</div>
<Badge variant="outline" className="text-xs">
{equipmentType?.category || 'unknown'}
</Badge>
</div>
<div className="text-xs text-gray-400 space-y-1 mb-2">
<div>
Capacity: {instance.usedCapacity}/{instance.totalCapacity}
{instance.quality < 100 && (
<span className="text-yellow-500 ml-1">
(Quality: {instance.quality}%)
</span>
)}
</div>
{instance.enchantments.length > 0 && (
<div className="flex flex-wrap gap-1">
{instance.enchantments.map((ench, i) => {
const effect = ENCHANTMENT_EFFECTS[ench.effectId];
return (
<Badge
key={i}
variant="outline"
className="text-xs"
>
{effect?.name || ench.effectId}
</Badge>
);
})}
</div>
)}
</div>
{validSlots.length > 0 && (
<div className="flex items-center gap-2">
<Select
onValueChange={(value) =>
handleEquip(instance.instanceId, value as EquipmentSlot)
}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="Equip to..." />
</SelectTrigger>
<SelectContent>
{validSlots.map((slot) => (
<SelectItem
key={slot}
value={slot}
className="text-xs"
>
{SLOT_ICONS[slot]} {SLOT_NAMES[slot]}
</SelectItem>
))}
</SelectContent>
</Select>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
className="h-8 text-xs text-red-400 hover:text-red-300 hover:bg-red-900/20"
onClick={() => store.deleteEquipmentInstance(instance.instanceId)}
>
🗑
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Delete this item</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
)}
</div>
);
})}
</div>
)}
</CardContent>
</Card>
{/* Equipment Stats Summary */}
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 game-panel-title text-xs">
Equipment Stats Summary
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<div className="p-3 bg-gray-800/50 rounded text-center">
<div className="text-2xl font-bold text-amber-400 game-mono">
{Object.values(store.equipmentInstances).length}
</div>
<div className="text-xs text-gray-400">Total Items</div>
</div>
<div className="p-3 bg-gray-800/50 rounded text-center">
<div className="text-2xl font-bold text-green-400 game-mono">
{equippedIds.size}
</div>
<div className="text-xs text-gray-400">Equipped</div>
</div>
<div className="p-3 bg-gray-800/50 rounded text-center">
<div className="text-2xl font-bold text-blue-400 game-mono">
{unequippedItems.length}
</div>
<div className="text-xs text-gray-400">In Inventory</div>
</div>
<div className="p-3 bg-gray-800/50 rounded text-center">
<div className="text-2xl font-bold text-purple-400 game-mono">
{Object.values(store.equipmentInstances).reduce(
(sum, inst) => sum + inst.enchantments.length,
0
)}
</div>
<div className="text-xs text-gray-400">Total Enchantments</div>
</div>
</div>
{/* Active Effects from Equipment */}
<div className="mt-4">
<div className="text-sm text-gray-400 mb-2">Active Effects from Equipment:</div>
<div className="flex flex-wrap gap-2">
{(() => {
const effects = store.getEquipmentEffects();
const effectEntries = Object.entries(effects).filter(([, v]) => v > 0);
if (effectEntries.length === 0) {
return <span className="text-gray-500 text-sm">No active effects</span>;
}
return effectEntries.map(([stat, value]) => (
<Badge key={stat} variant="outline" className="text-xs">
{stat}: +{fmt(value)}
</Badge>
));
})()}
</div>
</div>
</CardContent>
</Card>
</div>
);
}
+582
View 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>
);
}
+338
View File
@@ -0,0 +1,338 @@
'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 { Separator } from '@/components/ui/separator';
import {
Mountain, Zap, Clock, Swords, Target, Sparkles, Lock, Check, X
} from 'lucide-react';
import { GOLEMS_DEF, getGolemSlots, isGolemUnlocked, getGolemDamage, getGolemAttackSpeed, getGolemFloorDuration } from '@/lib/game/data/golems';
import { ELEMENTS } from '@/lib/game/constants';
import type { GameStore } from '@/lib/game/store';
export interface GolemancyTabProps {
store: GameStore;
}
export function GolemancyTab({ store }: GolemancyTabProps) {
const attunements = store.attunements;
const elements = store.elements;
const skills = store.skills;
const golemancy = store.golemancy;
const currentFloor = store.currentFloor;
const currentRoom = store.currentRoom;
const toggleGolem = store.toggleGolem;
// Get Fabricator level and golem slots
const fabricatorLevel = attunements.fabricator?.level || 0;
const fabricatorActive = attunements.fabricator?.active || false;
const maxSlots = getGolemSlots(fabricatorLevel);
// Get unlocked elements
const unlockedElements = Object.entries(elements)
.filter(([, e]) => e.unlocked)
.map(([id]) => id);
// Get all unlocked golems
const unlockedGolems = Object.values(GOLEMS_DEF).filter(golem =>
isGolemUnlocked(golem.id, attunements, unlockedElements)
);
// Check if golemancy is available
const hasGolemancy = fabricatorActive && fabricatorLevel >= 2;
// Check if currently in combat (not puzzle)
const inCombat = currentRoom.roomType !== 'puzzle';
// Get element info helper
const getElementInfo = (elementId: string) => {
return ELEMENTS[elementId];
};
// Render a golem card
const renderGolemCard = (golemId: string, isUnlocked: boolean) => {
const golem = GOLEMS_DEF[golemId];
if (!golem) return null;
const isEnabled = golemancy.enabledGolems.includes(golemId);
const isSelected = golemancy.summonedGolems.some(g => g.golemId === golemId);
// Calculate effective stats
const damage = getGolemDamage(golemId, skills);
const attackSpeed = getGolemAttackSpeed(golemId, skills);
const floorDuration = getGolemFloorDuration(skills);
// Get element color
const primaryElement = getElementInfo(golem.baseManaType);
const elementColor = primaryElement?.color || '#888';
if (!isUnlocked) {
// Locked golem card
return (
<Card key={golemId} className="bg-gray-900/80 border-gray-700 opacity-50">
<CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center gap-2">
<Lock className="w-4 h-4" />
<span className="text-gray-500">???</span>
</CardTitle>
</CardHeader>
<CardContent className="text-xs text-gray-500">
{golem.unlockCondition.type === 'attunement_level' && (
<div>Requires Fabricator Level {golem.unlockCondition.level}</div>
)}
{golem.unlockCondition.type === 'mana_unlocked' && (
<div>Requires {ELEMENTS[golem.unlockCondition.manaType || '']?.name || golem.unlockCondition.manaType} Mana</div>
)}
{golem.unlockCondition.type === 'dual_attunement' && (
<div>Requires Enchanter & Fabricator Level 5</div>
)}
</CardContent>
</Card>
);
}
return (
<Card
key={golemId}
className={`bg-gray-900/80 border-2 transition-all cursor-pointer ${
isEnabled
? 'border-green-500 bg-green-900/10'
: 'border-gray-700 hover:border-gray-600'
}`}
onClick={() => toggleGolem(golemId)}
>
<CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center justify-between">
<div className="flex items-center gap-2">
<Mountain className="w-4 h-4" style={{ color: elementColor }} />
<span style={{ color: elementColor }}>{golem.name}</span>
</div>
<div className="flex items-center gap-1">
{golem.isAoe && (
<Badge variant="outline" className="text-xs">AOE {golem.aoeTargets}</Badge>
)}
<Badge variant="outline" className="text-xs">T{golem.tier}</Badge>
{isEnabled ? (
<Check className="w-4 h-4 text-green-400" />
) : (
<X className="w-4 h-4 text-gray-500" />
)}
</div>
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<p className="text-xs text-gray-400">{golem.description}</p>
<Separator className="bg-gray-700" />
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="flex items-center gap-1">
<Swords className="w-3 h-3 text-red-400" />
<span className="text-gray-400">DMG:</span>
<span className="text-white">{damage}</span>
</div>
<div className="flex items-center gap-1">
<Zap className="w-3 h-3 text-yellow-400" />
<span className="text-gray-400">Speed:</span>
<span className="text-white">{attackSpeed.toFixed(1)}/hr</span>
</div>
<div className="flex items-center gap-1">
<Target className="w-3 h-3 text-blue-400" />
<span className="text-gray-400">Pierce:</span>
<span className="text-white">{Math.floor(golem.armorPierce * 100)}%</span>
</div>
<div className="flex items-center gap-1">
<Clock className="w-3 h-3 text-purple-400" />
<span className="text-gray-400">Duration:</span>
<span className="text-white">{floorDuration} floor(s)</span>
</div>
</div>
<Separator className="bg-gray-700" />
{/* Summon Cost */}
<div>
<div className="text-xs text-gray-500 mb-1">Summon Cost:</div>
<div className="flex flex-wrap gap-1">
{golem.summonCost.map((cost, idx) => {
const elem = getElementInfo(cost.element || '');
const available = cost.type === 'raw'
? store.rawMana
: elements[cost.element || '']?.current || 0;
const canAfford = available >= cost.amount;
return (
<Badge
key={idx}
variant="outline"
className={`text-xs ${canAfford ? 'border-green-500' : 'border-red-500'}`}
style={{ borderColor: canAfford ? undefined : '#ef4444' }}
>
<span style={{ color: elem?.color }}>{elem?.sym || '💎'}</span>
{' '}{cost.amount}
</Badge>
);
})}
</div>
</div>
{/* Maintenance Cost */}
<div>
<div className="text-xs text-gray-500 mb-1">Maintenance/hr:</div>
<div className="flex flex-wrap gap-1">
{golem.maintenanceCost.map((cost, idx) => {
const elem = getElementInfo(cost.element || '');
return (
<Badge key={idx} variant="outline" className="text-xs">
<span style={{ color: elem?.color }}>{elem?.sym || '💎'}</span>
{' '}{cost.amount}/hr
</Badge>
);
})}
</div>
</div>
{/* Status */}
{isSelected && (
<div className="mt-2 text-xs text-green-400 flex items-center gap-1">
<Sparkles className="w-3 h-3" />
Active on Floor {currentFloor}
</div>
)}
</CardContent>
</Card>
);
};
return (
<div className="space-y-4">
{/* Header */}
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-lg flex items-center gap-2">
<Mountain className="w-5 h-5 text-amber-500" />
Golemancy
</CardTitle>
</CardHeader>
<CardContent>
{!hasGolemancy ? (
<div className="text-center text-gray-400 py-4">
<Lock className="w-12 h-12 mx-auto mb-2 opacity-50" />
<p>Unlock the Fabricator attunement and reach Level 2 to summon golems.</p>
</div>
) : (
<div className="space-y-3">
<div className="flex justify-between items-center">
<span className="text-sm text-gray-400">Golem Slots:</span>
<span className="text-sm font-semibold">
<span className="text-amber-400">{golemancy.enabledGolems.length}</span>
<span className="text-gray-500"> / {maxSlots}</span>
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-gray-400">Fabricator Level:</span>
<span className="text-sm font-semibold text-amber-400">{fabricatorLevel}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-gray-400">Floor Duration:</span>
<span className="text-sm font-semibold">{getGolemFloorDuration(skills)} floor(s)</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-gray-400">Status:</span>
<span className={`text-sm ${inCombat ? 'text-green-400' : 'text-yellow-400'}`}>
{inCombat ? '⚔️ Combat Active' : '🧩 Puzzle Room (No Golems)'}
</span>
</div>
<p className="text-xs text-gray-500 mt-2">
Golems are automatically summoned at the start of each combat floor.
They cost mana to maintain and will be dismissed if you run out.
</p>
</div>
)}
</CardContent>
</Card>
{/* Active Golems */}
{hasGolemancy && golemancy.summonedGolems.length > 0 && (
<Card className="bg-gray-900/80 border-green-600">
<CardHeader className="pb-2">
<CardTitle className="text-sm text-green-400 flex items-center gap-2">
<Sparkles className="w-4 h-4" />
Active Golems ({golemancy.summonedGolems.length})
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{golemancy.summonedGolems.map(sg => {
const golem = GOLEMS_DEF[sg.golemId];
const elem = getElementInfo(golem?.baseManaType || '');
return (
<Badge key={sg.golemId} variant="outline" className="text-sm py-1 px-2">
<Mountain className="w-3 h-3 mr-1" style={{ color: elem?.color }} />
{golem?.name}
</Badge>
);
})}
</div>
</CardContent>
</Card>
)}
{/* Golem Selection */}
{hasGolemancy && (
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-sm">Select Golems to Summon</CardTitle>
</CardHeader>
<CardContent>
<ScrollArea className="h-96">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 pr-4">
{/* Unlocked Golems */}
{unlockedGolems.map(golem => renderGolemCard(golem.id, true))}
{/* Locked Golems */}
{Object.values(GOLEMS_DEF)
.filter(g => !isGolemUnlocked(g.id, attunements, unlockedElements))
.map(golem => renderGolemCard(golem.id, false))}
</div>
</ScrollArea>
</CardContent>
</Card>
)}
{/* Golemancy Skills Info */}
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-sm">Golemancy Skills</CardTitle>
</CardHeader>
<CardContent>
<div className="text-xs text-gray-400 space-y-1">
<div className="flex justify-between">
<span>Golem Mastery:</span>
<span className="text-white">+{skills.golemMastery || 0}0% damage</span>
</div>
<div className="flex justify-between">
<span>Golem Efficiency:</span>
<span className="text-white">+{(skills.golemEfficiency || 0) * 5}% attack speed</span>
</div>
<div className="flex justify-between">
<span>Golem Longevity:</span>
<span className="text-white">+{skills.golemLongevity || 0} floor duration</span>
</div>
<div className="flex justify-between">
<span>Golem Siphon:</span>
<span className="text-white">-{(skills.golemSiphon || 0) * 10}% maintenance</span>
</div>
</div>
</CardContent>
</Card>
</div>
);
}
+567
View File
@@ -0,0 +1,567 @@
'use client';
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 { RotateCcw, Save, Trash2, Star, Flame, Clock, AlertCircle } from 'lucide-react';
import { useGameContext } from '../GameContext';
import { GUARDIANS, PRESTIGE_DEF, SKILLS_DEF, ELEMENTS } from '@/lib/game/constants';
import { getTierMultiplier, getBaseSkillId } from '@/lib/game/skill-evolution';
import { fmt, fmtDec, getBoonBonuses } from '@/lib/game/stores';
import type { Memory } from '@/lib/game/types';
import { useMemo, useState } from 'react';
export function GrimoireTab() {
const { store } = useGameContext();
const [showMemoryPicker, setShowMemoryPicker] = useState(false);
// Get all skills that can be saved to memory
const saveableSkills = useMemo(() => {
const skills: { skillId: string; level: number; tier: number; upgrades: string[]; name: string }[] = [];
for (const [skillId, level] of Object.entries(store.skills)) {
if (level && level > 0) {
const baseSkillId = getBaseSkillId(skillId);
const tier = store.skillTiers?.[baseSkillId] || 1;
const tieredSkillId = tier > 1 ? `${baseSkillId}_t${tier}` : baseSkillId;
const upgrades = store.skillUpgrades?.[tieredSkillId] || [];
const skillDef = SKILLS_DEF[baseSkillId];
if (skillId === baseSkillId || skillId.includes('_t')) {
const actualLevel = store.skills[tieredSkillId] || store.skills[baseSkillId] || 0;
if (actualLevel > 0) {
skills.push({
skillId: baseSkillId,
level: actualLevel,
tier,
upgrades,
name: skillDef?.name || baseSkillId,
});
}
}
}
}
const uniqueSkills = new Map<string, typeof skills[0]>();
for (const skill of skills) {
const existing = uniqueSkills.get(skill.skillId);
if (!existing || skill.tier > existing.tier || (skill.tier === existing.tier && skill.level > existing.level)) {
uniqueSkills.set(skill.skillId, skill);
}
}
return Array.from(uniqueSkills.values()).sort((a, b) => {
if (a.tier !== b.tier) return b.tier - a.tier;
if (a.level !== b.level) return b.level - a.level;
return a.name.localeCompare(b.name);
});
}, [store.skills, store.skillTiers, store.skillUpgrades]);
const isSkillInMemory = (skillId: string) => store.memories.some(m => m.skillId === skillId);
const canAddMore = store.memories.length < store.memorySlots;
const addToMemory = (skill: typeof saveableSkills[0]) => {
const memory: Memory = {
skillId: skill.skillId,
level: skill.level,
tier: skill.tier,
upgrades: skill.upgrades,
};
store.addMemory(memory);
};
// Calculate total boons from active pacts
const activeBoons = useMemo(() => {
return getBoonBonuses(store.signedPacts);
}, [store.signedPacts]);
// Check if player can sign a pact
const canSignPact = (floor: number) => {
const guardian = GUARDIANS[floor];
if (!guardian) return false;
if (!store.defeatedGuardians.includes(floor)) return false;
if (store.signedPacts.includes(floor)) return false;
if (store.signedPacts.length >= store.pactSlots) return false;
if (store.rawMana < guardian.pactCost) return false;
if (store.pactRitualFloor !== null) return false;
return true;
};
// Get pact affinity bonus for display
const pactAffinityBonus = (store.prestigeUpgrades.pactAffinity || 0) * 10;
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Current Status */}
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 game-panel-title text-xs">Loop Status</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div className="p-3 bg-gray-800/50 rounded">
<div className="text-2xl font-bold text-amber-400 game-mono">{store.loopCount}</div>
<div className="text-xs text-gray-400">Loops Completed</div>
</div>
<div className="p-3 bg-gray-800/50 rounded">
<div className="text-2xl font-bold text-purple-400 game-mono">{fmt(store.insight)}</div>
<div className="text-xs text-gray-400">Current Insight</div>
</div>
<div className="p-3 bg-gray-800/50 rounded">
<div className="text-2xl font-bold text-blue-400 game-mono">{fmt(store.totalInsight)}</div>
<div className="text-xs text-gray-400">Total Insight</div>
</div>
<div className="p-3 bg-gray-800/50 rounded">
<div className="text-2xl font-bold text-green-400 game-mono">{store.memorySlots}</div>
<div className="text-xs text-gray-400">Memory Slots</div>
</div>
</div>
</CardContent>
</Card>
{/* Pact Slots & Active Ritual */}
<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">
<Flame className="w-4 h-4" />
Pact Slots ({store.signedPacts.length}/{store.pactSlots})
</CardTitle>
</CardHeader>
<CardContent>
{/* Active Ritual Progress */}
{store.pactRitualFloor !== null && (
<div className="mb-4 p-3 rounded border-2 border-amber-500/50 bg-amber-900/20">
{(() => {
const guardian = GUARDIANS[store.pactRitualFloor];
if (!guardian) return null;
const requiredTime = guardian.pactTime * (1 - (store.prestigeUpgrades.pactAffinity || 0) * 0.1);
const progress = Math.min(100, (store.pactRitualProgress / requiredTime) * 100);
return (
<>
<div className="flex items-center justify-between mb-2">
<span className="text-amber-300 font-semibold text-sm">Signing Pact with {guardian.name}</span>
<span className="text-xs text-gray-400">{fmtDec(progress, 1)}%</span>
</div>
<div className="w-full h-2 bg-gray-700 rounded overflow-hidden mb-2">
<div
className="h-full bg-amber-500 transition-all duration-300"
style={{ width: `${progress}%` }}
/>
</div>
<div className="flex items-center justify-between">
<div className="text-xs text-gray-400">
<Clock className="w-3 h-3 inline mr-1" />
{fmtDec(store.pactRitualProgress, 1)}h / {fmtDec(requiredTime, 1)}h
</div>
<Button
size="sm"
variant="outline"
className="h-6 text-xs border-red-500/50 text-red-400 hover:bg-red-900/20"
onClick={() => store.cancelPactRitual()}
>
Cancel Ritual
</Button>
</div>
</>
);
})()}
</div>
)}
{/* Active Pacts */}
{store.signedPacts.length > 0 ? (
<div className="space-y-2">
{store.signedPacts.map((floor) => {
const guardian = GUARDIANS[floor];
if (!guardian) return null;
return (
<div
key={floor}
className="p-3 rounded border"
style={{ borderColor: guardian.color, backgroundColor: `${guardian.color}15` }}
>
<div className="flex items-start justify-between">
<div>
<div className="font-semibold text-sm" style={{ color: guardian.color }}>
{guardian.name}
</div>
<div className="text-xs text-gray-400">Floor {floor} Guardian</div>
<div className="text-xs text-amber-300 mt-1 italic">&quot;{guardian.uniquePerk}&quot;</div>
</div>
<Button
size="sm"
variant="ghost"
className="h-6 w-6 p-0 text-red-400 hover:text-red-300"
onClick={() => store.removePact(floor)}
title="Break Pact"
>
<Trash2 className="w-3 h-3" />
</Button>
</div>
<div className="mt-2 flex flex-wrap gap-1">
{guardian.boons.map((boon, idx) => (
<Badge key={idx} className="bg-gray-700/50 text-gray-200 text-xs">
{boon.desc}
</Badge>
))}
</div>
</div>
);
})}
</div>
) : (
<div className="text-gray-500 text-sm text-center py-4">
No active pacts. Defeat guardians and sign pacts to gain boons.
</div>
)}
</CardContent>
</Card>
{/* Available Guardians for Pacts */}
<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">
<AlertCircle className="w-4 h-4" />
Available Guardians ({store.defeatedGuardians.length})
</CardTitle>
</CardHeader>
<CardContent>
{store.defeatedGuardians.length === 0 ? (
<div className="text-gray-500 text-sm text-center py-4">
Defeat guardians in the Spire to make them available for pacts.
</div>
) : (
<ScrollArea className="h-64">
<div className="space-y-2 pr-2">
{store.defeatedGuardians
.sort((a, b) => a - b)
.map((floor) => {
const guardian = GUARDIANS[floor];
if (!guardian) return null;
const canSign = canSignPact(floor);
const notEnoughMana = store.rawMana < guardian.pactCost;
const atCapacity = store.signedPacts.length >= store.pactSlots;
return (
<div
key={floor}
className="p-3 rounded border border-gray-700 bg-gray-800/50"
>
<div className="flex items-start justify-between mb-2">
<div>
<div className="font-semibold text-sm" style={{ color: guardian.color }}>
{guardian.name}
</div>
<div className="text-xs text-gray-400">
Floor {floor} {ELEMENTS[guardian.element]?.name || guardian.element}
</div>
</div>
<div className="text-right">
<div className="text-xs text-amber-300">{fmt(guardian.pactCost)} mana</div>
<div className="text-xs text-gray-400">{guardian.pactTime}h ritual</div>
</div>
</div>
<div className="text-xs text-purple-300 italic mb-2">&quot;{guardian.uniquePerk}&quot;</div>
<div className="flex flex-wrap gap-1 mb-2">
{guardian.boons.map((boon, idx) => (
<Badge key={idx} className="bg-gray-700/50 text-gray-200 text-xs">
{boon.desc}
</Badge>
))}
</div>
<Button
size="sm"
variant={canSign ? 'default' : 'outline'}
className="w-full"
disabled={!canSign}
onClick={() => store.startPactRitual(floor, store.rawMana)}
>
{atCapacity
? 'Pact Slots Full'
: notEnoughMana
? 'Not Enough Mana'
: store.pactRitualFloor !== null
? 'Ritual in Progress'
: 'Start Pact Ritual'}
</Button>
</div>
);
})}
</div>
</ScrollArea>
)}
</CardContent>
</Card>
{/* Memory Slots */}
<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">
<Save className="w-4 h-4" />
Memory Slots ({store.memories.length}/{store.memorySlots})
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-xs text-gray-400 mb-3">
Skills saved to memory will retain their level, tier, and upgrades when you start a new loop.
</p>
{/* Saved Memories */}
{store.memories.length > 0 ? (
<div className="space-y-1 mb-3">
{store.memories.map((memory) => {
const skillDef = SKILLS_DEF[memory.skillId];
const tierMult = getTierMultiplier(memory.tier > 1 ? `${memory.skillId}_t${memory.tier}` : memory.skillId);
return (
<div
key={memory.skillId}
className="flex items-center justify-between p-2 rounded border border-amber-600/30 bg-amber-900/10"
>
<div className="flex items-center gap-2">
<span className="font-semibold text-sm text-amber-300">{skillDef?.name || memory.skillId}</span>
{memory.tier > 1 && (
<Badge className="bg-purple-600/50 text-purple-200 text-xs">
T{memory.tier} ({tierMult}x)
</Badge>
)}
<span className="text-purple-400 text-xs">Lv.{memory.level}</span>
{memory.upgrades.length > 0 && (
<Badge className="bg-amber-700/50 text-amber-200 text-xs flex items-center gap-1">
<Star className="w-3 h-3" />
{memory.upgrades.length}
</Badge>
)}
</div>
<Button
size="sm"
variant="ghost"
className="h-6 w-6 p-0 text-red-400 hover:text-red-300"
onClick={() => store.removeMemory(memory.skillId)}
>
<Trash2 className="w-3 h-3" />
</Button>
</div>
);
})}
</div>
) : (
<div className="text-gray-500 text-sm mb-3 text-center py-2">
No memories saved. Add skills below.
</div>
)}
{/* Add Memory Button */}
{canAddMore && (
<Button
size="sm"
variant="outline"
className="w-full"
onClick={() => setShowMemoryPicker(!showMemoryPicker)}
>
{showMemoryPicker ? 'Hide Skills' : 'Add Skill to Memory'}
</Button>
)}
{/* Skill Picker */}
{showMemoryPicker && canAddMore && (
<ScrollArea className="h-48 mt-2">
<div className="space-y-1 pr-2">
{saveableSkills.length === 0 ? (
<div className="text-gray-500 text-xs text-center py-4">
No skills with progress to save
</div>
) : (
saveableSkills.map((skill) => {
const isInMemory = isSkillInMemory(skill.skillId);
const tierMult = getTierMultiplier(skill.tier > 1 ? `${skill.skillId}_t${skill.tier}` : skill.skillId);
return (
<div
key={skill.skillId}
className={`p-2 rounded border cursor-pointer transition-all ${
isInMemory
? 'border-amber-500 bg-amber-900/30 opacity-50'
: 'border-gray-700 bg-gray-800/50 hover:border-amber-500/50'
}`}
onClick={() => !isInMemory && addToMemory(skill)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="font-semibold text-sm">{skill.name}</span>
{skill.tier > 1 && (
<Badge className="bg-purple-600/50 text-purple-200 text-xs">
T{skill.tier} ({tierMult}x)
</Badge>
)}
</div>
<div className="flex items-center gap-2">
<span className="text-purple-400 text-sm">Lv.{skill.level}</span>
{skill.upgrades.length > 0 && (
<Badge className="bg-amber-700/50 text-amber-200 text-xs flex items-center gap-1">
<Star className="w-3 h-3" />
{skill.upgrades.length}
</Badge>
)}
</div>
</div>
</div>
);
})
)}
</div>
</ScrollArea>
)}
</CardContent>
</Card>
{/* Active Boons Summary */}
{store.signedPacts.length > 0 && (
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 game-panel-title text-xs">Active Boons Summary</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-2 text-xs">
{activeBoons.maxMana > 0 && (
<div className="flex justify-between">
<span className="text-gray-400">Max Mana:</span>
<span className="text-blue-300">+{activeBoons.maxMana}</span>
</div>
)}
{activeBoons.manaRegen > 0 && (
<div className="flex justify-between">
<span className="text-gray-400">Mana Regen:</span>
<span className="text-blue-300">+{activeBoons.manaRegen}/h</span>
</div>
)}
{activeBoons.castingSpeed > 0 && (
<div className="flex justify-between">
<span className="text-gray-400">Cast Speed:</span>
<span className="text-amber-300">+{activeBoons.castingSpeed}%</span>
</div>
)}
{activeBoons.elementalDamage > 0 && (
<div className="flex justify-between">
<span className="text-gray-400">Elem. Damage:</span>
<span className="text-red-300">+{activeBoons.elementalDamage}%</span>
</div>
)}
{activeBoons.rawDamage > 0 && (
<div className="flex justify-between">
<span className="text-gray-400">Raw Damage:</span>
<span className="text-red-300">+{activeBoons.rawDamage}%</span>
</div>
)}
{activeBoons.critChance > 0 && (
<div className="flex justify-between">
<span className="text-gray-400">Crit Chance:</span>
<span className="text-yellow-300">+{activeBoons.critChance}%</span>
</div>
)}
{activeBoons.critDamage > 0 && (
<div className="flex justify-between">
<span className="text-gray-400">Crit Damage:</span>
<span className="text-yellow-300">+{activeBoons.critDamage}%</span>
</div>
)}
{activeBoons.spellEfficiency > 0 && (
<div className="flex justify-between">
<span className="text-gray-400">Spell Cost:</span>
<span className="text-green-300">-{activeBoons.spellEfficiency}%</span>
</div>
)}
{activeBoons.manaGain > 0 && (
<div className="flex justify-between">
<span className="text-gray-400">Mana Gain:</span>
<span className="text-blue-300">+{activeBoons.manaGain}%</span>
</div>
)}
{activeBoons.insightGain > 0 && (
<div className="flex justify-between">
<span className="text-gray-400">Insight Gain:</span>
<span className="text-purple-300">+{activeBoons.insightGain}%</span>
</div>
)}
{activeBoons.studySpeed > 0 && (
<div className="flex justify-between">
<span className="text-gray-400">Study Speed:</span>
<span className="text-cyan-300">+{activeBoons.studySpeed}%</span>
</div>
)}
{activeBoons.prestigeInsight > 0 && (
<div className="flex justify-between">
<span className="text-gray-400">Prestige Insight:</span>
<span className="text-purple-300">+{activeBoons.prestigeInsight}/loop</span>
</div>
)}
</div>
</CardContent>
</Card>
)}
{/* Prestige Upgrades */}
<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">Insight Upgrades (Permanent)</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{Object.entries(PRESTIGE_DEF).map(([id, def]) => {
const level = store.prestigeUpgrades[id] || 0;
const maxed = level >= def.max;
const canBuy = !maxed && store.insight >= def.cost;
return (
<div key={id} className="p-3 rounded border border-gray-700 bg-gray-800/50">
<div className="flex items-center justify-between mb-2">
<div className="font-semibold text-amber-400 text-sm">{def.name}</div>
<Badge variant="outline" className="text-xs">
{level}/{def.max}
</Badge>
</div>
<div className="text-xs text-gray-400 italic mb-2">{def.desc}</div>
<Button
size="sm"
variant={canBuy ? 'default' : 'outline'}
className="w-full"
disabled={!canBuy}
onClick={() => store.doPrestige(id)}
>
{maxed ? 'Maxed' : `Upgrade (${fmt(def.cost)} insight)`}
</Button>
</div>
);
})}
</div>
{/* Reset Game Button */}
<div className="mt-4 pt-4 border-t border-gray-700">
<div className="flex items-center justify-between">
<div>
<div className="text-sm text-gray-400">Reset All Progress</div>
<div className="text-xs text-gray-500">Clear all data and start fresh</div>
</div>
<Button
size="sm"
variant="outline"
className="border-red-600/50 text-red-400 hover:bg-red-900/20"
onClick={() => {
if (confirm('Are you sure you want to reset ALL progress? This cannot be undone!')) {
store.resetGame();
}
}}
>
<RotateCcw className="w-4 h-4 mr-1" />
Reset
</Button>
</div>
</div>
</CardContent>
</Card>
</div>
);
}
+116
View File
@@ -0,0 +1,116 @@
'use client';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { ELEMENTS } from '@/lib/game/constants';
interface LabTabProps {
store: {
rawMana: number;
elements: Record<string, { current: number; max: number; unlocked: boolean }>;
skills: Record<string, number>;
craftComposite: (target: string) => void;
};
}
export function LabTab({ store }: LabTabProps) {
// Render elemental mana grid - only show elements with current > 0
const renderElementsGrid = () => (
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 lg:grid-cols-8 gap-2">
{Object.entries(store.elements)
.filter(([, state]) => state.unlocked && state.current > 0)
.map(([id, state]) => {
const def = ELEMENTS[id];
return (
<div
key={id}
className="p-2 rounded border border-gray-700 bg-gray-800/50"
>
<div className="text-lg text-center">{def?.sym}</div>
<div className="text-xs font-semibold text-center" style={{ color: def?.color }}>{def?.name}</div>
<div className="text-xs text-gray-400 game-mono text-center">{state.current}/{state.max}</div>
</div>
);
})}
</div>
);
// Render composite crafting
const renderCompositeCrafting = () => {
const compositeElements = Object.entries(ELEMENTS)
.filter(([, def]) => def.recipe)
.filter(([id]) => store.elements[id]?.unlocked);
if (compositeElements.length === 0) return null;
return (
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 text-sm">Composite Crafting</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{compositeElements.map(([id, def]) => {
const recipe = def.recipe || [];
const canCraft = recipe.every(r => (store.elements[r]?.current || 0) >= 1);
const craftBonus = 1 + (store.skills.elemCrafting || 0) * 0.25;
const output = Math.floor(craftBonus);
return (
<div key={id} className="p-2 rounded border border-gray-700 bg-gray-800/50 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-lg">{def.sym}</span>
<span className="text-sm" style={{ color: def.color }}>{def.name}</span>
<span className="text-xs text-gray-500">
({recipe.map(r => ELEMENTS[r]?.sym).join(' + ')})
</span>
</div>
<Button
size="sm"
variant="outline"
disabled={!canCraft}
onClick={() => store.craftComposite(id)}
>
Craft ({output})
</Button>
</div>
);
})}
</div>
</CardContent>
</Card>
);
};
// Check if there are any unlocked elements with current > 0
const hasUnlockedElements = Object.values(store.elements).some(e => e.unlocked && e.current > 0);
if (!hasUnlockedElements) {
return (
<Card className="bg-gray-900/80 border-gray-700">
<CardContent className="pt-6">
<div className="text-center text-gray-500">
No elemental mana available. Gather or convert mana to see elemental pools.
</div>
</CardContent>
</Card>
);
}
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Elemental Mana Display */}
<Card className="bg-gray-900/80 border-gray-700 lg:col-span-2">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 text-sm">Elemental Mana</CardTitle>
</CardHeader>
<CardContent>
{renderElementsGrid()}
</CardContent>
</Card>
{/* Composite Crafting */}
{renderCompositeCrafting()}
</div>
);
}
+46
View File
@@ -0,0 +1,46 @@
'use client';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import type { GameStore } from '@/lib/game/store';
import { LootInventoryDisplay } from '@/components/game/LootInventory';
export interface LootTabProps {
store: GameStore;
}
export function LootTab({ store }: LootTabProps) {
const inventory = store.lootInventory;
const elements = store.elements;
const equipmentInstances = store.equipmentInstances;
// Count items for badge
const materialCount = Object.values(inventory.materials).reduce((a, b) => a + b, 0);
const blueprintCount = inventory.blueprints.length;
const equipmentCount = Object.keys(equipmentInstances).length;
const totalItems = materialCount + blueprintCount + equipmentCount;
return (
<div className="space-y-4">
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 text-sm flex items-center gap-2">
💎 Loot Inventory
<Badge className="ml-auto bg-gray-800 text-gray-300">
{totalItems} items
</Badge>
</CardTitle>
</CardHeader>
<CardContent>
<LootInventoryDisplay
inventory={inventory}
elements={elements}
equipmentInstances={equipmentInstances}
onDeleteMaterial={store.deleteMaterial}
onDeleteEquipment={store.deleteEquipmentInstance}
/>
</CardContent>
</Card>
</div>
);
}
+369
View File
@@ -0,0 +1,369 @@
'use client';
import { useState } from 'react';
import { SKILLS_DEF, SKILL_CATEGORIES, getStudySpeedMultiplier, getStudyCostMultiplier } from '@/lib/game/constants';
import { SKILL_EVOLUTION_PATHS, getUpgradesForSkillAtMilestone, getNextTierSkill, getTierMultiplier } from '@/lib/game/skill-evolution';
import { getUnifiedEffects, hasSpecial, SPECIAL_EFFECTS } from '@/lib/game/effects';
import { getAvailableSkillCategories } from '@/lib/game/data/attunements';
import { fmt, fmtDec } from '@/lib/game/store';
import { formatStudyTime } from '@/lib/game/formatting';
import type { SkillUpgradeChoice, GameStore } from '@/lib/game/types';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { StudyProgress } from './StudyProgress';
import { UpgradeDialog } from './UpgradeDialog';
import { ChevronDown, ChevronRight } from 'lucide-react';
export interface SkillsTabProps {
store: GameStore;
}
// Check if skill has milestone available
function hasMilestoneUpgrade(
skillId: string,
level: number,
skillTiers: Record<string, number>,
skillUpgrades: Record<string, string[]>
): { milestone: 5 | 10; hasUpgrades: boolean; selectedCount: number } | null {
const baseSkillId = skillId.includes('_t') ? skillId.split('_t')[0] : skillId;
const path = SKILL_EVOLUTION_PATHS[baseSkillId];
if (!path) return null;
// Check level 5 milestone
if (level >= 5) {
const upgrades5 = getUpgradesForSkillAtMilestone(skillId, 5, skillTiers);
const selected5 = (skillUpgrades[skillId] || []).filter(id => id.includes('_l5'));
if (upgrades5.length > 0 && selected5.length < 2) {
return { milestone: 5, hasUpgrades: true, selectedCount: selected5.length };
}
}
// Check level 10 milestone
if (level >= 10) {
const upgrades10 = getUpgradesForSkillAtMilestone(skillId, 10, skillTiers);
const selected10 = (skillUpgrades[skillId] || []).filter(id => id.includes('_l10'));
if (upgrades10.length > 0 && selected10.length < 2) {
return { milestone: 10, hasUpgrades: true, selectedCount: selected10.length };
}
}
return null;
}
export function SkillsTab({ store }: SkillsTabProps) {
const [upgradeDialogSkill, setUpgradeDialogSkill] = useState<string | null>(null);
const [upgradeDialogMilestone, setUpgradeDialogMilestone] = useState<5 | 10>(5);
const [pendingUpgradeSelections, setPendingUpgradeSelections] = useState<string[]>([]);
const [collapsedCategories, setCollapsedCategories] = useState<Set<string>>(new Set());
const studySpeedMult = getStudySpeedMultiplier(store.skills);
const upgradeEffects = getUnifiedEffects(store);
// Toggle category collapse
const toggleCategory = (categoryId: string) => {
setCollapsedCategories(prev => {
const newSet = new Set(prev);
if (newSet.has(categoryId)) {
newSet.delete(categoryId);
} else {
newSet.add(categoryId);
}
return newSet;
});
};
// Get upgrade choices for dialog
const getUpgradeChoices = () => {
if (!upgradeDialogSkill) return { available: [] as SkillUpgradeChoice[], selected: [] as string[] };
return store.getSkillUpgradeChoices(upgradeDialogSkill, upgradeDialogMilestone);
};
const { available, selected: alreadySelected } = getUpgradeChoices();
// Toggle selection
const toggleUpgrade = (upgradeId: string) => {
const currentSelections = pendingUpgradeSelections.length > 0 ? pendingUpgradeSelections : alreadySelected;
if (currentSelections.includes(upgradeId)) {
setPendingUpgradeSelections(currentSelections.filter(id => id !== upgradeId));
} else if (currentSelections.length < 2) {
setPendingUpgradeSelections([...currentSelections, upgradeId]);
}
};
// Commit selections and close
const handleConfirm = () => {
const currentSelections = pendingUpgradeSelections.length > 0 ? pendingUpgradeSelections : alreadySelected;
if (currentSelections.length === 2 && upgradeDialogSkill) {
store.commitSkillUpgrades(upgradeDialogSkill, currentSelections, upgradeDialogMilestone);
}
setPendingUpgradeSelections([]);
setUpgradeDialogSkill(null);
};
// Cancel and close
const handleCancel = () => {
setPendingUpgradeSelections([]);
setUpgradeDialogSkill(null);
};
return (
<div className="space-y-4">
{/* Upgrade Selection Dialog */}
<UpgradeDialog
open={!!upgradeDialogSkill}
skillId={upgradeDialogSkill}
milestone={upgradeDialogMilestone}
pendingSelections={pendingUpgradeSelections}
available={available}
alreadySelected={alreadySelected}
onToggle={toggleUpgrade}
onConfirm={handleConfirm}
onCancel={handleCancel}
onOpenChange={(open) => {
if (!open) {
setPendingUpgradeSelections([]);
setUpgradeDialogSkill(null);
}
}}
/>
{/* Current Study Progress */}
{store.currentStudyTarget && store.currentStudyTarget.type === 'skill' && (
<Card className="bg-gray-900/80 border-purple-600/50">
<CardContent className="pt-4">
<StudyProgress
currentStudyTarget={store.currentStudyTarget}
skills={store.skills}
studySpeedMult={studySpeedMult}
cancelStudy={store.cancelStudy}
/>
</CardContent>
</Card>
)}
{/* Get available skill categories based on attunements */}
{(() => {
const availableCategories = getAvailableSkillCategories(store.attunements || {});
return SKILL_CATEGORIES
.filter(cat => availableCategories.includes(cat.id))
.map((cat) => {
const skillsInCat = Object.entries(SKILLS_DEF).filter(([, def]) => def.cat === cat.id);
if (skillsInCat.length === 0) return null;
const isCollapsed = collapsedCategories.has(cat.id);
return (
<Card key={cat.id} className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2 cursor-pointer" onClick={() => toggleCategory(cat.id)}>
<CardTitle className="text-amber-400 game-panel-title text-xs flex items-center justify-between">
<span>{cat.icon} {cat.name}</span>
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-xs">{skillsInCat.length} skills</Badge>
{isCollapsed ? <ChevronRight className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
</div>
</CardTitle>
</CardHeader>
{!isCollapsed && (
<CardContent>
<div className="space-y-2">
{skillsInCat.map(([id, def]) => {
// Get tier info
const currentTier = store.skillTiers?.[id] || 1;
const tieredSkillId = currentTier > 1 ? `${id}_t${currentTier}` : id;
const tierMultiplier = getTierMultiplier(tieredSkillId);
// Get the actual level from the tiered skill
const level = store.skills[tieredSkillId] || store.skills[id] || 0;
const maxed = level >= def.max;
// Check if studying this skill
const isStudying = (store.currentStudyTarget?.id === id || store.currentStudyTarget?.id === tieredSkillId) && store.currentStudyTarget?.type === 'skill';
// Get tier name for display
const tierDef = SKILL_EVOLUTION_PATHS[id]?.tiers.find(t => t.tier === currentTier);
const skillDisplayName = tierDef?.name || def.name;
// Check prerequisites
let prereqMet = true;
if (def.req) {
for (const [r, rl] of Object.entries(def.req)) {
if ((store.skills[r] || 0) < rl) {
prereqMet = false;
break;
}
}
}
// Apply skill modifiers
const costMult = getStudyCostMultiplier(store.skills);
const speedMult = getStudySpeedMultiplier(store.skills);
const studyEffects = getUnifiedEffects(store);
const effectiveSpeedMult = speedMult * studyEffects.studySpeedMultiplier;
// Study time scales with tier
const tierStudyTime = def.studyTime * currentTier;
const effectiveStudyTime = tierStudyTime / effectiveSpeedMult;
// Cost scales with tier
const baseCost = def.base * (level + 1) * currentTier;
const cost = Math.floor(baseCost * costMult);
// Can start studying?
const canStudy = !maxed && prereqMet && store.rawMana >= cost && !isStudying;
// Check for milestone upgrades
const milestoneInfo = hasMilestoneUpgrade(tieredSkillId, level, store.skillTiers || {}, store.skillUpgrades);
// Check for tier up
const nextTierSkill = getNextTierSkill(tieredSkillId);
const canTierUp = maxed && nextTierSkill;
// Get selected upgrades
const selectedUpgrades = store.skillUpgrades[tieredSkillId] || [];
const selectedL5 = selectedUpgrades.filter(u => u.includes('_l5'));
const selectedL10 = selectedUpgrades.filter(u => u.includes('_l10'));
return (
<div
key={id}
className={`flex flex-col sm:flex-row sm:items-center justify-between p-3 rounded border gap-2 ${
isStudying ? 'border-purple-500 bg-purple-900/20' :
milestoneInfo ? 'border-amber-500/50 bg-amber-900/10' :
'border-gray-700 bg-gray-800/30'
}`}
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-semibold text-sm">{skillDisplayName}</span>
{currentTier > 1 && (
<Badge className="bg-purple-600/50 text-purple-200 text-xs">Tier {currentTier} ({fmtDec(tierMultiplier, 0)}x)</Badge>
)}
{level > 0 && <span className="text-purple-400 text-sm">Lv.{level}</span>}
{selectedUpgrades.length > 0 && (
<div className="flex gap-1">
{selectedL5.length > 0 && (
<Badge className="bg-amber-700/50 text-amber-200 text-xs">L5: {selectedL5.length}</Badge>
)}
{selectedL10.length > 0 && (
<Badge className="bg-purple-700/50 text-purple-200 text-xs">L10: {selectedL10.length}</Badge>
)}
</div>
)}
</div>
<div className="text-xs text-gray-400 italic">{def.desc}{currentTier > 1 && ` (Tier ${currentTier}: ${fmtDec(tierMultiplier, 0)}x effect)`}</div>
{!prereqMet && def.req && (
<div className="text-xs text-red-400 mt-1">
Requires: {Object.entries(def.req).map(([r, rl]) => `${SKILLS_DEF[r]?.name} Lv.${rl}`).join(', ')}
</div>
)}
<div className="text-xs text-gray-500 mt-1">
<span className={effectiveSpeedMult > 1 ? 'text-green-400' : ''}>
Study: {formatStudyTime(effectiveStudyTime)}{effectiveSpeedMult > 1 && <span className="text-xs ml-1">({Math.round(effectiveSpeedMult * 100)}% speed)</span>}
</span>
{' • '}
<span className={costMult < 1 ? 'text-green-400' : ''}>
Cost: {fmt(cost)} mana{costMult < 1 && <span className="text-xs ml-1">({Math.round(costMult * 100)}% cost)</span>}
</span>
</div>
{milestoneInfo && (
<div className="text-xs text-amber-400 mt-1 flex items-center gap-1">
Level {milestoneInfo.milestone} milestone: {milestoneInfo.selectedCount}/2 upgrades selected
</div>
)}
</div>
<div className="flex items-center gap-3 flex-wrap sm:flex-nowrap">
{/* Level dots */}
<div className="flex gap-1 shrink-0">
{Array.from({ length: def.max }).map((_, i) => (
<div
key={i}
className={`w-2 h-2 rounded-full border ${
i < level ? 'bg-purple-500 border-purple-400' :
i === 4 || i === 9 ? 'border-amber-500' :
'border-gray-600'
}`}
/>
))}
</div>
{isStudying ? (
<div className="text-xs text-purple-400">
{formatStudyTime(store.currentStudyTarget?.progress || 0)}/{formatStudyTime(tierStudyTime)}
</div>
) : milestoneInfo ? (
<Button
size="sm"
className="bg-amber-600 hover:bg-amber-700"
onClick={() => {
setUpgradeDialogSkill(tieredSkillId);
setUpgradeDialogMilestone(milestoneInfo.milestone);
}}
>
Choose Upgrades
</Button>
) : canTierUp ? (
<Button
size="sm"
className="bg-purple-600 hover:bg-purple-700"
onClick={() => store.tierUpSkill(tieredSkillId)}
>
Tier Up
</Button>
) : maxed ? (
<Badge className="bg-green-900/50 text-green-300">Maxed</Badge>
) : (
<div className="flex gap-1">
<Button
size="sm"
variant={canStudy ? 'default' : 'outline'}
disabled={!canStudy}
className={canStudy ? 'bg-purple-600 hover:bg-purple-700' : 'opacity-50'}
onClick={() => store.startStudyingSkill(tieredSkillId)}
>
Study ({fmt(cost)})
</Button>
{/* Parallel Study button */}
{hasSpecial(upgradeEffects, SPECIAL_EFFECTS.PARALLEL_STUDY) &&
store.currentStudyTarget &&
!store.parallelStudyTarget &&
store.currentStudyTarget.id !== tieredSkillId &&
canStudy && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
className="border-cyan-500 text-cyan-400 hover:bg-cyan-900/30"
onClick={() => store.startParallelStudySkill(tieredSkillId)}
>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Study in parallel (50% speed)</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
)}
</div>
</div>
);
})}
</div>
</CardContent>
)}
</Card>
);
});
})()}
</div>
);
}
+180
View File
@@ -0,0 +1,180 @@
'use client';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { ELEMENTS, SPELLS_DEF } from '@/lib/game/constants';
import { ENCHANTMENT_EFFECTS } from '@/lib/game/data/enchantment-effects';
import { calcDamage, canAffordSpellCost, fmt } from '@/lib/game/store';
import { formatSpellCost, getSpellCostColor } from '@/lib/game/formatting';
interface SpellsTabProps {
store: {
spells: Record<string, { learned: boolean; level: number; studyProgress: number }>;
equippedInstances: Record<string, string | null>;
equipmentInstances: Record<string, { instanceId: string; name: string; enchantments: { effectId: string; stacks: number }[] }>;
activeSpell: string;
rawMana: number;
elements: Record<string, { current: number; max: number; unlocked: boolean }>;
signedPacts: number[];
unlockedEffects: string[];
setSpell: (spellId: string) => void;
};
}
export function SpellsTab({ store }: SpellsTabProps) {
// Get spells from equipment
const equipmentSpellIds: string[] = [];
const spellSources: Record<string, string[]> = {};
for (const instanceId of Object.values(store.equippedInstances)) {
if (!instanceId) continue;
const instance = store.equipmentInstances[instanceId];
if (!instance) continue;
for (const ench of instance.enchantments) {
const effectDef = ENCHANTMENT_EFFECTS[ench.effectId];
if (effectDef?.effect.type === 'spell' && effectDef.effect.spellId) {
const spellId = effectDef.effect.spellId;
if (!equipmentSpellIds.includes(spellId)) {
equipmentSpellIds.push(spellId);
}
if (!spellSources[spellId]) {
spellSources[spellId] = [];
}
spellSources[spellId].push(instance.name);
}
}
}
const canCastSpell = (spellId: string): boolean => {
const spell = SPELLS_DEF[spellId];
if (!spell) return false;
return canAffordSpellCost(spell.cost, store.rawMana, store.elements);
};
return (
<div className="space-y-6">
{/* Equipment-Granted Spells */}
<div className="mb-6">
<h3 className="text-lg font-semibold mb-3 text-cyan-400"> Known Spells</h3>
<p className="text-sm text-gray-400 mb-4">
Spells are obtained by enchanting equipment with spell effects.
Visit the Crafting tab to design and apply enchantments.
</p>
{equipmentSpellIds.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{equipmentSpellIds.map(id => {
const def = SPELLS_DEF[id];
if (!def) return null;
const isActive = store.activeSpell === id;
const canCast = canCastSpell(id);
const elemDef = def.elem === 'raw' ? null : ELEMENTS[def.elem];
const sources = spellSources[id] || [];
return (
<Card
key={id}
className={`bg-gray-900/80 border-cyan-600/50 ${canCast ? 'ring-1 ring-green-500/30' : ''}`}
>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-sm" style={{ color: def.elem === 'raw' ? '#60A5FA' : elemDef?.color }}>
{def.name}
</CardTitle>
<Badge className="bg-cyan-900/50 text-cyan-300 text-xs">Equipment</Badge>
</div>
</CardHeader>
<CardContent className="space-y-2">
<div className="text-xs text-gray-400">
{def.elem !== 'raw' && <span className="mr-2">{elemDef?.sym} {elemDef?.name}</span>}
<span> {def.dmg} dmg</span>
</div>
<div className="text-xs game-mono" style={{ color: getSpellCostColor(def.cost) }}>
Cost: {formatSpellCost(def.cost)}
</div>
<div className="text-xs text-cyan-400/70">From: {sources.join(', ')}</div>
<div className="flex gap-2">
{isActive ? (
<Badge className="bg-amber-900/50 text-amber-300">Active</Badge>
) : (
<Button size="sm" variant="outline" onClick={() => store.setSpell(id)}>
Set Active
</Button>
)}
</div>
</CardContent>
</Card>
);
})}
</div>
) : (
<div className="text-center p-8 bg-gray-800/30 rounded border border-gray-700">
<div className="text-gray-500 mb-2">No spells known yet</div>
<div className="text-sm text-gray-600">Enchant a staff with a spell effect to gain spells</div>
</div>
)}
</div>
{/* Pact Spells (from guardian defeats) */}
{store.signedPacts.length > 0 && (
<div className="mb-6">
<h3 className="text-lg font-semibold mb-3 text-amber-400">🏆 Pact Spells</h3>
<p className="text-sm text-gray-400 mb-3">Spells earned through guardian pacts appear here.</p>
</div>
)}
{/* Spell Reference - show all available spells for enchanting */}
<div className="mb-6">
<h3 className="text-lg font-semibold mb-3 text-purple-400">📚 Spell Reference</h3>
<p className="text-sm text-gray-400 mb-4">
These spells can be applied to equipment through the enchanting system.
Research enchantment effects in the Skills tab to unlock them for designing.
</p>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{Object.entries(SPELLS_DEF).map(([id, def]) => {
const elemDef = def.elem === 'raw' ? null : ELEMENTS[def.elem];
const isUnlocked = store.unlockedEffects?.includes(`spell_${id}`);
return (
<Card
key={id}
className={`bg-gray-900/80 border-gray-700 ${isUnlocked ? 'border-purple-500/50' : 'opacity-60'}`}
>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-sm" style={{ color: def.elem === 'raw' ? '#60A5FA' : elemDef?.color }}>
{def.name}
</CardTitle>
<div className="flex gap-1">
{def.tier > 0 && <Badge variant="outline" className="text-xs">T{def.tier}</Badge>}
{isUnlocked && <Badge className="bg-purple-900/50 text-purple-300 text-xs">Unlocked</Badge>}
</div>
</div>
</CardHeader>
<CardContent className="space-y-2">
<div className="text-xs text-gray-400">
{def.elem !== 'raw' && <span className="mr-2">{elemDef?.sym} {elemDef?.name}</span>}
<span> {def.dmg} dmg</span>
</div>
<div className="text-xs game-mono" style={{ color: getSpellCostColor(def.cost) }}>
Cost: {formatSpellCost(def.cost)}
</div>
{def.desc && (
<div className="text-xs text-gray-500 italic">{def.desc}</div>
)}
{!isUnlocked && (
<div className="text-xs text-amber-400/70">Research to unlock for enchanting</div>
)}
</CardContent>
</Card>
);
})}
</div>
</div>
</div>
);
}
+345
View File
@@ -0,0 +1,345 @@
'use client';
import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Separator } from '@/components/ui/separator';
import { TooltipProvider } from '@/components/ui/tooltip';
import { Swords, BookOpen, ChevronUp, ChevronDown, RotateCcw, X, Mountain } from 'lucide-react';
import type { GameStore } from '@/lib/game/types';
import { ELEMENTS, GUARDIANS, SPELLS_DEF, SKILLS_DEF } from '@/lib/game/constants';
import { GOLEMS_DEF, getGolemDamage, getGolemAttackSpeed } from '@/lib/game/data/golems';
import { fmt, fmtDec, getFloorElement, canAffordSpellCost } from '@/lib/game/store';
import { getActiveEquipmentSpells, getTotalDPS } from '@/lib/game/computed-stats';
import { formatSpellCost, getSpellCostColor, formatStudyTime } from '@/lib/game/formatting';
import { CraftingProgress, StudyProgress } from '@/components/game';
import { getUnifiedEffects } from '@/lib/game/effects';
interface SpireTabProps {
store: GameStore;
}
export function SpireTab({ store }: SpireTabProps) {
const floorElem = getFloorElement(store.currentFloor);
const floorElemDef = ELEMENTS[floorElem];
const isGuardianFloor = !!GUARDIANS[store.currentFloor];
const currentGuardian = GUARDIANS[store.currentFloor];
const climbDirection = store.climbDirection || 'up';
const clearedFloors = store.clearedFloors || {};
// Check if current floor is cleared (for respawn indicator)
const isFloorCleared = clearedFloors[store.currentFloor];
// Get active equipment spells
const activeEquipmentSpells = getActiveEquipmentSpells(store.equippedInstances, store.equipmentInstances);
// Get upgrade effects and DPS
const upgradeEffects = getUnifiedEffects(store);
const totalDPS = getTotalDPS(store, upgradeEffects, floorElem);
const studySpeedMult = 1; // Base study speed
const canCastSpell = (spellId: string): boolean => {
const spell = SPELLS_DEF[spellId];
if (!spell) return false;
return canAffordSpellCost(spell.cost, store.rawMana, store.elements);
};
return (
<TooltipProvider>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Current Floor Card */}
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 game-panel-title text-xs">Current Floor</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-baseline gap-2">
<span className="text-4xl font-bold game-title" style={{ color: floorElemDef?.color }}>
{store.currentFloor}
</span>
<span className="text-gray-400 text-sm">/ 100</span>
<span className="ml-auto text-sm" style={{ color: floorElemDef?.color }}>
{floorElemDef?.sym} {floorElemDef?.name}
</span>
{isGuardianFloor && (
<Badge className="bg-red-900/50 text-red-300 border-red-600">GUARDIAN</Badge>
)}
</div>
{isGuardianFloor && currentGuardian && (
<div className="text-sm font-semibold game-panel-title" style={{ color: floorElemDef?.color }}>
{currentGuardian.name}
</div>
)}
{/* HP Bar */}
<div className="space-y-1">
<div className="h-3 bg-gray-800 rounded-full overflow-hidden">
<div
className="h-full rounded-full transition-all duration-300"
style={{
width: `${Math.max(0, (store.floorHP / store.floorMaxHP) * 100)}%`,
background: `linear-gradient(90deg, ${floorElemDef?.color}99, ${floorElemDef?.color})`,
boxShadow: `0 0 10px ${floorElemDef?.glow}`,
}}
/>
</div>
<div className="flex justify-between text-xs text-gray-400 game-mono">
<span>{fmt(store.floorHP)} / {fmt(store.floorMaxHP)} HP</span>
<span>DPS: {store.currentAction === 'climb' && activeEquipmentSpells.length > 0 ? fmtDec(totalDPS) : '—'}</span>
</div>
</div>
<Separator className="bg-gray-700" />
{/* Floor Navigation - Direction indicator only */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs text-gray-400">Direction</span>
<div className="flex gap-1">
<Badge variant={climbDirection === 'up' ? 'default' : 'outline'}
className={climbDirection === 'up' ? 'bg-green-600' : ''}>
<ChevronUp className="w-3 h-3 mr-1" />
Up
</Badge>
<Badge variant={climbDirection === 'down' ? 'default' : 'outline'}
className={climbDirection === 'down' ? 'bg-blue-600' : ''}>
<ChevronDown className="w-3 h-3 mr-1" />
Down
</Badge>
</div>
</div>
{isFloorCleared && (
<div className="text-xs text-amber-400 text-center flex items-center justify-center gap-1">
<RotateCcw className="w-3 h-3" />
Floor cleared! Advancing...
</div>
)}
</div>
<Separator className="bg-gray-700" />
<div className="text-sm text-gray-400">
Best: Floor <strong className="text-gray-200">{store.maxFloorReached}</strong>
Pacts: <strong className="text-amber-400">{store.signedPacts.length}</strong>
</div>
</CardContent>
</Card>
{/* Active Spells Card - Shows all spells from equipped weapons */}
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 game-panel-title text-xs">
Active Spells ({activeEquipmentSpells.length})
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{activeEquipmentSpells.length > 0 ? (
<div className="space-y-3">
{activeEquipmentSpells.map(({ spellId, equipmentId }) => {
const spellDef = SPELLS_DEF[spellId];
if (!spellDef) return null;
const spellState = store.equipmentSpellStates?.find(
s => s.spellId === spellId && s.sourceEquipment === equipmentId
);
const progress = spellState?.castProgress || 0;
const canCast = canAffordSpellCost(spellDef.cost, store.rawMana, store.elements);
return (
<div key={`${spellId}-${equipmentId}`} className="p-2 rounded border border-gray-700 bg-gray-800/30">
<div className="flex items-center justify-between mb-1">
<div className="text-sm font-semibold game-panel-title" style={{ color: spellDef.elem === 'raw' ? '#60A5FA' : ELEMENTS[spellDef.elem]?.color }}>
{spellDef.name}
{spellDef.tier === 0 && <Badge className="ml-2 bg-gray-600 text-gray-200 text-xs">Basic</Badge>}
{spellDef.tier >= 4 && <Badge className="ml-2 bg-amber-600 text-amber-100 text-xs">Legendary</Badge>}
</div>
<span className={`text-xs ${canCast ? 'text-green-400' : 'text-red-400'}`}>
{canCast ? '✓' : '✗'}
</span>
</div>
<div className="text-xs text-gray-400 game-mono mb-1">
{fmt(totalDPS)} DPS
<span style={{ color: getSpellCostColor(spellDef.cost) }}>
{' '}{formatSpellCost(spellDef.cost)}
</span>
{' '} {(spellDef.castSpeed || 1).toFixed(1)}/hr
</div>
{/* Cast progress bar when climbing */}
{store.currentAction === 'climb' && (
<div className="space-y-0.5">
<div className="flex justify-between text-xs text-gray-500">
<span>Cast</span>
<span>{(progress * 100).toFixed(0)}%</span>
</div>
<Progress value={Math.min(100, progress * 100)} className="h-1.5 bg-gray-700" />
</div>
)}
{spellDef.effects && spellDef.effects.length > 0 && (
<div className="flex gap-1 flex-wrap mt-1">
{spellDef.effects.map((eff, i) => (
<Badge key={i} variant="outline" className="text-xs py-0">
{eff.type === 'burn' && `🔥 Burn`}
{eff.type === 'freeze' && `❄️ Freeze`}
{eff.type === 'stun' && `⚡ Stun`}
{eff.type === 'armor_pierce' && `🗡️ Pierce`}
{eff.type === 'buff' && `⬆ Buff`}
{eff.type === 'chain' && `⛓️ Chain`}
{eff.type === 'aoe' && `💥 AOE`}
</Badge>
))}
</div>
)}
</div>
);
})}
</div>
) : (
<div className="text-gray-500 text-sm">No spells on equipped weapons. Enchant a staff with spell effects in the Crafting tab.</div>
)}
</CardContent>
</Card>
{/* Summoned Golems Card */}
{store.golemancy.summonedGolems.length > 0 && (
<Card className="bg-gray-900/80 border-amber-600/50">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 game-panel-title text-xs flex items-center gap-2">
<Mountain className="w-4 h-4" />
Active Golems ({store.golemancy.summonedGolems.length})
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{store.golemancy.summonedGolems.map((summoned) => {
const golemDef = GOLEMS_DEF[summoned.golemId];
if (!golemDef) return null;
const elemColor = ELEMENTS[golemDef.baseManaType]?.color || '#888';
const damage = getGolemDamage(summoned.golemId, store.skills);
const attackSpeed = getGolemAttackSpeed(summoned.golemId, store.skills);
return (
<div key={summoned.golemId} className="p-2 rounded border border-gray-700 bg-gray-800/30">
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2">
<Mountain className="w-4 h-4" style={{ color: elemColor }} />
<span className="text-sm font-semibold game-panel-title" style={{ color: elemColor }}>
{golemDef.name}
</span>
</div>
{golemDef.isAoe && (
<Badge variant="outline" className="text-xs">AOE {golemDef.aoeTargets}</Badge>
)}
</div>
<div className="text-xs text-gray-400 game-mono">
{damage} DMG {attackSpeed.toFixed(1)}/hr
🛡 {Math.floor(golemDef.armorPierce * 100)}% Pierce
</div>
{/* Attack progress bar when climbing */}
{store.currentAction === 'climb' && summoned.attackProgress > 0 && (
<div className="space-y-0.5 mt-1">
<div className="flex justify-between text-xs text-gray-500">
<span>Attack</span>
<span>{Math.min(100, (summoned.attackProgress * 100)).toFixed(0)}%</span>
</div>
<Progress value={Math.min(100, summoned.attackProgress * 100)} className="h-1.5 bg-gray-700" />
</div>
)}
</div>
);
})}
</CardContent>
</Card>
)}
{/* Current Study (if any) */}
{store.currentStudyTarget && (
<Card className="bg-gray-900/80 border-purple-600/50 lg:col-span-2">
<CardContent className="pt-4 space-y-3">
<StudyProgress
currentStudyTarget={store.currentStudyTarget}
skills={store.skills}
studySpeedMult={studySpeedMult}
cancelStudy={store.cancelStudy}
/>
{/* Parallel Study Progress */}
{store.parallelStudyTarget && (
<div className="p-3 rounded border border-cyan-600/50 bg-cyan-900/20">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<BookOpen className="w-4 h-4 text-cyan-400" />
<span className="text-sm font-semibold text-cyan-300">
Parallel: {SKILLS_DEF[store.parallelStudyTarget.id]?.name}
{store.parallelStudyTarget.type === 'skill' && ` Lv.${(store.skills[store.parallelStudyTarget.id] || 0) + 1}`}
</span>
</div>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-gray-400 hover:text-white"
onClick={() => store.cancelParallelStudy?.()}
>
<X className="w-4 h-4" />
</Button>
</div>
<Progress value={Math.min(100, (store.parallelStudyTarget.progress / store.parallelStudyTarget.required) * 100)} className="h-2 bg-gray-800" />
<div className="flex justify-between text-xs text-gray-400 mt-1">
<span>{formatStudyTime(store.parallelStudyTarget.progress)} / {formatStudyTime(store.parallelStudyTarget.required)}</span>
<span>50% speed (Parallel Study)</span>
</div>
</div>
)}
</CardContent>
</Card>
)}
{/* Crafting Progress (if any) */}
{(store.designProgress || store.preparationProgress || store.applicationProgress) && (
<Card className="bg-gray-900/80 border-cyan-600/50 lg:col-span-2">
<CardContent className="pt-4">
<CraftingProgress
designProgress={store.designProgress}
preparationProgress={store.preparationProgress}
applicationProgress={store.applicationProgress}
equipmentInstances={store.equipmentInstances}
enchantmentDesigns={store.enchantmentDesigns}
cancelDesign={store.cancelDesign!}
cancelPreparation={store.cancelPreparation!}
pauseApplication={store.pauseApplication!}
resumeApplication={store.resumeApplication!}
cancelApplication={store.cancelApplication!}
/>
</CardContent>
</Card>
)}
{/* Activity Log */}
<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">Activity Log</CardTitle>
</CardHeader>
<CardContent>
<ScrollArea className="h-32">
<div className="space-y-1">
{store.log.slice(0, 20).map((entry, i) => (
<div
key={i}
className={`text-sm ${i === 0 ? 'text-gray-200' : 'text-gray-500'} italic`}
>
{entry}
</div>
))}
</div>
</ScrollArea>
</CardContent>
</Card>
</div>
</TooltipProvider>
);
}
+545
View File
@@ -0,0 +1,545 @@
'use client';
import { ELEMENTS, GUARDIANS, SKILLS_DEF } from '@/lib/game/constants';
import { SKILL_EVOLUTION_PATHS, getTierMultiplier } from '@/lib/game/skill-evolution';
import { hasSpecial, SPECIAL_EFFECTS } from '@/lib/game/effects';
import { fmt, fmtDec, calcDamage } from '@/lib/game/store';
import type { SkillUpgradeChoice, GameStore, UnifiedEffects } from '@/lib/game/types';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import { Droplet, Swords, BookOpen, FlaskConical, Trophy, RotateCcw, Star } from 'lucide-react';
export interface StatsTabProps {
store: GameStore;
upgradeEffects: UnifiedEffects;
maxMana: number;
baseRegen: number;
clickMana: number;
meditationMultiplier: number;
effectiveRegen: number;
incursionStrength: number;
manaCascadeBonus: number;
studySpeedMult: number;
studyCostMult: number;
}
export function StatsTab({
store,
upgradeEffects,
maxMana,
baseRegen,
clickMana,
meditationMultiplier,
effectiveRegen,
incursionStrength,
manaCascadeBonus,
studySpeedMult,
studyCostMult,
}: StatsTabProps) {
// Compute element max
const elemMax = (() => {
const ea = store.skillTiers?.elemAttune || 1;
const tieredSkillId = ea > 1 ? `elemAttune_t${ea}` : 'elemAttune';
const level = store.skills[tieredSkillId] || store.skills.elemAttune || 0;
const tierMult = getTierMultiplier(tieredSkillId);
return 10 + level * 50 * tierMult + (store.prestigeUpgrades.elementalAttune || 0) * 25;
})();
// Get all selected skill upgrades
const getAllSelectedUpgrades = () => {
const upgrades: { skillId: string; upgrade: SkillUpgradeChoice }[] = [];
for (const [skillId, selectedIds] of Object.entries(store.skillUpgrades)) {
const baseSkillId = skillId.includes('_t') ? skillId.split('_t')[0] : skillId;
const path = SKILL_EVOLUTION_PATHS[baseSkillId];
if (!path) continue;
for (const tier of path.tiers) {
if (tier.skillId === skillId) {
for (const upgradeId of selectedIds) {
const upgrade = tier.upgrades.find(u => u.id === upgradeId);
if (upgrade) {
upgrades.push({ skillId, upgrade });
}
}
}
}
}
return upgrades;
};
const selectedUpgrades = getAllSelectedUpgrades();
return (
<div className="space-y-4">
{/* Mana Stats */}
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-blue-400 game-panel-title text-xs flex items-center gap-2">
<Droplet className="w-4 h-4" />
Mana Stats
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-gray-400">Base Max Mana:</span>
<span className="text-gray-200">100</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Mana Well Bonus:</span>
<span className="text-blue-300">
{(() => {
const mw = store.skillTiers?.manaWell || 1;
const tieredSkillId = mw > 1 ? `manaWell_t${mw}` : 'manaWell';
const level = store.skills[tieredSkillId] || store.skills.manaWell || 0;
const tierMult = getTierMultiplier(tieredSkillId);
return `+${fmt(level * 100 * tierMult)} (${level} lvl × 100 × ${tierMult}x tier)`;
})()}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Prestige Mana Well:</span>
<span className="text-blue-300">+{fmt((store.prestigeUpgrades.manaWell || 0) * 500)}</span>
</div>
{upgradeEffects.maxManaBonus > 0 && (
<div className="flex justify-between text-sm">
<span className="text-amber-400">Upgrade Mana Bonus:</span>
<span className="text-amber-300">+{fmt(upgradeEffects.maxManaBonus)}</span>
</div>
)}
{upgradeEffects.maxManaMultiplier > 1 && (
<div className="flex justify-between text-sm">
<span className="text-amber-400">Upgrade Mana Multiplier:</span>
<span className="text-amber-300">×{fmtDec(upgradeEffects.maxManaMultiplier, 2)}</span>
</div>
)}
<div className="flex justify-between text-sm font-semibold border-t border-gray-700 pt-2">
<span className="text-gray-300">Total Max Mana:</span>
<span className="text-blue-400">{fmt(maxMana)}</span>
</div>
</div>
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-gray-400">Base Regen:</span>
<span className="text-gray-200">2/hr</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Mana Flow Bonus:</span>
<span className="text-blue-300">
{(() => {
const mf = store.skillTiers?.manaFlow || 1;
const tieredSkillId = mf > 1 ? `manaFlow_t${mf}` : 'manaFlow';
const level = store.skills[tieredSkillId] || store.skills.manaFlow || 0;
const tierMult = getTierMultiplier(tieredSkillId);
return `+${fmtDec(level * 1 * tierMult)}/hr (${level} lvl × 1 × ${tierMult}x tier)`;
})()}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Mana Spring Bonus:</span>
<span className="text-blue-300">+{(store.skills.manaSpring || 0) * 2}/hr</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Prestige Mana Flow:</span>
<span className="text-blue-300">+{fmtDec((store.prestigeUpgrades.manaFlow || 0) * 0.5)}/hr</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Temporal Echo:</span>
<span className="text-blue-300">×{fmtDec(1 + (store.prestigeUpgrades.temporalEcho || 0) * 0.1, 2)}</span>
</div>
<div className="flex justify-between text-sm font-semibold border-t border-gray-700 pt-2">
<span className="text-gray-300">Base Regen:</span>
<span className="text-blue-400">{fmtDec(baseRegen, 2)}/hr</span>
</div>
{upgradeEffects.regenBonus > 0 && (
<div className="flex justify-between text-sm">
<span className="text-amber-400">Upgrade Regen Bonus:</span>
<span className="text-amber-300">+{fmtDec(upgradeEffects.regenBonus, 2)}/hr</span>
</div>
)}
{upgradeEffects.permanentRegenBonus > 0 && (
<div className="flex justify-between text-sm">
<span className="text-amber-400">Permanent Regen Bonus:</span>
<span className="text-amber-300">+{fmtDec(upgradeEffects.permanentRegenBonus, 2)}/hr</span>
</div>
)}
{upgradeEffects.regenMultiplier > 1 && (
<div className="flex justify-between text-sm">
<span className="text-amber-400">Upgrade Regen Multiplier:</span>
<span className="text-amber-300">×{fmtDec(upgradeEffects.regenMultiplier, 2)}</span>
</div>
)}
</div>
</div>
<Separator className="bg-gray-700 my-3" />
{upgradeEffects.activeUpgrades.length > 0 && (
<>
<div className="mb-2">
<span className="text-xs text-amber-400 game-panel-title">Active Skill Upgrades</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 mb-3">
{upgradeEffects.activeUpgrades.map((upgrade, idx) => (
<div key={idx} className="flex justify-between text-xs bg-gray-800/50 rounded px-2 py-1">
<span className="text-gray-300">{upgrade.name}</span>
<span className="text-gray-400">{upgrade.desc}</span>
</div>
))}
</div>
<Separator className="bg-gray-700 my-3" />
</>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-gray-400">Click Mana Value:</span>
<span className="text-purple-300">+{clickMana}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Mana Tap Bonus:</span>
<span className="text-purple-300">+{store.skills.manaTap || 0}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Mana Surge Bonus:</span>
<span className="text-purple-300">+{(store.skills.manaSurge || 0) * 3}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Mana Overflow:</span>
<span className="text-purple-300">×{fmtDec(1 + (store.skills.manaOverflow || 0) * 0.25, 2)}</span>
</div>
</div>
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-gray-400">Meditation Multiplier:</span>
<span className={`font-semibold ${meditationMultiplier > 1.5 ? 'text-purple-400' : 'text-gray-300'}`}>
{fmtDec(meditationMultiplier, 2)}x
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Effective Regen:</span>
<span className="text-green-400 font-semibold">{fmtDec(effectiveRegen, 2)}/hr</span>
</div>
{incursionStrength > 0 && !hasSpecial(upgradeEffects, SPECIAL_EFFECTS.STEADY_STREAM) && (
<div className="flex justify-between text-sm">
<span className="text-red-400">Incursion Penalty:</span>
<span className="text-red-400">-{Math.round(incursionStrength * 100)}%</span>
</div>
)}
{hasSpecial(upgradeEffects, SPECIAL_EFFECTS.STEADY_STREAM) && incursionStrength > 0 && (
<div className="flex justify-between text-sm">
<span className="text-green-400">Steady Stream:</span>
<span className="text-green-400">Immune to incursion</span>
</div>
)}
{manaCascadeBonus > 0 && (
<div className="flex justify-between text-sm">
<span className="text-cyan-400">Mana Cascade Bonus:</span>
<span className="text-cyan-400">+{fmtDec(manaCascadeBonus, 2)}/hr</span>
</div>
)}
{hasSpecial(upgradeEffects, SPECIAL_EFFECTS.MANA_TORRENT) && store.rawMana > maxMana * 0.75 && (
<div className="flex justify-between text-sm">
<span className="text-cyan-400">Mana Torrent:</span>
<span className="text-cyan-400">+50% regen (high mana)</span>
</div>
)}
{hasSpecial(upgradeEffects, SPECIAL_EFFECTS.DESPERATE_WELLS) && store.rawMana < maxMana * 0.25 && (
<div className="flex justify-between text-sm">
<span className="text-cyan-400">Desperate Wells:</span>
<span className="text-cyan-400">+50% regen (low mana)</span>
</div>
)}
</div>
</div>
</CardContent>
</Card>
{/* Combat Stats */}
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-red-400 game-panel-title text-xs flex items-center gap-2">
<Swords className="w-4 h-4" />
Combat Stats
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-gray-400">Combat Training Bonus:</span>
<span className="text-red-300">+{(store.skills.combatTrain || 0) * 5}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Arcane Fury Multiplier:</span>
<span className="text-red-300">×{fmtDec(1 + (store.skills.arcaneFury || 0) * 0.1, 2)}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Elemental Mastery:</span>
<span className="text-red-300">×{fmtDec(1 + (store.skills.elementalMastery || 0) * 0.15, 2)}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Guardian Bane:</span>
<span className="text-red-300">×{fmtDec(1 + (store.skills.guardianBane || 0) * 0.2, 2)} (vs guardians)</span>
</div>
</div>
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-gray-400">Critical Hit Chance:</span>
<span className="text-amber-300">{((store.skills.precision || 0) * 5)}%</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Critical Multiplier:</span>
<span className="text-amber-300">1.5x</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Spell Echo Chance:</span>
<span className="text-amber-300">{((store.skills.spellEcho || 0) * 10)}%</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Pact Multiplier:</span>
<span className="text-amber-300">×{fmtDec(store.signedPacts.reduce((m, f) => m * (GUARDIANS[f]?.pact || 1), 1), 2)}</span>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Study Stats */}
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-purple-400 game-panel-title text-xs flex items-center gap-2">
<BookOpen className="w-4 h-4" />
Study Stats
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-gray-400">Study Speed:</span>
<span className="text-purple-300">×{fmtDec(studySpeedMult, 2)}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Quick Learner Bonus:</span>
<span className="text-purple-300">+{((store.skills.quickLearner || 0) * 10)}%</span>
</div>
</div>
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-gray-400">Study Cost:</span>
<span className="text-purple-300">{Math.round(studyCostMult * 100)}%</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Focused Mind Bonus:</span>
<span className="text-purple-300">-{((store.skills.focusedMind || 0) * 5)}%</span>
</div>
</div>
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-gray-400">Progress Retention:</span>
<span className="text-purple-300">{Math.round((1 + (store.skills.knowledgeRetention || 0) * 0.2) * 100)}%</span>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Element Stats */}
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-green-400 game-panel-title text-xs flex items-center gap-2">
<FlaskConical className="w-4 h-4" />
Element Stats
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-gray-400">Element Capacity:</span>
<span className="text-green-300">{elemMax}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Elem. Attunement Bonus:</span>
<span className="text-green-300">
{(() => {
const ea = store.skillTiers?.elemAttune || 1;
const tieredSkillId = ea > 1 ? `elemAttune_t${ea}` : 'elemAttune';
const level = store.skills[tieredSkillId] || store.skills.elemAttune || 0;
const tierMult = getTierMultiplier(tieredSkillId);
return `+${level * 50 * tierMult}`;
})()}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Prestige Attunement:</span>
<span className="text-green-300">+{(store.prestigeUpgrades.elementalAttune || 0) * 25}</span>
</div>
</div>
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-gray-400">Unlocked Elements:</span>
<span className="text-green-300">{Object.values(store.elements).filter(e => e.unlocked).length} / {Object.keys(ELEMENTS).length}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Elem. Crafting Bonus:</span>
<span className="text-green-300">×{fmtDec(1 + (store.skills.elemCrafting || 0) * 0.25, 2)}</span>
</div>
</div>
</div>
<Separator className="bg-gray-700 my-3" />
<div className="text-xs text-gray-400 mb-2">Elemental Mana Pools:</div>
<div className="grid grid-cols-4 sm:grid-cols-6 md:grid-cols-8 gap-2">
{Object.entries(store.elements)
.filter(([, state]) => state.unlocked)
.map(([id, state]) => {
const def = ELEMENTS[id];
return (
<div key={id} className="p-2 rounded border border-gray-700 bg-gray-800/50 text-center">
<div className="text-lg">{def?.sym}</div>
<div className="text-xs text-gray-400">{state.current}/{state.max}</div>
</div>
);
})}
</div>
</CardContent>
</Card>
{/* Active Upgrades */}
<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">
<Star className="w-4 h-4" />
Active Skill Upgrades ({selectedUpgrades.length})
</CardTitle>
</CardHeader>
<CardContent>
{selectedUpgrades.length === 0 ? (
<div className="text-gray-500 text-sm">No skill upgrades selected yet. Level skills to 5 or 10 to choose upgrades.</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{selectedUpgrades.map(({ skillId, upgrade }) => (
<div key={upgrade.id} className="p-2 rounded border border-amber-600/30 bg-amber-900/10">
<div className="flex items-center justify-between">
<span className="text-amber-300 text-sm font-semibold">{upgrade.name}</span>
<Badge variant="outline" className="text-xs text-gray-400">
{SKILLS_DEF[skillId]?.name || skillId}
</Badge>
</div>
<div className="text-xs text-gray-400 mt-1">{upgrade.desc}</div>
{upgrade.effect.type === 'multiplier' && (
<div className="text-xs text-green-400 mt-1">
+{Math.round((upgrade.effect.value! - 1) * 100)}% {upgrade.effect.stat}
</div>
)}
{upgrade.effect.type === 'bonus' && (
<div className="text-xs text-blue-400 mt-1">
+{upgrade.effect.value} {upgrade.effect.stat}
</div>
)}
{upgrade.effect.type === 'special' && (
<div className="text-xs text-cyan-400 mt-1">
{upgrade.effect.specialDesc || 'Special effect active'}
</div>
)}
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Pact Bonuses */}
<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" />
Signed Pacts ({store.signedPacts.length}/10)
</CardTitle>
</CardHeader>
<CardContent>
{store.signedPacts.length === 0 ? (
<div className="text-gray-500 text-sm">No pacts signed yet. Defeat guardians to earn pacts.</div>
) : (
<div className="space-y-2">
{store.signedPacts.map((floor) => {
const guardian = GUARDIANS[floor];
if (!guardian) return null;
return (
<div
key={floor}
className="flex items-center justify-between p-2 rounded border"
style={{ borderColor: guardian.color, backgroundColor: `${guardian.color}15` }}
>
<div>
<div className="font-semibold text-sm" style={{ color: guardian.color }}>
{guardian.name}
</div>
<div className="text-xs text-gray-400">Floor {floor}</div>
</div>
<Badge className="bg-amber-900/50 text-amber-300">
{guardian.pact}x multiplier
</Badge>
</div>
);
})}
<div className="flex justify-between text-sm font-semibold border-t border-gray-700 pt-2 mt-2">
<span className="text-gray-300">Combined Pact Multiplier:</span>
<span className="text-amber-400">×{fmtDec(store.signedPacts.reduce((m, f) => m * (GUARDIANS[f]?.pact || 1), 1), 2)}</span>
</div>
</div>
)}
</CardContent>
</Card>
{/* Loop Stats */}
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-purple-400 game-panel-title text-xs flex items-center gap-2">
<RotateCcw className="w-4 h-4" />
Loop Stats
</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-2xl font-bold text-amber-400 game-mono">{store.loopCount}</div>
<div className="text-xs text-gray-400">Loops Completed</div>
</div>
<div className="p-3 bg-gray-800/50 rounded text-center">
<div className="text-2xl font-bold text-purple-400 game-mono">{fmt(store.insight)}</div>
<div className="text-xs text-gray-400">Current Insight</div>
</div>
<div className="p-3 bg-gray-800/50 rounded text-center">
<div className="text-2xl font-bold text-blue-400 game-mono">{fmt(store.totalInsight)}</div>
<div className="text-xs text-gray-400">Total Insight</div>
</div>
<div className="p-3 bg-gray-800/50 rounded text-center">
<div className="text-2xl font-bold text-green-400 game-mono">{store.maxFloorReached}</div>
<div className="text-xs text-gray-400">Max Floor</div>
</div>
</div>
<Separator className="bg-gray-700 my-3" />
<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-gray-300 game-mono">{Object.values(store.spells).filter(s => s.learned).length}</div>
<div className="text-xs text-gray-400">Spells Learned</div>
</div>
<div className="p-3 bg-gray-800/50 rounded text-center">
<div className="text-xl font-bold text-gray-300 game-mono">{Object.values(store.skills).reduce((a, b) => a + b, 0)}</div>
<div className="text-xs text-gray-400">Total Skill Levels</div>
</div>
<div className="p-3 bg-gray-800/50 rounded text-center">
<div className="text-xl font-bold text-gray-300 game-mono">{fmt(store.totalManaGathered)}</div>
<div className="text-xs text-gray-400">Total Mana Gathered</div>
</div>
<div className="p-3 bg-gray-800/50 rounded text-center">
<div className="text-xl font-bold text-gray-300 game-mono">{store.memorySlots}</div>
<div className="text-xs text-gray-400">Memory Slots</div>
</div>
</div>
</CardContent>
</Card>
</div>
);
}
+73
View File
@@ -0,0 +1,73 @@
'use client';
import { SKILLS_DEF } from '@/lib/game/constants';
import { formatStudyTime } from '@/lib/game/formatting';
import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';
import type { StudyTarget } from '@/lib/game/types';
export interface StudyProgressProps {
currentStudyTarget: StudyTarget;
skills: Record<string, number>;
studySpeedMult: number;
cancelStudy: () => void;
}
export function StudyProgress({
currentStudyTarget,
skills,
studySpeedMult,
cancelStudy
}: StudyProgressProps) {
const { id, progress, required } = currentStudyTarget;
// Get skill name
const baseId = id.includes('_t') ? id.split('_t')[0] : id;
const skillDef = SKILLS_DEF[baseId];
const skillName = skillDef?.name || id;
// Get current level
const currentLevel = skills[id] || skills[baseId] || 0;
// Calculate progress percentage
const progressPercent = Math.min((progress / required) * 100, 100);
// Estimated completion
const remainingHours = required - progress;
const effectiveSpeed = studySpeedMult;
const realTimeRemaining = remainingHours / effectiveSpeed;
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<div>
<span className="text-purple-300 font-semibold">{skillName}</span>
<span className="text-gray-400 ml-2">
Level {currentLevel} {currentLevel + 1}
</span>
</div>
<Button
size="sm"
variant="destructive"
onClick={cancelStudy}
className="text-xs"
>
Cancel
</Button>
</div>
<div className="space-y-1">
<div className="flex justify-between text-xs text-gray-400">
<span>{formatStudyTime(progress)} / {formatStudyTime(required)}</span>
<span>{progressPercent.toFixed(1)}%</span>
</div>
<Progress value={progressPercent} className="h-2" />
{studySpeedMult > 1 && (
<div className="text-xs text-green-400">
Speed: {(studySpeedMult * 100).toFixed(0)}% ETA: {formatStudyTime(realTimeRemaining)}
</div>
)}
</div>
</div>
);
}
+116
View File
@@ -0,0 +1,116 @@
'use client';
import { SKILLS_DEF } from '@/lib/game/constants';
import { getUpgradesForSkillAtMilestone, getTierMultiplier } from '@/lib/game/skill-evolution';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import type { SkillUpgradeChoice } from '@/lib/game/types';
export interface UpgradeDialogProps {
open: boolean;
skillId: string | null;
milestone: 5 | 10;
pendingSelections: string[];
available: SkillUpgradeChoice[];
alreadySelected: string[];
onToggle: (upgradeId: string) => void;
onConfirm: () => void;
onCancel: () => void;
onOpenChange: (open: boolean) => void;
}
export function UpgradeDialog({
open,
skillId,
milestone,
pendingSelections,
available,
alreadySelected,
onToggle,
onConfirm,
onCancel,
onOpenChange,
}: UpgradeDialogProps) {
if (!skillId) return null;
// Get skill name
const baseId = skillId.includes('_t') ? skillId.split('_t')[0] : skillId;
const skillDef = SKILLS_DEF[baseId];
const skillName = skillDef?.name || skillId;
const currentSelections = pendingSelections.length > 0 ? pendingSelections : alreadySelected;
const canConfirm = currentSelections.length === 2;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md bg-gray-900 border-purple-600/50">
<DialogHeader>
<DialogTitle className="text-amber-400">
Level {milestone} Milestone: {skillName}
</DialogTitle>
<DialogDescription className="text-gray-400">
Choose 2 upgrades for this skill. These choices are permanent.
</DialogDescription>
</DialogHeader>
<div className="space-y-2 py-4">
{available.map((upgrade) => {
const isSelected = currentSelections.includes(upgrade.id);
const canSelect = isSelected || currentSelections.length < 2;
return (
<div
key={upgrade.id}
onClick={() => canSelect && onToggle(upgrade.id)}
className={`p-3 rounded border cursor-pointer transition-all ${
isSelected
? 'border-amber-500 bg-amber-900/30'
: canSelect
? 'border-gray-700 hover:border-gray-500 bg-gray-800/30'
: 'border-gray-800 bg-gray-900/30 opacity-50 cursor-not-allowed'
}`}
>
<div className="flex items-center justify-between">
<span className={`font-semibold text-sm ${isSelected ? 'text-amber-300' : 'text-gray-200'}`}>
{upgrade.name}
</span>
{isSelected && (
<Badge className="bg-amber-600/50 text-amber-200 text-xs">Selected</Badge>
)}
</div>
<p className="text-xs text-gray-400 mt-1">{upgrade.desc}</p>
</div>
);
})}
{available.length === 0 && (
<div className="text-center text-gray-500 py-4">
No upgrades available at this milestone.
</div>
)}
</div>
<DialogFooter className="flex gap-2 sm:gap-0">
<Button variant="outline" onClick={onCancel}>
Cancel
</Button>
<Button
className="bg-amber-600 hover:bg-amber-700"
disabled={!canConfirm}
onClick={onConfirm}
>
Confirm ({currentSelections.length}/2)
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+15
View File
@@ -0,0 +1,15 @@
// ─── Tab Components Index ──────────────────────────────────────────────────────
// Re-exports all tab components for cleaner imports
export { CraftingTab } from './CraftingTab';
export { SpireTab } from './SpireTab';
export { SpellsTab } from './SpellsTab';
export { LabTab } from './LabTab';
export { SkillsTab } from './SkillsTab';
export { StatsTab } from './StatsTab';
export { EquipmentTab } from './EquipmentTab';
export { AttunementsTab } from './AttunementsTab';
export { DebugTab } from './DebugTab';
export { LootTab } from './LootTab';
export { AchievementsTab } from './AchievementsTab';
export { GolemancyTab } from './GolemancyTab';
+47
View File
@@ -0,0 +1,47 @@
import type { SpellCost } from '@/lib/game/types';
import { Flame, Droplet, Wind, Mountain, Sun, Moon, Leaf, Skull } from 'lucide-react';
// Format spell cost for display
export function formatSpellCost(cost: SpellCost): string {
if (cost.type === 'raw') {
return `${cost.amount} raw`;
}
const elemDef = ELEMENTS[cost.element || ''];
return `${cost.amount} ${elemDef?.sym || '?'}`;
}
// Format time (hour to HH:MM)
export function formatTime(hour: number): string {
const h = Math.floor(hour);
const m = Math.floor((hour % 1) * 60);
return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}`;
}
// Format study time
export function formatStudyTime(hours: number): string {
if (hours < 1) return `${Math.round(hours * 60)}m`;
return `${hours.toFixed(1)}h`;
}
// Element icons mapping
export const ELEMENT_ICONS: Record<string, typeof Flame> = {
fire: Flame,
water: Droplet,
wind: Wind,
earth: Mountain,
light: Sun,
shadow: Moon,
nature: Leaf,
death: Skull,
};
// Import ELEMENTS at the bottom to avoid circular deps
import { ELEMENTS } from '@/lib/game/constants';
// Get cost color
export function getSpellCostColor(cost: SpellCost): string {
if (cost.type === 'raw') {
return '#60A5FA'; // Blue for raw mana
}
return ELEMENTS[cost.element || '']?.color || '#9CA3AF';
}