refactor: Major codebase refactoring for maintainability
All checks were successful
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 3m4s
All checks were successful
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 3m4s
Store refactoring (2138 → 1651 lines, 23% reduction): - Extract computed-stats.ts with 18 utility functions - Extract navigation-slice.ts for floor navigation actions - Extract study-slice.ts for study-related actions - Move fmt/fmtDec to computed-stats, re-export from formatting Page refactoring (2554 → 1695 lines, 34% reduction): - Use existing SpireTab component instead of inline render - Extract ActionButtons component - Extract CalendarDisplay component - Extract CraftingProgress component - Extract StudyProgress component - Extract ManaDisplay component - Extract TimeDisplay component - Create tabs/index.ts for cleaner exports This improves code organization and makes the codebase more maintainable.
This commit is contained in:
909
src/app/page.tsx
909
src/app/page.tsx
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useGameStore, useGameLoop, fmt, fmtDec, getFloorElement, computeMaxMana, computeRegen, computeClickMana, calcDamage, getMeditationBonus, getIncursionStrength, canAffordSpellCost } from '@/lib/game/store';
|
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 { ENCHANTMENT_EFFECTS } from '@/lib/game/data/enchantment-effects';
|
||||||
import { EQUIPMENT_TYPES } from '@/lib/game/data/equipment';
|
import { EQUIPMENT_TYPES } from '@/lib/game/data/equipment';
|
||||||
import { LOOT_DROPS } from '@/lib/game/data/loot-drops';
|
import { LOOT_DROPS } from '@/lib/game/data/loot-drops';
|
||||||
@@ -21,9 +21,9 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp
|
|||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
||||||
import { Sparkles, Swords, BookOpen, FlaskConical, Trophy, RotateCcw, Pause, Play, Zap, Clock, Target, Flame, Droplet, Wind, Mountain, Sun, Moon, Leaf, Skull, Brain, Link, Wind as Force, Droplets, TreeDeciduous, Hourglass, Gem, Star, CircleDot, X, Circle, BarChart3, Award, Package, Heart, ChevronUp, ChevronDown } 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 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 { 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 { LootInventoryDisplay } from '@/components/game/LootInventory';
|
||||||
import { AchievementsDisplay } from '@/components/game/AchievementsDisplay';
|
import { AchievementsDisplay } from '@/components/game/AchievementsDisplay';
|
||||||
import { ACHIEVEMENTS } from '@/lib/game/data/achievements';
|
import { ACHIEVEMENTS } from '@/lib/game/data/achievements';
|
||||||
@@ -237,279 +237,6 @@ export default function ManaLoopGame() {
|
|||||||
// Format time (use shared utility)
|
// Format time (use shared utility)
|
||||||
const formatTime = formatHour;
|
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
|
// Check if spell can be cast
|
||||||
const canCastSpell = (spellId: string): boolean => {
|
const canCastSpell = (spellId: string): boolean => {
|
||||||
const spell = SPELLS_DEF[spellId];
|
const spell = SPELLS_DEF[spellId];
|
||||||
@@ -517,610 +244,8 @@ export default function ManaLoopGame() {
|
|||||||
return canAffordSpellCost(spell.cost, store.rawMana, store.elements);
|
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" />
|
|
||||||
|
|
||||||
{/* 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={(store.climbDirection || 'up') === 'up' ? 'default' : 'outline'}
|
|
||||||
size="sm"
|
|
||||||
className={`h-7 px-2 ${(store.climbDirection || 'up') === '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={store.climbDirection === 'down' ? 'default' : 'outline'}
|
|
||||||
size="sm"
|
|
||||||
className={`h-7 px-2 ${store.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>
|
|
||||||
|
|
||||||
{store.clearedFloors?.[store.currentFloor] && (
|
|
||||||
<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>
|
|
||||||
</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
|
// Check if skill has milestone available
|
||||||
const hasMilestoneUpgrade = (skillId: string, level: number): { milestone: 5 | 10; hasUpgrades: boolean; selectedCount: number } | null => {
|
const hasMilestoneUpgrade = (skillId: string, level: number): { milestone: 5 | 10; hasUpgrades: boolean; selectedCount: number } | null => {
|
||||||
@@ -1279,7 +404,12 @@ export default function ManaLoopGame() {
|
|||||||
{store.currentStudyTarget && store.currentStudyTarget.type === 'skill' && (
|
{store.currentStudyTarget && store.currentStudyTarget.type === 'skill' && (
|
||||||
<Card className="bg-gray-900/80 border-purple-600/50">
|
<Card className="bg-gray-900/80 border-purple-600/50">
|
||||||
<CardContent className="pt-4">
|
<CardContent className="pt-4">
|
||||||
{renderStudyProgress()}
|
<StudyProgress
|
||||||
|
currentStudyTarget={store.currentStudyTarget}
|
||||||
|
skills={store.skills}
|
||||||
|
studySpeedMult={studySpeedMult}
|
||||||
|
cancelStudy={store.cancelStudy}
|
||||||
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
@@ -2236,7 +1366,7 @@ export default function ManaLoopGame() {
|
|||||||
|
|
||||||
{/* Calendar */}
|
{/* Calendar */}
|
||||||
<div className="mt-2 flex gap-1 overflow-x-auto pb-1">
|
<div className="mt-2 flex gap-1 overflow-x-auto pb-1">
|
||||||
{renderCalendar()}
|
<CalendarDisplay currentDay={store.day} />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -2281,7 +1411,13 @@ export default function ManaLoopGame() {
|
|||||||
<Card className="bg-gray-900/80 border-gray-700">
|
<Card className="bg-gray-900/80 border-gray-700">
|
||||||
<CardContent className="pt-4">
|
<CardContent className="pt-4">
|
||||||
<div className="text-xs text-amber-400 game-panel-title mb-2">Current Action</div>
|
<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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -2323,15 +1459,20 @@ export default function ManaLoopGame() {
|
|||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="spire">
|
<TabsContent value="spire">
|
||||||
{renderSpireTab()}
|
<SpireTab
|
||||||
|
store={store}
|
||||||
|
totalDPS={totalDPS}
|
||||||
|
studySpeedMult={studySpeedMult}
|
||||||
|
incursionStrength={incursionStrength}
|
||||||
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="spells">
|
<TabsContent value="spells">
|
||||||
{renderSpellsTab()}
|
<SpellsTab store={store} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="lab">
|
<TabsContent value="lab">
|
||||||
{renderLabTab()}
|
<LabTab store={store} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="crafting">
|
<TabsContent value="crafting">
|
||||||
<CraftingTab
|
<CraftingTab
|
||||||
|
|||||||
87
src/components/game/ActionButtons.tsx
Normal file
87
src/components/game/ActionButtons.tsx
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Sparkles, Swords, BookOpen, FlaskConical, Target } from 'lucide-react';
|
||||||
|
import type { GameAction } from '@/lib/game/types';
|
||||||
|
|
||||||
|
interface ActionButtonsProps {
|
||||||
|
currentAction: GameAction;
|
||||||
|
designProgress: { progress: number; required: number } | null;
|
||||||
|
preparationProgress: { progress: number; required: number } | null;
|
||||||
|
applicationProgress: { progress: number; required: number } | null;
|
||||||
|
setAction: (action: GameAction) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ActionButtons({
|
||||||
|
currentAction,
|
||||||
|
designProgress,
|
||||||
|
preparationProgress,
|
||||||
|
applicationProgress,
|
||||||
|
setAction,
|
||||||
|
}: ActionButtonsProps) {
|
||||||
|
const actions: { id: GameAction; label: string; icon: typeof Swords }[] = [
|
||||||
|
{ id: 'meditate', label: 'Meditate', icon: Sparkles },
|
||||||
|
{ id: 'climb', label: 'Climb', icon: Swords },
|
||||||
|
{ id: 'study', label: 'Study', icon: BookOpen },
|
||||||
|
{ id: 'convert', label: 'Convert', icon: FlaskConical },
|
||||||
|
];
|
||||||
|
|
||||||
|
const hasDesignProgress = designProgress !== null;
|
||||||
|
const hasPrepProgress = preparationProgress !== null;
|
||||||
|
const hasAppProgress = applicationProgress !== null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{actions.map(({ id, label, icon: Icon }) => (
|
||||||
|
<Button
|
||||||
|
key={id}
|
||||||
|
variant={currentAction === id ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
className={`h-9 ${currentAction === id ? 'bg-blue-600 hover:bg-blue-700' : 'bg-gray-800/50 hover:bg-gray-700/50 border-gray-600'}`}
|
||||||
|
onClick={() => setAction(id)}
|
||||||
|
>
|
||||||
|
<Icon className="w-4 h-4 mr-1" />
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Crafting actions row - shown when there's active crafting progress */}
|
||||||
|
{(hasDesignProgress || hasPrepProgress || hasAppProgress) && (
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
<Button
|
||||||
|
variant={currentAction === 'design' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
disabled={!hasDesignProgress}
|
||||||
|
className={`h-9 ${currentAction === 'design' ? 'bg-purple-600 hover:bg-purple-700' : 'bg-gray-800/50 hover:bg-gray-700/50 border-gray-600'}`}
|
||||||
|
onClick={() => hasDesignProgress && setAction('design')}
|
||||||
|
>
|
||||||
|
<Target className="w-4 h-4 mr-1" />
|
||||||
|
Design
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={currentAction === 'prepare' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
disabled={!hasPrepProgress}
|
||||||
|
className={`h-9 ${currentAction === 'prepare' ? 'bg-purple-600 hover:bg-purple-700' : 'bg-gray-800/50 hover:bg-gray-700/50 border-gray-600'}`}
|
||||||
|
onClick={() => hasPrepProgress && setAction('prepare')}
|
||||||
|
>
|
||||||
|
<FlaskConical className="w-4 h-4 mr-1" />
|
||||||
|
Prepare
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={currentAction === 'enchant' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
disabled={!hasAppProgress}
|
||||||
|
className={`h-9 ${currentAction === 'enchant' ? 'bg-purple-600 hover:bg-purple-700' : 'bg-gray-800/50 hover:bg-gray-700/50 border-gray-600'}`}
|
||||||
|
onClick={() => hasAppProgress && setAction('enchant')}
|
||||||
|
>
|
||||||
|
<Sparkles className="w-4 h-4 mr-1" />
|
||||||
|
Enchant
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
44
src/components/game/CalendarDisplay.tsx
Normal file
44
src/components/game/CalendarDisplay.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
|
import { MAX_DAY, INCURSION_START_DAY } from '@/lib/game/constants';
|
||||||
|
|
||||||
|
interface CalendarDisplayProps {
|
||||||
|
currentDay: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CalendarDisplay({ currentDay }: CalendarDisplayProps) {
|
||||||
|
const days: React.ReactElement[] = [];
|
||||||
|
|
||||||
|
for (let d = 1; d <= MAX_DAY; d++) {
|
||||||
|
let dayClass = 'w-7 h-7 rounded text-xs flex items-center justify-center font-mono border transition-all ';
|
||||||
|
|
||||||
|
if (d < currentDay) {
|
||||||
|
dayClass += 'bg-blue-900/30 border-blue-800/50 text-blue-400';
|
||||||
|
} else if (d === currentDay) {
|
||||||
|
dayClass += 'bg-blue-600/40 border-blue-500 text-blue-300 shadow-lg shadow-blue-500/30';
|
||||||
|
} else {
|
||||||
|
dayClass += 'bg-gray-800/30 border-gray-700/50 text-gray-500';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (d >= INCURSION_START_DAY) {
|
||||||
|
dayClass += ' border-red-600/50';
|
||||||
|
}
|
||||||
|
|
||||||
|
days.push(
|
||||||
|
<Tooltip key={d}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<div className={dayClass}>
|
||||||
|
{d}
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Day {d}</p>
|
||||||
|
{d >= INCURSION_START_DAY && <p className="text-red-400">Incursion Active</p>}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{days}</>;
|
||||||
|
}
|
||||||
161
src/components/game/CraftingProgress.tsx
Normal file
161
src/components/game/CraftingProgress.tsx
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Progress } from '@/components/ui/progress';
|
||||||
|
import { Target, FlaskConical, Sparkles, Play, Pause, X } from 'lucide-react';
|
||||||
|
import { fmt } from '@/lib/game/store';
|
||||||
|
import { formatStudyTime } from '@/lib/game/formatting';
|
||||||
|
import type { EquipmentInstance, EnchantmentDesign } from '@/lib/game/types';
|
||||||
|
|
||||||
|
interface CraftingProgressProps {
|
||||||
|
designProgress: { designId: string; progress: number; required: number } | null;
|
||||||
|
preparationProgress: { equipmentInstanceId: string; progress: number; required: number; manaCostPaid: number } | null;
|
||||||
|
applicationProgress: { equipmentInstanceId: string; designId: string; progress: number; required: number; manaPerHour: number; paused: boolean } | null;
|
||||||
|
equipmentInstances: Record<string, EquipmentInstance>;
|
||||||
|
enchantmentDesigns: EnchantmentDesign[];
|
||||||
|
cancelDesign: () => void;
|
||||||
|
cancelPreparation: () => void;
|
||||||
|
pauseApplication: () => void;
|
||||||
|
resumeApplication: () => void;
|
||||||
|
cancelApplication: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CraftingProgress({
|
||||||
|
designProgress,
|
||||||
|
preparationProgress,
|
||||||
|
applicationProgress,
|
||||||
|
equipmentInstances,
|
||||||
|
enchantmentDesigns,
|
||||||
|
cancelDesign,
|
||||||
|
cancelPreparation,
|
||||||
|
pauseApplication,
|
||||||
|
resumeApplication,
|
||||||
|
cancelApplication,
|
||||||
|
}: CraftingProgressProps) {
|
||||||
|
const progressSections: React.ReactNode[] = [];
|
||||||
|
|
||||||
|
// Design progress
|
||||||
|
if (designProgress) {
|
||||||
|
const progressPct = Math.min(100, (designProgress.progress / designProgress.required) * 100);
|
||||||
|
progressSections.push(
|
||||||
|
<div key="design" className="p-3 rounded border border-cyan-600/50 bg-cyan-900/20">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Target className="w-4 h-4 text-cyan-400" />
|
||||||
|
<span className="text-sm font-semibold text-cyan-300">
|
||||||
|
Designing Enchantment
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 w-6 p-0 text-gray-400 hover:text-white"
|
||||||
|
onClick={cancelDesign}
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Progress value={progressPct} className="h-2 bg-gray-800" />
|
||||||
|
<div className="flex justify-between text-xs text-gray-400 mt-1">
|
||||||
|
<span>{formatStudyTime(designProgress.progress)} / {formatStudyTime(designProgress.required)}</span>
|
||||||
|
<span>Design Time</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preparation progress
|
||||||
|
if (preparationProgress) {
|
||||||
|
const progressPct = Math.min(100, (preparationProgress.progress / preparationProgress.required) * 100);
|
||||||
|
const instance = equipmentInstances[preparationProgress.equipmentInstanceId];
|
||||||
|
progressSections.push(
|
||||||
|
<div key="prepare" className="p-3 rounded border border-green-600/50 bg-green-900/20">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FlaskConical className="w-4 h-4 text-green-400" />
|
||||||
|
<span className="text-sm font-semibold text-green-300">
|
||||||
|
Preparing {instance?.name || 'Equipment'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 w-6 p-0 text-gray-400 hover:text-white"
|
||||||
|
onClick={cancelPreparation}
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Progress value={progressPct} className="h-2 bg-gray-800" />
|
||||||
|
<div className="flex justify-between text-xs text-gray-400 mt-1">
|
||||||
|
<span>{formatStudyTime(preparationProgress.progress)} / {formatStudyTime(preparationProgress.required)}</span>
|
||||||
|
<span>Mana spent: {fmt(preparationProgress.manaCostPaid)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Application progress
|
||||||
|
if (applicationProgress) {
|
||||||
|
const progressPct = Math.min(100, (applicationProgress.progress / applicationProgress.required) * 100);
|
||||||
|
const instance = equipmentInstances[applicationProgress.equipmentInstanceId];
|
||||||
|
const design = enchantmentDesigns.find(d => d.id === applicationProgress.designId);
|
||||||
|
progressSections.push(
|
||||||
|
<div key="enchant" className="p-3 rounded border border-amber-600/50 bg-amber-900/20">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Sparkles className="w-4 h-4 text-amber-400" />
|
||||||
|
<span className="text-sm font-semibold text-amber-300">
|
||||||
|
Enchanting {instance?.name || 'Equipment'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{applicationProgress.paused ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 w-6 p-0 text-green-400 hover:text-green-300"
|
||||||
|
onClick={resumeApplication}
|
||||||
|
>
|
||||||
|
<Play className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 w-6 p-0 text-yellow-400 hover:text-yellow-300"
|
||||||
|
onClick={pauseApplication}
|
||||||
|
>
|
||||||
|
<Pause className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 w-6 p-0 text-gray-400 hover:text-white"
|
||||||
|
onClick={cancelApplication}
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Progress value={progressPct} className="h-2 bg-gray-800" />
|
||||||
|
<div className="flex justify-between text-xs text-gray-400 mt-1">
|
||||||
|
<span>{formatStudyTime(applicationProgress.progress)} / {formatStudyTime(applicationProgress.required)}</span>
|
||||||
|
<span>Mana/hr: {fmt(applicationProgress.manaPerHour)}</span>
|
||||||
|
</div>
|
||||||
|
{design && (
|
||||||
|
<div className="text-xs text-amber-400/70 mt-1">
|
||||||
|
Applying: {design.name}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return progressSections.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{progressSections}
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
}
|
||||||
63
src/components/game/ManaDisplay.tsx
Normal file
63
src/components/game/ManaDisplay.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Progress } from '@/components/ui/progress';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { Zap } from 'lucide-react';
|
||||||
|
import { fmt, fmtDec } from '@/lib/game/store';
|
||||||
|
|
||||||
|
interface ManaDisplayProps {
|
||||||
|
rawMana: number;
|
||||||
|
maxMana: number;
|
||||||
|
effectiveRegen: number;
|
||||||
|
meditationMultiplier: number;
|
||||||
|
clickMana: number;
|
||||||
|
isGathering: boolean;
|
||||||
|
onGatherStart: () => void;
|
||||||
|
onGatherEnd: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ManaDisplay({
|
||||||
|
rawMana,
|
||||||
|
maxMana,
|
||||||
|
effectiveRegen,
|
||||||
|
meditationMultiplier,
|
||||||
|
clickMana,
|
||||||
|
isGathering,
|
||||||
|
onGatherStart,
|
||||||
|
onGatherEnd,
|
||||||
|
}: ManaDisplayProps) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-gray-900/80 border-gray-700">
|
||||||
|
<CardContent className="pt-4 space-y-3">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-baseline gap-1">
|
||||||
|
<span className="text-3xl font-bold game-mono text-blue-400">{fmt(rawMana)}</span>
|
||||||
|
<span className="text-sm text-gray-400">/ {fmt(maxMana)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-400">
|
||||||
|
+{fmtDec(effectiveRegen)} mana/hr {meditationMultiplier > 1.01 && <span className="text-purple-400">({fmtDec(meditationMultiplier, 1)}x med)</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Progress
|
||||||
|
value={(rawMana / maxMana) * 100}
|
||||||
|
className="h-2 bg-gray-800"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
className={`w-full bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 ${isGathering ? 'animate-pulse' : ''}`}
|
||||||
|
onMouseDown={onGatherStart}
|
||||||
|
onMouseUp={onGatherEnd}
|
||||||
|
onMouseLeave={onGatherEnd}
|
||||||
|
onTouchStart={onGatherStart}
|
||||||
|
onTouchEnd={onGatherEnd}
|
||||||
|
>
|
||||||
|
<Zap className="w-4 h-4 mr-2" />
|
||||||
|
Gather +{clickMana} Mana
|
||||||
|
{isGathering && <span className="ml-2 text-xs">(Holding...)</span>}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
57
src/components/game/StudyProgress.tsx
Normal file
57
src/components/game/StudyProgress.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Progress } from '@/components/ui/progress';
|
||||||
|
import { BookOpen, X } from 'lucide-react';
|
||||||
|
import { SKILLS_DEF, SPELLS_DEF } from '@/lib/game/constants';
|
||||||
|
import { formatStudyTime } from '@/lib/game/formatting';
|
||||||
|
import type { StudyTarget } from '@/lib/game/types';
|
||||||
|
|
||||||
|
interface StudyProgressProps {
|
||||||
|
currentStudyTarget: StudyTarget | null;
|
||||||
|
skills: Record<string, number>;
|
||||||
|
studySpeedMult: number;
|
||||||
|
cancelStudy: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StudyProgress({
|
||||||
|
currentStudyTarget,
|
||||||
|
skills,
|
||||||
|
studySpeedMult,
|
||||||
|
cancelStudy,
|
||||||
|
}: StudyProgressProps) {
|
||||||
|
if (!currentStudyTarget) return null;
|
||||||
|
|
||||||
|
const target = currentStudyTarget;
|
||||||
|
const progressPct = Math.min(100, (target.progress / target.required) * 100);
|
||||||
|
const isSkill = target.type === 'skill';
|
||||||
|
const def = isSkill ? SKILLS_DEF[target.id] : SPELLS_DEF[target.id];
|
||||||
|
const currentLevel = isSkill ? (skills[target.id] || 0) : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-3 rounded border border-purple-600/50 bg-purple-900/20">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<BookOpen className="w-4 h-4 text-purple-400" />
|
||||||
|
<span className="text-sm font-semibold text-purple-300">
|
||||||
|
{def?.name}
|
||||||
|
{isSkill && ` Lv.${currentLevel + 1}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 w-6 p-0 text-gray-400 hover:text-white"
|
||||||
|
onClick={cancelStudy}
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Progress value={progressPct} className="h-2 bg-gray-800" />
|
||||||
|
<div className="flex justify-between text-xs text-gray-400 mt-1">
|
||||||
|
<span>{formatStudyTime(target.progress)} / {formatStudyTime(target.required)}</span>
|
||||||
|
<span>{studySpeedMult.toFixed(1)}x speed</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
src/components/game/TimeDisplay.tsx
Normal file
51
src/components/game/TimeDisplay.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Play, Pause } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { fmt } from '@/lib/game/store';
|
||||||
|
import { formatHour } from '@/lib/game/formatting';
|
||||||
|
|
||||||
|
interface TimeDisplayProps {
|
||||||
|
day: number;
|
||||||
|
hour: number;
|
||||||
|
insight: number;
|
||||||
|
paused: boolean;
|
||||||
|
onTogglePause: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TimeDisplay({
|
||||||
|
day,
|
||||||
|
hour,
|
||||||
|
insight,
|
||||||
|
paused,
|
||||||
|
onTogglePause,
|
||||||
|
}: TimeDisplayProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-lg font-bold game-mono text-amber-400">
|
||||||
|
Day {day}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-400">
|
||||||
|
{formatHour(hour)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-lg font-bold game-mono text-purple-400">
|
||||||
|
{fmt(insight)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-400">Insight</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={onTogglePause}
|
||||||
|
className="text-gray-400 hover:text-white"
|
||||||
|
>
|
||||||
|
{paused ? <Play className="w-4 h-4" /> : <Pause className="w-4 h-4" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,17 @@
|
|||||||
// ─── Game Components Index ──────────────────────────────────────────────────────
|
// ─── Game Components Index ──────────────────────────────────────────────────────
|
||||||
// Re-exports all game tab components for cleaner imports
|
// Re-exports all game tab components for cleaner imports
|
||||||
|
|
||||||
|
// Tab components
|
||||||
export { CraftingTab } from './tabs/CraftingTab';
|
export { CraftingTab } from './tabs/CraftingTab';
|
||||||
export { SpireTab } from './tabs/SpireTab';
|
export { SpireTab } from './tabs/SpireTab';
|
||||||
export { SpellsTab } from './tabs/SpellsTab';
|
export { SpellsTab } from './tabs/SpellsTab';
|
||||||
export { LabTab } from './tabs/LabTab';
|
export { LabTab } from './tabs/LabTab';
|
||||||
|
|
||||||
|
// UI components
|
||||||
|
export { ActionButtons } from './ActionButtons';
|
||||||
|
export { CalendarDisplay } from './CalendarDisplay';
|
||||||
|
export { ComboMeter } from './ComboMeter';
|
||||||
|
export { CraftingProgress } from './CraftingProgress';
|
||||||
|
export { StudyProgress } from './StudyProgress';
|
||||||
|
export { ManaDisplay } from './ManaDisplay';
|
||||||
|
export { TimeDisplay } from './TimeDisplay';
|
||||||
|
|||||||
@@ -6,13 +6,47 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
import { TooltipProvider } from '@/components/ui/tooltip';
|
||||||
import { Swords, Sparkles, BookOpen, ChevronUp, ChevronDown, ArrowUp, ArrowDown, RefreshCw } from 'lucide-react';
|
import { Swords, BookOpen, ChevronUp, ChevronDown, RotateCcw, X } from 'lucide-react';
|
||||||
import type { GameState, GameAction } from '@/lib/game/types';
|
import type { GameState, GameAction, EquipmentSpellState, StudyTarget } from '@/lib/game/types';
|
||||||
import { ELEMENTS, GUARDIANS, SPELLS_DEF, MAX_DAY, INCURSION_START_DAY } from '@/lib/game/constants';
|
import { ELEMENTS, GUARDIANS, SPELLS_DEF, SKILLS_DEF } from '@/lib/game/constants';
|
||||||
import { fmt, fmtDec, getFloorElement, calcDamage, canAffordSpellCost } from '@/lib/game/store';
|
import { fmt, fmtDec, getFloorElement, calcDamage, canAffordSpellCost } from '@/lib/game/store';
|
||||||
import { formatSpellCost, getSpellCostColor, formatStudyTime } from '@/lib/game/formatting';
|
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 {
|
interface SpireTabProps {
|
||||||
store: GameState & {
|
store: GameState & {
|
||||||
@@ -22,80 +56,42 @@ interface SpireTabProps {
|
|||||||
cancelParallelStudy: () => void;
|
cancelParallelStudy: () => void;
|
||||||
setClimbDirection: (direction: 'up' | 'down') => void;
|
setClimbDirection: (direction: 'up' | 'down') => void;
|
||||||
changeFloor: (direction: 'up' | 'down') => void;
|
changeFloor: (direction: 'up' | 'down') => void;
|
||||||
|
cancelDesign?: () => void;
|
||||||
|
cancelPreparation?: () => void;
|
||||||
|
pauseApplication?: () => void;
|
||||||
|
resumeApplication?: () => void;
|
||||||
|
cancelApplication?: () => void;
|
||||||
};
|
};
|
||||||
upgradeEffects: ComputedEffects;
|
totalDPS: number;
|
||||||
maxMana: number;
|
|
||||||
effectiveRegen: number;
|
|
||||||
incursionStrength: number;
|
|
||||||
dps: number;
|
|
||||||
studySpeedMult: number;
|
studySpeedMult: number;
|
||||||
formatTime: (hour: number) => string;
|
incursionStrength: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SpireTab({
|
export function SpireTab({
|
||||||
store,
|
store,
|
||||||
upgradeEffects,
|
totalDPS,
|
||||||
maxMana,
|
|
||||||
effectiveRegen,
|
|
||||||
incursionStrength,
|
|
||||||
dps,
|
|
||||||
studySpeedMult,
|
studySpeedMult,
|
||||||
formatTime,
|
incursionStrength,
|
||||||
}: SpireTabProps) {
|
}: SpireTabProps) {
|
||||||
const floorElem = getFloorElement(store.currentFloor);
|
const floorElem = getFloorElement(store.currentFloor);
|
||||||
const floorElemDef = ELEMENTS[floorElem];
|
const floorElemDef = ELEMENTS[floorElem];
|
||||||
const isGuardianFloor = !!GUARDIANS[store.currentFloor];
|
const isGuardianFloor = !!GUARDIANS[store.currentFloor];
|
||||||
const currentGuardian = GUARDIANS[store.currentFloor];
|
const currentGuardian = GUARDIANS[store.currentFloor];
|
||||||
const activeSpellDef = SPELLS_DEF[store.activeSpell];
|
|
||||||
const climbDirection = store.climbDirection || 'up';
|
const climbDirection = store.climbDirection || 'up';
|
||||||
const clearedFloors = store.clearedFloors || {};
|
const clearedFloors = store.clearedFloors || {};
|
||||||
|
|
||||||
// Check if current floor is cleared (for respawn indicator)
|
// Check if current floor is cleared (for respawn indicator)
|
||||||
const isFloorCleared = clearedFloors[store.currentFloor];
|
const isFloorCleared = clearedFloors[store.currentFloor];
|
||||||
|
|
||||||
|
// Get active equipment spells
|
||||||
|
const activeEquipmentSpells = getActiveEquipmentSpells(store.equippedInstances, store.equipmentInstances);
|
||||||
|
|
||||||
const canCastSpell = (spellId: string): boolean => {
|
const canCastSpell = (spellId: string): boolean => {
|
||||||
const spell = SPELLS_DEF[spellId];
|
const spell = SPELLS_DEF[spellId];
|
||||||
if (!spell) return false;
|
if (!spell) return false;
|
||||||
return canAffordSpellCost(spell.cost, store.rawMana, store.elements);
|
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 (
|
return (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
@@ -138,16 +134,16 @@ export function SpireTab({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between text-xs text-gray-400 game-mono">
|
<div className="flex justify-between text-xs text-gray-400 game-mono">
|
||||||
<span>{fmt(store.floorHP)} / {fmt(store.floorMaxHP)} HP</span>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Separator className="bg-gray-700" />
|
<Separator className="bg-gray-700" />
|
||||||
|
|
||||||
{/* Floor Navigation */}
|
{/* Floor Navigation Controls */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-xs text-gray-400">Direction</span>
|
<span className="text-xs text-gray-400">Auto-Direction</span>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<Button
|
<Button
|
||||||
variant={climbDirection === 'up' ? 'default' : 'outline'}
|
variant={climbDirection === 'up' ? 'default' : 'outline'}
|
||||||
@@ -155,7 +151,7 @@ export function SpireTab({
|
|||||||
className={`h-7 px-2 ${climbDirection === 'up' ? 'bg-green-600 hover:bg-green-700' : 'bg-gray-800/50'}`}
|
className={`h-7 px-2 ${climbDirection === 'up' ? 'bg-green-600 hover:bg-green-700' : 'bg-gray-800/50'}`}
|
||||||
onClick={() => store.setClimbDirection('up')}
|
onClick={() => store.setClimbDirection('up')}
|
||||||
>
|
>
|
||||||
<ArrowUp className="w-4 h-4 mr-1" />
|
<ChevronUp className="w-4 h-4 mr-1" />
|
||||||
Up
|
Up
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -164,7 +160,7 @@ export function SpireTab({
|
|||||||
className={`h-7 px-2 ${climbDirection === 'down' ? 'bg-blue-600 hover:bg-blue-700' : 'bg-gray-800/50'}`}
|
className={`h-7 px-2 ${climbDirection === 'down' ? 'bg-blue-600 hover:bg-blue-700' : 'bg-gray-800/50'}`}
|
||||||
onClick={() => store.setClimbDirection('down')}
|
onClick={() => store.setClimbDirection('down')}
|
||||||
>
|
>
|
||||||
<ArrowDown className="w-4 h-4 mr-1" />
|
<ChevronDown className="w-4 h-4 mr-1" />
|
||||||
Down
|
Down
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -179,7 +175,7 @@ export function SpireTab({
|
|||||||
onClick={() => store.changeFloor('down')}
|
onClick={() => store.changeFloor('down')}
|
||||||
>
|
>
|
||||||
<ChevronDown className="w-4 h-4 mr-1" />
|
<ChevronDown className="w-4 h-4 mr-1" />
|
||||||
Go Down
|
Descend
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -189,13 +185,14 @@ export function SpireTab({
|
|||||||
onClick={() => store.changeFloor('up')}
|
onClick={() => store.changeFloor('up')}
|
||||||
>
|
>
|
||||||
<ChevronUp className="w-4 h-4 mr-1" />
|
<ChevronUp className="w-4 h-4 mr-1" />
|
||||||
Go Up
|
Ascend
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isFloorCleared && (
|
{isFloorCleared && (
|
||||||
<div className="text-xs text-amber-400 text-center">
|
<div className="text-xs text-amber-400 text-center flex items-center justify-center gap-1">
|
||||||
⚠️ Floor will respawn when you return
|
<RotateCcw className="w-3 h-3" />
|
||||||
|
Floor will respawn when you leave and return
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -209,51 +206,74 @@ export function SpireTab({
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Active Spell Card */}
|
{/* Active Spells Card - Shows all spells from equipped weapons */}
|
||||||
<Card className="bg-gray-900/80 border-gray-700">
|
<Card className="bg-gray-900/80 border-gray-700">
|
||||||
<CardHeader className="pb-2">
|
<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>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
{activeSpellDef ? (
|
{activeEquipmentSpells.length > 0 ? (
|
||||||
<>
|
<div className="space-y-3">
|
||||||
<div className="text-lg font-semibold game-panel-title" style={{ color: activeSpellDef.elem === 'raw' ? '#60A5FA' : ELEMENTS[activeSpellDef.elem]?.color }}>
|
{activeEquipmentSpells.map(({ spellId, equipmentId }) => {
|
||||||
{activeSpellDef.name}
|
const spellDef = SPELLS_DEF[spellId];
|
||||||
{activeSpellDef.tier === 0 && <Badge className="ml-2 bg-gray-600 text-gray-200">Basic</Badge>}
|
if (!spellDef) return null;
|
||||||
{activeSpellDef.tier >= 4 && <Badge className="ml-2 bg-amber-600 text-amber-100">Legendary</Badge>}
|
|
||||||
</div>
|
const spellState = store.equipmentSpellStates?.find(
|
||||||
<div className="text-sm text-gray-400 game-mono">
|
s => s.spellId === spellId && s.sourceEquipment === equipmentId
|
||||||
⚔️ {fmt(calcDamage(store, store.activeSpell))} dmg •
|
);
|
||||||
<span style={{ color: getSpellCostColor(activeSpellDef.cost) }}>
|
const progress = spellState?.castProgress || 0;
|
||||||
{' '}{formatSpellCost(activeSpellDef.cost)}
|
const canCast = canAffordSpellCost(spellDef.cost, store.rawMana, store.elements);
|
||||||
</span>
|
|
||||||
{' '}• ⚡ {(activeSpellDef.castSpeed || 1).toFixed(1)} casts/hr
|
return (
|
||||||
</div>
|
<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">
|
||||||
{/* Cast progress bar when climbing */}
|
<div className="text-sm font-semibold game-panel-title" style={{ color: spellDef.elem === 'raw' ? '#60A5FA' : ELEMENTS[spellDef.elem]?.color }}>
|
||||||
{store.currentAction === 'climb' && (
|
{spellDef.name}
|
||||||
<div className="space-y-1">
|
{spellDef.tier === 0 && <Badge className="ml-2 bg-gray-600 text-gray-200 text-xs">Basic</Badge>}
|
||||||
<div className="flex justify-between text-xs text-gray-400">
|
{spellDef.tier >= 4 && <Badge className="ml-2 bg-amber-600 text-amber-100 text-xs">Legendary</Badge>}
|
||||||
<span>Cast Progress</span>
|
</div>
|
||||||
<span>{((store.castProgress || 0) * 100).toFixed(0)}%</span>
|
<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>
|
||||||
<Progress value={Math.min(100, (store.castProgress || 0) * 100)} className="h-2 bg-gray-800" />
|
);
|
||||||
</div>
|
})}
|
||||||
)}
|
|
||||||
|
|
||||||
{activeSpellDef.desc && (
|
|
||||||
<div className="text-xs text-gray-500 italic">{activeSpellDef.desc}</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="text-gray-500">No spell selected</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Can cast indicator */}
|
|
||||||
{activeSpellDef && (
|
|
||||||
<div className={`text-xs ${canCastSpell(store.activeSpell) ? 'text-green-400' : 'text-red-400'}`}>
|
|
||||||
{canCastSpell(store.activeSpell) ? '✓ Can cast' : '✗ Insufficient mana'}
|
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-gray-500 text-sm">No spells on equipped weapons. Enchant a staff with spell effects.</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{incursionStrength > 0 && (
|
{incursionStrength > 0 && (
|
||||||
@@ -267,11 +287,67 @@ export function SpireTab({
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Combo Meter - Shows when climbing or has active combo */}
|
||||||
|
<ComboMeter combo={store.combo} isClimbing={store.currentAction === 'climb'} />
|
||||||
|
|
||||||
{/* Current Study (if any) */}
|
{/* Current Study (if any) */}
|
||||||
{store.currentStudyTarget && (
|
{store.currentStudyTarget && (
|
||||||
<Card className="bg-gray-900/80 border-purple-600/50 lg:col-span-2">
|
<Card className="bg-gray-900/80 border-purple-600/50 lg:col-span-2">
|
||||||
<CardContent className="pt-4 space-y-3">
|
<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>
|
</CardContent>
|
||||||
</Card>
|
</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 };
|
||||||
|
}
|
||||||
@@ -4,6 +4,9 @@
|
|||||||
import { ELEMENTS } from '@/lib/game/constants';
|
import { ELEMENTS } from '@/lib/game/constants';
|
||||||
import type { SpellCost } from '@/lib/game/types';
|
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
|
* 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 { create } from 'zustand';
|
||||||
import { persist } from 'zustand/middleware';
|
import { persist } from 'zustand/middleware';
|
||||||
import type { GameState, GameAction, StudyTarget, SpellCost, SkillUpgradeChoice, EquipmentSlot, EquipmentInstance, EnchantmentDesign, DesignEffect, LootInventory } from './types';
|
import type { GameState, GameAction, StudyTarget, SkillUpgradeChoice, EquipmentSlot, EnchantmentDesign, DesignEffect, LootInventory } from './types';
|
||||||
import {
|
import {
|
||||||
ELEMENTS,
|
ELEMENTS,
|
||||||
GUARDIANS,
|
GUARDIANS,
|
||||||
@@ -17,19 +17,13 @@ import {
|
|||||||
INCURSION_START_DAY,
|
INCURSION_START_DAY,
|
||||||
MANA_PER_ELEMENT,
|
MANA_PER_ELEMENT,
|
||||||
getStudySpeedMultiplier,
|
getStudySpeedMultiplier,
|
||||||
getStudyCostMultiplier,
|
|
||||||
ELEMENT_OPPOSITES,
|
ELEMENT_OPPOSITES,
|
||||||
EFFECT_RESEARCH_MAPPING,
|
EFFECT_RESEARCH_MAPPING,
|
||||||
BASE_UNLOCKED_EFFECTS,
|
BASE_UNLOCKED_EFFECTS,
|
||||||
ENCHANTING_UNLOCK_EFFECTS,
|
ENCHANTING_UNLOCK_EFFECTS,
|
||||||
} from './constants';
|
} from './constants';
|
||||||
import { computeEffects, hasSpecial, SPECIAL_EFFECTS, type ComputedEffects } from './upgrade-effects';
|
import { hasSpecial, SPECIAL_EFFECTS } from './upgrade-effects';
|
||||||
import {
|
import { getUnifiedEffects } from './effects';
|
||||||
computeAllEffects,
|
|
||||||
getUnifiedEffects,
|
|
||||||
computeEquipmentEffects,
|
|
||||||
type UnifiedEffects
|
|
||||||
} from './effects';
|
|
||||||
import { SKILL_EVOLUTION_PATHS } from './skill-evolution';
|
import { SKILL_EVOLUTION_PATHS } from './skill-evolution';
|
||||||
import {
|
import {
|
||||||
createStartingEquipment,
|
createStartingEquipment,
|
||||||
@@ -37,7 +31,6 @@ import {
|
|||||||
getSpellsFromEquipment,
|
getSpellsFromEquipment,
|
||||||
type CraftingActions
|
type CraftingActions
|
||||||
} from './crafting-slice';
|
} from './crafting-slice';
|
||||||
import { EQUIPMENT_TYPES } from './data/equipment';
|
|
||||||
import { ENCHANTMENT_EFFECTS, calculateEffectCapacityCost } from './data/enchantment-effects';
|
import { ENCHANTMENT_EFFECTS, calculateEffectCapacityCost } from './data/enchantment-effects';
|
||||||
import {
|
import {
|
||||||
createFamiliarSlice,
|
createFamiliarSlice,
|
||||||
@@ -47,228 +40,56 @@ import {
|
|||||||
type FamiliarBonuses,
|
type FamiliarBonuses,
|
||||||
DEFAULT_FAMILIAR_BONUSES,
|
DEFAULT_FAMILIAR_BONUSES,
|
||||||
} from './familiar-slice';
|
} from './familiar-slice';
|
||||||
|
import {
|
||||||
|
createNavigationSlice,
|
||||||
|
type NavigationActions,
|
||||||
|
} from './navigation-slice';
|
||||||
|
import {
|
||||||
|
createStudySlice,
|
||||||
|
type StudyActions,
|
||||||
|
} from './study-slice';
|
||||||
import { rollLootDrops, LOOT_DROPS } from './data/loot-drops';
|
import { rollLootDrops, LOOT_DROPS } from './data/loot-drops';
|
||||||
import { CRAFTING_RECIPES, canCraftRecipe } from './data/crafting-recipes';
|
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
|
// Re-export formatting functions and computed stats for backward compatibility
|
||||||
const DEFAULT_EFFECTS: ComputedEffects = {
|
export {
|
||||||
maxManaMultiplier: 1,
|
fmt,
|
||||||
maxManaBonus: 0,
|
fmtDec,
|
||||||
regenMultiplier: 1,
|
getFloorElement,
|
||||||
regenBonus: 0,
|
computeMaxMana,
|
||||||
clickManaMultiplier: 1,
|
computeRegen,
|
||||||
clickManaBonus: 0,
|
computeClickMana,
|
||||||
meditationEfficiency: 1,
|
calcDamage,
|
||||||
spellCostMultiplier: 1,
|
getMeditationBonus,
|
||||||
conversionEfficiency: 1,
|
getIncursionStrength,
|
||||||
baseDamageMultiplier: 1,
|
canAffordSpellCost,
|
||||||
baseDamageBonus: 0,
|
getFloorMaxHP,
|
||||||
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: [],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── Helper Functions ─────────────────────────────────────────────────────────
|
// ─── Local 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Elemental damage bonus: +50% if spell element opposes floor element (super effective)
|
// Elemental damage bonus: +50% if spell element opposes floor element (super effective)
|
||||||
// -25% if spell element matches its own opposite (weak)
|
// -25% if spell element matches its own opposite (weak)
|
||||||
@@ -288,129 +109,6 @@ function getElementalBonus(spellElem: string, floorElem: string): number {
|
|||||||
return 1.0; // Neutral
|
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 ────────────────────────────────────────────────────
|
// ─── Initial State Factory ────────────────────────────────────────────────────
|
||||||
|
|
||||||
function makeInitial(overrides: Partial<GameState> = {}): GameState {
|
function makeInitial(overrides: Partial<GameState> = {}): GameState {
|
||||||
@@ -587,17 +285,12 @@ function makeInitial(overrides: Partial<GameState> = {}): GameState {
|
|||||||
|
|
||||||
// ─── Game Store ───────────────────────────────────────────────────────────────
|
// ─── Game Store ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface GameStore extends GameState, CraftingActions, FamiliarActions {
|
interface GameStore extends GameState, CraftingActions, FamiliarActions, NavigationActions, StudyActions {
|
||||||
// Actions
|
// Actions
|
||||||
tick: () => void;
|
tick: () => void;
|
||||||
gatherMana: () => void;
|
gatherMana: () => void;
|
||||||
setAction: (action: GameAction) => void;
|
setAction: (action: GameAction) => void;
|
||||||
setSpell: (spellId: string) => 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;
|
convertMana: (element: string, amount: number) => void;
|
||||||
unlockElement: (element: string) => void;
|
unlockElement: (element: string) => void;
|
||||||
craftComposite: (target: string) => void;
|
craftComposite: (target: string) => void;
|
||||||
@@ -611,10 +304,6 @@ interface GameStore extends GameState, CraftingActions, FamiliarActions {
|
|||||||
commitSkillUpgrades: (skillId: string, upgradeIds: string[]) => void;
|
commitSkillUpgrades: (skillId: string, upgradeIds: string[]) => void;
|
||||||
tierUpSkill: (skillId: string) => void;
|
tierUpSkill: (skillId: string) => void;
|
||||||
|
|
||||||
// Floor Navigation
|
|
||||||
setClimbDirection: (direction: 'up' | 'down') => void;
|
|
||||||
changeFloor: (direction: 'up' | 'down') => void;
|
|
||||||
|
|
||||||
// Inventory Management
|
// Inventory Management
|
||||||
updateLootInventory: (inventory: LootInventory) => void;
|
updateLootInventory: (inventory: LootInventory) => void;
|
||||||
|
|
||||||
@@ -633,6 +322,8 @@ export const useGameStore = create<GameStore>()(
|
|||||||
(set, get) => ({
|
(set, get) => ({
|
||||||
...makeInitial(),
|
...makeInitial(),
|
||||||
...createFamiliarSlice(set, get),
|
...createFamiliarSlice(set, get),
|
||||||
|
...createNavigationSlice(set, get),
|
||||||
|
...createStudySlice(set, get),
|
||||||
|
|
||||||
getMaxMana: () => computeMaxMana(get()),
|
getMaxMana: () => computeMaxMana(get()),
|
||||||
getRegen: () => computeRegen(get()),
|
getRegen: () => computeRegen(get()),
|
||||||
@@ -1221,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) => {
|
convertMana: (element: string, amount: number = 1) => {
|
||||||
const state = get();
|
const state = get();
|
||||||
const e = state.elements[element];
|
const e = state.elements[element];
|
||||||
@@ -1527,42 +1116,7 @@ export const useGameStore = create<GameStore>()(
|
|||||||
log: [`🌟 ${SKILLS_DEF[baseSkillId]?.name || baseSkillId} evolved to Tier ${nextTier}!`, ...state.log.slice(0, 49)],
|
log: [`🌟 ${SKILLS_DEF[baseSkillId]?.name || baseSkillId} evolved to Tier ${nextTier}!`, ...state.log.slice(0, 49)],
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
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) => {
|
getSkillUpgradeChoices: (skillId: string, milestone: 5 | 10) => {
|
||||||
const state = get();
|
const state = get();
|
||||||
const baseSkillId = skillId.includes('_t') ? skillId.split('_t')[0] : skillId;
|
const baseSkillId = skillId.includes('_t') ? skillId.split('_t')[0] : skillId;
|
||||||
@@ -2007,47 +1561,6 @@ export const useGameStore = create<GameStore>()(
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
// ─── Floor Navigation ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
setClimbDirection: (direction: 'up' | 'down') => {
|
|
||||||
set({ climbDirection: direction });
|
|
||||||
},
|
|
||||||
|
|
||||||
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)],
|
|
||||||
});
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: 'mana-loop-storage',
|
name: 'mana-loop-storage',
|
||||||
|
|||||||
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)],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
131
worklog.md
131
worklog.md
@@ -309,3 +309,134 @@ Stage Summary:
|
|||||||
- Multi-casting is fully functional
|
- Multi-casting is fully functional
|
||||||
- Game compiles and runs without errors
|
- Game compiles and runs without errors
|
||||||
- Lint passes with no issues
|
- 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