feat: recreate Crafting Tab with Fabricator and Enchanter sub-tabs
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 2m18s
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 2m18s
- Add fabricator-recipes.ts with 12 recipes across earth/metal/crystal/sand mana types - Add FabricatorSubTab with mana-type filtering, recipe cards, materials inventory - Add EnchanterSubTab integrating existing 3-phase flow (Design → Prepare → Apply) - Add CraftingTab main component with clsx-based sub-tab system (matches DisciplinesTab pattern) - Wire into tabs barrel export and page.tsx with lazy loading + DebugName wrapper - Add 17 tests covering exports, displayNames, recipe data integrity, helpers, file sizes - All files under 400 lines
This commit is contained in:
@@ -0,0 +1,260 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Anvil, Hammer, Package } from 'lucide-react';
|
||||
import {
|
||||
FABRICATOR_RECIPES,
|
||||
getRecipesByManaType,
|
||||
canCraftRecipe,
|
||||
} from '@/lib/game/data/fabricator-recipes';
|
||||
import { LOOT_DROPS, LOOT_RARITY_COLORS } from '@/lib/game/data/loot-drops';
|
||||
import { useCraftingStore, useManaStore } from '@/lib/game/stores';
|
||||
import type { FabricatorRecipe } from '@/lib/game/data/fabricator-recipes';
|
||||
|
||||
const MANA_TYPE_LABELS: Record<string, string> = {
|
||||
earth: '⛰️ Earth',
|
||||
metal: '🔩 Metal',
|
||||
crystal: '💎 Crystal',
|
||||
sand: '🏜️ Sand',
|
||||
};
|
||||
|
||||
function RecipeCard({
|
||||
recipe,
|
||||
materials,
|
||||
manaAmount,
|
||||
onCraft,
|
||||
isCrafting,
|
||||
}: {
|
||||
recipe: FabricatorRecipe;
|
||||
materials: Record<string, number>;
|
||||
manaAmount: number;
|
||||
onCraft: (recipe: FabricatorRecipe) => void;
|
||||
isCrafting: boolean;
|
||||
}) {
|
||||
const { canCraft, missingMaterials, missingMana } = canCraftRecipe(
|
||||
recipe,
|
||||
materials,
|
||||
manaAmount,
|
||||
);
|
||||
const rarityStyle = LOOT_RARITY_COLORS[recipe.rarity];
|
||||
|
||||
return (
|
||||
<div
|
||||
className="p-3 rounded border bg-gray-800/50"
|
||||
style={{ borderColor: rarityStyle?.color }}
|
||||
>
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<div>
|
||||
<div className="font-semibold" style={{ color: rarityStyle?.color }}>
|
||||
{recipe.name}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 capitalize">{recipe.rarity}</div>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{MANA_TYPE_LABELS[recipe.manaType] ?? recipe.manaType}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-gray-400 mb-2">{recipe.description}</div>
|
||||
<div className="text-xs text-amber-400/80 italic mb-2">{recipe.gearTrait}</div>
|
||||
|
||||
<Separator className="bg-gray-700 my-2" />
|
||||
|
||||
<div className="text-xs space-y-1">
|
||||
<div className="text-gray-500">Materials:</div>
|
||||
{Object.entries(recipe.materials).map(([matId, amount]) => {
|
||||
const available = materials[matId] || 0;
|
||||
const matDrop = LOOT_DROPS[matId];
|
||||
const hasEnough = available >= amount;
|
||||
|
||||
return (
|
||||
<div key={matId} className="flex justify-between">
|
||||
<span>{matDrop?.name ?? matId}</span>
|
||||
<span className={hasEnough ? 'text-green-400' : 'text-red-400'}>
|
||||
{available} / {amount}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="flex justify-between mt-2">
|
||||
<span>{MANA_TYPE_LABELS[recipe.manaType]?.split(' ')[1] ?? recipe.manaType} Mana:</span>
|
||||
<span className={manaAmount >= recipe.manaCost ? 'text-green-400' : 'text-red-400'}>
|
||||
{manaAmount} / {recipe.manaCost}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between">
|
||||
<span>Craft Time:</span>
|
||||
<span>{recipe.craftTime}h</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="w-full mt-3"
|
||||
size="sm"
|
||||
disabled={!canCraft || isCrafting}
|
||||
onClick={() => onCraft(recipe)}
|
||||
>
|
||||
{canCraft ? 'Craft' : 'Missing Resources'}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FabricatorSubTab() {
|
||||
const [selectedManaType, setSelectedManaType] = useState<string>('earth');
|
||||
|
||||
const lootInventory = useCraftingStore((s) => s.lootInventory);
|
||||
const equipmentCraftingProgress = useCraftingStore((s) => s.equipmentCraftingProgress);
|
||||
const rawMana = useManaStore((s) => s.rawMana);
|
||||
const startCraftingEquipment = useCraftingStore((s) => s.startCraftingEquipment);
|
||||
const cancelEquipmentCrafting = useCraftingStore((s) => s.cancelEquipmentCrafting);
|
||||
|
||||
const availableManaTypes = useMemo(() => {
|
||||
return [...new Set(FABRICATOR_RECIPES.map((r) => r.manaType))];
|
||||
}, []);
|
||||
|
||||
const filteredRecipes = useMemo(
|
||||
() => getRecipesByManaType(selectedManaType),
|
||||
[selectedManaType],
|
||||
);
|
||||
|
||||
const isCrafting = equipmentCraftingProgress !== null;
|
||||
|
||||
const handleCraft = (recipe: FabricatorRecipe) => {
|
||||
// Use the existing equipment crafting system with a fabricator-specific blueprint ID
|
||||
startCraftingEquipment(`fabricator-${recipe.id}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Mana type filter */}
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{availableManaTypes.map((mt) => (
|
||||
<Button
|
||||
key={mt}
|
||||
variant={selectedManaType === mt ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setSelectedManaType(mt)}
|
||||
>
|
||||
{MANA_TYPE_LABELS[mt] ?? mt}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* Recipe list */}
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-amber-400 text-sm flex items-center gap-2">
|
||||
<Hammer className="w-4 h-4" />
|
||||
{MANA_TYPE_LABELS[selectedManaType] ?? selectedManaType} Recipes
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isCrafting ? (
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm text-gray-400">
|
||||
Crafting: {equipmentCraftingProgress.blueprintId}
|
||||
</div>
|
||||
<Progress
|
||||
value={
|
||||
(equipmentCraftingProgress.progress /
|
||||
equipmentCraftingProgress.required) *
|
||||
100
|
||||
}
|
||||
className="h-3"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-gray-400">
|
||||
<span>
|
||||
{equipmentCraftingProgress.progress.toFixed(1)}h /{' '}
|
||||
{equipmentCraftingProgress.required.toFixed(1)}h
|
||||
</span>
|
||||
<span>Mana spent: {equipmentCraftingProgress.manaSpent}</span>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={cancelEquipmentCrafting}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="h-80">
|
||||
<div className="space-y-2">
|
||||
{filteredRecipes.length === 0 ? (
|
||||
<div className="text-center text-gray-400 py-4">
|
||||
<Anvil className="w-12 h-12 mx-auto mb-2 opacity-50" />
|
||||
<p>No recipes for this mana type yet.</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredRecipes.map((recipe) => (
|
||||
<RecipeCard
|
||||
key={recipe.id}
|
||||
recipe={recipe}
|
||||
materials={lootInventory.materials}
|
||||
manaAmount={rawMana}
|
||||
onCraft={handleCraft}
|
||||
isCrafting={isCrafting}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Materials inventory */}
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-amber-400 text-sm flex items-center gap-2">
|
||||
<Package className="w-4 h-4" />
|
||||
Materials ({Object.values(lootInventory.materials).reduce((a, b) => a + b, 0)})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ScrollArea className="h-80">
|
||||
{Object.keys(lootInventory.materials).length === 0 ? (
|
||||
<div className="text-center text-gray-400 py-4">
|
||||
<Package className="w-12 h-12 mx-auto mb-2 opacity-50" />
|
||||
<p>No materials collected yet.</p>
|
||||
<p className="text-xs mt-1">Defeat floors to gather materials!</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{Object.entries(lootInventory.materials).map(([matId, count]) => {
|
||||
if (count <= 0) return null;
|
||||
const drop = LOOT_DROPS[matId];
|
||||
if (!drop) return null;
|
||||
|
||||
const rarityStyle = LOOT_RARITY_COLORS[drop.rarity];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={matId}
|
||||
className="p-2 rounded border bg-gray-800/50"
|
||||
style={{ borderColor: rarityStyle?.color }}
|
||||
>
|
||||
<div className="text-sm font-semibold" style={{ color: rarityStyle?.color }}>
|
||||
{drop.name}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">x{count}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
FabricatorSubTab.displayName = 'FabricatorSubTab';
|
||||
Reference in New Issue
Block a user