feat: recreate Equipment Tab with equip/unequip gear management
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m19s
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m19s
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
'use client';
|
||||
|
||||
import { computeEquipmentEffects } from '@/lib/game/effects';
|
||||
import type { EquipmentInstance } from '@/lib/game/types';
|
||||
|
||||
interface EquipmentEffectsSummaryProps {
|
||||
equipmentInstances: Record<string, EquipmentInstance>;
|
||||
equippedInstances: Record<string, string | null>;
|
||||
}
|
||||
|
||||
const BONUS_LABELS: Record<string, string> = {
|
||||
maxMana: 'Max Mana',
|
||||
regen: 'Mana Regen',
|
||||
clickMana: 'Click Mana',
|
||||
baseDamage: 'Base Damage',
|
||||
elementCap: 'Element Cap',
|
||||
critChance: 'Crit Chance',
|
||||
attackSpeed: 'Attack Speed',
|
||||
meditationEfficiency: 'Meditation Efficiency',
|
||||
studySpeed: 'Study Speed',
|
||||
};
|
||||
|
||||
const MULT_LABELS: Record<string, string> = {
|
||||
maxMana: 'Max Mana',
|
||||
regen: 'Mana Regen',
|
||||
clickMana: 'Click Mana',
|
||||
baseDamage: 'Base Damage',
|
||||
attackSpeed: 'Attack Speed',
|
||||
elementCap: 'Element Cap',
|
||||
meditationEfficiency: 'Meditation Efficiency',
|
||||
studySpeed: 'Study Speed',
|
||||
};
|
||||
|
||||
export function EquipmentEffectsSummary({ equipmentInstances, equippedInstances }: EquipmentEffectsSummaryProps) {
|
||||
const { bonuses, multipliers, specials } = computeEquipmentEffects(equipmentInstances, equippedInstances);
|
||||
|
||||
const bonusEntries = Object.entries(bonuses).filter(([, v]) => v !== 0);
|
||||
const multEntries = Object.entries(multipliers).filter(([, v]) => v !== 1);
|
||||
const specialEntries = Array.from(specials);
|
||||
|
||||
if (bonusEntries.length === 0 && multEntries.length === 0 && specialEntries.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-3 rounded border border-[var(--border-default)] bg-[var(--bg-sunken)] space-y-2">
|
||||
<h3 className="text-sm font-semibold text-[var(--text-primary)]">Equipment Effects</h3>
|
||||
|
||||
{bonusEntries.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-[var(--text-muted)]">Bonuses</div>
|
||||
{bonusEntries.map(([key, value]) => (
|
||||
<div key={key} className="flex justify-between text-xs">
|
||||
<span className="text-[var(--text-secondary)]">
|
||||
{BONUS_LABELS[key] || key}
|
||||
</span>
|
||||
<span className="text-[var(--color-success)]">
|
||||
{value > 0 ? '+' : ''}{typeof value === 'number' && !Number.isInteger(value) ? value.toFixed(2) : value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{multEntries.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-[var(--text-muted)]">Multipliers</div>
|
||||
{multEntries.map(([key, value]) => (
|
||||
<div key={key} className="flex justify-between text-xs">
|
||||
<span className="text-[var(--text-secondary)]">
|
||||
{MULT_LABELS[key] || key}
|
||||
</span>
|
||||
<span className="text-[var(--color-info)]">
|
||||
×{typeof value === 'number' ? value.toFixed(2) : value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{specialEntries.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-[var(--text-muted)]">Specials</div>
|
||||
{specialEntries.map((id) => (
|
||||
<div key={id} className="text-xs text-[var(--color-warning)]">
|
||||
★ {id}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
'use client';
|
||||
|
||||
import { Package } from 'lucide-react';
|
||||
import type { EquipmentInstance, EquipmentSlot } from '@/lib/game/types';
|
||||
import { EQUIPMENT_TYPES, SLOT_NAMES } from '@/lib/game/data/equipment';
|
||||
import { RARITY_CSS_VAR } from '@/components/game/LootInventory/types';
|
||||
import { CATEGORY_ICONS } from '@/components/game/LootInventory/icons';
|
||||
import { ActionButton } from '@/components/ui/action-button';
|
||||
|
||||
interface EquipmentSlotGridProps {
|
||||
equippedInstances: Record<string, string | null>;
|
||||
equipmentInstances: Record<string, EquipmentInstance>;
|
||||
onUnequip: (slot: EquipmentSlot) => void;
|
||||
}
|
||||
|
||||
const SLOTS: EquipmentSlot[] = ['mainHand', 'offHand', 'head', 'body', 'hands', 'feet', 'accessory1', 'accessory2'];
|
||||
|
||||
export function EquipmentSlotGrid({ equippedInstances, equipmentInstances, onUnequip }: EquipmentSlotGridProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
{SLOTS.map((slot) => {
|
||||
const instanceId = equippedInstances[slot];
|
||||
const instance = instanceId ? equipmentInstances[instanceId] : null;
|
||||
|
||||
if (instance) {
|
||||
const type = EQUIPMENT_TYPES[instance.typeId];
|
||||
const Icon = type ? CATEGORY_ICONS[type.category] || Package : Package;
|
||||
const rarityColor = RARITY_CSS_VAR[instance.rarity] || 'var(--rarity-common)';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={slot}
|
||||
className="p-3 rounded border bg-[var(--bg-sunken)] space-y-2"
|
||||
style={{ borderColor: rarityColor }}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-[var(--text-muted)]">{SLOT_NAMES[slot]}</span>
|
||||
<Icon className="w-4 h-4" style={{ color: rarityColor }} />
|
||||
</div>
|
||||
<div className="text-sm font-semibold truncate" style={{ color: rarityColor }}>
|
||||
{instance.name}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-secondary)]">
|
||||
{type?.name || instance.typeId}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-muted)]">
|
||||
{instance.usedCapacity}/{instance.totalCapacity} cap
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-muted)]">
|
||||
{instance.enchantments.length} enchant{instance.enchantments.length !== 1 ? 's' : ''} • {instance.quality}% quality
|
||||
</div>
|
||||
<ActionButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full text-xs"
|
||||
onClick={() => onUnequip(slot)}
|
||||
>
|
||||
Unequip
|
||||
</ActionButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={slot}
|
||||
className="p-3 rounded border border-dashed border-[var(--border-default)] bg-[var(--bg-sunken)] space-y-2"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-[var(--text-muted)]">{SLOT_NAMES[slot]}</span>
|
||||
<Package className="w-4 h-4 text-[var(--text-muted)]" />
|
||||
</div>
|
||||
<div className="text-sm text-[var(--text-muted)] italic">Empty</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Package, Trash2 } from 'lucide-react';
|
||||
import type { EquipmentInstance, EquipmentSlot } from '@/lib/game/types';
|
||||
import { EQUIPMENT_TYPES, getValidSlotsForEquipmentType } from '@/lib/game/data/equipment';
|
||||
import { RARITY_CSS_VAR, RARITY_GLOW_CSS_VAR } from '@/components/game/LootInventory/types';
|
||||
import { CATEGORY_ICONS } from '@/components/game/LootInventory/icons';
|
||||
import { ActionButton } from '@/components/ui/action-button';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
|
||||
interface InventoryListProps {
|
||||
inventoryItems: [string, EquipmentInstance][];
|
||||
equippedInstances: Record<string, string | null>;
|
||||
onEquip: (instanceId: string, slot: EquipmentSlot) => boolean;
|
||||
onDelete: (instanceId: string) => void;
|
||||
}
|
||||
|
||||
export function InventoryList({ inventoryItems, equippedInstances, onEquip, onDelete }: InventoryListProps) {
|
||||
const [selectedSlot, setSelectedSlot] = useState<Record<string, EquipmentSlot>>({});
|
||||
|
||||
if (inventoryItems.length === 0) {
|
||||
return (
|
||||
<div className="text-sm text-[var(--text-muted)] italic text-center py-4">
|
||||
No items in inventory. Craft or find equipment to fill your slots.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{inventoryItems.map(([instanceId, instance]) => {
|
||||
const type = EQUIPMENT_TYPES[instance.typeId];
|
||||
const Icon = type ? CATEGORY_ICONS[type.category] || Package : Package;
|
||||
const rarityColor = RARITY_CSS_VAR[instance.rarity] || 'var(--rarity-common)';
|
||||
const rarityGlow = RARITY_GLOW_CSS_VAR[instance.rarity] || 'var(--rarity-common-glow)';
|
||||
const validSlots = type ? getValidSlotsForEquipmentType(type) : [];
|
||||
const chosenSlot = selectedSlot[instanceId];
|
||||
const availableSlots = validSlots.filter((s) => !equippedInstances[s]);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={instanceId}
|
||||
className="p-3 rounded border bg-[var(--bg-sunken)] group flex items-center gap-3"
|
||||
style={{
|
||||
borderColor: rarityColor,
|
||||
backgroundColor: rarityGlow,
|
||||
}}
|
||||
>
|
||||
<Icon className="w-5 h-5 shrink-0" style={{ color: rarityColor }} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-semibold truncate" style={{ color: rarityColor }}>
|
||||
{instance.name}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-secondary)]">
|
||||
{type?.name || instance.typeId} • {instance.usedCapacity}/{instance.totalCapacity} cap
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-muted)] capitalize">
|
||||
{instance.rarity} • {instance.enchantments.length} enchant{instance.enchantments.length !== 1 ? 's' : ''} • {instance.quality}% quality
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{availableSlots.length > 1 ? (
|
||||
<select
|
||||
className="text-xs bg-[var(--bg-base)] border border-[var(--border-default)] rounded px-2 py-1 text-[var(--text-primary)]"
|
||||
value={chosenSlot || ''}
|
||||
onChange={(e) => setSelectedSlot((prev) => ({ ...prev, [instanceId]: e.target.value as EquipmentSlot }))}
|
||||
>
|
||||
<option value="">Select slot</option>
|
||||
{availableSlots.map((s) => (
|
||||
<option key={s} value={s}>{s}</option>
|
||||
))}
|
||||
</select>
|
||||
) : null}
|
||||
<ActionButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
onClick={() => {
|
||||
const slot = availableSlots.length === 1 ? availableSlots[0] : chosenSlot;
|
||||
if (slot) {
|
||||
onEquip(instanceId, slot);
|
||||
setSelectedSlot((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[instanceId];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}}
|
||||
disabled={availableSlots.length === 0 || (availableSlots.length > 1 && !chosenSlot)}
|
||||
>
|
||||
Equip
|
||||
</ActionButton>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<ActionButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0 text-[var(--color-danger)] hover:text-[var(--interactive-danger-hover)] hover:bg-[var(--interactive-danger)]/20"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</ActionButton>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete {instance.name}?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This action cannot be undone. The item will be permanently destroyed.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => onDelete(instanceId)}>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user