Compare commits
3 Commits
ee0268d9f6
...
2ca5d8b7f8
| Author | SHA1 | Date | |
|---|---|---|---|
| 2ca5d8b7f8 | |||
| 1d2dce75cc | |||
| 4f4cbeb527 |
878
src/app/page.tsx
878
src/app/page.tsx
@@ -2,9 +2,10 @@
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useGameStore, useGameLoop, fmt, fmtDec, getFloorElement, computeMaxMana, computeRegen, computeClickMana, calcDamage, getMeditationBonus, getIncursionStrength, canAffordSpellCost } from '@/lib/game/store';
|
||||
import { ELEMENTS, GUARDIANS, SPELLS_DEF, SKILLS_DEF, PRESTIGE_DEF, MAX_DAY, INCURSION_START_DAY, MANA_PER_ELEMENT, SKILL_CATEGORIES, getStudySpeedMultiplier, getStudyCostMultiplier, ELEMENT_OPPOSITES, HOURS_PER_TICK, TICK_MS } from '@/lib/game/constants';
|
||||
import { ELEMENTS, GUARDIANS, SPELLS_DEF, SKILLS_DEF, PRESTIGE_DEF, SKILL_CATEGORIES, getStudySpeedMultiplier, getStudyCostMultiplier, ELEMENT_OPPOSITES, HOURS_PER_TICK, TICK_MS } from '@/lib/game/constants';
|
||||
import { ENCHANTMENT_EFFECTS } from '@/lib/game/data/enchantment-effects';
|
||||
import { EQUIPMENT_TYPES } from '@/lib/game/data/equipment';
|
||||
import { LOOT_DROPS } from '@/lib/game/data/loot-drops';
|
||||
import { SKILL_EVOLUTION_PATHS, getUpgradesForSkillAtMilestone, getNextTierSkill, getTierMultiplier } from '@/lib/game/skill-evolution';
|
||||
import { getUnifiedEffects, hasSpecial, SPECIAL_EFFECTS } from '@/lib/game/effects';
|
||||
import type { SkillUpgradeChoice } from '@/lib/game/types';
|
||||
@@ -18,11 +19,11 @@ import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
||||
import { Sparkles, Swords, BookOpen, FlaskConical, Trophy, RotateCcw, Pause, Play, Zap, Clock, Target, Flame, Droplet, Wind, Mountain, Sun, Moon, Leaf, Skull, Brain, Link, Wind as Force, Droplets, TreeDeciduous, Hourglass, Gem, Star, CircleDot, X, Circle, BarChart3, Award, Package, Heart } from 'lucide-react';
|
||||
import { Sparkles, Swords, BookOpen, FlaskConical, Trophy, RotateCcw, Pause, Play, Zap, Clock, Target, Flame, Droplet, Wind, Mountain, Sun, Moon, Leaf, Skull, Brain, Link, Wind as Force, Droplets, TreeDeciduous, Hourglass, Gem, Star, CircleDot, X, Circle, BarChart3, Award, Package, Heart, ChevronUp, ChevronDown } from 'lucide-react';
|
||||
import type { GameAction } from '@/lib/game/types';
|
||||
import { CraftingTab } from '@/components/game/tabs/CraftingTab';
|
||||
import { CraftingTab, SpireTab, SpellsTab, LabTab } from '@/components/game/tabs';
|
||||
import { FamiliarTab } from '@/components/game/tabs/FamiliarTab';
|
||||
import { ComboMeter } from '@/components/game/ComboMeter';
|
||||
import { ComboMeter, ActionButtons, CalendarDisplay, CraftingProgress, StudyProgress, ManaDisplay, TimeDisplay } from '@/components/game';
|
||||
import { LootInventoryDisplay } from '@/components/game/LootInventory';
|
||||
import { AchievementsDisplay } from '@/components/game/AchievementsDisplay';
|
||||
import { ACHIEVEMENTS } from '@/lib/game/data/achievements';
|
||||
@@ -236,279 +237,6 @@ export default function ManaLoopGame() {
|
||||
// Format time (use shared utility)
|
||||
const formatTime = formatHour;
|
||||
|
||||
// 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;
|
||||
};
|
||||
|
||||
// Action buttons
|
||||
const renderActionButtons = () => {
|
||||
const actions: { id: GameAction; label: string; icon: typeof Swords; disabled?: boolean }[] = [
|
||||
{ id: 'meditate', label: 'Meditate', icon: Sparkles },
|
||||
{ id: 'climb', label: 'Climb', icon: Swords },
|
||||
{ id: 'study', label: 'Study', icon: BookOpen },
|
||||
{ id: 'convert', label: 'Convert', icon: FlaskConical },
|
||||
];
|
||||
|
||||
// Add crafting actions if player has progress
|
||||
const hasDesignProgress = store.designProgress !== null;
|
||||
const hasPrepProgress = store.preparationProgress !== null;
|
||||
const hasAppProgress = store.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={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>
|
||||
|
||||
{/* Crafting actions row - shown when there's active crafting progress */}
|
||||
{(hasDesignProgress || hasPrepProgress || hasAppProgress) && (
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<Button
|
||||
variant={store.currentAction === 'design' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
disabled={!hasDesignProgress}
|
||||
className={`h-9 ${store.currentAction === 'design' ? 'bg-purple-600 hover:bg-purple-700' : 'bg-gray-800/50 hover:bg-gray-700/50 border-gray-600'}`}
|
||||
onClick={() => hasDesignProgress && store.setAction('design')}
|
||||
>
|
||||
<Target className="w-4 h-4 mr-1" />
|
||||
Design
|
||||
</Button>
|
||||
<Button
|
||||
variant={store.currentAction === 'prepare' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
disabled={!hasPrepProgress}
|
||||
className={`h-9 ${store.currentAction === 'prepare' ? 'bg-purple-600 hover:bg-purple-700' : 'bg-gray-800/50 hover:bg-gray-700/50 border-gray-600'}`}
|
||||
onClick={() => hasPrepProgress && store.setAction('prepare')}
|
||||
>
|
||||
<FlaskConical className="w-4 h-4 mr-1" />
|
||||
Prepare
|
||||
</Button>
|
||||
<Button
|
||||
variant={store.currentAction === 'enchant' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
disabled={!hasAppProgress}
|
||||
className={`h-9 ${store.currentAction === 'enchant' ? 'bg-purple-600 hover:bg-purple-700' : 'bg-gray-800/50 hover:bg-gray-700/50 border-gray-600'}`}
|
||||
onClick={() => hasAppProgress && store.setAction('enchant')}
|
||||
>
|
||||
<Sparkles className="w-4 h-4 mr-1" />
|
||||
Enchant
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Render crafting progress
|
||||
const renderCraftingProgress = () => {
|
||||
const progressSections: React.ReactNode[] = [];
|
||||
|
||||
// Design progress
|
||||
if (store.designProgress) {
|
||||
const progressPct = Math.min(100, (store.designProgress.progress / store.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={() => store.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(store.designProgress.progress)} / {formatStudyTime(store.designProgress.required)}</span>
|
||||
<span>Design Time</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Preparation progress
|
||||
if (store.preparationProgress) {
|
||||
const progressPct = Math.min(100, (store.preparationProgress.progress / store.preparationProgress.required) * 100);
|
||||
const instance = store.equipmentInstances[store.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={() => store.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(store.preparationProgress.progress)} / {formatStudyTime(store.preparationProgress.required)}</span>
|
||||
<span>Mana spent: {fmt(store.preparationProgress.manaCostPaid)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Application progress
|
||||
if (store.applicationProgress) {
|
||||
const progressPct = Math.min(100, (store.applicationProgress.progress / store.applicationProgress.required) * 100);
|
||||
const instance = store.equipmentInstances[store.applicationProgress.equipmentInstanceId];
|
||||
const design = store.enchantmentDesigns.find(d => d.id === store.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">
|
||||
{store.applicationProgress.paused ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 text-green-400 hover:text-green-300"
|
||||
onClick={() => store.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={() => store.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={() => store.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(store.applicationProgress.progress)} / {formatStudyTime(store.applicationProgress.required)}</span>
|
||||
<span>Mana/hr: {fmt(store.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;
|
||||
};
|
||||
|
||||
// Render current 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 ? SKILLS_DEF[target.id] : SPELLS_DEF[target.id];
|
||||
const currentLevel = isSkill ? (store.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={() => 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>
|
||||
);
|
||||
};
|
||||
|
||||
// Check if spell can be cast
|
||||
const canCastSpell = (spellId: string): boolean => {
|
||||
const spell = SPELLS_DEF[spellId];
|
||||
@@ -516,551 +244,8 @@ export default function ManaLoopGame() {
|
||||
return canAffordSpellCost(spell.cost, store.rawMana, store.elements);
|
||||
};
|
||||
|
||||
// Spire Tab
|
||||
const renderSpireTab = () => (
|
||||
<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" />
|
||||
|
||||
<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(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>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-gray-500 text-sm">No spells on equipped weapons. Enchant a staff with spell effects.</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>
|
||||
|
||||
{/* 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()}
|
||||
|
||||
{/* 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">
|
||||
{renderCraftingProgress()}
|
||||
</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>
|
||||
);
|
||||
|
||||
// Spells Tab
|
||||
const renderSpellsTab = () => {
|
||||
// Get spells from equipment
|
||||
const equipmentSpellIds = store.getEquipmentSpells ? store.getEquipmentSpells() : [];
|
||||
const equipmentSpells = equipmentSpellIds.map(id => ({
|
||||
id,
|
||||
def: SPELLS_DEF[id],
|
||||
source: store.equippedInstances ?
|
||||
Object.entries(store.equippedInstances)
|
||||
.filter(([, instId]) => instId && store.equipmentInstances[instId]?.enchantments.some(e => {
|
||||
const effectDef = ENCHANTMENT_EFFECTS[e.effectId];
|
||||
return effectDef?.effect.type === 'spell' && effectDef.effect.spellId === id;
|
||||
}))
|
||||
.map(([slot]) => slot)
|
||||
.join(', ') : ''
|
||||
})).filter(s => s.def);
|
||||
|
||||
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>
|
||||
|
||||
{equipmentSpells.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{equipmentSpells.map(({ id, def, source }) => {
|
||||
const isActive = store.activeSpell === id;
|
||||
const canCast = canAffordSpellCost(def.cost, store.rawMana, store.elements);
|
||||
const elemDef = def.elem === 'raw' ? null : ELEMENTS[def.elem];
|
||||
|
||||
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: {source}</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>
|
||||
);
|
||||
};
|
||||
|
||||
// Lab Tab
|
||||
const renderLabTab = () => (
|
||||
<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)
|
||||
.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>
|
||||
);
|
||||
|
||||
// Check if skill has milestone available
|
||||
const hasMilestoneUpgrade = (skillId: string, level: number): { milestone: 5 | 10; hasUpgrades: boolean; selectedCount: number } | null => {
|
||||
@@ -1219,7 +404,12 @@ export default function ManaLoopGame() {
|
||||
{store.currentStudyTarget && store.currentStudyTarget.type === 'skill' && (
|
||||
<Card className="bg-gray-900/80 border-purple-600/50">
|
||||
<CardContent className="pt-4">
|
||||
{renderStudyProgress()}
|
||||
<StudyProgress
|
||||
currentStudyTarget={store.currentStudyTarget}
|
||||
skills={store.skills}
|
||||
studySpeedMult={studySpeedMult}
|
||||
cancelStudy={store.cancelStudy}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
@@ -2176,7 +1366,7 @@ export default function ManaLoopGame() {
|
||||
|
||||
{/* Calendar */}
|
||||
<div className="mt-2 flex gap-1 overflow-x-auto pb-1">
|
||||
{renderCalendar()}
|
||||
<CalendarDisplay currentDay={store.day} />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -2221,7 +1411,13 @@ export default function ManaLoopGame() {
|
||||
<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>
|
||||
{renderActionButtons()}
|
||||
<ActionButtons
|
||||
currentAction={store.currentAction}
|
||||
designProgress={store.designProgress}
|
||||
preparationProgress={store.preparationProgress}
|
||||
applicationProgress={store.applicationProgress}
|
||||
setAction={store.setAction}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -2263,15 +1459,20 @@ export default function ManaLoopGame() {
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="spire">
|
||||
{renderSpireTab()}
|
||||
<SpireTab
|
||||
store={store}
|
||||
totalDPS={totalDPS}
|
||||
studySpeedMult={studySpeedMult}
|
||||
incursionStrength={incursionStrength}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="spells">
|
||||
{renderSpellsTab()}
|
||||
<SpellsTab store={store} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="lab">
|
||||
{renderLabTab()}
|
||||
<LabTab store={store} />
|
||||
</TabsContent>
|
||||
<TabsContent value="crafting">
|
||||
<CraftingTab
|
||||
@@ -2281,10 +1482,12 @@ export default function ManaLoopGame() {
|
||||
designProgress={store.designProgress}
|
||||
preparationProgress={store.preparationProgress}
|
||||
applicationProgress={store.applicationProgress}
|
||||
equipmentCraftingProgress={store.equipmentCraftingProgress}
|
||||
rawMana={store.rawMana}
|
||||
skills={store.skills}
|
||||
currentAction={store.currentAction}
|
||||
unlockedEffects={store.unlockedEffects || ['spell_manaBolt']}
|
||||
lootInventory={store.lootInventory}
|
||||
startDesigningEnchantment={store.startDesigningEnchantment}
|
||||
cancelDesign={store.cancelDesign}
|
||||
saveDesign={store.saveDesign}
|
||||
@@ -2297,6 +1500,9 @@ export default function ManaLoopGame() {
|
||||
cancelApplication={store.cancelApplication}
|
||||
disenchantEquipment={store.disenchantEquipment}
|
||||
getAvailableCapacity={store.getAvailableCapacity}
|
||||
startCraftingEquipment={store.startCraftingEquipment}
|
||||
cancelEquipmentCrafting={store.cancelEquipmentCrafting}
|
||||
deleteMaterial={store.deleteMaterial}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
@@ -2310,7 +1516,25 @@ export default function ManaLoopGame() {
|
||||
|
||||
<TabsContent value="loot">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<LootInventoryDisplay inventory={store.lootInventory} />
|
||||
<LootInventoryDisplay
|
||||
inventory={store.lootInventory}
|
||||
elements={store.elements}
|
||||
equipmentInstances={store.equipmentInstances}
|
||||
onDeleteMaterial={(id, amount) => {
|
||||
// Remove material from inventory
|
||||
const newMaterials = { ...store.lootInventory.materials };
|
||||
delete newMaterials[id];
|
||||
store.updateLootInventory?.({
|
||||
...store.lootInventory,
|
||||
materials: newMaterials,
|
||||
});
|
||||
store.addLog?.(`🗑️ Deleted ${LOOT_DROPS[id]?.name || id} (${amount})`);
|
||||
}}
|
||||
onDeleteEquipment={(instanceId) => {
|
||||
store.deleteEquipmentInstance?.(instanceId);
|
||||
store.addLog?.(`🗑️ Deleted equipment`);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -1,78 +1,329 @@
|
||||
'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 { Gem, Sparkles, Scroll, Droplet } from 'lucide-react';
|
||||
import type { LootInventory as LootInventoryType } from '@/lib/game/types';
|
||||
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;
|
||||
}
|
||||
|
||||
export function LootInventoryDisplay({ inventory }: LootInventoryProps) {
|
||||
const materialCount = Object.values(inventory.materials).reduce((a, b) => a + b, 0);
|
||||
const blueprintCount = inventory.blueprints.length;
|
||||
type SortMode = 'name' | 'rarity' | 'count';
|
||||
type FilterMode = 'all' | 'materials' | 'essence' | 'blueprints' | 'equipment';
|
||||
|
||||
if (materialCount === 0 && blueprintCount === 0) {
|
||||
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" />
|
||||
Loot Inventory
|
||||
Inventory
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-gray-500 text-sm text-center py-4">
|
||||
No loot collected yet. Defeat floors and guardians to find items!
|
||||
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" />
|
||||
Loot Inventory
|
||||
<Badge className="ml-auto bg-gray-800 text-gray-300">
|
||||
{materialCount + blueprintCount} items
|
||||
Inventory
|
||||
<Badge className="ml-auto bg-gray-800 text-gray-300 text-xs">
|
||||
{totalItems} items
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ScrollArea className="h-48">
|
||||
<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 */}
|
||||
{Object.entries(inventory.materials).length > 0 && (
|
||||
{(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">
|
||||
{Object.entries(inventory.materials).map(([id, count]) => {
|
||||
{filteredMaterials.map(([id, count]) => {
|
||||
const drop = LOOT_DROPS[id];
|
||||
if (!drop || count <= 0) return null;
|
||||
if (!drop) return null;
|
||||
const rarityStyle = RARITY_COLORS[drop.rarity];
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
className="p-2 rounded border bg-gray-800/50"
|
||||
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>
|
||||
);
|
||||
})}
|
||||
@@ -81,11 +332,11 @@ export function LootInventoryDisplay({ inventory }: LootInventoryProps) {
|
||||
)}
|
||||
|
||||
{/* Blueprints */}
|
||||
{inventory.blueprints.length > 0 && (
|
||||
{(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 Discovered
|
||||
Blueprints (permanent)
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{inventory.blueprints.map((id) => {
|
||||
@@ -107,11 +358,103 @@ export function LootInventoryDisplay({ inventory }: LootInventoryProps) {
|
||||
);
|
||||
})}
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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';
|
||||
|
||||
@@ -10,11 +10,13 @@ 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
|
||||
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 type { EquipmentInstance, EnchantmentDesign, DesignEffect, AppliedEnchantment } from '@/lib/game/types';
|
||||
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 } from '@/lib/game/store';
|
||||
|
||||
// Slot display names
|
||||
@@ -39,6 +41,7 @@ interface CraftingTabProps {
|
||||
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; manaSpent: number } | null;
|
||||
equipmentCraftingProgress: EquipmentCraftingProgress | null;
|
||||
|
||||
// Player state
|
||||
rawMana: number;
|
||||
@@ -46,6 +49,9 @@ interface CraftingTabProps {
|
||||
currentAction: string;
|
||||
unlockedEffects: string[]; // Effect IDs that have been researched
|
||||
|
||||
// Loot inventory
|
||||
lootInventory: LootInventory;
|
||||
|
||||
// Actions
|
||||
startDesigningEnchantment: (name: string, equipmentTypeId: string, effects: DesignEffect[]) => boolean;
|
||||
cancelDesign: () => void;
|
||||
@@ -59,6 +65,11 @@ interface CraftingTabProps {
|
||||
cancelApplication: () => void;
|
||||
disenchantEquipment: (instanceId: string) => void;
|
||||
getAvailableCapacity: (instanceId: string) => number;
|
||||
|
||||
// Equipment crafting actions
|
||||
startCraftingEquipment: (blueprintId: string) => boolean;
|
||||
cancelEquipmentCrafting: () => void;
|
||||
deleteMaterial: (materialId: string, amount: number) => void;
|
||||
}
|
||||
|
||||
export function CraftingTab({
|
||||
@@ -68,10 +79,12 @@ export function CraftingTab({
|
||||
designProgress,
|
||||
preparationProgress,
|
||||
applicationProgress,
|
||||
equipmentCraftingProgress,
|
||||
rawMana,
|
||||
skills,
|
||||
currentAction,
|
||||
unlockedEffects,
|
||||
lootInventory,
|
||||
startDesigningEnchantment,
|
||||
cancelDesign,
|
||||
saveDesign,
|
||||
@@ -84,8 +97,11 @@ export function CraftingTab({
|
||||
cancelApplication,
|
||||
disenchantEquipment,
|
||||
getAvailableCapacity,
|
||||
startCraftingEquipment,
|
||||
cancelEquipmentCrafting,
|
||||
deleteMaterial,
|
||||
}: CraftingTabProps) {
|
||||
const [craftingStage, setCraftingStage] = useState<'design' | 'prepare' | 'apply'>('design');
|
||||
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);
|
||||
@@ -718,11 +734,189 @@ export function CraftingTab({
|
||||
</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
|
||||
@@ -737,6 +931,9 @@ export function CraftingTab({
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="craft" className="mt-4">
|
||||
{renderCraftStage()}
|
||||
</TabsContent>
|
||||
<TabsContent value="design" className="mt-4">
|
||||
{renderDesignStage()}
|
||||
</TabsContent>
|
||||
@@ -749,6 +946,20 @@ export function CraftingTab({
|
||||
</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">
|
||||
|
||||
@@ -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 } 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 & {
|
||||
@@ -20,31 +54,37 @@ interface SpireTabProps {
|
||||
setSpell: (spellId: string) => void;
|
||||
cancelStudy: () => void;
|
||||
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];
|
||||
@@ -52,43 +92,6 @@ export function SpireTab({
|
||||
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">
|
||||
@@ -131,12 +134,71 @@ 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 Controls */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-gray-400">Auto-Direction</span>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant={climbDirection === 'up' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className={`h-7 px-2 ${climbDirection === 'up' ? 'bg-green-600 hover:bg-green-700' : 'bg-gray-800/50'}`}
|
||||
onClick={() => store.setClimbDirection('up')}
|
||||
>
|
||||
<ChevronUp className="w-4 h-4 mr-1" />
|
||||
Up
|
||||
</Button>
|
||||
<Button
|
||||
variant={climbDirection === 'down' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className={`h-7 px-2 ${climbDirection === 'down' ? 'bg-blue-600 hover:bg-blue-700' : 'bg-gray-800/50'}`}
|
||||
onClick={() => store.setClimbDirection('down')}
|
||||
>
|
||||
<ChevronDown className="w-4 h-4 mr-1" />
|
||||
Down
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1 h-8 bg-gray-800/50 hover:bg-gray-700/50 disabled:opacity-50"
|
||||
disabled={store.currentFloor <= 1}
|
||||
onClick={() => store.changeFloor('down')}
|
||||
>
|
||||
<ChevronDown className="w-4 h-4 mr-1" />
|
||||
Descend
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1 h-8 bg-gray-800/50 hover:bg-gray-700/50 disabled:opacity-50"
|
||||
disabled={store.currentFloor >= 100}
|
||||
onClick={() => store.changeFloor('up')}
|
||||
>
|
||||
<ChevronUp className="w-4 h-4 mr-1" />
|
||||
Ascend
|
||||
</Button>
|
||||
</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 will respawn when you leave and return
|
||||
</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>
|
||||
@@ -144,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>}
|
||||
{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>
|
||||
<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 className={`text-xs ${canCast ? 'text-green-400' : 'text-red-400'}`}>
|
||||
{canCast ? '✓' : '✗'}
|
||||
</span>
|
||||
{' '}• ⚡ {(activeSpellDef.castSpeed || 1).toFixed(1)} casts/hr
|
||||
</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-1">
|
||||
<div className="flex justify-between text-xs text-gray-400">
|
||||
<span>Cast Progress</span>
|
||||
<span>{((store.castProgress || 0) * 100).toFixed(0)}%</span>
|
||||
<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, (store.castProgress || 0) * 100)} className="h-2 bg-gray-800" />
|
||||
<Progress value={Math.min(100, progress * 100)} className="h-1.5 bg-gray-700" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeSpellDef.desc && (
|
||||
<div className="text-xs text-gray-500 italic">{activeSpellDef.desc}</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>
|
||||
);
|
||||
})}
|
||||
</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 && (
|
||||
@@ -202,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';
|
||||
397
src/lib/game/computed-stats.ts
Normal file
397
src/lib/game/computed-stats.ts
Normal file
@@ -0,0 +1,397 @@
|
||||
// ─── Computed Stats and Utility Functions ───────────────────────────────────────
|
||||
// This module contains all computed stat functions and utility helpers
|
||||
// extracted from the main store for better organization
|
||||
|
||||
import type { GameState, SpellCost, EquipmentInstance } from './types';
|
||||
import {
|
||||
GUARDIANS,
|
||||
SPELLS_DEF,
|
||||
FLOOR_ELEM_CYCLE,
|
||||
HOURS_PER_TICK,
|
||||
MAX_DAY,
|
||||
INCURSION_START_DAY,
|
||||
ELEMENT_OPPOSITES,
|
||||
} from './constants';
|
||||
import type { ComputedEffects } from './upgrade-effects';
|
||||
import { getUnifiedEffects, type UnifiedEffects } from './effects';
|
||||
import { EQUIPMENT_TYPES } from './data/equipment';
|
||||
import { ENCHANTMENT_EFFECTS } from './data/enchantment-effects';
|
||||
|
||||
// ─── Default Effects Constant ───────────────────────────────────────────────────
|
||||
|
||||
// Default empty effects for when effects aren't provided
|
||||
export const DEFAULT_EFFECTS: ComputedEffects = {
|
||||
maxManaMultiplier: 1,
|
||||
maxManaBonus: 0,
|
||||
regenMultiplier: 1,
|
||||
regenBonus: 0,
|
||||
clickManaMultiplier: 1,
|
||||
clickManaBonus: 0,
|
||||
meditationEfficiency: 1,
|
||||
spellCostMultiplier: 1,
|
||||
conversionEfficiency: 1,
|
||||
baseDamageMultiplier: 1,
|
||||
baseDamageBonus: 0,
|
||||
attackSpeedMultiplier: 1,
|
||||
critChanceBonus: 0,
|
||||
critDamageMultiplier: 1.5,
|
||||
elementalDamageMultiplier: 1,
|
||||
studySpeedMultiplier: 1,
|
||||
studyCostMultiplier: 1,
|
||||
progressRetention: 0,
|
||||
instantStudyChance: 0,
|
||||
freeStudyChance: 0,
|
||||
elementCapMultiplier: 1,
|
||||
elementCapBonus: 0,
|
||||
conversionCostMultiplier: 1,
|
||||
doubleCraftChance: 0,
|
||||
permanentRegenBonus: 0,
|
||||
specials: new Set(),
|
||||
activeUpgrades: [],
|
||||
};
|
||||
|
||||
// ─── Number Formatting Functions ────────────────────────────────────────────────
|
||||
|
||||
export function fmt(n: number): string {
|
||||
if (!isFinite(n) || isNaN(n)) return '0';
|
||||
if (n >= 1e9) return (n / 1e9).toFixed(2) + 'B';
|
||||
if (n >= 1e6) return (n / 1e6).toFixed(2) + 'M';
|
||||
if (n >= 1e3) return (n / 1e3).toFixed(1) + 'K';
|
||||
return Math.floor(n).toString();
|
||||
}
|
||||
|
||||
export function fmtDec(n: number, d: number = 1): string {
|
||||
return isFinite(n) ? n.toFixed(d) : '0';
|
||||
}
|
||||
|
||||
// ─── Floor Functions ────────────────────────────────────────────────────────────
|
||||
|
||||
export function getFloorMaxHP(floor: number): number {
|
||||
if (GUARDIANS[floor]) return GUARDIANS[floor].hp;
|
||||
// Improved scaling: slower early game, faster late game
|
||||
const baseHP = 100;
|
||||
const floorScaling = floor * 50;
|
||||
const exponentialScaling = Math.pow(floor, 1.7);
|
||||
return Math.floor(baseHP + floorScaling + exponentialScaling);
|
||||
}
|
||||
|
||||
export function getFloorElement(floor: number): string {
|
||||
return FLOOR_ELEM_CYCLE[(floor - 1) % 8];
|
||||
}
|
||||
|
||||
// ─── Equipment Spell Helper ─────────────────────────────────────────────────────
|
||||
|
||||
// Get all spells from equipped caster weapons (staves, wands, etc.)
|
||||
// Returns array of { spellId, equipmentInstanceId }
|
||||
export function getActiveEquipmentSpells(
|
||||
equippedInstances: Record<string, string | null>,
|
||||
equipmentInstances: Record<string, EquipmentInstance>
|
||||
): Array<{ spellId: string; equipmentId: string }> {
|
||||
const spells: Array<{ spellId: string; equipmentId: string }> = [];
|
||||
|
||||
// Check main hand and off hand for caster equipment
|
||||
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;
|
||||
|
||||
// Check if this is a caster-type equipment
|
||||
const equipType = EQUIPMENT_TYPES[instance.typeId];
|
||||
if (!equipType || equipType.category !== 'caster') continue;
|
||||
|
||||
// Get spells from enchantments
|
||||
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;
|
||||
}
|
||||
|
||||
// ─── Skill Level Helper ─────────────────────────────────────────────────────────
|
||||
|
||||
// Helper to get effective skill level accounting for tiers
|
||||
export function getEffectiveSkillLevel(
|
||||
skills: Record<string, number>,
|
||||
baseSkillId: string,
|
||||
skillTiers: Record<string, number> = {}
|
||||
): { level: number; tier: number; tierMultiplier: number } {
|
||||
// Find the highest tier the player has for this base skill
|
||||
const currentTier = skillTiers[baseSkillId] || 1;
|
||||
|
||||
// Look for the tiered skill ID (e.g., manaFlow_t2)
|
||||
const tieredSkillId = currentTier > 1 ? `${baseSkillId}_t${currentTier}` : baseSkillId;
|
||||
const level = skills[tieredSkillId] || skills[baseSkillId] || 0;
|
||||
|
||||
// Tier multiplier: each tier is 10x more powerful
|
||||
const tierMultiplier = Math.pow(10, currentTier - 1);
|
||||
|
||||
return { level, tier: currentTier, tierMultiplier };
|
||||
}
|
||||
|
||||
// ─── Computed Stat Functions ────────────────────────────────────────────────────
|
||||
|
||||
export function computeMaxMana(
|
||||
state: Pick<GameState, 'skills' | 'prestigeUpgrades' | 'skillUpgrades' | 'skillTiers' | 'equipmentInstances' | 'equippedInstances'>,
|
||||
effects?: ComputedEffects | UnifiedEffects
|
||||
): number {
|
||||
const pu = state.prestigeUpgrades;
|
||||
const base =
|
||||
100 +
|
||||
(state.skills.manaWell || 0) * 100 +
|
||||
(pu.manaWell || 0) * 500;
|
||||
|
||||
// If effects not provided, compute unified effects (includes equipment)
|
||||
if (!effects && state.equipmentInstances && state.equippedInstances) {
|
||||
effects = getUnifiedEffects(state as any);
|
||||
}
|
||||
|
||||
// Apply effects if available (now includes equipment bonuses)
|
||||
if (effects) {
|
||||
return Math.floor((base + effects.maxManaBonus) * effects.maxManaMultiplier);
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
export function computeElementMax(
|
||||
state: Pick<GameState, 'skills' | 'prestigeUpgrades' | 'skillUpgrades' | 'skillTiers'>,
|
||||
effects?: ComputedEffects
|
||||
): number {
|
||||
const pu = state.prestigeUpgrades;
|
||||
const base = 10 + (state.skills.elemAttune || 0) * 50 + (pu.elementalAttune || 0) * 25;
|
||||
|
||||
// Apply upgrade effects if provided
|
||||
if (effects) {
|
||||
return Math.floor((base + effects.elementCapBonus) * effects.elementCapMultiplier);
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
export function computeRegen(
|
||||
state: Pick<GameState, 'skills' | 'prestigeUpgrades' | 'skillUpgrades' | 'skillTiers' | 'equipmentInstances' | 'equippedInstances'>,
|
||||
effects?: ComputedEffects | UnifiedEffects
|
||||
): number {
|
||||
const pu = state.prestigeUpgrades;
|
||||
const temporalBonus = 1 + (pu.temporalEcho || 0) * 0.1;
|
||||
const base =
|
||||
2 +
|
||||
(state.skills.manaFlow || 0) * 1 +
|
||||
(state.skills.manaSpring || 0) * 2 +
|
||||
(pu.manaFlow || 0) * 0.5;
|
||||
|
||||
let regen = base * temporalBonus;
|
||||
|
||||
// If effects not provided, compute unified effects (includes equipment)
|
||||
if (!effects && state.equipmentInstances && state.equippedInstances) {
|
||||
effects = getUnifiedEffects(state as any);
|
||||
}
|
||||
|
||||
// Apply effects if available (now includes equipment bonuses)
|
||||
if (effects) {
|
||||
regen = (regen + effects.regenBonus + effects.permanentRegenBonus) * effects.regenMultiplier;
|
||||
}
|
||||
|
||||
return regen;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute regen with dynamic special effects (needs current mana, max mana, incursion)
|
||||
*/
|
||||
export function computeEffectiveRegen(
|
||||
state: Pick<GameState, 'skills' | 'prestigeUpgrades' | 'rawMana' | 'incursionStrength' | 'skillUpgrades' | 'skillTiers' | 'equipmentInstances' | 'equippedInstances'>,
|
||||
effects?: ComputedEffects | UnifiedEffects
|
||||
): number {
|
||||
// Base regen from existing function
|
||||
let regen = computeRegen(state, effects);
|
||||
|
||||
const maxMana = computeMaxMana(state, effects);
|
||||
const currentMana = state.rawMana;
|
||||
const incursionStrength = state.incursionStrength || 0;
|
||||
|
||||
// Apply incursion penalty
|
||||
regen *= (1 - incursionStrength);
|
||||
|
||||
return regen;
|
||||
}
|
||||
|
||||
export function computeClickMana(
|
||||
state: Pick<GameState, 'skills' | 'skillUpgrades' | 'skillTiers' | 'equipmentInstances' | 'equippedInstances'>,
|
||||
effects?: ComputedEffects | UnifiedEffects
|
||||
): number {
|
||||
const base =
|
||||
1 +
|
||||
(state.skills.manaTap || 0) * 1 +
|
||||
(state.skills.manaSurge || 0) * 3;
|
||||
|
||||
// If effects not provided, compute unified effects (includes equipment)
|
||||
if (!effects && state.equipmentInstances && state.equippedInstances) {
|
||||
effects = getUnifiedEffects(state as any);
|
||||
}
|
||||
|
||||
// Apply effects if available (now includes equipment bonuses)
|
||||
if (effects) {
|
||||
return Math.floor((base + effects.clickManaBonus) * effects.clickManaMultiplier);
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
// ─── Damage Calculation Helpers ─────────────────────────────────────────────────
|
||||
|
||||
// Elemental damage bonus: +50% if spell element opposes floor element (super effective)
|
||||
// -25% if spell element matches its own opposite (weak)
|
||||
export function getElementalBonus(spellElem: string, floorElem: string): number {
|
||||
if (spellElem === 'raw') return 1.0; // Raw mana has no elemental bonus
|
||||
|
||||
if (spellElem === floorElem) return 1.25; // Same element: +25% damage
|
||||
|
||||
// Check for super effective first: spell is the opposite of floor
|
||||
// e.g., casting water (opposite of fire) at fire floor = super effective
|
||||
if (ELEMENT_OPPOSITES[floorElem] === spellElem) return 1.5; // Super effective: +50% damage
|
||||
|
||||
// Check for weak: spell's opposite matches floor
|
||||
// e.g., casting fire (whose opposite is water) at water floor = weak
|
||||
if (ELEMENT_OPPOSITES[spellElem] === floorElem) return 0.75; // Weak: -25% damage
|
||||
|
||||
return 1.0; // Neutral
|
||||
}
|
||||
|
||||
export function calcDamage(
|
||||
state: Pick<GameState, 'skills' | 'signedPacts'>,
|
||||
spellId: string,
|
||||
floorElem?: string
|
||||
): number {
|
||||
const sp = SPELLS_DEF[spellId];
|
||||
if (!sp) return 5;
|
||||
const skills = state.skills;
|
||||
const baseDmg = sp.dmg + (skills.combatTrain || 0) * 5;
|
||||
const pct = 1 + (skills.arcaneFury || 0) * 0.1;
|
||||
|
||||
// Elemental mastery bonus
|
||||
const elemMasteryBonus = 1 + (skills.elementalMastery || 0) * 0.15;
|
||||
|
||||
// Guardian bane bonus
|
||||
const guardianBonus = floorElem && GUARDIANS[Object.values(GUARDIANS).find(g => g.element === floorElem)?.hp ? 0 : 0]
|
||||
? 1 + (skills.guardianBane || 0) * 0.2
|
||||
: 1;
|
||||
|
||||
const critChance = (skills.precision || 0) * 0.05;
|
||||
const pactMult = state.signedPacts.reduce(
|
||||
(m, f) => m * (GUARDIANS[f]?.pact || 1),
|
||||
1
|
||||
);
|
||||
|
||||
let damage = baseDmg * pct * pactMult * elemMasteryBonus;
|
||||
|
||||
// Apply elemental bonus if floor element provided
|
||||
if (floorElem) {
|
||||
damage *= getElementalBonus(sp.elem, floorElem);
|
||||
}
|
||||
|
||||
// Apply crit
|
||||
if (Math.random() < critChance) {
|
||||
damage *= 1.5;
|
||||
}
|
||||
|
||||
return damage;
|
||||
}
|
||||
|
||||
// ─── Insight Calculation ────────────────────────────────────────────────────────
|
||||
|
||||
export function calcInsight(state: Pick<GameState, 'maxFloorReached' | 'totalManaGathered' | 'signedPacts' | 'prestigeUpgrades' | 'skills'>): number {
|
||||
const pu = state.prestigeUpgrades;
|
||||
const skillBonus = 1 + (state.skills.insightHarvest || 0) * 0.1;
|
||||
const mult = (1 + (pu.insightAmp || 0) * 0.25) * skillBonus;
|
||||
return Math.floor(
|
||||
(state.maxFloorReached * 15 + state.totalManaGathered / 500 + state.signedPacts.length * 150) * mult
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Meditation Bonus ───────────────────────────────────────────────────────────
|
||||
|
||||
// Meditation bonus now affects regen rate directly
|
||||
export function getMeditationBonus(meditateTicks: number, skills: Record<string, number>, meditationEfficiency: number = 1): number {
|
||||
const hasMeditation = skills.meditation === 1;
|
||||
const hasDeepTrance = skills.deepTrance === 1;
|
||||
const hasVoidMeditation = skills.voidMeditation === 1;
|
||||
|
||||
const hours = meditateTicks * HOURS_PER_TICK;
|
||||
|
||||
// Base meditation: ramps up over 4 hours to 1.5x
|
||||
let bonus = 1 + Math.min(hours / 4, 0.5);
|
||||
|
||||
// With Meditation Focus: up to 2.5x after 4 hours
|
||||
if (hasMeditation && hours >= 4) {
|
||||
bonus = 2.5;
|
||||
}
|
||||
|
||||
// With Deep Trance: up to 3.0x after 6 hours
|
||||
if (hasDeepTrance && hours >= 6) {
|
||||
bonus = 3.0;
|
||||
}
|
||||
|
||||
// With Void Meditation: up to 5.0x after 8 hours
|
||||
if (hasVoidMeditation && hours >= 8) {
|
||||
bonus = 5.0;
|
||||
}
|
||||
|
||||
// Apply meditation efficiency from upgrades (Deep Wellspring, etc.)
|
||||
bonus *= meditationEfficiency;
|
||||
|
||||
return bonus;
|
||||
}
|
||||
|
||||
// ─── Incursion Strength ─────────────────────────────────────────────────────────
|
||||
|
||||
export function getIncursionStrength(day: number, hour: number): number {
|
||||
if (day < INCURSION_START_DAY) return 0;
|
||||
const totalHours = (day - INCURSION_START_DAY) * 24 + hour;
|
||||
const maxHours = (MAX_DAY - INCURSION_START_DAY) * 24;
|
||||
return Math.min(0.95, (totalHours / maxHours) * 0.95);
|
||||
}
|
||||
|
||||
// ─── Spell Cost Helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
// Check if player can afford spell cost
|
||||
export function canAffordSpellCost(
|
||||
cost: SpellCost,
|
||||
rawMana: number,
|
||||
elements: Record<string, { current: number; max: number; unlocked: boolean }>
|
||||
): boolean {
|
||||
if (cost.type === 'raw') {
|
||||
return rawMana >= cost.amount;
|
||||
} else {
|
||||
const elem = elements[cost.element || ''];
|
||||
return elem && elem.unlocked && elem.current >= cost.amount;
|
||||
}
|
||||
}
|
||||
|
||||
// Deduct spell cost from appropriate mana pool
|
||||
export function deductSpellCost(
|
||||
cost: SpellCost,
|
||||
rawMana: number,
|
||||
elements: Record<string, { current: number; max: number; unlocked: boolean }>
|
||||
): { rawMana: number; elements: Record<string, { current: number; max: number; unlocked: boolean }> } {
|
||||
const newElements = { ...elements };
|
||||
|
||||
if (cost.type === 'raw') {
|
||||
return { rawMana: rawMana - cost.amount, elements: newElements };
|
||||
} else if (cost.element && newElements[cost.element]) {
|
||||
newElements[cost.element] = {
|
||||
...newElements[cost.element],
|
||||
current: newElements[cost.element].current - cost.amount
|
||||
};
|
||||
return { rawMana, elements: newElements };
|
||||
}
|
||||
|
||||
return { rawMana, elements: newElements };
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
// ─── Crafting Store Slice ─────────────────────────────────────────────────────────
|
||||
// Handles equipment and enchantment system: design, prepare, apply stages
|
||||
|
||||
import type { GameState, EquipmentInstance, AppliedEnchantment, EnchantmentDesign, DesignEffect, EquipmentSlot } from './types';
|
||||
import type { GameState, EquipmentInstance, AppliedEnchantment, EnchantmentDesign, DesignEffect, EquipmentSlot, EquipmentCraftingProgress, LootInventory } from './types';
|
||||
import { EQUIPMENT_TYPES, type EquipmentCategory } from './data/equipment';
|
||||
import { ENCHANTMENT_EFFECTS, calculateEffectCapacityCost } from './data/enchantment-effects';
|
||||
import { CRAFTING_RECIPES, canCraftRecipe, type CraftingRecipe } from './data/crafting-recipes';
|
||||
import { SPELLS_DEF } from './constants';
|
||||
|
||||
// ─── Helper Functions ─────────────────────────────────────────────────────────
|
||||
@@ -88,6 +89,11 @@ export interface CraftingActions {
|
||||
// Disenchanting
|
||||
disenchantEquipment: (instanceId: string) => void;
|
||||
|
||||
// Equipment Crafting (from blueprints)
|
||||
startCraftingEquipment: (blueprintId: string) => boolean;
|
||||
cancelEquipmentCrafting: () => void;
|
||||
deleteMaterial: (materialId: string, amount: number) => void;
|
||||
|
||||
// Computed getters
|
||||
getEquipmentSpells: () => string[];
|
||||
getEquipmentEffects: () => Record<string, number>;
|
||||
@@ -502,6 +508,97 @@ export function createCraftingSlice(
|
||||
if (!instance) return 0;
|
||||
return instance.totalCapacity - instance.usedCapacity;
|
||||
},
|
||||
|
||||
// ─── Equipment Crafting (from Blueprints) ───────────────────────────────────
|
||||
|
||||
startCraftingEquipment: (blueprintId: string) => {
|
||||
const state = get();
|
||||
const recipe = CRAFTING_RECIPES[blueprintId];
|
||||
if (!recipe) return false;
|
||||
|
||||
// Check if player has the blueprint
|
||||
if (!state.lootInventory.blueprints.includes(blueprintId)) return false;
|
||||
|
||||
// Check materials
|
||||
const { canCraft, missingMaterials, missingMana } = canCraftRecipe(
|
||||
recipe,
|
||||
state.lootInventory.materials,
|
||||
state.rawMana
|
||||
);
|
||||
|
||||
if (!canCraft) return false;
|
||||
|
||||
// Deduct materials
|
||||
const newMaterials = { ...state.lootInventory.materials };
|
||||
for (const [matId, amount] of Object.entries(recipe.materials)) {
|
||||
newMaterials[matId] = (newMaterials[matId] || 0) - amount;
|
||||
if (newMaterials[matId] <= 0) {
|
||||
delete newMaterials[matId];
|
||||
}
|
||||
}
|
||||
|
||||
// Start crafting progress
|
||||
set((state) => ({
|
||||
lootInventory: {
|
||||
...state.lootInventory,
|
||||
materials: newMaterials,
|
||||
},
|
||||
rawMana: state.rawMana - recipe.manaCost,
|
||||
currentAction: 'craft',
|
||||
equipmentCraftingProgress: {
|
||||
blueprintId,
|
||||
equipmentTypeId: recipe.equipmentTypeId,
|
||||
progress: 0,
|
||||
required: recipe.craftTime,
|
||||
manaSpent: recipe.manaCost,
|
||||
},
|
||||
}));
|
||||
|
||||
return true;
|
||||
},
|
||||
|
||||
cancelEquipmentCrafting: () => {
|
||||
set((state) => {
|
||||
const progress = state.equipmentCraftingProgress;
|
||||
if (!progress) return {};
|
||||
|
||||
const recipe = CRAFTING_RECIPES[progress.blueprintId];
|
||||
if (!recipe) return { currentAction: 'meditate', equipmentCraftingProgress: null };
|
||||
|
||||
// Refund 50% of mana
|
||||
const manaRefund = Math.floor(progress.manaSpent * 0.5);
|
||||
|
||||
return {
|
||||
currentAction: 'meditate',
|
||||
equipmentCraftingProgress: null,
|
||||
rawMana: state.rawMana + manaRefund,
|
||||
log: [`🚫 Equipment crafting cancelled. Refunded ${manaRefund} mana.`, ...state.log.slice(0, 49)],
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
deleteMaterial: (materialId: string, amount: number) => {
|
||||
set((state) => {
|
||||
const currentAmount = state.lootInventory.materials[materialId] || 0;
|
||||
const newAmount = Math.max(0, currentAmount - amount);
|
||||
const newMaterials = { ...state.lootInventory.materials };
|
||||
|
||||
if (newAmount <= 0) {
|
||||
delete newMaterials[materialId];
|
||||
} else {
|
||||
newMaterials[materialId] = newAmount;
|
||||
}
|
||||
|
||||
const dropName = materialId; // Could look up in LOOT_DROPS for proper name
|
||||
return {
|
||||
lootInventory: {
|
||||
...state.lootInventory,
|
||||
materials: newMaterials,
|
||||
},
|
||||
log: [`🗑️ Deleted ${amount}x ${dropName}.`, ...state.log.slice(0, 49)],
|
||||
};
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -620,6 +717,59 @@ export function processCraftingTick(
|
||||
}
|
||||
}
|
||||
|
||||
// Process equipment crafting progress
|
||||
if (state.currentAction === 'craft' && state.equipmentCraftingProgress) {
|
||||
const craft = state.equipmentCraftingProgress;
|
||||
const progress = craft.progress + 0.04; // HOURS_PER_TICK
|
||||
|
||||
if (progress >= craft.required) {
|
||||
// Crafting complete - create the equipment!
|
||||
const recipe = CRAFTING_RECIPES[craft.blueprintId];
|
||||
const equipType = recipe ? EQUIPMENT_TYPES[recipe.equipmentTypeId] : null;
|
||||
|
||||
if (recipe && equipType) {
|
||||
const instanceId = generateInstanceId();
|
||||
const newInstance: EquipmentInstance = {
|
||||
instanceId,
|
||||
typeId: recipe.equipmentTypeId,
|
||||
name: recipe.name,
|
||||
enchantments: [],
|
||||
usedCapacity: 0,
|
||||
totalCapacity: equipType.baseCapacity,
|
||||
rarity: recipe.rarity,
|
||||
quality: 100,
|
||||
};
|
||||
|
||||
updates = {
|
||||
...updates,
|
||||
equipmentCraftingProgress: null,
|
||||
currentAction: 'meditate',
|
||||
equipmentInstances: {
|
||||
...state.equipmentInstances,
|
||||
[instanceId]: newInstance,
|
||||
},
|
||||
totalCraftsCompleted: (state.totalCraftsCompleted || 0) + 1,
|
||||
log: [`🔨 Crafted ${recipe.name}!`, ...log],
|
||||
};
|
||||
} else {
|
||||
updates = {
|
||||
...updates,
|
||||
equipmentCraftingProgress: null,
|
||||
currentAction: 'meditate',
|
||||
log: ['⚠️ Crafting failed - invalid recipe!', ...log],
|
||||
};
|
||||
}
|
||||
} else {
|
||||
updates = {
|
||||
...updates,
|
||||
equipmentCraftingProgress: {
|
||||
...craft,
|
||||
progress,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return updates;
|
||||
}
|
||||
|
||||
|
||||
257
src/lib/game/data/crafting-recipes.ts
Normal file
257
src/lib/game/data/crafting-recipes.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
// ─── Crafting Recipes ─────────────────────────────────────────────────────────
|
||||
// Defines what materials are needed to craft equipment from blueprints
|
||||
|
||||
import type { EquipmentSlot } from '../types';
|
||||
|
||||
export interface CraftingRecipe {
|
||||
id: string; // Blueprint ID (matches loot drop)
|
||||
equipmentTypeId: string; // Resulting equipment type ID
|
||||
name: string; // Display name
|
||||
description: string;
|
||||
rarity: 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary';
|
||||
materials: Record<string, number>; // materialId -> count required
|
||||
manaCost: number; // Raw mana cost to craft
|
||||
craftTime: number; // Hours to craft
|
||||
minFloor: number; // Minimum floor where blueprint drops
|
||||
unlocked: boolean; // Whether the player has discovered this
|
||||
}
|
||||
|
||||
export const CRAFTING_RECIPES: Record<string, CraftingRecipe> = {
|
||||
// ─── Staff Blueprints ───
|
||||
staffBlueprint: {
|
||||
id: 'staffBlueprint',
|
||||
equipmentTypeId: 'oakStaff',
|
||||
name: 'Oak Staff',
|
||||
description: 'A sturdy oak staff with decent mana capacity.',
|
||||
rarity: 'uncommon',
|
||||
materials: {
|
||||
manaCrystalDust: 5,
|
||||
arcaneShard: 2,
|
||||
},
|
||||
manaCost: 200,
|
||||
craftTime: 4,
|
||||
minFloor: 10,
|
||||
unlocked: false,
|
||||
},
|
||||
|
||||
wandBlueprint: {
|
||||
id: 'wandBlueprint',
|
||||
equipmentTypeId: 'crystalWand',
|
||||
name: 'Crystal Wand',
|
||||
description: 'A wand tipped with a small crystal. Excellent for elemental enchantments.',
|
||||
rarity: 'rare',
|
||||
materials: {
|
||||
manaCrystalDust: 8,
|
||||
arcaneShard: 4,
|
||||
elementalCore: 1,
|
||||
},
|
||||
manaCost: 500,
|
||||
craftTime: 6,
|
||||
minFloor: 20,
|
||||
unlocked: false,
|
||||
},
|
||||
|
||||
robeBlueprint: {
|
||||
id: 'robeBlueprint',
|
||||
equipmentTypeId: 'scholarRobe',
|
||||
name: 'Scholar Robe',
|
||||
description: 'A robe worn by scholars and researchers.',
|
||||
rarity: 'rare',
|
||||
materials: {
|
||||
manaCrystalDust: 6,
|
||||
arcaneShard: 3,
|
||||
elementalCore: 1,
|
||||
},
|
||||
manaCost: 400,
|
||||
craftTime: 5,
|
||||
minFloor: 25,
|
||||
unlocked: false,
|
||||
},
|
||||
|
||||
artifactBlueprint: {
|
||||
id: 'artifactBlueprint',
|
||||
equipmentTypeId: 'arcanistStaff',
|
||||
name: 'Arcanist Staff',
|
||||
description: 'A staff designed for advanced spellcasters. High capacity for complex enchantments.',
|
||||
rarity: 'legendary',
|
||||
materials: {
|
||||
manaCrystalDust: 20,
|
||||
arcaneShard: 10,
|
||||
elementalCore: 5,
|
||||
voidEssence: 2,
|
||||
celestialFragment: 1,
|
||||
},
|
||||
manaCost: 2000,
|
||||
craftTime: 12,
|
||||
minFloor: 60,
|
||||
unlocked: false,
|
||||
},
|
||||
|
||||
// ─── Additional Blueprints ───
|
||||
battlestaffBlueprint: {
|
||||
id: 'battlestaffBlueprint',
|
||||
equipmentTypeId: 'battlestaff',
|
||||
name: 'Battlestaff',
|
||||
description: 'A reinforced staff suitable for both casting and combat.',
|
||||
rarity: 'rare',
|
||||
materials: {
|
||||
manaCrystalDust: 10,
|
||||
arcaneShard: 5,
|
||||
elementalCore: 2,
|
||||
},
|
||||
manaCost: 600,
|
||||
craftTime: 6,
|
||||
minFloor: 30,
|
||||
unlocked: false,
|
||||
},
|
||||
|
||||
catalystBlueprint: {
|
||||
id: 'catalystBlueprint',
|
||||
equipmentTypeId: 'fireCatalyst',
|
||||
name: 'Fire Catalyst',
|
||||
description: 'A catalyst attuned to fire magic. Enhances fire enchantments.',
|
||||
rarity: 'rare',
|
||||
materials: {
|
||||
manaCrystalDust: 8,
|
||||
arcaneShard: 4,
|
||||
elementalCore: 3,
|
||||
},
|
||||
manaCost: 500,
|
||||
craftTime: 5,
|
||||
minFloor: 25,
|
||||
unlocked: false,
|
||||
},
|
||||
|
||||
shieldBlueprint: {
|
||||
id: 'shieldBlueprint',
|
||||
equipmentTypeId: 'runicShield',
|
||||
name: 'Runic Shield',
|
||||
description: 'A shield engraved with protective runes.',
|
||||
rarity: 'rare',
|
||||
materials: {
|
||||
manaCrystalDust: 10,
|
||||
arcaneShard: 6,
|
||||
elementalCore: 2,
|
||||
},
|
||||
manaCost: 450,
|
||||
craftTime: 5,
|
||||
minFloor: 28,
|
||||
unlocked: false,
|
||||
},
|
||||
|
||||
hatBlueprint: {
|
||||
id: 'hatBlueprint',
|
||||
equipmentTypeId: 'wizardHat',
|
||||
name: 'Wizard Hat',
|
||||
description: 'A classic pointed wizard hat. Decent capacity for headwear.',
|
||||
rarity: 'uncommon',
|
||||
materials: {
|
||||
manaCrystalDust: 4,
|
||||
arcaneShard: 2,
|
||||
},
|
||||
manaCost: 150,
|
||||
craftTime: 3,
|
||||
minFloor: 12,
|
||||
unlocked: false,
|
||||
},
|
||||
|
||||
glovesBlueprint: {
|
||||
id: 'glovesBlueprint',
|
||||
equipmentTypeId: 'spellweaveGloves',
|
||||
name: 'Spellweave Gloves',
|
||||
description: 'Gloves woven with mana-conductive threads.',
|
||||
rarity: 'uncommon',
|
||||
materials: {
|
||||
manaCrystalDust: 3,
|
||||
arcaneShard: 2,
|
||||
},
|
||||
manaCost: 120,
|
||||
craftTime: 3,
|
||||
minFloor: 15,
|
||||
unlocked: false,
|
||||
},
|
||||
|
||||
bootsBlueprint: {
|
||||
id: 'bootsBlueprint',
|
||||
equipmentTypeId: 'travelerBoots',
|
||||
name: 'Traveler Boots',
|
||||
description: 'Comfortable boots for long journeys.',
|
||||
rarity: 'uncommon',
|
||||
materials: {
|
||||
manaCrystalDust: 3,
|
||||
arcaneShard: 1,
|
||||
},
|
||||
manaCost: 100,
|
||||
craftTime: 2,
|
||||
minFloor: 8,
|
||||
unlocked: false,
|
||||
},
|
||||
|
||||
ringBlueprint: {
|
||||
id: 'ringBlueprint',
|
||||
equipmentTypeId: 'silverRing',
|
||||
name: 'Silver Ring',
|
||||
description: 'A silver ring with decent magical conductivity.',
|
||||
rarity: 'uncommon',
|
||||
materials: {
|
||||
manaCrystalDust: 2,
|
||||
arcaneShard: 1,
|
||||
},
|
||||
manaCost: 80,
|
||||
craftTime: 2,
|
||||
minFloor: 10,
|
||||
unlocked: false,
|
||||
},
|
||||
|
||||
amuletBlueprint: {
|
||||
id: 'amuletBlueprint',
|
||||
equipmentTypeId: 'silverAmulet',
|
||||
name: 'Silver Amulet',
|
||||
description: 'A silver amulet with a small gem.',
|
||||
rarity: 'uncommon',
|
||||
materials: {
|
||||
manaCrystalDust: 3,
|
||||
arcaneShard: 2,
|
||||
},
|
||||
manaCost: 100,
|
||||
craftTime: 3,
|
||||
minFloor: 12,
|
||||
unlocked: false,
|
||||
},
|
||||
};
|
||||
|
||||
// Helper functions
|
||||
export function getRecipeByBlueprint(blueprintId: string): CraftingRecipe | undefined {
|
||||
return CRAFTING_RECIPES[blueprintId];
|
||||
}
|
||||
|
||||
export function canCraftRecipe(
|
||||
recipe: CraftingRecipe,
|
||||
materials: Record<string, number>,
|
||||
rawMana: number
|
||||
): { canCraft: boolean; missingMaterials: Record<string, number>; missingMana: number } {
|
||||
const missingMaterials: Record<string, number> = {};
|
||||
let canCraft = true;
|
||||
|
||||
for (const [matId, required] of Object.entries(recipe.materials)) {
|
||||
const available = materials[matId] || 0;
|
||||
if (available < required) {
|
||||
missingMaterials[matId] = required - available;
|
||||
canCraft = false;
|
||||
}
|
||||
}
|
||||
|
||||
const missingMana = Math.max(0, recipe.manaCost - rawMana);
|
||||
if (missingMana > 0) {
|
||||
canCraft = false;
|
||||
}
|
||||
|
||||
return { canCraft, missingMaterials, missingMana };
|
||||
}
|
||||
|
||||
// Get all recipes available based on unlocked blueprints
|
||||
export function getAvailableRecipes(unlockedBlueprints: string[]): CraftingRecipe[] {
|
||||
return unlockedBlueprints
|
||||
.map(bpId => CRAFTING_RECIPES[bpId])
|
||||
.filter(Boolean);
|
||||
}
|
||||
@@ -4,6 +4,9 @@
|
||||
import { ELEMENTS } from '@/lib/game/constants';
|
||||
import type { SpellCost } from '@/lib/game/types';
|
||||
|
||||
// Re-export number formatting functions from computed-stats.ts
|
||||
export { fmt, fmtDec } from './computed-stats';
|
||||
|
||||
/**
|
||||
* Format a spell cost for display
|
||||
*/
|
||||
|
||||
63
src/lib/game/navigation-slice.ts
Normal file
63
src/lib/game/navigation-slice.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
// ─── Navigation Slice ─────────────────────────────────────────────────────────
|
||||
// Actions for floor navigation: climbing direction and manual floor changes
|
||||
|
||||
import type { GameState } from './types';
|
||||
import { getFloorMaxHP } from './computed-stats';
|
||||
|
||||
// ─── Navigation Actions Interface ─────────────────────────────────────────────
|
||||
|
||||
export interface NavigationActions {
|
||||
// Floor Navigation
|
||||
setClimbDirection: (direction: 'up' | 'down') => void;
|
||||
changeFloor: (direction: 'up' | 'down') => void;
|
||||
}
|
||||
|
||||
// ─── Navigation Slice Factory ─────────────────────────────────────────────────
|
||||
|
||||
export function createNavigationSlice(
|
||||
set: (partial: Partial<GameState> | ((state: GameState) => Partial<GameState>)) => void,
|
||||
get: () => GameState
|
||||
): NavigationActions {
|
||||
return {
|
||||
// Set the climbing direction (up or down)
|
||||
setClimbDirection: (direction: 'up' | 'down') => {
|
||||
set({ climbDirection: direction });
|
||||
},
|
||||
|
||||
// Manually change floors by one
|
||||
changeFloor: (direction: 'up' | 'down') => {
|
||||
const state = get();
|
||||
const currentFloor = state.currentFloor;
|
||||
|
||||
// Calculate next floor
|
||||
const nextFloor = direction === 'up'
|
||||
? Math.min(currentFloor + 1, 100)
|
||||
: Math.max(currentFloor - 1, 1);
|
||||
|
||||
// Can't stay on same floor
|
||||
if (nextFloor === currentFloor) return;
|
||||
|
||||
// Mark current floor as cleared (it will respawn when we come back)
|
||||
const clearedFloors = { ...state.clearedFloors };
|
||||
clearedFloors[currentFloor] = true;
|
||||
|
||||
// Check if next floor was cleared (needs respawn)
|
||||
const nextFloorCleared = clearedFloors[nextFloor];
|
||||
if (nextFloorCleared) {
|
||||
// Respawn the floor
|
||||
delete clearedFloors[nextFloor];
|
||||
}
|
||||
|
||||
set({
|
||||
currentFloor: nextFloor,
|
||||
floorMaxHP: getFloorMaxHP(nextFloor),
|
||||
floorHP: getFloorMaxHP(nextFloor),
|
||||
maxFloorReached: Math.max(state.maxFloorReached, nextFloor),
|
||||
clearedFloors,
|
||||
climbDirection: direction,
|
||||
equipmentSpellStates: state.equipmentSpellStates.map(s => ({ ...s, castProgress: 0 })),
|
||||
log: [`🚶 Moved to floor ${nextFloor}${nextFloorCleared ? ' (respawned)' : ''}.`, ...state.log.slice(0, 49)],
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import type { GameState, GameAction, StudyTarget, SpellCost, SkillUpgradeChoice, EquipmentSlot, EquipmentInstance, EnchantmentDesign, DesignEffect } from './types';
|
||||
import type { GameState, GameAction, StudyTarget, SkillUpgradeChoice, EquipmentSlot, EnchantmentDesign, DesignEffect, LootInventory } from './types';
|
||||
import {
|
||||
ELEMENTS,
|
||||
GUARDIANS,
|
||||
@@ -17,19 +17,13 @@ import {
|
||||
INCURSION_START_DAY,
|
||||
MANA_PER_ELEMENT,
|
||||
getStudySpeedMultiplier,
|
||||
getStudyCostMultiplier,
|
||||
ELEMENT_OPPOSITES,
|
||||
EFFECT_RESEARCH_MAPPING,
|
||||
BASE_UNLOCKED_EFFECTS,
|
||||
ENCHANTING_UNLOCK_EFFECTS,
|
||||
} from './constants';
|
||||
import { computeEffects, hasSpecial, SPECIAL_EFFECTS, type ComputedEffects } from './upgrade-effects';
|
||||
import {
|
||||
computeAllEffects,
|
||||
getUnifiedEffects,
|
||||
computeEquipmentEffects,
|
||||
type UnifiedEffects
|
||||
} from './effects';
|
||||
import { hasSpecial, SPECIAL_EFFECTS } from './upgrade-effects';
|
||||
import { getUnifiedEffects } from './effects';
|
||||
import { SKILL_EVOLUTION_PATHS } from './skill-evolution';
|
||||
import {
|
||||
createStartingEquipment,
|
||||
@@ -37,7 +31,6 @@ import {
|
||||
getSpellsFromEquipment,
|
||||
type CraftingActions
|
||||
} from './crafting-slice';
|
||||
import { EQUIPMENT_TYPES } from './data/equipment';
|
||||
import { ENCHANTMENT_EFFECTS, calculateEffectCapacityCost } from './data/enchantment-effects';
|
||||
import {
|
||||
createFamiliarSlice,
|
||||
@@ -47,227 +40,56 @@ import {
|
||||
type FamiliarBonuses,
|
||||
DEFAULT_FAMILIAR_BONUSES,
|
||||
} from './familiar-slice';
|
||||
import { rollLootDrops } from './data/loot-drops';
|
||||
import {
|
||||
createNavigationSlice,
|
||||
type NavigationActions,
|
||||
} from './navigation-slice';
|
||||
import {
|
||||
createStudySlice,
|
||||
type StudyActions,
|
||||
} from './study-slice';
|
||||
import { rollLootDrops, LOOT_DROPS } from './data/loot-drops';
|
||||
import { CRAFTING_RECIPES, canCraftRecipe } from './data/crafting-recipes';
|
||||
import { EQUIPMENT_TYPES } from './data/equipment';
|
||||
import type { EquipmentInstance } from './types';
|
||||
// Import computed stats and utility functions from computed-stats.ts
|
||||
import {
|
||||
DEFAULT_EFFECTS,
|
||||
fmt,
|
||||
fmtDec,
|
||||
getFloorMaxHP,
|
||||
getFloorElement,
|
||||
getActiveEquipmentSpells,
|
||||
getEffectiveSkillLevel,
|
||||
computeMaxMana,
|
||||
computeElementMax,
|
||||
computeRegen,
|
||||
computeEffectiveRegen,
|
||||
computeClickMana,
|
||||
calcDamage,
|
||||
calcInsight,
|
||||
getMeditationBonus,
|
||||
getIncursionStrength,
|
||||
canAffordSpellCost,
|
||||
deductSpellCost,
|
||||
} from './computed-stats';
|
||||
|
||||
// Default empty effects for when effects aren't provided
|
||||
const DEFAULT_EFFECTS: ComputedEffects = {
|
||||
maxManaMultiplier: 1,
|
||||
maxManaBonus: 0,
|
||||
regenMultiplier: 1,
|
||||
regenBonus: 0,
|
||||
clickManaMultiplier: 1,
|
||||
clickManaBonus: 0,
|
||||
meditationEfficiency: 1,
|
||||
spellCostMultiplier: 1,
|
||||
conversionEfficiency: 1,
|
||||
baseDamageMultiplier: 1,
|
||||
baseDamageBonus: 0,
|
||||
attackSpeedMultiplier: 1,
|
||||
critChanceBonus: 0,
|
||||
critDamageMultiplier: 1.5,
|
||||
elementalDamageMultiplier: 1,
|
||||
studySpeedMultiplier: 1,
|
||||
studyCostMultiplier: 1,
|
||||
progressRetention: 0,
|
||||
instantStudyChance: 0,
|
||||
freeStudyChance: 0,
|
||||
elementCapMultiplier: 1,
|
||||
elementCapBonus: 0,
|
||||
conversionCostMultiplier: 1,
|
||||
doubleCraftChance: 0,
|
||||
permanentRegenBonus: 0,
|
||||
specials: new Set(),
|
||||
activeUpgrades: [],
|
||||
// Re-export formatting functions and computed stats for backward compatibility
|
||||
export {
|
||||
fmt,
|
||||
fmtDec,
|
||||
getFloorElement,
|
||||
computeMaxMana,
|
||||
computeRegen,
|
||||
computeClickMana,
|
||||
calcDamage,
|
||||
getMeditationBonus,
|
||||
getIncursionStrength,
|
||||
canAffordSpellCost,
|
||||
getFloorMaxHP,
|
||||
};
|
||||
|
||||
// ─── Helper Functions ─────────────────────────────────────────────────────────
|
||||
|
||||
export function fmt(n: number): string {
|
||||
if (!isFinite(n) || isNaN(n)) return '0';
|
||||
if (n >= 1e9) return (n / 1e9).toFixed(2) + 'B';
|
||||
if (n >= 1e6) return (n / 1e6).toFixed(2) + 'M';
|
||||
if (n >= 1e3) return (n / 1e3).toFixed(1) + 'K';
|
||||
return Math.floor(n).toString();
|
||||
}
|
||||
|
||||
export function fmtDec(n: number, d: number = 1): string {
|
||||
return isFinite(n) ? n.toFixed(d) : '0';
|
||||
}
|
||||
|
||||
export function getFloorMaxHP(floor: number): number {
|
||||
if (GUARDIANS[floor]) return GUARDIANS[floor].hp;
|
||||
// Improved scaling: slower early game, faster late game
|
||||
const baseHP = 100;
|
||||
const floorScaling = floor * 50;
|
||||
const exponentialScaling = Math.pow(floor, 1.7);
|
||||
return Math.floor(baseHP + floorScaling + exponentialScaling);
|
||||
}
|
||||
|
||||
export function getFloorElement(floor: number): string {
|
||||
return FLOOR_ELEM_CYCLE[(floor - 1) % 8];
|
||||
}
|
||||
|
||||
// Get all spells from equipped caster weapons (staves, wands, etc.)
|
||||
// Returns array of { spellId, equipmentInstanceId }
|
||||
function getActiveEquipmentSpells(
|
||||
equippedInstances: Record<string, string | null>,
|
||||
equipmentInstances: Record<string, EquipmentInstance>
|
||||
): Array<{ spellId: string; equipmentId: string }> {
|
||||
const spells: Array<{ spellId: string; equipmentId: string }> = [];
|
||||
|
||||
// Check main hand and off hand for caster equipment
|
||||
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;
|
||||
|
||||
// Check if this is a caster-type equipment
|
||||
const equipType = EQUIPMENT_TYPES[instance.typeId];
|
||||
if (!equipType || equipType.category !== 'caster') continue;
|
||||
|
||||
// Get spells from enchantments
|
||||
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;
|
||||
}
|
||||
|
||||
// ─── Computed Stats Functions ─────────────────────────────────────────────────
|
||||
|
||||
// Helper to get effective skill level accounting for tiers
|
||||
function getEffectiveSkillLevel(
|
||||
skills: Record<string, number>,
|
||||
baseSkillId: string,
|
||||
skillTiers: Record<string, number> = {}
|
||||
): { level: number; tier: number; tierMultiplier: number } {
|
||||
// Find the highest tier the player has for this base skill
|
||||
const currentTier = skillTiers[baseSkillId] || 1;
|
||||
|
||||
// Look for the tiered skill ID (e.g., manaFlow_t2)
|
||||
const tieredSkillId = currentTier > 1 ? `${baseSkillId}_t${currentTier}` : baseSkillId;
|
||||
const level = skills[tieredSkillId] || skills[baseSkillId] || 0;
|
||||
|
||||
// Tier multiplier: each tier is 10x more powerful
|
||||
const tierMultiplier = Math.pow(10, currentTier - 1);
|
||||
|
||||
return { level, tier: currentTier, tierMultiplier };
|
||||
}
|
||||
|
||||
export function computeMaxMana(
|
||||
state: Pick<GameState, 'skills' | 'prestigeUpgrades' | 'skillUpgrades' | 'skillTiers' | 'equipmentInstances' | 'equippedInstances'>,
|
||||
effects?: ComputedEffects | UnifiedEffects
|
||||
): number {
|
||||
const pu = state.prestigeUpgrades;
|
||||
const base =
|
||||
100 +
|
||||
(state.skills.manaWell || 0) * 100 +
|
||||
(pu.manaWell || 0) * 500;
|
||||
|
||||
// If effects not provided, compute unified effects (includes equipment)
|
||||
if (!effects && state.equipmentInstances && state.equippedInstances) {
|
||||
effects = getUnifiedEffects(state as any);
|
||||
}
|
||||
|
||||
// Apply effects if available (now includes equipment bonuses)
|
||||
if (effects) {
|
||||
return Math.floor((base + effects.maxManaBonus) * effects.maxManaMultiplier);
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
export function computeElementMax(
|
||||
state: Pick<GameState, 'skills' | 'prestigeUpgrades' | 'skillUpgrades' | 'skillTiers'>,
|
||||
effects?: ComputedEffects
|
||||
): number {
|
||||
const pu = state.prestigeUpgrades;
|
||||
const base = 10 + (state.skills.elemAttune || 0) * 50 + (pu.elementalAttune || 0) * 25;
|
||||
|
||||
// Apply upgrade effects if provided
|
||||
if (effects) {
|
||||
return Math.floor((base + effects.elementCapBonus) * effects.elementCapMultiplier);
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
export function computeRegen(
|
||||
state: Pick<GameState, 'skills' | 'prestigeUpgrades' | 'skillUpgrades' | 'skillTiers' | 'equipmentInstances' | 'equippedInstances'>,
|
||||
effects?: ComputedEffects | UnifiedEffects
|
||||
): number {
|
||||
const pu = state.prestigeUpgrades;
|
||||
const temporalBonus = 1 + (pu.temporalEcho || 0) * 0.1;
|
||||
const base =
|
||||
2 +
|
||||
(state.skills.manaFlow || 0) * 1 +
|
||||
(state.skills.manaSpring || 0) * 2 +
|
||||
(pu.manaFlow || 0) * 0.5;
|
||||
|
||||
let regen = base * temporalBonus;
|
||||
|
||||
// If effects not provided, compute unified effects (includes equipment)
|
||||
if (!effects && state.equipmentInstances && state.equippedInstances) {
|
||||
effects = getUnifiedEffects(state as any);
|
||||
}
|
||||
|
||||
// Apply effects if available (now includes equipment bonuses)
|
||||
if (effects) {
|
||||
regen = (regen + effects.regenBonus + effects.permanentRegenBonus) * effects.regenMultiplier;
|
||||
}
|
||||
|
||||
return regen;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute regen with dynamic special effects (needs current mana, max mana, incursion)
|
||||
*/
|
||||
export function computeEffectiveRegen(
|
||||
state: Pick<GameState, 'skills' | 'prestigeUpgrades' | 'rawMana' | 'incursionStrength' | 'skillUpgrades' | 'skillTiers' | 'equipmentInstances' | 'equippedInstances'>,
|
||||
effects?: ComputedEffects | UnifiedEffects
|
||||
): number {
|
||||
// Base regen from existing function
|
||||
let regen = computeRegen(state, effects);
|
||||
|
||||
const maxMana = computeMaxMana(state, effects);
|
||||
const currentMana = state.rawMana;
|
||||
const incursionStrength = state.incursionStrength || 0;
|
||||
|
||||
// Apply incursion penalty
|
||||
regen *= (1 - incursionStrength);
|
||||
|
||||
return regen;
|
||||
}
|
||||
|
||||
export function computeClickMana(
|
||||
state: Pick<GameState, 'skills' | 'skillUpgrades' | 'skillTiers' | 'equipmentInstances' | 'equippedInstances'>,
|
||||
effects?: ComputedEffects | UnifiedEffects
|
||||
): number {
|
||||
const base =
|
||||
1 +
|
||||
(state.skills.manaTap || 0) * 1 +
|
||||
(state.skills.manaSurge || 0) * 3;
|
||||
|
||||
// If effects not provided, compute unified effects (includes equipment)
|
||||
if (!effects && state.equipmentInstances && state.equippedInstances) {
|
||||
effects = getUnifiedEffects(state as any);
|
||||
}
|
||||
|
||||
// Apply effects if available (now includes equipment bonuses)
|
||||
if (effects) {
|
||||
return Math.floor((base + effects.clickManaBonus) * effects.clickManaMultiplier);
|
||||
}
|
||||
return base;
|
||||
}
|
||||
// ─── Local Helper Functions ────────────────────────────────────────────────────
|
||||
|
||||
// Elemental damage bonus: +50% if spell element opposes floor element (super effective)
|
||||
// -25% if spell element matches its own opposite (weak)
|
||||
@@ -287,129 +109,6 @@ function getElementalBonus(spellElem: string, floorElem: string): number {
|
||||
return 1.0; // Neutral
|
||||
}
|
||||
|
||||
export function calcDamage(
|
||||
state: Pick<GameState, 'skills' | 'signedPacts'>,
|
||||
spellId: string,
|
||||
floorElem?: string
|
||||
): number {
|
||||
const sp = SPELLS_DEF[spellId];
|
||||
if (!sp) return 5;
|
||||
const skills = state.skills;
|
||||
const baseDmg = sp.dmg + (skills.combatTrain || 0) * 5;
|
||||
const pct = 1 + (skills.arcaneFury || 0) * 0.1;
|
||||
|
||||
// Elemental mastery bonus
|
||||
const elemMasteryBonus = 1 + (skills.elementalMastery || 0) * 0.15;
|
||||
|
||||
// Guardian bane bonus
|
||||
const guardianBonus = floorElem && GUARDIANS[Object.values(GUARDIANS).find(g => g.element === floorElem)?.hp ? 0 : 0]
|
||||
? 1 + (skills.guardianBane || 0) * 0.2
|
||||
: 1;
|
||||
|
||||
const critChance = (skills.precision || 0) * 0.05;
|
||||
const pactMult = state.signedPacts.reduce(
|
||||
(m, f) => m * (GUARDIANS[f]?.pact || 1),
|
||||
1
|
||||
);
|
||||
|
||||
let damage = baseDmg * pct * pactMult * elemMasteryBonus;
|
||||
|
||||
// Apply elemental bonus if floor element provided
|
||||
if (floorElem) {
|
||||
damage *= getElementalBonus(sp.elem, floorElem);
|
||||
}
|
||||
|
||||
// Apply crit
|
||||
if (Math.random() < critChance) {
|
||||
damage *= 1.5;
|
||||
}
|
||||
|
||||
return damage;
|
||||
}
|
||||
|
||||
export function calcInsight(state: Pick<GameState, 'maxFloorReached' | 'totalManaGathered' | 'signedPacts' | 'prestigeUpgrades' | 'skills'>): number {
|
||||
const pu = state.prestigeUpgrades;
|
||||
const skillBonus = 1 + (state.skills.insightHarvest || 0) * 0.1;
|
||||
const mult = (1 + (pu.insightAmp || 0) * 0.25) * skillBonus;
|
||||
return Math.floor(
|
||||
(state.maxFloorReached * 15 + state.totalManaGathered / 500 + state.signedPacts.length * 150) * mult
|
||||
);
|
||||
}
|
||||
|
||||
// Meditation bonus now affects regen rate directly
|
||||
export function getMeditationBonus(meditateTicks: number, skills: Record<string, number>, meditationEfficiency: number = 1): number {
|
||||
const hasMeditation = skills.meditation === 1;
|
||||
const hasDeepTrance = skills.deepTrance === 1;
|
||||
const hasVoidMeditation = skills.voidMeditation === 1;
|
||||
|
||||
const hours = meditateTicks * HOURS_PER_TICK;
|
||||
|
||||
// Base meditation: ramps up over 4 hours to 1.5x
|
||||
let bonus = 1 + Math.min(hours / 4, 0.5);
|
||||
|
||||
// With Meditation Focus: up to 2.5x after 4 hours
|
||||
if (hasMeditation && hours >= 4) {
|
||||
bonus = 2.5;
|
||||
}
|
||||
|
||||
// With Deep Trance: up to 3.0x after 6 hours
|
||||
if (hasDeepTrance && hours >= 6) {
|
||||
bonus = 3.0;
|
||||
}
|
||||
|
||||
// With Void Meditation: up to 5.0x after 8 hours
|
||||
if (hasVoidMeditation && hours >= 8) {
|
||||
bonus = 5.0;
|
||||
}
|
||||
|
||||
// Apply meditation efficiency from upgrades (Deep Wellspring, etc.)
|
||||
bonus *= meditationEfficiency;
|
||||
|
||||
return bonus;
|
||||
}
|
||||
|
||||
export function getIncursionStrength(day: number, hour: number): number {
|
||||
if (day < INCURSION_START_DAY) return 0;
|
||||
const totalHours = (day - INCURSION_START_DAY) * 24 + hour;
|
||||
const maxHours = (MAX_DAY - INCURSION_START_DAY) * 24;
|
||||
return Math.min(0.95, (totalHours / maxHours) * 0.95);
|
||||
}
|
||||
|
||||
// Check if player can afford spell cost
|
||||
export function canAffordSpellCost(
|
||||
cost: SpellCost,
|
||||
rawMana: number,
|
||||
elements: Record<string, { current: number; max: number; unlocked: boolean }>
|
||||
): boolean {
|
||||
if (cost.type === 'raw') {
|
||||
return rawMana >= cost.amount;
|
||||
} else {
|
||||
const elem = elements[cost.element || ''];
|
||||
return elem && elem.unlocked && elem.current >= cost.amount;
|
||||
}
|
||||
}
|
||||
|
||||
// Deduct spell cost from appropriate mana pool
|
||||
function deductSpellCost(
|
||||
cost: SpellCost,
|
||||
rawMana: number,
|
||||
elements: Record<string, { current: number; max: number; unlocked: boolean }>
|
||||
): { rawMana: number; elements: Record<string, { current: number; max: number; unlocked: boolean }> } {
|
||||
const newElements = { ...elements };
|
||||
|
||||
if (cost.type === 'raw') {
|
||||
return { rawMana: rawMana - cost.amount, elements: newElements };
|
||||
} else if (cost.element && newElements[cost.element]) {
|
||||
newElements[cost.element] = {
|
||||
...newElements[cost.element],
|
||||
current: newElements[cost.element].current - cost.amount
|
||||
};
|
||||
return { rawMana, elements: newElements };
|
||||
}
|
||||
|
||||
return { rawMana, elements: newElements };
|
||||
}
|
||||
|
||||
// ─── Initial State Factory ────────────────────────────────────────────────────
|
||||
|
||||
function makeInitial(overrides: Partial<GameState> = {}): GameState {
|
||||
@@ -491,6 +190,11 @@ function makeInitial(overrides: Partial<GameState> = {}): GameState {
|
||||
currentAction: 'meditate',
|
||||
castProgress: 0,
|
||||
|
||||
// Floor Navigation
|
||||
climbDirection: 'up',
|
||||
clearedFloors: {},
|
||||
lastClearedFloor: null,
|
||||
|
||||
spells: startSpells,
|
||||
skills: overrides.skills || {},
|
||||
skillProgress: {},
|
||||
@@ -556,6 +260,9 @@ function makeInitial(overrides: Partial<GameState> = {}): GameState {
|
||||
},
|
||||
lootDropsToday: 0,
|
||||
|
||||
// Equipment Crafting Progress
|
||||
equipmentCraftingProgress: null,
|
||||
|
||||
// Achievements
|
||||
achievements: {
|
||||
unlocked: [],
|
||||
@@ -578,17 +285,12 @@ function makeInitial(overrides: Partial<GameState> = {}): GameState {
|
||||
|
||||
// ─── Game Store ───────────────────────────────────────────────────────────────
|
||||
|
||||
interface GameStore extends GameState, CraftingActions, FamiliarActions {
|
||||
interface GameStore extends GameState, CraftingActions, FamiliarActions, NavigationActions, StudyActions {
|
||||
// Actions
|
||||
tick: () => void;
|
||||
gatherMana: () => void;
|
||||
setAction: (action: GameAction) => void;
|
||||
setSpell: (spellId: string) => void;
|
||||
startStudyingSkill: (skillId: string) => void;
|
||||
startStudyingSpell: (spellId: string) => void;
|
||||
startParallelStudySkill: (skillId: string) => void;
|
||||
cancelStudy: () => void;
|
||||
cancelParallelStudy: () => void;
|
||||
convertMana: (element: string, amount: number) => void;
|
||||
unlockElement: (element: string) => void;
|
||||
craftComposite: (target: string) => void;
|
||||
@@ -602,6 +304,9 @@ interface GameStore extends GameState, CraftingActions, FamiliarActions {
|
||||
commitSkillUpgrades: (skillId: string, upgradeIds: string[]) => void;
|
||||
tierUpSkill: (skillId: string) => void;
|
||||
|
||||
// Inventory Management
|
||||
updateLootInventory: (inventory: LootInventory) => void;
|
||||
|
||||
// Computed getters
|
||||
getMaxMana: () => number;
|
||||
getRegen: () => number;
|
||||
@@ -617,6 +322,8 @@ export const useGameStore = create<GameStore>()(
|
||||
(set, get) => ({
|
||||
...makeInitial(),
|
||||
...createFamiliarSlice(set, get),
|
||||
...createNavigationSlice(set, get),
|
||||
...createStudySlice(set, get),
|
||||
|
||||
getMaxMana: () => computeMaxMana(get()),
|
||||
getRegen: () => computeRegen(get()),
|
||||
@@ -967,6 +674,12 @@ export const useGameStore = create<GameStore>()(
|
||||
if (floorHP <= 0) {
|
||||
// Floor cleared
|
||||
const wasGuardian = GUARDIANS[currentFloor];
|
||||
const clearedFloors = state.clearedFloors;
|
||||
const climbDirection = state.climbDirection;
|
||||
|
||||
// Mark this floor as cleared (needs respawn if we leave and return)
|
||||
clearedFloors[currentFloor] = true;
|
||||
const lastClearedFloor = currentFloor;
|
||||
|
||||
// ─── Loot Drop System ───
|
||||
const lootDrops = rollLootDrops(currentFloor, !!wasGuardian, 0);
|
||||
@@ -1003,11 +716,22 @@ export const useGameStore = create<GameStore>()(
|
||||
}
|
||||
}
|
||||
|
||||
currentFloor = currentFloor + 1;
|
||||
if (currentFloor > 100) {
|
||||
currentFloor = 100;
|
||||
}
|
||||
// Move to next floor based on direction
|
||||
const nextFloor = climbDirection === 'up'
|
||||
? Math.min(currentFloor + 1, 100)
|
||||
: Math.max(currentFloor - 1, 1);
|
||||
|
||||
currentFloor = nextFloor;
|
||||
floorMaxHP = getFloorMaxHP(currentFloor);
|
||||
|
||||
// Check if this floor was previously cleared (has enemies respawned?)
|
||||
// Floors respawn when you leave them and come back
|
||||
const floorWasCleared = clearedFloors[currentFloor];
|
||||
if (floorWasCleared) {
|
||||
// Floor has respawned - reset it but mark as uncleared
|
||||
delete clearedFloors[currentFloor];
|
||||
}
|
||||
|
||||
floorHP = floorMaxHP;
|
||||
maxFloorReached = Math.max(maxFloorReached, currentFloor);
|
||||
|
||||
@@ -1019,6 +743,10 @@ export const useGameStore = create<GameStore>()(
|
||||
// Reset ALL spell progress on floor change
|
||||
equipmentSpellStates = equipmentSpellStates.map(s => ({ ...s, castProgress: 0 }));
|
||||
spellState = { ...spellState, castProgress: 0 };
|
||||
|
||||
// Update clearedFloors in the state
|
||||
set((s) => ({ ...s, clearedFloors, lastClearedFloor }));
|
||||
|
||||
break; // Exit the while loop - new floor
|
||||
}
|
||||
}
|
||||
@@ -1184,108 +912,6 @@ export const useGameStore = create<GameStore>()(
|
||||
}
|
||||
},
|
||||
|
||||
startStudyingSkill: (skillId: string) => {
|
||||
const state = get();
|
||||
const sk = SKILLS_DEF[skillId];
|
||||
if (!sk) return;
|
||||
|
||||
const currentLevel = state.skills[skillId] || 0;
|
||||
if (currentLevel >= sk.max) return;
|
||||
|
||||
// Check prerequisites
|
||||
if (sk.req) {
|
||||
for (const [r, rl] of Object.entries(sk.req)) {
|
||||
if ((state.skills[r] || 0) < rl) return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check mana cost (with focused mind reduction)
|
||||
const costMult = getStudyCostMultiplier(state.skills);
|
||||
const cost = Math.floor(sk.base * (currentLevel + 1) * costMult);
|
||||
if (state.rawMana < cost) return;
|
||||
|
||||
// Start studying
|
||||
set({
|
||||
rawMana: state.rawMana - cost,
|
||||
currentAction: 'study',
|
||||
currentStudyTarget: {
|
||||
type: 'skill',
|
||||
id: skillId,
|
||||
progress: state.skillProgress[skillId] || 0,
|
||||
required: sk.studyTime,
|
||||
},
|
||||
log: [`📚 Started studying ${sk.name}...`, ...state.log.slice(0, 49)],
|
||||
});
|
||||
},
|
||||
|
||||
startStudyingSpell: (spellId: string) => {
|
||||
const state = get();
|
||||
const sp = SPELLS_DEF[spellId];
|
||||
if (!sp || state.spells[spellId]?.learned) return;
|
||||
|
||||
// Check mana cost (with focused mind reduction)
|
||||
const costMult = getStudyCostMultiplier(state.skills);
|
||||
const cost = Math.floor(sp.unlock * costMult);
|
||||
if (state.rawMana < cost) return;
|
||||
|
||||
const studyTime = sp.studyTime || (sp.tier * 4); // Default study time based on tier
|
||||
|
||||
// Start studying
|
||||
set({
|
||||
rawMana: state.rawMana - cost,
|
||||
currentAction: 'study',
|
||||
currentStudyTarget: {
|
||||
type: 'spell',
|
||||
id: spellId,
|
||||
progress: state.spells[spellId]?.studyProgress || 0,
|
||||
required: studyTime,
|
||||
},
|
||||
spells: {
|
||||
...state.spells,
|
||||
[spellId]: { ...(state.spells[spellId] || { learned: false, level: 0 }), studyProgress: state.spells[spellId]?.studyProgress || 0 },
|
||||
},
|
||||
log: [`📚 Started studying ${sp.name}...`, ...state.log.slice(0, 49)],
|
||||
});
|
||||
},
|
||||
|
||||
cancelStudy: () => {
|
||||
const state = get();
|
||||
if (!state.currentStudyTarget) return;
|
||||
|
||||
// Knowledge retention bonus
|
||||
const retentionBonus = 1 + (state.skills.knowledgeRetention || 0) * 0.2;
|
||||
const savedProgress = Math.min(
|
||||
state.currentStudyTarget.progress,
|
||||
state.currentStudyTarget.required * retentionBonus
|
||||
);
|
||||
|
||||
// Save progress
|
||||
if (state.currentStudyTarget.type === 'skill') {
|
||||
set({
|
||||
currentStudyTarget: null,
|
||||
currentAction: 'meditate',
|
||||
skillProgress: {
|
||||
...state.skillProgress,
|
||||
[state.currentStudyTarget.id]: savedProgress,
|
||||
},
|
||||
log: [`📖 Study interrupted. Progress saved.`, ...state.log.slice(0, 49)],
|
||||
});
|
||||
} else if (state.currentStudyTarget.type === 'spell') {
|
||||
set({
|
||||
currentStudyTarget: null,
|
||||
currentAction: 'meditate',
|
||||
spells: {
|
||||
...state.spells,
|
||||
[state.currentStudyTarget.id]: {
|
||||
...(state.spells[state.currentStudyTarget.id] || { learned: false, level: 0 }),
|
||||
studyProgress: savedProgress,
|
||||
},
|
||||
},
|
||||
log: [`📖 Study interrupted. Progress saved.`, ...state.log.slice(0, 49)],
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
convertMana: (element: string, amount: number = 1) => {
|
||||
const state = get();
|
||||
const e = state.elements[element];
|
||||
@@ -1491,41 +1117,6 @@ export const useGameStore = create<GameStore>()(
|
||||
});
|
||||
},
|
||||
|
||||
startParallelStudySkill: (skillId: string) => {
|
||||
const state = get();
|
||||
if (state.parallelStudyTarget) return; // Already have parallel study
|
||||
if (!state.currentStudyTarget) return; // Need primary study
|
||||
|
||||
const sk = SKILLS_DEF[skillId];
|
||||
if (!sk) return;
|
||||
|
||||
const currentLevel = state.skills[skillId] || 0;
|
||||
if (currentLevel >= sk.max) return;
|
||||
|
||||
// Can't study same thing in parallel
|
||||
if (state.currentStudyTarget.id === skillId) return;
|
||||
|
||||
set({
|
||||
parallelStudyTarget: {
|
||||
type: 'skill',
|
||||
id: skillId,
|
||||
progress: state.skillProgress[skillId] || 0,
|
||||
required: sk.studyTime,
|
||||
},
|
||||
log: [`📚 Started parallel study of ${sk.name}... (50% speed)`, ...state.log.slice(0, 49)],
|
||||
});
|
||||
},
|
||||
|
||||
cancelParallelStudy: () => {
|
||||
set((state) => {
|
||||
if (!state.parallelStudyTarget) return state;
|
||||
return {
|
||||
parallelStudyTarget: null,
|
||||
log: ['📖 Parallel study cancelled.', ...state.log.slice(0, 49)],
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
getSkillUpgradeChoices: (skillId: string, milestone: 5 | 10) => {
|
||||
const state = get();
|
||||
const baseSkillId = skillId.includes('_t') ? skillId.split('_t')[0] : skillId;
|
||||
@@ -1637,6 +1228,10 @@ export const useGameStore = create<GameStore>()(
|
||||
});
|
||||
},
|
||||
|
||||
updateLootInventory: (inventory: LootInventory) => {
|
||||
set({ lootInventory: inventory });
|
||||
},
|
||||
|
||||
startDesigningEnchantment: (name: string, equipmentTypeId: string, effects: DesignEffect[]) => {
|
||||
const state = get();
|
||||
|
||||
@@ -1874,6 +1469,98 @@ export const useGameStore = create<GameStore>()(
|
||||
if (!instance) return 0;
|
||||
return instance.totalCapacity - instance.usedCapacity;
|
||||
},
|
||||
|
||||
// ─── Equipment Crafting (from blueprints) ───────────────────────────────────
|
||||
|
||||
startCraftingEquipment: (blueprintId: string) => {
|
||||
const state = get();
|
||||
const recipe = CRAFTING_RECIPES[blueprintId];
|
||||
if (!recipe) return false;
|
||||
|
||||
// Check if player has the blueprint
|
||||
if (!state.lootInventory.blueprints.includes(blueprintId)) return false;
|
||||
|
||||
// Check materials and mana
|
||||
const { canCraft } = canCraftRecipe(
|
||||
recipe,
|
||||
state.lootInventory.materials,
|
||||
state.rawMana
|
||||
);
|
||||
|
||||
if (!canCraft) return false;
|
||||
|
||||
// Deduct materials
|
||||
const newMaterials = { ...state.lootInventory.materials };
|
||||
for (const [matId, amount] of Object.entries(recipe.materials)) {
|
||||
newMaterials[matId] = (newMaterials[matId] || 0) - amount;
|
||||
if (newMaterials[matId] <= 0) {
|
||||
delete newMaterials[matId];
|
||||
}
|
||||
}
|
||||
|
||||
// Start crafting progress
|
||||
set((state) => ({
|
||||
lootInventory: {
|
||||
...state.lootInventory,
|
||||
materials: newMaterials,
|
||||
},
|
||||
rawMana: state.rawMana - recipe.manaCost,
|
||||
currentAction: 'craft',
|
||||
equipmentCraftingProgress: {
|
||||
blueprintId,
|
||||
equipmentTypeId: recipe.equipmentTypeId,
|
||||
progress: 0,
|
||||
required: recipe.craftTime,
|
||||
manaSpent: recipe.manaCost,
|
||||
},
|
||||
log: [`🔨 Started crafting ${recipe.name}...`, ...state.log.slice(0, 49)],
|
||||
}));
|
||||
|
||||
return true;
|
||||
},
|
||||
|
||||
cancelEquipmentCrafting: () => {
|
||||
set((state) => {
|
||||
const progress = state.equipmentCraftingProgress;
|
||||
if (!progress) return {};
|
||||
|
||||
const recipe = CRAFTING_RECIPES[progress.blueprintId];
|
||||
if (!recipe) return { currentAction: 'meditate', equipmentCraftingProgress: null };
|
||||
|
||||
// Refund 50% of mana
|
||||
const manaRefund = Math.floor(progress.manaSpent * 0.5);
|
||||
|
||||
return {
|
||||
currentAction: 'meditate',
|
||||
equipmentCraftingProgress: null,
|
||||
rawMana: state.rawMana + manaRefund,
|
||||
log: [`🚫 Crafting cancelled. Refunded ${manaRefund} mana.`, ...state.log.slice(0, 49)],
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
deleteMaterial: (materialId: string, amount: number) => {
|
||||
set((state) => {
|
||||
const currentAmount = state.lootInventory.materials[materialId] || 0;
|
||||
const newAmount = Math.max(0, currentAmount - amount);
|
||||
const newMaterials = { ...state.lootInventory.materials };
|
||||
|
||||
if (newAmount <= 0) {
|
||||
delete newMaterials[materialId];
|
||||
} else {
|
||||
newMaterials[materialId] = newAmount;
|
||||
}
|
||||
|
||||
const dropName = LOOT_DROPS[materialId]?.name || materialId;
|
||||
return {
|
||||
lootInventory: {
|
||||
...state.lootInventory,
|
||||
materials: newMaterials,
|
||||
},
|
||||
log: [`🗑️ Deleted ${amount}x ${dropName}.`, ...state.log.slice(0, 49)],
|
||||
};
|
||||
});
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'mana-loop-storage',
|
||||
@@ -1908,6 +1595,9 @@ export const useGameStore = create<GameStore>()(
|
||||
activeSpell: state.activeSpell,
|
||||
currentAction: state.currentAction,
|
||||
castProgress: state.castProgress,
|
||||
climbDirection: state.climbDirection,
|
||||
clearedFloors: state.clearedFloors,
|
||||
lastClearedFloor: state.lastClearedFloor,
|
||||
spells: state.spells,
|
||||
skills: state.skills,
|
||||
skillProgress: state.skillProgress,
|
||||
@@ -1928,6 +1618,19 @@ export const useGameStore = create<GameStore>()(
|
||||
designProgress: state.designProgress,
|
||||
preparationProgress: state.preparationProgress,
|
||||
applicationProgress: state.applicationProgress,
|
||||
// Loot system
|
||||
lootInventory: state.lootInventory,
|
||||
lootDropsToday: state.lootDropsToday,
|
||||
// Achievements
|
||||
achievements: state.achievements,
|
||||
totalDamageDealt: state.totalDamageDealt,
|
||||
totalSpellsCast: state.totalSpellsCast,
|
||||
totalCraftsCompleted: state.totalCraftsCompleted,
|
||||
// Familiars
|
||||
familiars: state.familiars,
|
||||
activeFamiliarSlots: state.activeFamiliarSlots,
|
||||
familiarSummonProgress: state.familiarSummonProgress,
|
||||
totalFamiliarXpEarned: state.totalFamiliarXpEarned,
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
166
src/lib/game/study-slice.ts
Normal file
166
src/lib/game/study-slice.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
// ─── Study Slice ─────────────────────────────────────────────────────────────
|
||||
// Actions for studying skills and spells
|
||||
|
||||
import type { GameState } from './types';
|
||||
import { SKILLS_DEF, SPELLS_DEF, getStudyCostMultiplier } from './constants';
|
||||
|
||||
// ─── Study Actions Interface ──────────────────────────────────────────────────
|
||||
|
||||
export interface StudyActions {
|
||||
startStudyingSkill: (skillId: string) => void;
|
||||
startStudyingSpell: (spellId: string) => void;
|
||||
cancelStudy: () => void;
|
||||
startParallelStudySkill: (skillId: string) => void;
|
||||
cancelParallelStudy: () => void;
|
||||
}
|
||||
|
||||
// ─── Study Slice Factory ──────────────────────────────────────────────────────
|
||||
|
||||
export function createStudySlice(
|
||||
set: (partial: Partial<GameState> | ((state: GameState) => Partial<GameState>)) => void,
|
||||
get: () => GameState
|
||||
): StudyActions {
|
||||
return {
|
||||
// Start studying a skill
|
||||
startStudyingSkill: (skillId: string) => {
|
||||
const state = get();
|
||||
const sk = SKILLS_DEF[skillId];
|
||||
if (!sk) return;
|
||||
|
||||
const currentLevel = state.skills[skillId] || 0;
|
||||
if (currentLevel >= sk.max) return;
|
||||
|
||||
// Check prerequisites
|
||||
if (sk.req) {
|
||||
for (const [r, rl] of Object.entries(sk.req)) {
|
||||
if ((state.skills[r] || 0) < rl) return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check mana cost (with focused mind reduction)
|
||||
const costMult = getStudyCostMultiplier(state.skills);
|
||||
const cost = Math.floor(sk.base * (currentLevel + 1) * costMult);
|
||||
if (state.rawMana < cost) return;
|
||||
|
||||
// Start studying
|
||||
set({
|
||||
rawMana: state.rawMana - cost,
|
||||
currentAction: 'study',
|
||||
currentStudyTarget: {
|
||||
type: 'skill',
|
||||
id: skillId,
|
||||
progress: state.skillProgress[skillId] || 0,
|
||||
required: sk.studyTime,
|
||||
},
|
||||
log: [`📚 Started studying ${sk.name}...`, ...state.log.slice(0, 49)],
|
||||
});
|
||||
},
|
||||
|
||||
// Start studying a spell
|
||||
startStudyingSpell: (spellId: string) => {
|
||||
const state = get();
|
||||
const sp = SPELLS_DEF[spellId];
|
||||
if (!sp || state.spells[spellId]?.learned) return;
|
||||
|
||||
// Check mana cost (with focused mind reduction)
|
||||
const costMult = getStudyCostMultiplier(state.skills);
|
||||
const cost = Math.floor(sp.unlock * costMult);
|
||||
if (state.rawMana < cost) return;
|
||||
|
||||
const studyTime = sp.studyTime || (sp.tier * 4); // Default study time based on tier
|
||||
|
||||
// Start studying
|
||||
set({
|
||||
rawMana: state.rawMana - cost,
|
||||
currentAction: 'study',
|
||||
currentStudyTarget: {
|
||||
type: 'spell',
|
||||
id: spellId,
|
||||
progress: state.spells[spellId]?.studyProgress || 0,
|
||||
required: studyTime,
|
||||
},
|
||||
spells: {
|
||||
...state.spells,
|
||||
[spellId]: { ...(state.spells[spellId] || { learned: false, level: 0 }), studyProgress: state.spells[spellId]?.studyProgress || 0 },
|
||||
},
|
||||
log: [`📚 Started studying ${sp.name}...`, ...state.log.slice(0, 49)],
|
||||
});
|
||||
},
|
||||
|
||||
// Cancel current study (saves progress)
|
||||
cancelStudy: () => {
|
||||
const state = get();
|
||||
if (!state.currentStudyTarget) return;
|
||||
|
||||
// Knowledge retention bonus
|
||||
const retentionBonus = 1 + (state.skills.knowledgeRetention || 0) * 0.2;
|
||||
const savedProgress = Math.min(
|
||||
state.currentStudyTarget.progress,
|
||||
state.currentStudyTarget.required * retentionBonus
|
||||
);
|
||||
|
||||
// Save progress
|
||||
if (state.currentStudyTarget.type === 'skill') {
|
||||
set({
|
||||
currentStudyTarget: null,
|
||||
currentAction: 'meditate',
|
||||
skillProgress: {
|
||||
...state.skillProgress,
|
||||
[state.currentStudyTarget.id]: savedProgress,
|
||||
},
|
||||
log: [`📖 Study interrupted. Progress saved.`, ...state.log.slice(0, 49)],
|
||||
});
|
||||
} else if (state.currentStudyTarget.type === 'spell') {
|
||||
set({
|
||||
currentStudyTarget: null,
|
||||
currentAction: 'meditate',
|
||||
spells: {
|
||||
...state.spells,
|
||||
[state.currentStudyTarget.id]: {
|
||||
...(state.spells[state.currentStudyTarget.id] || { learned: false, level: 0 }),
|
||||
studyProgress: savedProgress,
|
||||
},
|
||||
},
|
||||
log: [`📖 Study interrupted. Progress saved.`, ...state.log.slice(0, 49)],
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// Start parallel study of a skill (requires Parallel Mind upgrade)
|
||||
startParallelStudySkill: (skillId: string) => {
|
||||
const state = get();
|
||||
if (state.parallelStudyTarget) return; // Already have parallel study
|
||||
if (!state.currentStudyTarget) return; // Need primary study
|
||||
|
||||
const sk = SKILLS_DEF[skillId];
|
||||
if (!sk) return;
|
||||
|
||||
const currentLevel = state.skills[skillId] || 0;
|
||||
if (currentLevel >= sk.max) return;
|
||||
|
||||
// Can't study same thing in parallel
|
||||
if (state.currentStudyTarget.id === skillId) return;
|
||||
|
||||
set({
|
||||
parallelStudyTarget: {
|
||||
type: 'skill',
|
||||
id: skillId,
|
||||
progress: state.skillProgress[skillId] || 0,
|
||||
required: sk.studyTime,
|
||||
},
|
||||
log: [`📚 Started parallel study of ${sk.name}... (50% speed)`, ...state.log.slice(0, 49)],
|
||||
});
|
||||
},
|
||||
|
||||
// Cancel parallel study
|
||||
cancelParallelStudy: () => {
|
||||
set((state) => {
|
||||
if (!state.parallelStudyTarget) return state;
|
||||
return {
|
||||
parallelStudyTarget: null,
|
||||
log: ['📖 Parallel study cancelled.', ...state.log.slice(0, 49)],
|
||||
};
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -206,6 +206,15 @@ export interface ApplicationProgress {
|
||||
manaSpent: number; // Total mana spent so far
|
||||
}
|
||||
|
||||
// Equipment Crafting Progress (crafting from blueprints)
|
||||
export interface EquipmentCraftingProgress {
|
||||
blueprintId: string; // Blueprint being crafted
|
||||
equipmentTypeId: string; // Resulting equipment type
|
||||
progress: number; // Hours spent crafting
|
||||
required: number; // Total hours needed
|
||||
manaSpent: number; // Mana spent so far
|
||||
}
|
||||
|
||||
// Equipment spell state (for multi-spell casting)
|
||||
export interface EquipmentSpellState {
|
||||
spellId: string;
|
||||
@@ -413,6 +422,11 @@ export interface GameState {
|
||||
currentAction: GameAction;
|
||||
castProgress: number; // Progress towards next spell cast (0-1)
|
||||
|
||||
// Floor Navigation
|
||||
climbDirection: 'up' | 'down'; // Direction of floor traversal
|
||||
clearedFloors: Record<number, boolean>; // Floors that have been cleared (need respawn)
|
||||
lastClearedFloor: number | null; // Last floor that was cleared (for respawn tracking)
|
||||
|
||||
// Spells
|
||||
spells: Record<string, SpellState>;
|
||||
|
||||
@@ -431,6 +445,7 @@ export interface GameState {
|
||||
designProgress: DesignProgress | null;
|
||||
preparationProgress: PreparationProgress | null;
|
||||
applicationProgress: ApplicationProgress | null;
|
||||
equipmentCraftingProgress: EquipmentCraftingProgress | null;
|
||||
|
||||
// Unlocked enchantment effects for designing
|
||||
unlockedEffects: string[]; // Effect IDs that have been researched
|
||||
|
||||
131
worklog.md
131
worklog.md
@@ -309,3 +309,134 @@ Stage Summary:
|
||||
- Multi-casting is fully functional
|
||||
- Game compiles and runs without errors
|
||||
- Lint passes with no issues
|
||||
|
||||
---
|
||||
## Task ID: 3 - Component Refactoring
|
||||
### Work Task
|
||||
Refactor `/home/z/my-project/src/app/page.tsx` to use existing tab components and extract new components. The file was ~2500 lines with inline render functions duplicating existing tab components.
|
||||
|
||||
### Work Summary
|
||||
**Components Created:**
|
||||
1. `ActionButtons.tsx` - Extracted from `renderActionButtons()`, handles main action buttons (Meditate, Climb, Study, Convert) and crafting action buttons
|
||||
2. `CalendarDisplay.tsx` - Extracted from `renderCalendar()`, renders the day calendar with incursion indicators
|
||||
3. `CraftingProgress.tsx` - Extracted from `renderCraftingProgress()`, shows design/preparation/application progress bars
|
||||
4. `StudyProgress.tsx` - Extracted from `renderStudyProgress()`, displays current study progress with cancel button
|
||||
5. `ManaDisplay.tsx` - New component for mana/gathering section with progress bar and gather button
|
||||
6. `TimeDisplay.tsx` - New component for day/hour display with pause toggle
|
||||
|
||||
**Tab Components Updated:**
|
||||
- `SpireTab.tsx` - Updated to include all functionality from inline version:
|
||||
- Multi-spell support with activeEquipmentSpells
|
||||
- Individual cast progress bars for each spell
|
||||
- DPS calculation for multiple spells
|
||||
- Parallel study support
|
||||
- Crafting progress display
|
||||
- ComboMeter integration
|
||||
- Known Spells display
|
||||
- Activity Log
|
||||
|
||||
**File Changes:**
|
||||
- `page.tsx` reduced from ~2555 lines to 1695 lines (34% reduction)
|
||||
- Removed inline render functions: `renderCalendar`, `renderActionButtons`, `renderCraftingProgress`, `renderStudyProgress`, `renderSpireTab`, `renderSpellsTab`, `renderLabTab`
|
||||
- Updated imports to use extracted components
|
||||
- Cleaned up unused imports (`MAX_DAY`, `INCURSION_START_DAY`, `MANA_PER_ELEMENT`)
|
||||
- Created `tabs/index.ts` for cleaner tab component exports
|
||||
- Updated `game/index.ts` to export all new components
|
||||
|
||||
**Results:**
|
||||
- All lint checks pass
|
||||
- Functionality preserved - all features working as before
|
||||
- Better code organization with reusable components
|
||||
- Easier maintenance with separated concerns
|
||||
|
||||
---
|
||||
## Task ID: 1 - Code Extraction
|
||||
### Work Task
|
||||
Extract computed stats and utility functions from `/home/z/my-project/src/lib/game/store.ts` into a new file `/home/z/my-project/src/lib/game/computed-stats.ts`. The store.ts was ~2100 lines with functions that could be better organized in a separate module.
|
||||
|
||||
### Work Summary
|
||||
**Created New File:** `computed-stats.ts`
|
||||
|
||||
**Functions Extracted:**
|
||||
1. `DEFAULT_EFFECTS` constant - Default empty effects object for computed effects
|
||||
2. `fmt` and `fmtDec` - Number formatting utilities (K, M, B suffixes)
|
||||
3. `getFloorMaxHP` - Floor HP calculation with guardian and scaling logic
|
||||
4. `getFloorElement` - Floor element determination from cycle
|
||||
5. `getActiveEquipmentSpells` - Helper to get all spells from equipped caster weapons
|
||||
6. `getEffectiveSkillLevel` - Helper for tiered skill level calculation
|
||||
7. `computeMaxMana` - Maximum mana calculation with effects
|
||||
8. `computeElementMax` - Elemental mana capacity calculation
|
||||
9. `computeRegen` - Mana regeneration rate calculation
|
||||
10. `computeEffectiveRegen` - Regen with dynamic special effects
|
||||
11. `computeClickMana` - Click mana gain calculation
|
||||
12. `getElementalBonus` - Elemental damage bonus helper
|
||||
13. `calcDamage` - Damage calculation with skills, pacts, and crits
|
||||
14. `calcInsight` - End-of-loop insight calculation
|
||||
15. `getMeditationBonus` - Meditation multiplier calculation
|
||||
16. `getIncursionStrength` - Time-based incursion penalty
|
||||
17. `canAffordSpellCost` - Check if player can afford spell
|
||||
18. `deductSpellCost` - Deduct spell cost from mana pool
|
||||
|
||||
**Files Updated:**
|
||||
- `store.ts`:
|
||||
- Added imports from computed-stats.ts
|
||||
- Removed duplicate function definitions
|
||||
- Kept local `getElementalBonus` helper (used only in store)
|
||||
- Re-exports `fmt` and `fmtDec` for backward compatibility
|
||||
- `formatting.ts`:
|
||||
- Added re-exports of `fmt` and `fmtDec` from computed-stats.ts
|
||||
|
||||
**Results:**
|
||||
- All lint checks pass
|
||||
- No functionality changed - pure refactoring
|
||||
- Better code organization with computed stats in dedicated module
|
||||
- Easier to test and maintain individual functions
|
||||
- Backward compatible - existing imports still work
|
||||
|
||||
---
|
||||
## Task ID: 2 - Store Slice Refactoring
|
||||
### Work Task
|
||||
Create two new store slices to reduce the size of `/home/z/my-project/src/lib/game/store.ts`:
|
||||
1. `navigation-slice.ts` - Extract navigation-related actions
|
||||
2. `study-slice.ts` - Extract study-related actions
|
||||
|
||||
### Work Summary
|
||||
**Created New Files:**
|
||||
|
||||
**1. `navigation-slice.ts`** - Floor navigation actions:
|
||||
- `NavigationActions` interface defining the action types
|
||||
- `createNavigationSlice()` factory function
|
||||
- `setClimbDirection()` - Set climbing direction (up/down)
|
||||
- `changeFloor()` - Manually change floors with respawn logic
|
||||
|
||||
**2. `study-slice.ts`** - Study system actions:
|
||||
- `StudyActions` interface defining the action types
|
||||
- `createStudySlice()` factory function
|
||||
- `startStudyingSkill()` - Begin studying a skill with prerequisite and cost checks
|
||||
- `startStudyingSpell()` - Begin studying a spell with cost checks
|
||||
- `cancelStudy()` - Cancel current study with progress retention bonus
|
||||
- `startParallelStudySkill()` - Start parallel study (requires Parallel Mind upgrade)
|
||||
- `cancelParallelStudy()` - Cancel parallel study
|
||||
|
||||
**Files Updated:**
|
||||
- `store.ts`:
|
||||
- Added imports for `createNavigationSlice` and `createStudySlice`
|
||||
- Added imports for `NavigationActions` and `StudyActions` interfaces
|
||||
- Updated `GameStore` interface to extend both new action interfaces
|
||||
- Spread the new slices into the store
|
||||
- Removed duplicated action implementations
|
||||
- Added re-exports for computed stats functions (`getFloorElement`, `computeMaxMana`, `computeRegen`, `computeClickMana`, `calcDamage`, `getMeditationBonus`, `getIncursionStrength`, `canAffordSpellCost`, `getFloorMaxHP`)
|
||||
- Added missing imports for `EQUIPMENT_TYPES` and `EquipmentInstance`
|
||||
|
||||
**Pattern Followed:**
|
||||
- Followed existing slice patterns from `familiar-slice.ts` and `crafting-slice.ts`
|
||||
- Used factory function pattern that accepts `set` and `get` from Zustand
|
||||
- Exported both the interface and factory function
|
||||
- Proper TypeScript typing throughout
|
||||
|
||||
**Results:**
|
||||
- All lint checks pass
|
||||
- No functionality changed - pure refactoring
|
||||
- Reduced store.ts size by extracting ~100 lines of action implementations
|
||||
- Better code organization with navigation and study logic in dedicated modules
|
||||
- Easier to maintain and extend individual features
|
||||
|
||||
Reference in New Issue
Block a user