Fix build errors and add Equipment tab
Some checks failed
Build and Publish Mana Loop Docker Image / build-and-publish (push) Failing after 1m7s
Some checks failed
Build and Publish Mana Loop Docker Image / build-and-publish (push) Failing after 1m7s
- Create missing StudyProgress component for SkillsTab - Create missing UpgradeDialog component for SkillsTab - Add EquipmentTab with gear selection interface - Shows all 8 equipment slots with equipped items - Displays inventory of unequipped items - Allows equipping/unequipping gear - Shows equipment stats summary and active effects
This commit is contained in:
@@ -12,7 +12,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { RotateCcw } from 'lucide-react';
|
import { RotateCcw } from 'lucide-react';
|
||||||
import { CraftingTab, SpireTab, SpellsTab, LabTab, SkillsTab, StatsTab } from '@/components/game/tabs';
|
import { CraftingTab, SpireTab, SpellsTab, LabTab, SkillsTab, StatsTab, EquipmentTab } from '@/components/game/tabs';
|
||||||
import { ComboMeter, ActionButtons, CalendarDisplay, ManaDisplay, TimeDisplay } from '@/components/game';
|
import { ComboMeter, ActionButtons, CalendarDisplay, 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';
|
||||||
@@ -226,10 +226,11 @@ export default function ManaLoopGame() {
|
|||||||
{/* Right Panel - Tabs */}
|
{/* Right Panel - Tabs */}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||||
<TabsList className="grid grid-cols-7 w-full mb-4">
|
<TabsList className="grid grid-cols-8 w-full mb-4">
|
||||||
<TabsTrigger value="spire">⚔️ Spire</TabsTrigger>
|
<TabsTrigger value="spire">⚔️ Spire</TabsTrigger>
|
||||||
<TabsTrigger value="skills">📚 Skills</TabsTrigger>
|
<TabsTrigger value="skills">📚 Skills</TabsTrigger>
|
||||||
<TabsTrigger value="spells">✨ Spells</TabsTrigger>
|
<TabsTrigger value="spells">✨ Spells</TabsTrigger>
|
||||||
|
<TabsTrigger value="equipment">🛡️ Gear</TabsTrigger>
|
||||||
<TabsTrigger value="crafting">🔧 Craft</TabsTrigger>
|
<TabsTrigger value="crafting">🔧 Craft</TabsTrigger>
|
||||||
<TabsTrigger value="lab">🔬 Lab</TabsTrigger>
|
<TabsTrigger value="lab">🔬 Lab</TabsTrigger>
|
||||||
<TabsTrigger value="stats">📊 Stats</TabsTrigger>
|
<TabsTrigger value="stats">📊 Stats</TabsTrigger>
|
||||||
@@ -256,6 +257,10 @@ export default function ManaLoopGame() {
|
|||||||
<SpellsTab store={store} />
|
<SpellsTab store={store} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="equipment">
|
||||||
|
<EquipmentTab store={store} />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="crafting">
|
<TabsContent value="crafting">
|
||||||
<CraftingTab store={store} />
|
<CraftingTab store={store} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|||||||
393
src/components/game/tabs/EquipmentTab.tsx
Normal file
393
src/components/game/tabs/EquipmentTab.tsx
Normal file
@@ -0,0 +1,393 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
EQUIPMENT_TYPES,
|
||||||
|
EQUIPMENT_SLOTS,
|
||||||
|
getEquipmentBySlot,
|
||||||
|
type EquipmentSlot,
|
||||||
|
type EquipmentType,
|
||||||
|
} from '@/lib/game/data/equipment';
|
||||||
|
import { ENCHANTMENT_EFFECTS } from '@/lib/game/data/enchantment-effects';
|
||||||
|
import { fmt } from '@/lib/game/store';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger
|
||||||
|
} from '@/components/ui/tooltip';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import type { GameStore, EquipmentInstance } from '@/lib/game/types';
|
||||||
|
|
||||||
|
export interface EquipmentTabProps {
|
||||||
|
store: GameStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slot display names
|
||||||
|
const SLOT_NAMES: Record<EquipmentSlot, string> = {
|
||||||
|
mainHand: 'Main Hand',
|
||||||
|
offHand: 'Off Hand',
|
||||||
|
head: 'Head',
|
||||||
|
body: 'Body',
|
||||||
|
hands: 'Hands',
|
||||||
|
feet: 'Feet',
|
||||||
|
accessory1: 'Accessory 1',
|
||||||
|
accessory2: 'Accessory 2',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Slot icons
|
||||||
|
const SLOT_ICONS: Record<EquipmentSlot, string> = {
|
||||||
|
mainHand: '⚔️',
|
||||||
|
offHand: '🛡️',
|
||||||
|
head: '🎩',
|
||||||
|
body: '👕',
|
||||||
|
hands: '🧤',
|
||||||
|
feet: '👢',
|
||||||
|
accessory1: '💍',
|
||||||
|
accessory2: '📿',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Rarity colors
|
||||||
|
const RARITY_COLORS: Record<string, string> = {
|
||||||
|
common: 'border-gray-500 bg-gray-800/30',
|
||||||
|
uncommon: 'border-green-500 bg-green-900/20',
|
||||||
|
rare: 'border-blue-500 bg-blue-900/20',
|
||||||
|
epic: 'border-purple-500 bg-purple-900/20',
|
||||||
|
legendary: 'border-amber-500 bg-amber-900/20',
|
||||||
|
mythic: 'border-red-500 bg-red-900/20',
|
||||||
|
};
|
||||||
|
|
||||||
|
const RARITY_TEXT_COLORS: Record<string, string> = {
|
||||||
|
common: 'text-gray-300',
|
||||||
|
uncommon: 'text-green-400',
|
||||||
|
rare: 'text-blue-400',
|
||||||
|
epic: 'text-purple-400',
|
||||||
|
legendary: 'text-amber-400',
|
||||||
|
mythic: 'text-red-400',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function EquipmentTab({ store }: EquipmentTabProps) {
|
||||||
|
const [selectedSlot, setSelectedSlot] = useState<EquipmentSlot | null>(null);
|
||||||
|
|
||||||
|
// Get unequipped items
|
||||||
|
const equippedIds = new Set(Object.values(store.equippedInstances).filter(Boolean));
|
||||||
|
const unequippedItems = Object.values(store.equipmentInstances).filter(
|
||||||
|
(inst) => !equippedIds.has(inst.instanceId)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Equip an item to a slot
|
||||||
|
const handleEquip = (instanceId: string, slot: EquipmentSlot) => {
|
||||||
|
store.equipItem(instanceId, slot);
|
||||||
|
setSelectedSlot(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Unequip from a slot
|
||||||
|
const handleUnequip = (slot: EquipmentSlot) => {
|
||||||
|
store.unequipItem(slot);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get items that can be equipped in a slot
|
||||||
|
const getEquippableItems = (slot: EquipmentSlot): EquipmentInstance[] => {
|
||||||
|
const equipmentTypes = getEquipmentBySlot(slot);
|
||||||
|
const typeIds = new Set(equipmentTypes.map((t) => t.id));
|
||||||
|
|
||||||
|
return unequippedItems.filter((inst) => typeIds.has(inst.typeId));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get all items that can go in a slot (including accessories that can go in either accessory slot)
|
||||||
|
const getItemsForSlot = (slot: EquipmentSlot): EquipmentInstance[] => {
|
||||||
|
if (slot === 'accessory1' || slot === 'accessory2') {
|
||||||
|
// Accessories can go in either slot
|
||||||
|
const accessoryTypes = EQUIPMENT_TYPES;
|
||||||
|
const accessoryTypeIds = Object.values(accessoryTypes)
|
||||||
|
.filter((t) => t.category === 'accessory')
|
||||||
|
.map((t) => t.id);
|
||||||
|
|
||||||
|
return unequippedItems.filter((inst) => accessoryTypeIds.includes(inst.typeId));
|
||||||
|
}
|
||||||
|
return getEquippableItems(slot);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Equipment Slots */}
|
||||||
|
<Card className="bg-gray-900/80 border-gray-700">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-amber-400 game-panel-title text-xs">
|
||||||
|
Equipped Gear
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||||
|
{EQUIPMENT_SLOTS.map((slot) => {
|
||||||
|
const instanceId = store.equippedInstances[slot];
|
||||||
|
const instance = instanceId ? store.equipmentInstances[instanceId] : null;
|
||||||
|
const equipmentType = instance ? EQUIPMENT_TYPES[instance.typeId] : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={slot}
|
||||||
|
className={`p-3 rounded border ${
|
||||||
|
instance
|
||||||
|
? RARITY_COLORS[instance.rarity]
|
||||||
|
: 'border-gray-700 bg-gray-800/30'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span>{SLOT_ICONS[slot]}</span>
|
||||||
|
<span className="text-sm font-semibold text-gray-300">
|
||||||
|
{SLOT_NAMES[slot]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{instance && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-6 text-xs text-red-400 hover:text-red-300 hover:bg-red-900/20"
|
||||||
|
onClick={() => handleUnequip(slot)}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{instance ? (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className={`font-semibold text-sm ${RARITY_TEXT_COLORS[instance.rarity]}`}>
|
||||||
|
{instance.name}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-400">
|
||||||
|
Capacity: {instance.usedCapacity}/{instance.totalCapacity}
|
||||||
|
</div>
|
||||||
|
{instance.enchantments.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1 mt-1">
|
||||||
|
{instance.enchantments.map((ench, i) => {
|
||||||
|
const effect = ENCHANTMENT_EFFECTS[ench.effectId];
|
||||||
|
return (
|
||||||
|
<TooltipProvider key={i}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="text-xs cursor-help"
|
||||||
|
>
|
||||||
|
{effect?.name || ench.effectId}
|
||||||
|
{ench.stacks > 1 && ` x${ench.stacks}`}
|
||||||
|
</Badge>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>{effect?.description || 'Unknown effect'}</p>
|
||||||
|
<p className="text-gray-400 text-xs">
|
||||||
|
Category: {effect?.category || 'unknown'}
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-gray-500 italic">
|
||||||
|
Empty
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Inventory */}
|
||||||
|
<Card className="bg-gray-900/80 border-gray-700">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-amber-400 game-panel-title text-xs">
|
||||||
|
Equipment Inventory ({unequippedItems.length} items)
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{unequippedItems.length === 0 ? (
|
||||||
|
<div className="text-gray-500 text-sm text-center py-4">
|
||||||
|
No unequipped items. Craft new gear in the Crafting tab.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 max-h-96 overflow-y-auto">
|
||||||
|
{unequippedItems.map((instance) => {
|
||||||
|
const equipmentType = EQUIPMENT_TYPES[instance.typeId];
|
||||||
|
const validSlots = equipmentType
|
||||||
|
? (equipmentType.category === 'accessory'
|
||||||
|
? ['accessory1', 'accessory2'] as EquipmentSlot[]
|
||||||
|
: [equipmentType.slot])
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={instance.instanceId}
|
||||||
|
className={`p-3 rounded border ${RARITY_COLORS[instance.rarity]}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between mb-2">
|
||||||
|
<div>
|
||||||
|
<div className={`font-semibold text-sm ${RARITY_TEXT_COLORS[instance.rarity]}`}>
|
||||||
|
{instance.name}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-400">
|
||||||
|
{equipmentType?.description}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{equipmentType?.category || 'unknown'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-xs text-gray-400 space-y-1 mb-2">
|
||||||
|
<div>
|
||||||
|
Capacity: {instance.usedCapacity}/{instance.totalCapacity}
|
||||||
|
{instance.quality < 100 && (
|
||||||
|
<span className="text-yellow-500 ml-1">
|
||||||
|
(Quality: {instance.quality}%)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{instance.enchantments.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{instance.enchantments.map((ench, i) => {
|
||||||
|
const effect = ENCHANTMENT_EFFECTS[ench.effectId];
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
key={i}
|
||||||
|
variant="outline"
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
{effect?.name || ench.effectId}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{validSlots.length > 0 && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Select
|
||||||
|
onValueChange={(value) =>
|
||||||
|
handleEquip(instance.instanceId, value as EquipmentSlot)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue placeholder="Equip to..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{validSlots.map((slot) => (
|
||||||
|
<SelectItem
|
||||||
|
key={slot}
|
||||||
|
value={slot}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
{SLOT_ICONS[slot]} {SLOT_NAMES[slot]}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-8 text-xs text-red-400 hover:text-red-300 hover:bg-red-900/20"
|
||||||
|
onClick={() => store.deleteEquipmentInstance(instance.instanceId)}
|
||||||
|
>
|
||||||
|
🗑️
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Delete this item</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Equipment Stats Summary */}
|
||||||
|
<Card className="bg-gray-900/80 border-gray-700">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-amber-400 game-panel-title text-xs">
|
||||||
|
Equipment Stats Summary
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||||
|
<div className="p-3 bg-gray-800/50 rounded text-center">
|
||||||
|
<div className="text-2xl font-bold text-amber-400 game-mono">
|
||||||
|
{Object.values(store.equipmentInstances).length}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-400">Total Items</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-gray-800/50 rounded text-center">
|
||||||
|
<div className="text-2xl font-bold text-green-400 game-mono">
|
||||||
|
{equippedIds.size}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-400">Equipped</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-gray-800/50 rounded text-center">
|
||||||
|
<div className="text-2xl font-bold text-blue-400 game-mono">
|
||||||
|
{unequippedItems.length}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-400">In Inventory</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-gray-800/50 rounded text-center">
|
||||||
|
<div className="text-2xl font-bold text-purple-400 game-mono">
|
||||||
|
{Object.values(store.equipmentInstances).reduce(
|
||||||
|
(sum, inst) => sum + inst.enchantments.length,
|
||||||
|
0
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-400">Total Enchantments</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Active Effects from Equipment */}
|
||||||
|
<div className="mt-4">
|
||||||
|
<div className="text-sm text-gray-400 mb-2">Active Effects from Equipment:</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{(() => {
|
||||||
|
const effects = store.getEquipmentEffects();
|
||||||
|
const effectEntries = Object.entries(effects).filter(([, v]) => v > 0);
|
||||||
|
|
||||||
|
if (effectEntries.length === 0) {
|
||||||
|
return <span className="text-gray-500 text-sm">No active effects</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return effectEntries.map(([stat, value]) => (
|
||||||
|
<Badge key={stat} variant="outline" className="text-xs">
|
||||||
|
{stat}: +{fmt(value)}
|
||||||
|
</Badge>
|
||||||
|
));
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
73
src/components/game/tabs/StudyProgress.tsx
Normal file
73
src/components/game/tabs/StudyProgress.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { SKILLS_DEF } from '@/lib/game/constants';
|
||||||
|
import { formatStudyTime } from '@/lib/game/formatting';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Progress } from '@/components/ui/progress';
|
||||||
|
import type { StudyTarget } from '@/lib/game/types';
|
||||||
|
|
||||||
|
export interface StudyProgressProps {
|
||||||
|
currentStudyTarget: StudyTarget;
|
||||||
|
skills: Record<string, number>;
|
||||||
|
studySpeedMult: number;
|
||||||
|
cancelStudy: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StudyProgress({
|
||||||
|
currentStudyTarget,
|
||||||
|
skills,
|
||||||
|
studySpeedMult,
|
||||||
|
cancelStudy
|
||||||
|
}: StudyProgressProps) {
|
||||||
|
const { id, progress, required } = currentStudyTarget;
|
||||||
|
|
||||||
|
// Get skill name
|
||||||
|
const baseId = id.includes('_t') ? id.split('_t')[0] : id;
|
||||||
|
const skillDef = SKILLS_DEF[baseId];
|
||||||
|
const skillName = skillDef?.name || id;
|
||||||
|
|
||||||
|
// Get current level
|
||||||
|
const currentLevel = skills[id] || skills[baseId] || 0;
|
||||||
|
|
||||||
|
// Calculate progress percentage
|
||||||
|
const progressPercent = Math.min((progress / required) * 100, 100);
|
||||||
|
|
||||||
|
// Estimated completion
|
||||||
|
const remainingHours = required - progress;
|
||||||
|
const effectiveSpeed = studySpeedMult;
|
||||||
|
const realTimeRemaining = remainingHours / effectiveSpeed;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<span className="text-purple-300 font-semibold">{skillName}</span>
|
||||||
|
<span className="text-gray-400 ml-2">
|
||||||
|
Level {currentLevel} → {currentLevel + 1}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={cancelStudy}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex justify-between text-xs text-gray-400">
|
||||||
|
<span>{formatStudyTime(progress)} / {formatStudyTime(required)}</span>
|
||||||
|
<span>{progressPercent.toFixed(1)}%</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={progressPercent} className="h-2" />
|
||||||
|
{studySpeedMult > 1 && (
|
||||||
|
<div className="text-xs text-green-400">
|
||||||
|
Speed: {(studySpeedMult * 100).toFixed(0)}% • ETA: {formatStudyTime(realTimeRemaining)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
116
src/components/game/tabs/UpgradeDialog.tsx
Normal file
116
src/components/game/tabs/UpgradeDialog.tsx
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { SKILLS_DEF } from '@/lib/game/constants';
|
||||||
|
import { getUpgradesForSkillAtMilestone, getTierMultiplier } from '@/lib/game/skill-evolution';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import type { SkillUpgradeChoice } from '@/lib/game/types';
|
||||||
|
|
||||||
|
export interface UpgradeDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
skillId: string | null;
|
||||||
|
milestone: 5 | 10;
|
||||||
|
pendingSelections: string[];
|
||||||
|
available: SkillUpgradeChoice[];
|
||||||
|
alreadySelected: string[];
|
||||||
|
onToggle: (upgradeId: string) => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UpgradeDialog({
|
||||||
|
open,
|
||||||
|
skillId,
|
||||||
|
milestone,
|
||||||
|
pendingSelections,
|
||||||
|
available,
|
||||||
|
alreadySelected,
|
||||||
|
onToggle,
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
onOpenChange,
|
||||||
|
}: UpgradeDialogProps) {
|
||||||
|
if (!skillId) return null;
|
||||||
|
|
||||||
|
// Get skill name
|
||||||
|
const baseId = skillId.includes('_t') ? skillId.split('_t')[0] : skillId;
|
||||||
|
const skillDef = SKILLS_DEF[baseId];
|
||||||
|
const skillName = skillDef?.name || skillId;
|
||||||
|
|
||||||
|
const currentSelections = pendingSelections.length > 0 ? pendingSelections : alreadySelected;
|
||||||
|
const canConfirm = currentSelections.length === 2;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-md bg-gray-900 border-purple-600/50">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-amber-400">
|
||||||
|
Level {milestone} Milestone: {skillName}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-gray-400">
|
||||||
|
Choose 2 upgrades for this skill. These choices are permanent.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-2 py-4">
|
||||||
|
{available.map((upgrade) => {
|
||||||
|
const isSelected = currentSelections.includes(upgrade.id);
|
||||||
|
const canSelect = isSelected || currentSelections.length < 2;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={upgrade.id}
|
||||||
|
onClick={() => canSelect && onToggle(upgrade.id)}
|
||||||
|
className={`p-3 rounded border cursor-pointer transition-all ${
|
||||||
|
isSelected
|
||||||
|
? 'border-amber-500 bg-amber-900/30'
|
||||||
|
: canSelect
|
||||||
|
? 'border-gray-700 hover:border-gray-500 bg-gray-800/30'
|
||||||
|
: 'border-gray-800 bg-gray-900/30 opacity-50 cursor-not-allowed'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className={`font-semibold text-sm ${isSelected ? 'text-amber-300' : 'text-gray-200'}`}>
|
||||||
|
{upgrade.name}
|
||||||
|
</span>
|
||||||
|
{isSelected && (
|
||||||
|
<Badge className="bg-amber-600/50 text-amber-200 text-xs">Selected</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-400 mt-1">{upgrade.desc}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{available.length === 0 && (
|
||||||
|
<div className="text-center text-gray-500 py-4">
|
||||||
|
No upgrades available at this milestone.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="flex gap-2 sm:gap-0">
|
||||||
|
<Button variant="outline" onClick={onCancel}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="bg-amber-600 hover:bg-amber-700"
|
||||||
|
disabled={!canConfirm}
|
||||||
|
onClick={onConfirm}
|
||||||
|
>
|
||||||
|
Confirm ({currentSelections.length}/2)
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,3 +7,4 @@ export { SpellsTab } from './SpellsTab';
|
|||||||
export { LabTab } from './LabTab';
|
export { LabTab } from './LabTab';
|
||||||
export { SkillsTab } from './SkillsTab';
|
export { SkillsTab } from './SkillsTab';
|
||||||
export { StatsTab } from './StatsTab';
|
export { StatsTab } from './StatsTab';
|
||||||
|
export { EquipmentTab } from './EquipmentTab';
|
||||||
|
|||||||
Reference in New Issue
Block a user