419 lines
19 KiB
TypeScript
Executable File
419 lines
19 KiB
TypeScript
Executable File
'use client';
|
||
|
||
import { useState } from 'react';
|
||
import { useGameStore, fmt, fmtDec } from '@/lib/game/store';
|
||
import { SKILLS_DEF, SKILL_CATEGORIES, getStudySpeedMultiplier, getStudyCostMultiplier } from '@/lib/game/constants';
|
||
import { SKILL_EVOLUTION_PATHS, getUpgradesForSkillAtMilestone, getNextTierSkill, getTierMultiplier } from '@/lib/game/skill-evolution';
|
||
import { computeEffects, hasSpecial, SPECIAL_EFFECTS } from '@/lib/game/upgrade-effects';
|
||
import { useStudyStats } from '@/lib/game/hooks/useGameDerived';
|
||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||
import { Badge } from '@/components/ui/badge';
|
||
import { Button } from '@/components/ui/button';
|
||
import { Progress } from '@/components/ui/progress';
|
||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||
import { BookOpen, X } from 'lucide-react';
|
||
import type { SkillUpgradeChoice } from '@/lib/game/types';
|
||
|
||
// Format study time
|
||
function formatStudyTime(hours: number): string {
|
||
if (hours < 1) return `${Math.round(hours * 60)}m`;
|
||
return `${hours.toFixed(1)}h`;
|
||
}
|
||
|
||
export function SkillsTab() {
|
||
const store = useGameStore();
|
||
const { studySpeedMult, studyCostMult, hasParallelStudy } = useStudyStats();
|
||
|
||
const [upgradeDialogSkill, setUpgradeDialogSkill] = useState<string | null>(null);
|
||
const [upgradeDialogMilestone, setUpgradeDialogMilestone] = useState<5 | 10>(5);
|
||
const [pendingUpgradeSelections, setPendingUpgradeSelections] = useState<string[]>([]);
|
||
|
||
const upgradeEffects = computeEffects(store.skillUpgrades || {}, store.skillTiers || {});
|
||
|
||
// Check if skill has milestone available
|
||
const hasMilestoneUpgrade = (skillId: string, level: number): { milestone: 5 | 10; hasUpgrades: boolean; selectedCount: number } | null => {
|
||
const baseSkillId = skillId.includes('_t') ? skillId.split('_t')[0] : skillId;
|
||
const path = SKILL_EVOLUTION_PATHS[baseSkillId];
|
||
if (!path) return null;
|
||
|
||
if (level >= 5) {
|
||
const upgrades5 = getUpgradesForSkillAtMilestone(skillId, 5, store.skillTiers);
|
||
const selected5 = (store.skillUpgrades[skillId] || []).filter(id => id.includes('_l5'));
|
||
if (upgrades5.length > 0 && selected5.length < 2) {
|
||
return { milestone: 5, hasUpgrades: true, selectedCount: selected5.length };
|
||
}
|
||
}
|
||
|
||
if (level >= 10) {
|
||
const upgrades10 = getUpgradesForSkillAtMilestone(skillId, 10, store.skillTiers);
|
||
const selected10 = (store.skillUpgrades[skillId] || []).filter(id => id.includes('_l10'));
|
||
if (upgrades10.length > 0 && selected10.length < 2) {
|
||
return { milestone: 10, hasUpgrades: true, selectedCount: selected10.length };
|
||
}
|
||
}
|
||
|
||
return null;
|
||
};
|
||
|
||
// Render upgrade selection dialog
|
||
const renderUpgradeDialog = () => {
|
||
if (!upgradeDialogSkill) return null;
|
||
|
||
const skillDef = SKILLS_DEF[upgradeDialogSkill];
|
||
const level = store.skills[upgradeDialogSkill] || 0;
|
||
const { available, selected: alreadySelected } = store.getSkillUpgradeChoices(upgradeDialogSkill, upgradeDialogMilestone);
|
||
|
||
const currentSelections = pendingUpgradeSelections.length > 0 ? pendingUpgradeSelections : alreadySelected;
|
||
|
||
const toggleUpgrade = (upgradeId: string) => {
|
||
if (currentSelections.includes(upgradeId)) {
|
||
setPendingUpgradeSelections(currentSelections.filter(id => id !== upgradeId));
|
||
} else if (currentSelections.length < 2) {
|
||
setPendingUpgradeSelections([...currentSelections, upgradeId]);
|
||
}
|
||
};
|
||
|
||
const handleDone = () => {
|
||
if (currentSelections.length === 2 && upgradeDialogSkill) {
|
||
store.commitSkillUpgrades(upgradeDialogSkill, currentSelections, upgradeDialogMilestone);
|
||
}
|
||
setPendingUpgradeSelections([]);
|
||
setUpgradeDialogSkill(null);
|
||
};
|
||
|
||
const handleCancel = () => {
|
||
setPendingUpgradeSelections([]);
|
||
setUpgradeDialogSkill(null);
|
||
};
|
||
|
||
return (
|
||
<Dialog open={!!upgradeDialogSkill} onOpenChange={(open) => {
|
||
if (!open) {
|
||
setPendingUpgradeSelections([]);
|
||
setUpgradeDialogSkill(null);
|
||
}
|
||
}}>
|
||
<DialogContent className="bg-gray-900 border-gray-700 max-w-lg">
|
||
<DialogHeader>
|
||
<DialogTitle className="text-amber-400">
|
||
Choose Upgrade - {skillDef?.name || upgradeDialogSkill}
|
||
</DialogTitle>
|
||
<DialogDescription className="text-gray-400">
|
||
Level {upgradeDialogMilestone} Milestone - Select 2 upgrades ({currentSelections.length}/2 chosen)
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
|
||
<div className="space-y-2 mt-4">
|
||
{available.map((upgrade) => {
|
||
const isSelected = currentSelections.includes(upgrade.id);
|
||
const canToggle = currentSelections.length < 2 || isSelected;
|
||
|
||
return (
|
||
<div
|
||
key={upgrade.id}
|
||
className={`p-3 rounded border cursor-pointer transition-all ${
|
||
isSelected
|
||
? 'border-amber-500 bg-amber-900/30'
|
||
: canToggle
|
||
? 'border-gray-600 bg-gray-800/50 hover:border-amber-500/50 hover:bg-gray-800'
|
||
: 'border-gray-700 bg-gray-800/30 opacity-50 cursor-not-allowed'
|
||
}`}
|
||
onClick={() => {
|
||
if (canToggle) {
|
||
toggleUpgrade(upgrade.id);
|
||
}
|
||
}}
|
||
>
|
||
<div className="flex items-center justify-between">
|
||
<div className="font-semibold text-sm text-amber-300">{upgrade.name}</div>
|
||
{isSelected && <Badge className="bg-amber-600 text-amber-100">Selected</Badge>}
|
||
</div>
|
||
<div className="text-xs text-gray-400 mt-1">{upgrade.desc}</div>
|
||
{upgrade.effect.type === 'multiplier' && (
|
||
<div className="text-xs text-green-400 mt-1">
|
||
+{Math.round((upgrade.effect.value! - 1) * 100)}% {upgrade.effect.stat}
|
||
</div>
|
||
)}
|
||
{upgrade.effect.type === 'bonus' && (
|
||
<div className="text-xs text-blue-400 mt-1">
|
||
+{upgrade.effect.value} {upgrade.effect.stat}
|
||
</div>
|
||
)}
|
||
{upgrade.effect.type === 'special' && (
|
||
<div className="text-xs text-cyan-400 mt-1">
|
||
⚡ {upgrade.effect.specialDesc || 'Special effect'}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
<div className="flex justify-end gap-2 mt-4">
|
||
<Button
|
||
variant="outline"
|
||
onClick={handleCancel}
|
||
>
|
||
Cancel
|
||
</Button>
|
||
<Button
|
||
variant="default"
|
||
onClick={handleDone}
|
||
disabled={currentSelections.length !== 2}
|
||
>
|
||
{currentSelections.length < 2 ? `Select ${2 - currentSelections.length} more` : 'Confirm'}
|
||
</Button>
|
||
</div>
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
};
|
||
|
||
// Render study progress
|
||
const renderStudyProgress = () => {
|
||
if (!store.currentStudyTarget) return null;
|
||
|
||
const target = store.currentStudyTarget;
|
||
const progressPct = Math.min(100, (target.progress / target.required) * 100);
|
||
const def = SKILLS_DEF[target.id] || SKILLS_DEF[target.id.split('_t')[0]];
|
||
|
||
return (
|
||
<div className="p-3 rounded border border-purple-600/50 bg-purple-900/20">
|
||
<div className="flex items-center justify-between mb-2">
|
||
<div className="flex items-center gap-2">
|
||
<BookOpen className="w-4 h-4 text-purple-400" />
|
||
<span className="text-sm font-semibold text-purple-300">
|
||
{def?.name}
|
||
</span>
|
||
</div>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
className="h-6 w-6 p-0 text-gray-400 hover:text-white"
|
||
onClick={() => store.cancelStudy()}
|
||
>
|
||
<X className="w-4 h-4" />
|
||
</Button>
|
||
</div>
|
||
<Progress value={progressPct} className="h-2 bg-gray-800" />
|
||
<div className="flex justify-between text-xs text-gray-400 mt-1">
|
||
<span>{formatStudyTime(target.progress)} / {formatStudyTime(target.required)}</span>
|
||
<span>{studySpeedMult.toFixed(1)}x speed</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
{/* Upgrade Selection Dialog */}
|
||
{renderUpgradeDialog()}
|
||
|
||
{/* Current Study Progress */}
|
||
{store.currentStudyTarget && store.currentStudyTarget.type === 'skill' && (
|
||
<Card className="bg-gray-900/80 border-purple-600/50">
|
||
<CardContent className="pt-4">
|
||
{renderStudyProgress()}
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
{SKILL_CATEGORIES.map((cat) => {
|
||
const skillsInCat = Object.entries(SKILLS_DEF).filter(([, def]) => def.cat === cat.id);
|
||
if (skillsInCat.length === 0) return null;
|
||
|
||
return (
|
||
<Card key={cat.id} className="bg-gray-900/80 border-gray-700">
|
||
<CardHeader className="pb-2">
|
||
<CardTitle className="text-amber-400 game-panel-title text-xs">
|
||
{cat.icon} {cat.name}
|
||
</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="space-y-2">
|
||
{skillsInCat.map(([id, def]) => {
|
||
const currentTier = store.skillTiers?.[id] || 1;
|
||
const tieredSkillId = currentTier > 1 ? `${id}_t${currentTier}` : id;
|
||
const tierMultiplier = getTierMultiplier(tieredSkillId);
|
||
|
||
const level = store.skills[tieredSkillId] || store.skills[id] || 0;
|
||
const maxed = level >= def.max;
|
||
|
||
const isStudying = (store.currentStudyTarget?.id === id || store.currentStudyTarget?.id === tieredSkillId) && store.currentStudyTarget?.type === 'skill';
|
||
const savedProgress = store.skillProgress[tieredSkillId] || store.skillProgress[id] || 0;
|
||
|
||
const tierDef = SKILL_EVOLUTION_PATHS[id]?.tiers.find(t => t.tier === currentTier);
|
||
const skillDisplayName = tierDef?.name || def.name;
|
||
|
||
// Check prerequisites
|
||
let prereqMet = true;
|
||
if (def.req) {
|
||
for (const [r, rl] of Object.entries(def.req)) {
|
||
if ((store.skills[r] || 0) < rl) {
|
||
prereqMet = false;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Apply skill modifiers
|
||
const studyEffects = computeEffects(store.skillUpgrades || {}, store.skillTiers || {});
|
||
const effectiveSpeedMult = studySpeedMult * studyEffects.studySpeedMultiplier;
|
||
|
||
const tierStudyTime = def.studyTime * currentTier;
|
||
const effectiveStudyTime = tierStudyTime / effectiveSpeedMult;
|
||
|
||
const baseCost = def.base * (level + 1) * currentTier;
|
||
const cost = Math.floor(baseCost * studyCostMult);
|
||
|
||
const canStudy = !maxed && prereqMet && store.rawMana >= cost && !isStudying;
|
||
|
||
const milestoneInfo = hasMilestoneUpgrade(tieredSkillId, level);
|
||
const nextTierSkill = getNextTierSkill(tieredSkillId);
|
||
const canTierUp = maxed && nextTierSkill;
|
||
|
||
const selectedUpgrades = store.skillUpgrades[tieredSkillId] || [];
|
||
const selectedL5 = selectedUpgrades.filter(u => u.includes('_l5'));
|
||
const selectedL10 = selectedUpgrades.filter(u => u.includes('_l10'));
|
||
|
||
return (
|
||
<div
|
||
key={id}
|
||
className={`flex flex-col sm:flex-row sm:items-center justify-between p-3 rounded border gap-2 ${
|
||
isStudying ? 'border-purple-500 bg-purple-900/20' :
|
||
milestoneInfo ? 'border-amber-500/50 bg-amber-900/10' :
|
||
'border-gray-700 bg-gray-800/30'
|
||
}`}
|
||
>
|
||
<div className="flex-1 min-w-0">
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
<span className="font-semibold text-sm">{skillDisplayName}</span>
|
||
{currentTier > 1 && (
|
||
<Badge className="bg-purple-600/50 text-purple-200 text-xs">Tier {currentTier} ({fmtDec(tierMultiplier, 0)}x)</Badge>
|
||
)}
|
||
{level > 0 && <span className="text-purple-400 text-sm">Lv.{level}</span>}
|
||
{selectedUpgrades.length > 0 && (
|
||
<div className="flex gap-1">
|
||
{selectedL5.length > 0 && (
|
||
<Badge className="bg-amber-700/50 text-amber-200 text-xs">L5: {selectedL5.length}</Badge>
|
||
)}
|
||
{selectedL10.length > 0 && (
|
||
<Badge className="bg-purple-700/50 text-purple-200 text-xs">L10: {selectedL10.length}</Badge>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="text-xs text-gray-400 italic">{def.desc}{currentTier > 1 && ` (Tier ${currentTier}: ${fmtDec(tierMultiplier, 0)}x effect)`}</div>
|
||
{!prereqMet && def.req && (
|
||
<div className="text-xs text-red-400 mt-1">
|
||
Requires: {Object.entries(def.req).map(([r, rl]) => `${SKILLS_DEF[r]?.name} Lv.${rl}`).join(', ')}
|
||
</div>
|
||
)}
|
||
<div className="text-xs text-gray-500 mt-1">
|
||
<span className={effectiveSpeedMult > 1 ? 'text-green-400' : ''}>
|
||
Study: {formatStudyTime(effectiveStudyTime)}{effectiveSpeedMult > 1 && <span className="text-xs ml-1">({Math.round(effectiveSpeedMult * 100)}% speed)</span>}
|
||
</span>
|
||
{' • '}
|
||
<span className={studyCostMult < 1 ? 'text-green-400' : ''}>
|
||
Cost: {fmt(cost)} mana{studyCostMult < 1 && <span className="text-xs ml-1">({Math.round(studyCostMult * 100)}% cost)</span>}
|
||
</span>
|
||
</div>
|
||
|
||
{milestoneInfo && (
|
||
<div className="text-xs text-amber-400 mt-1 flex items-center gap-1">
|
||
⭐ Level {milestoneInfo.milestone} milestone: {milestoneInfo.selectedCount}/2 upgrades selected
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="flex items-center gap-3 flex-wrap sm:flex-nowrap">
|
||
{/* Level dots */}
|
||
<div className="flex gap-1 shrink-0">
|
||
{Array.from({ length: def.max }).map((_, i) => (
|
||
<div
|
||
key={i}
|
||
className={`w-2 h-2 rounded-full border ${
|
||
i < level ? 'bg-purple-500 border-purple-400' :
|
||
i === 4 || i === 9 ? 'border-amber-500' :
|
||
'border-gray-600'
|
||
}`}
|
||
/>
|
||
))}
|
||
</div>
|
||
|
||
{isStudying ? (
|
||
<div className="text-xs text-purple-400">
|
||
{formatStudyTime(store.currentStudyTarget?.progress || 0)}/{formatStudyTime(tierStudyTime)}
|
||
</div>
|
||
) : milestoneInfo ? (
|
||
<Button
|
||
size="sm"
|
||
className="bg-amber-600 hover:bg-amber-700"
|
||
onClick={() => {
|
||
setUpgradeDialogSkill(tieredSkillId);
|
||
setUpgradeDialogMilestone(milestoneInfo.milestone);
|
||
}}
|
||
>
|
||
Choose Upgrades
|
||
</Button>
|
||
) : canTierUp ? (
|
||
<Button
|
||
size="sm"
|
||
className="bg-purple-600 hover:bg-purple-700"
|
||
onClick={() => store.tierUpSkill(tieredSkillId)}
|
||
>
|
||
⬆️ Tier Up
|
||
</Button>
|
||
) : maxed ? (
|
||
<Badge className="bg-green-900/50 text-green-300">Maxed</Badge>
|
||
) : (
|
||
<div className="flex gap-1">
|
||
<Button
|
||
size="sm"
|
||
variant={canStudy ? 'default' : 'outline'}
|
||
disabled={!canStudy}
|
||
className={canStudy ? 'bg-purple-600 hover:bg-purple-700' : 'opacity-50'}
|
||
onClick={() => store.startStudyingSkill(tieredSkillId)}
|
||
>
|
||
Study ({fmt(cost)})
|
||
</Button>
|
||
{/* Parallel Study button */}
|
||
{hasParallelStudy &&
|
||
store.currentStudyTarget &&
|
||
!store.parallelStudyTarget &&
|
||
store.currentStudyTarget.id !== tieredSkillId &&
|
||
canStudy && (
|
||
<TooltipProvider>
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
className="border-cyan-500 text-cyan-400 hover:bg-cyan-900/30"
|
||
onClick={() => store.startParallelStudySkill(tieredSkillId)}
|
||
>
|
||
⚡
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent>
|
||
<p>Study in parallel (50% speed)</p>
|
||
</TooltipContent>
|
||
</Tooltip>
|
||
</TooltipProvider>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
}
|