refactor: Major codebase refactoring for maintainability
All checks were successful
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 3m4s
All checks were successful
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 3m4s
Store refactoring (2138 → 1651 lines, 23% reduction): - Extract computed-stats.ts with 18 utility functions - Extract navigation-slice.ts for floor navigation actions - Extract study-slice.ts for study-related actions - Move fmt/fmtDec to computed-stats, re-export from formatting Page refactoring (2554 → 1695 lines, 34% reduction): - Use existing SpireTab component instead of inline render - Extract ActionButtons component - Extract CalendarDisplay component - Extract CraftingProgress component - Extract StudyProgress component - Extract ManaDisplay component - Extract TimeDisplay component - Create tabs/index.ts for cleaner exports This improves code organization and makes the codebase more maintainable.
This commit is contained in:
87
src/components/game/ActionButtons.tsx
Normal file
87
src/components/game/ActionButtons.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Sparkles, Swords, BookOpen, FlaskConical, Target } 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 },
|
||||
{ id: 'convert', label: 'Convert', icon: FlaskConical },
|
||||
];
|
||||
|
||||
const hasDesignProgress = designProgress !== null;
|
||||
const hasPrepProgress = preparationProgress !== null;
|
||||
const hasAppProgress = applicationProgress !== null;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="grid grid-cols-2 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>
|
||||
);
|
||||
}
|
||||
44
src/components/game/CalendarDisplay.tsx
Normal file
44
src/components/game/CalendarDisplay.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
'use client';
|
||||
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { MAX_DAY, INCURSION_START_DAY } from '@/lib/game/constants';
|
||||
|
||||
interface CalendarDisplayProps {
|
||||
currentDay: number;
|
||||
}
|
||||
|
||||
export function CalendarDisplay({ currentDay }: CalendarDisplayProps) {
|
||||
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 < currentDay) {
|
||||
dayClass += 'bg-blue-900/30 border-blue-800/50 text-blue-400';
|
||||
} else if (d === currentDay) {
|
||||
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}</>;
|
||||
}
|
||||
161
src/components/game/CraftingProgress.tsx
Normal file
161
src/components/game/CraftingProgress.tsx
Normal 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;
|
||||
}
|
||||
63
src/components/game/ManaDisplay.tsx
Normal file
63
src/components/game/ManaDisplay.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Zap } from 'lucide-react';
|
||||
import { fmt, fmtDec } from '@/lib/game/store';
|
||||
|
||||
interface ManaDisplayProps {
|
||||
rawMana: number;
|
||||
maxMana: number;
|
||||
effectiveRegen: number;
|
||||
meditationMultiplier: number;
|
||||
clickMana: number;
|
||||
isGathering: boolean;
|
||||
onGatherStart: () => void;
|
||||
onGatherEnd: () => void;
|
||||
}
|
||||
|
||||
export function ManaDisplay({
|
||||
rawMana,
|
||||
maxMana,
|
||||
effectiveRegen,
|
||||
meditationMultiplier,
|
||||
clickMana,
|
||||
isGathering,
|
||||
onGatherStart,
|
||||
onGatherEnd,
|
||||
}: ManaDisplayProps) {
|
||||
return (
|
||||
<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(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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
57
src/components/game/StudyProgress.tsx
Normal file
57
src/components/game/StudyProgress.tsx
Normal 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
src/components/game/TimeDisplay.tsx
Normal file
51
src/components/game/TimeDisplay.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,17 @@
|
||||
// ─── 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';
|
||||
|
||||
// UI components
|
||||
export { ActionButtons } from './ActionButtons';
|
||||
export { CalendarDisplay } from './CalendarDisplay';
|
||||
export { ComboMeter } from './ComboMeter';
|
||||
export { CraftingProgress } from './CraftingProgress';
|
||||
export { StudyProgress } from './StudyProgress';
|
||||
export { ManaDisplay } from './ManaDisplay';
|
||||
export { TimeDisplay } from './TimeDisplay';
|
||||
|
||||
@@ -6,13 +6,47 @@ 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 { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { Swords, Sparkles, BookOpen, ChevronUp, ChevronDown, ArrowUp, ArrowDown, RefreshCw } from 'lucide-react';
|
||||
import type { GameState, GameAction } from '@/lib/game/types';
|
||||
import { ELEMENTS, GUARDIANS, SPELLS_DEF, MAX_DAY, INCURSION_START_DAY } from '@/lib/game/constants';
|
||||
import { TooltipProvider } from '@/components/ui/tooltip';
|
||||
import { Swords, BookOpen, ChevronUp, ChevronDown, RotateCcw, X } from 'lucide-react';
|
||||
import type { GameState, GameAction, EquipmentSpellState, StudyTarget } from '@/lib/game/types';
|
||||
import { ELEMENTS, GUARDIANS, SPELLS_DEF, SKILLS_DEF } from '@/lib/game/constants';
|
||||
import { fmt, fmtDec, getFloorElement, calcDamage, canAffordSpellCost } from '@/lib/game/store';
|
||||
import { formatSpellCost, getSpellCostColor, formatStudyTime } from '@/lib/game/formatting';
|
||||
import type { ComputedEffects } from '@/lib/game/effects';
|
||||
import { ComboMeter, CraftingProgress, StudyProgress } from '@/components/game';
|
||||
import { ENCHANTMENT_EFFECTS } from '@/lib/game/data/enchantment-effects';
|
||||
import { EQUIPMENT_TYPES } from '@/lib/game/data/equipment';
|
||||
|
||||
// Helper to get active spells from equipped caster weapons
|
||||
function getActiveEquipmentSpells(
|
||||
equippedInstances: Record<string, string | null>,
|
||||
equipmentInstances: Record<string, { instanceId: string; typeId: string; enchantments: Array<{ effectId: string }> }>
|
||||
): Array<{ spellId: string; equipmentId: string }> {
|
||||
const spells: Array<{ spellId: string; equipmentId: string }> = [];
|
||||
const weaponSlots = ['mainHand', 'offHand'] as const;
|
||||
|
||||
for (const slot of weaponSlots) {
|
||||
const instanceId = equippedInstances[slot];
|
||||
if (!instanceId) continue;
|
||||
|
||||
const instance = equipmentInstances[instanceId];
|
||||
if (!instance) continue;
|
||||
|
||||
const equipType = EQUIPMENT_TYPES[instance.typeId];
|
||||
if (!equipType || equipType.category !== 'caster') continue;
|
||||
|
||||
for (const ench of instance.enchantments) {
|
||||
const effectDef = ENCHANTMENT_EFFECTS[ench.effectId];
|
||||
if (effectDef?.effect.type === 'spell' && effectDef.effect.spellId) {
|
||||
spells.push({
|
||||
spellId: effectDef.effect.spellId,
|
||||
equipmentId: instanceId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return spells;
|
||||
}
|
||||
|
||||
interface SpireTabProps {
|
||||
store: GameState & {
|
||||
@@ -22,80 +56,42 @@ interface SpireTabProps {
|
||||
cancelParallelStudy: () => void;
|
||||
setClimbDirection: (direction: 'up' | 'down') => void;
|
||||
changeFloor: (direction: 'up' | 'down') => void;
|
||||
cancelDesign?: () => void;
|
||||
cancelPreparation?: () => void;
|
||||
pauseApplication?: () => void;
|
||||
resumeApplication?: () => void;
|
||||
cancelApplication?: () => void;
|
||||
};
|
||||
upgradeEffects: ComputedEffects;
|
||||
maxMana: number;
|
||||
effectiveRegen: number;
|
||||
incursionStrength: number;
|
||||
dps: number;
|
||||
totalDPS: number;
|
||||
studySpeedMult: number;
|
||||
formatTime: (hour: number) => string;
|
||||
incursionStrength: number;
|
||||
}
|
||||
|
||||
export function SpireTab({
|
||||
store,
|
||||
upgradeEffects,
|
||||
maxMana,
|
||||
effectiveRegen,
|
||||
incursionStrength,
|
||||
dps,
|
||||
totalDPS,
|
||||
studySpeedMult,
|
||||
formatTime,
|
||||
incursionStrength,
|
||||
}: SpireTabProps) {
|
||||
const floorElem = getFloorElement(store.currentFloor);
|
||||
const floorElemDef = ELEMENTS[floorElem];
|
||||
const isGuardianFloor = !!GUARDIANS[store.currentFloor];
|
||||
const currentGuardian = GUARDIANS[store.currentFloor];
|
||||
const activeSpellDef = SPELLS_DEF[store.activeSpell];
|
||||
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);
|
||||
|
||||
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}
|
||||
{isSkill && ` Lv.${(store.skills[target.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.cancelStudy()}
|
||||
>
|
||||
✕
|
||||
</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 (
|
||||
<TooltipProvider>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
@@ -138,16 +134,16 @@ export function SpireTab({
|
||||
</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>
|
||||
<span>DPS: {store.currentAction === 'climb' && activeEquipmentSpells.length > 0 ? fmtDec(totalDPS) : '—'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator className="bg-gray-700" />
|
||||
|
||||
{/* Floor Navigation */}
|
||||
{/* Floor Navigation Controls */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-gray-400">Direction</span>
|
||||
<span className="text-xs text-gray-400">Auto-Direction</span>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant={climbDirection === 'up' ? 'default' : 'outline'}
|
||||
@@ -155,7 +151,7 @@ export function SpireTab({
|
||||
className={`h-7 px-2 ${climbDirection === 'up' ? 'bg-green-600 hover:bg-green-700' : 'bg-gray-800/50'}`}
|
||||
onClick={() => store.setClimbDirection('up')}
|
||||
>
|
||||
<ArrowUp className="w-4 h-4 mr-1" />
|
||||
<ChevronUp className="w-4 h-4 mr-1" />
|
||||
Up
|
||||
</Button>
|
||||
<Button
|
||||
@@ -164,7 +160,7 @@ export function SpireTab({
|
||||
className={`h-7 px-2 ${climbDirection === 'down' ? 'bg-blue-600 hover:bg-blue-700' : 'bg-gray-800/50'}`}
|
||||
onClick={() => store.setClimbDirection('down')}
|
||||
>
|
||||
<ArrowDown className="w-4 h-4 mr-1" />
|
||||
<ChevronDown className="w-4 h-4 mr-1" />
|
||||
Down
|
||||
</Button>
|
||||
</div>
|
||||
@@ -179,7 +175,7 @@ export function SpireTab({
|
||||
onClick={() => store.changeFloor('down')}
|
||||
>
|
||||
<ChevronDown className="w-4 h-4 mr-1" />
|
||||
Go Down
|
||||
Descend
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -189,13 +185,14 @@ export function SpireTab({
|
||||
onClick={() => store.changeFloor('up')}
|
||||
>
|
||||
<ChevronUp className="w-4 h-4 mr-1" />
|
||||
Go Up
|
||||
Ascend
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isFloorCleared && (
|
||||
<div className="text-xs text-amber-400 text-center">
|
||||
⚠️ Floor will respawn when you return
|
||||
<div className="text-xs text-amber-400 text-center flex items-center justify-center gap-1">
|
||||
<RotateCcw className="w-3 h-3" />
|
||||
Floor will respawn when you leave and return
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -209,51 +206,74 @@ export function SpireTab({
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Active Spell 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 Spell</CardTitle>
|
||||
<CardTitle className="text-amber-400 game-panel-title text-xs">
|
||||
Active Spells ({activeEquipmentSpells.length})
|
||||
</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>
|
||||
{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(calcDamage(store, spellId, floorElem))} dmg •
|
||||
<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 === 'lifesteal' && `🩸 ${Math.round(eff.value * 100)}%`}
|
||||
{eff.type === 'burn' && `🔥 Burn`}
|
||||
{eff.type === 'freeze' && `❄️ Freeze`}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<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>
|
||||
) : (
|
||||
<div className="text-gray-500 text-sm">No spells on equipped weapons. Enchant a staff with spell effects.</div>
|
||||
)}
|
||||
|
||||
{incursionStrength > 0 && (
|
||||
@@ -267,11 +287,67 @@ export function SpireTab({
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Combo Meter - Shows when climbing or has active combo */}
|
||||
<ComboMeter combo={store.combo} isClimbing={store.currentAction === 'climb'} />
|
||||
|
||||
{/* 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()}
|
||||
<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>
|
||||
)}
|
||||
|
||||
7
src/components/game/tabs/index.ts
Normal file
7
src/components/game/tabs/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
// ─── 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';
|
||||
Reference in New Issue
Block a user