358 lines
13 KiB
TypeScript
Executable File
358 lines
13 KiB
TypeScript
Executable File
'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';
|