Files
Mana-Loop/src/components/game/tabs/EquipmentTab.tsx
T
Refactoring Agent c9ae2576f4
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m51s
fix: SLOT_NAMES export and refactor: split crafting-slice.ts into modules
2026-05-02 10:59:36 +02:00

358 lines
13 KiB
TypeScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useState, useMemo } from 'react';
import {
EQUIPMENT_TYPES,
EQUIPMENT_SLOTS,
SLOT_NAMES,
getEquipmentBySlot,
type EquipmentSlot,
type EquipmentType,
} from '@/lib/game/data/equipment';
import { ENCHANTMENT_EFFECTS } from '@/lib/game/data/enchantment-effects';
import { getUnifiedEffects } from '@/lib/game/effects';
import { fmt } from '@/lib/game/store';
import { Button } from '@/components/ui/button';
import { GameCard } from '@/components/ui/game-card';
import { SectionHeader } from '@/components/ui/section-header';
import { StatRow } from '@/components/ui/stat-row';
import { Badge } from '@/components/ui/badge';
import type { GameStore, EquipmentInstance } from '@/lib/game/types';
import { EquipmentSlotGrid } from './EquipmentSlotGrid';
import { EquipmentInventory } from './EquipmentInventory';
import { EnchantmentsPanel } from './EnchantmentsPanel';
import { useGameToast } from '@/components/game/GameToast';
import { ConfirmDialog } from '@/components/game/ConfirmDialog';
// Rarity color mappings using design system tokens
export const RARITY_BORDER_COLORS: Record<string, string> = {
common: 'border-[var(--text-muted)]',
uncommon: 'border-[var(--color-success)]',
rare: 'border-[var(--mana-water)]',
epic: 'border-[var(--mana-stellar)]',
legendary: 'border-[var(--mana-light)]',
mythic: 'border-[var(--mana-dark)]',
};
export const RARITY_BG_COLORS: Record<string, string> = {
common: 'bg-[var(--bg-sunken)]/30',
uncommon: 'bg-[var(--color-success)]/10',
rare: 'bg-[var(--mana-water)]/10',
epic: 'bg-[var(--mana-stellar)]/10',
legendary: 'bg-[var(--mana-light)]/10',
mythic: 'bg-[var(--mana-dark)]/10',
};
export const RARITY_TEXT_COLORS: Record<string, string> = {
common: 'text-[var(--text-secondary)]',
uncommon: 'text-[var(--color-success)]',
rare: 'text-[var(--mana-water)]',
epic: 'text-[var(--mana-stellar)]',
legendary: 'text-[var(--mana-light)]',
mythic: 'text-[var(--mana-dark)]',
};
const SLOT_ICONS: Record<EquipmentSlot, React.ElementType> = {
mainHand: () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M14.5 4H5.5L2 10v10a2 2 0 002 2h16a2 2 0 002-2V10l-3.5-6z" />
<line x1="6" y1="10" x2="18" y2="10" />
</svg>
),
offHand: () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M20 7H9a2 2 0 01-2-2V4a2 2 0 012-2h5l3 6-3 6h-5a2 2 0 00-2 2v4a2 2 0 002 2h7l3 6-3 6H4a2 2 0 01-2-2v-2" />
</svg>
),
head: () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
<path d="M14 2v6h6M10 14l2 2 4-4" />
</svg>
),
body: () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M20.38 8.5c.6-1.4 1.3-4.9-1.2-8.5-1.1-1.8-3.6-1.8-4.7 0-2.5 3.6-1.9 7.1-1.2 8.5.7 1.4 2.5 1.9 4 1.9 1.5 0 3.3-.5 4-1.9z" />
<path d="M12 8v7M8 18h8" />
</svg>
),
hands: () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M18 11V6a2 2 0 00-2-2h-2a2 2 0 00-2 2v5M6 11v6a2 2 0 002 2h5a2 2 0 002-2v-6" />
<path d="M10 15V7a2 2 0 012-2h2a2 2 0 012 2v8" />
</svg>
),
feet: () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 2v20M5 13a4 4 0 000 8h14a4 4 0 000-8" />
<path d="M9 13V5a2 2 0 012-2h2a2 2 0 012 2v8" />
</svg>
),
accessory1: () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="8" />
<path d="M12 4v8l2 4" />
</svg>
),
accessory2: () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="8" />
<path d="M12 4v8l2 4" />
</svg>
),
};
// Slot grouping for visual layout
type SlotGroup = {
label: string;
slots: EquipmentSlot[];
};
const SLOT_GROUPS: SlotGroup[] = [
{ label: 'Weapon & Shield', slots: ['mainHand', 'offHand'] },
{ label: 'Armor', slots: ['head', 'body', 'hands', 'feet'] },
{ label: 'Accessories', slots: ['accessory1', 'accessory2'] },
];
export function EquipmentTab({ store }: { store: GameStore }) {
const showToast = useGameToast();
const [selectedSlot, setSelectedSlot] = useState<EquipmentSlot | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<{ instanceId: string; name: string } | null>(null);
// Get unequipped items
const equippedIds = useMemo(() =>
new Set(Object.values(store.equippedInstances).filter(Boolean)),
[store.equippedInstances]
);
const unequippedItems = useMemo(() =>
Object.values(store.equipmentInstances).filter(
(inst) => !equippedIds.has(inst.instanceId)
),
[store.equipmentInstances, equippedIds]
);
// Equip an item to a slot
const handleEquip = (instanceId: string, slot: EquipmentSlot) => {
const instance = store.equipmentInstances[instanceId];
store.equipItem(instanceId, slot);
setSelectedSlot(null);
showToast('success', 'Item Equipped', `${instance?.name || 'Item'} equipped to ${SLOT_NAMES[slot]}`);
};
// Unequip from a slot
const handleUnequip = (slot: EquipmentSlot) => {
const instanceId = store.equippedInstances[slot];
const instance = instanceId ? store.equipmentInstances[instanceId] : null;
store.unequipItem(slot);
showToast('success', 'Item Unequipped', `${instance?.name || 'Item'} removed from ${SLOT_NAMES[slot]}`);
};
// Check if a slot is blocked by a 2-handed weapon
const isSlotBlocked = (slot: EquipmentSlot): boolean => {
if (slot === 'offHand' && store.equippedInstances.mainHand) {
const mainHandInstance = store.equipmentInstances[store.equippedInstances.mainHand];
if (!mainHandInstance) return false;
const mainHandType = EQUIPMENT_TYPES[mainHandInstance.typeId];
return mainHandType?.twoHanded === true;
}
return false;
};
// 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
const getItemsForSlot = (slot: EquipmentSlot): EquipmentInstance[] => {
if (isSlotBlocked(slot)) return [];
if (slot === 'accessory1' || slot === 'accessory2') {
const accessoryTypeIds = Object.values(EQUIPMENT_TYPES)
.filter((t) => t.category === 'accessory')
.map((t) => t.id);
return unequippedItems.filter((inst) => accessoryTypeIds.includes(inst.typeId));
}
if (slot === 'offHand') {
return getEquippableItems(slot).filter((inst) => {
const type = EQUIPMENT_TYPES[inst.typeId];
return !type?.twoHanded;
});
}
return getEquippableItems(slot);
};
// Check if an instance is currently equipped
const isEquipped = (instanceId: string): boolean =>
Object.values(store.equippedInstances).includes(instanceId);
// Get all slots an item type can be equipped to
const getEquippableSlots = (typeId: string): EquipmentSlot[] => {
const equipmentType = EQUIPMENT_TYPES[typeId];
if (!equipmentType) return [];
if (equipmentType.category === 'accessory') {
return ['accessory1', 'accessory2'];
}
return [equipmentType.slot];
};
// Handle item deletion
const handleDelete = (instanceId: string, name: string) => {
setDeleteConfirm({ instanceId, name });
};
const confirmDelete = () => {
if (deleteConfirm) {
store.deleteEquipmentInstance(deleteConfirm.instanceId);
showToast('success', 'Item Discarded', `${deleteConfirm.name} has been removed from inventory`);
setDeleteConfirm(null);
}
};
return (
<div className="space-y-4 max-w-full overflow-x-hidden">
{/* Equipment Slots */}
<GameCard variant="default">
<SectionHeader
title="Equipped Gear"
action={
<span className="text-xs text-[var(--text-muted)]">
{Object.values(store.equippedInstances).filter(Boolean).length} / {EQUIPMENT_SLOTS.length} slots filled
</span>
}
/>
<EquipmentSlotGrid
store={store}
selectedSlot={selectedSlot}
onSlotClick={setSelectedSlot}
onUnequip={handleUnequip}
isSlotBlocked={isSlotBlocked}
SLOT_GROUPS={SLOT_GROUPS}
/>
</GameCard>
{/* Equipment Inventory */}
<GameCard variant="default">
<SectionHeader
title={`Equipment Inventory (${unequippedItems.length} items)`}
/>
{unequippedItems.length === 0 ? (
<div className="text-[var(--text-muted)] text-sm text-center py-4" role="status">
No unequipped items. Craft new gear in the Crafting tab.
</div>
) : (
<EquipmentInventory
store={store}
unequippedItems={unequippedItems}
onEquip={handleEquip}
onDelete={handleDelete}
getEquippableSlots={getEquippableSlots}
SLOT_NAMES={SLOT_NAMES}
SLOT_ICONS={SLOT_ICONS}
/>
)}
</GameCard>
{/* Equipment Stats Summary */}
<GameCard variant="default">
<SectionHeader title="Equipment Stats Summary" />
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<div className="p-3 bg-[var(--bg-sunken)]/50 rounded text-center">
<div className="text-2xl font-bold text-[var(--mana-light)] font-[var(--font-mono)]">
{Object.values(store.equipmentInstances).length}
</div>
<div className="text-xs text-[var(--text-muted)]">Total Items</div>
</div>
<div className="p-3 bg-[var(--bg-sunken)]/50 rounded text-center">
<div className="text-2xl font-bold text-[var(--color-success)] font-[var(--font-mono)]">
{equippedIds.size}
</div>
<div className="text-xs text-[var(--text-muted)]">Equipped</div>
</div>
<div className="p-3 bg-[var(--bg-sunken)]/50 rounded text-center">
<div className="text-2xl font-bold text-[var(--mana-water)] font-[var(--font-mono)]">
{unequippedItems.length}
</div>
<div className="text-xs text-[var(--text-muted)]">In Inventory</div>
</div>
<div className="p-3 bg-[var(--bg-sunken)]/50 rounded text-center">
<div className="text-2xl font-bold text-[var(--mana-stellar)] font-[var(--font-mono)]">
{Object.values(store.equipmentInstances).reduce(
(sum, inst) => sum + inst.enchantments.length,
0
)}
</div>
<div className="text-xs text-[var(--text-muted)]">Total Enchantments</div>
</div>
</div>
{/* Enchantment Power */}
<GameCard className="mt-4">
<div className="pb-2">
<h3 className="text-sm font-semibold text-[var(--text-primary)] flex items-center gap-2">
Enchantment Power
</h3>
</div>
<div>
{(() => {
const unifiedEffects = getUnifiedEffects(store);
const enchantPower = unifiedEffects.enchantmentPowerMultiplier || 1;
return (
<>
<StatRow
label="Enchantment Power:"
value={`${enchantPower.toFixed(2)}×`}
highlight={enchantPower > 1 ? "success" : "default"}
/>
<p className="text-xs text-[var(--text-muted)] mt-2">
Increases the power of all enchantments by {(enchantPower - 1) * 100}%. Multiplier applied to all enchantment effects.
</p>
</>
);
})()}
</div>
</GameCard>
{/* Active Effects from Equipment */}
<div className="mt-4">
<div className="text-sm text-[var(--text-muted)] 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-[var(--text-muted)] text-sm">No active effects</span>;
}
return effectEntries.map(([stat, value]) => (
<Badge key={stat} variant="outline" className="text-xs border-[var(--border-default)] text-[var(--text-secondary)]">
{stat}: +{fmt(value)}
</Badge>
));
})()}
</div>
</div>
</GameCard>
{/* Delete Confirmation Dialog */}
<ConfirmDialog
open={!!deleteConfirm}
onOpenChange={() => setDeleteConfirm(null)}
title="Discard Item?"
description={`Discard ${deleteConfirm?.name}? This cannot be undone.`}
variant="danger"
confirmText="Discard"
onConfirm={confirmDelete}
/>
</div>
);
}
EquipmentTab.displayName = 'EquipmentTab';