326 lines
12 KiB
TypeScript
326 lines
12 KiB
TypeScript
import { useMemo, useState } from 'react';
|
||
|
||
import { formatCurrency } from '../data/economy';
|
||
import {
|
||
getEquipmentSlotFromItem,
|
||
getEquipmentSlotLabel,
|
||
isInventoryItemEquippable,
|
||
} from '../data/equipmentEffects';
|
||
import { type ForgeRecipeView, getReforgeCostView } from '../data/forgeSystem';
|
||
import { resolveInventoryItemUseEffect } from '../data/inventoryEffects';
|
||
import { buildInitialPlayerInventory } from '../data/npcInteractions';
|
||
import { Character, InventoryItem, WorldType } from '../types';
|
||
import { getNineSliceStyle, UI_CHROME } from '../uiAssets';
|
||
import {
|
||
InventoryItemDetailModal,
|
||
InventoryItemGrid,
|
||
} from './InventoryItemViews';
|
||
|
||
interface InventoryPanelProps {
|
||
playerCharacter: Character;
|
||
worldType: WorldType | null;
|
||
playerInventory: InventoryItem[];
|
||
playerCurrency: number;
|
||
playerHp: number;
|
||
playerMaxHp: number;
|
||
playerMana: number;
|
||
playerMaxMana: number;
|
||
inBattle: boolean;
|
||
onUseItem: (itemId: string) => Promise<boolean>;
|
||
onEquipItem: (itemId: string) => Promise<boolean>;
|
||
forgeRecipes: ForgeRecipeView[];
|
||
onCraftRecipe: (recipeId: string) => Promise<boolean>;
|
||
onDismantleItem: (itemId: string) => Promise<boolean>;
|
||
onReforgeItem: (itemId: string) => Promise<boolean>;
|
||
}
|
||
|
||
export function InventoryPanel({
|
||
playerCharacter,
|
||
worldType,
|
||
playerInventory,
|
||
playerCurrency,
|
||
playerHp,
|
||
playerMaxHp,
|
||
playerMana,
|
||
playerMaxMana,
|
||
inBattle,
|
||
onUseItem,
|
||
onEquipItem,
|
||
forgeRecipes,
|
||
onCraftRecipe,
|
||
onDismantleItem,
|
||
onReforgeItem,
|
||
}: InventoryPanelProps) {
|
||
const [selectedItem, setSelectedItem] = useState<InventoryItem | null>(null);
|
||
const [isUsingItem, setIsUsingItem] = useState(false);
|
||
const [equipmentActionKey, setEquipmentActionKey] = useState<string | null>(
|
||
null,
|
||
);
|
||
const [forgeActionKey, setForgeActionKey] = useState<string | null>(null);
|
||
|
||
const inventoryItems = useMemo(
|
||
() =>
|
||
playerInventory.length > 0
|
||
? playerInventory
|
||
: buildInitialPlayerInventory(playerCharacter, worldType),
|
||
[playerCharacter, playerInventory, worldType],
|
||
);
|
||
|
||
const selectedItemUseEffect = selectedItem
|
||
? resolveInventoryItemUseEffect(selectedItem, playerCharacter)
|
||
: null;
|
||
const selectedItemEquipSlot = selectedItem
|
||
? getEquipmentSlotFromItem(selectedItem)
|
||
: null;
|
||
const selectedItemReforgeCost = selectedItem
|
||
? getReforgeCostView(selectedItem, worldType)
|
||
: null;
|
||
|
||
const canUseSelectedItem = Boolean(
|
||
selectedItem &&
|
||
selectedItemUseEffect &&
|
||
((selectedItemUseEffect.hpRestore > 0 && playerHp < playerMaxHp) ||
|
||
(selectedItemUseEffect.manaRestore > 0 && playerMana < playerMaxMana) ||
|
||
selectedItemUseEffect.cooldownReduction > 0 ||
|
||
selectedItemUseEffect.buildBuffs.length > 0),
|
||
);
|
||
|
||
const canEquipSelectedItem = Boolean(
|
||
selectedItem &&
|
||
selectedItemEquipSlot &&
|
||
isInventoryItemEquippable(selectedItem) &&
|
||
!inBattle,
|
||
);
|
||
|
||
const canDismantleSelectedItem = Boolean(
|
||
selectedItem &&
|
||
!inBattle &&
|
||
(isInventoryItemEquippable(selectedItem) || selectedItem.buildProfile),
|
||
);
|
||
|
||
const canReforgeSelectedItem = Boolean(
|
||
selectedItem &&
|
||
!inBattle &&
|
||
isInventoryItemEquippable(selectedItem) &&
|
||
selectedItem.buildProfile &&
|
||
selectedItemReforgeCost &&
|
||
selectedItemReforgeCost.currencyCost <= playerCurrency,
|
||
);
|
||
|
||
return (
|
||
<div className="flex min-h-0 flex-1 flex-col">
|
||
<div className="flex-1 overflow-y-auto scrollbar-hide">
|
||
<InventoryItemGrid
|
||
items={inventoryItems}
|
||
selectedItemId={selectedItem?.id ?? null}
|
||
onSelectItem={setSelectedItem}
|
||
/>
|
||
|
||
<div className="mt-4 rounded-2xl border border-white/10 bg-black/20 p-4">
|
||
<div className="mb-2 flex items-center justify-between gap-3 text-xs uppercase tracking-[0.2em] text-zinc-500">
|
||
<span>工坊</span>
|
||
<span className="text-emerald-200/80">
|
||
{formatCurrency(playerCurrency, worldType)}
|
||
</span>
|
||
</div>
|
||
<div className="space-y-3">
|
||
{forgeRecipes.map((recipe) => (
|
||
<div
|
||
key={recipe.id}
|
||
className="rounded-xl border border-white/8 bg-black/20 p-3"
|
||
>
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div className="min-w-0">
|
||
<div className="text-sm font-semibold text-white">
|
||
{recipe.name}
|
||
</div>
|
||
<div className="mt-1 text-xs text-zinc-400">
|
||
{recipe.description}
|
||
</div>
|
||
<div className="mt-2 text-xs text-emerald-200/80">
|
||
产物:{recipe.resultLabel}
|
||
</div>
|
||
<div className="mt-1 text-[11px] text-zinc-500">
|
||
花费:{recipe.currencyText}
|
||
</div>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
disabled={
|
||
!recipe.canCraft ||
|
||
inBattle ||
|
||
forgeActionKey === recipe.id
|
||
}
|
||
onClick={async () => {
|
||
setForgeActionKey(recipe.id);
|
||
const crafted = await onCraftRecipe(recipe.id);
|
||
setForgeActionKey(null);
|
||
if (crafted && selectedItem) {
|
||
setSelectedItem(null);
|
||
}
|
||
}}
|
||
className={`rounded-lg border px-3 py-1.5 text-xs transition ${
|
||
recipe.canCraft && !inBattle
|
||
? 'border-emerald-400/30 bg-emerald-500/10 text-emerald-100 hover:bg-emerald-500/20'
|
||
: 'border-white/8 bg-black/20 text-zinc-500'
|
||
}`}
|
||
>
|
||
{forgeActionKey === recipe.id
|
||
? '制作中...'
|
||
: recipe.kind === 'forge'
|
||
? '锻造'
|
||
: '合成'}
|
||
</button>
|
||
</div>
|
||
<div className="mt-2 flex flex-wrap gap-2">
|
||
{recipe.requirements.map((requirement) => (
|
||
<span
|
||
key={`${recipe.id}-${requirement.id}`}
|
||
className={`rounded-full border px-2 py-1 text-[10px] ${
|
||
requirement.owned >= requirement.quantity
|
||
? 'border-emerald-400/20 bg-emerald-500/10 text-emerald-100'
|
||
: 'border-white/10 bg-black/20 text-zinc-400'
|
||
}`}
|
||
>
|
||
{requirement.label} {requirement.owned}/
|
||
{requirement.quantity}
|
||
</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<InventoryItemDetailModal
|
||
item={selectedItem}
|
||
playerCharacter={playerCharacter}
|
||
worldType={worldType}
|
||
onClose={() => setSelectedItem(null)}
|
||
footer={
|
||
selectedItem ? (
|
||
<div className="flex justify-end gap-3">
|
||
<button
|
||
type="button"
|
||
disabled={
|
||
!canDismantleSelectedItem ||
|
||
forgeActionKey === selectedItem.id
|
||
}
|
||
onClick={async () => {
|
||
setForgeActionKey(selectedItem.id);
|
||
const dismantled = await onDismantleItem(selectedItem.id);
|
||
setForgeActionKey(null);
|
||
if (dismantled) {
|
||
setSelectedItem(null);
|
||
}
|
||
}}
|
||
className={`pixel-nine-slice pixel-pressable px-4 py-2 text-xs ${
|
||
canDismantleSelectedItem && forgeActionKey !== selectedItem.id
|
||
? 'text-white'
|
||
: 'text-zinc-600'
|
||
}`}
|
||
style={getNineSliceStyle(UI_CHROME.choiceButton, {
|
||
paddingX: 14,
|
||
paddingY: 8,
|
||
})}
|
||
>
|
||
{forgeActionKey === selectedItem.id ? '拆解中...' : '拆解'}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
disabled={
|
||
!canReforgeSelectedItem ||
|
||
forgeActionKey === `${selectedItem.id}:reforge`
|
||
}
|
||
onClick={async () => {
|
||
setForgeActionKey(`${selectedItem.id}:reforge`);
|
||
const reforged = await onReforgeItem(selectedItem.id);
|
||
setForgeActionKey(null);
|
||
if (reforged) {
|
||
setSelectedItem(null);
|
||
}
|
||
}}
|
||
className={`pixel-nine-slice pixel-pressable px-4 py-2 text-xs ${
|
||
canReforgeSelectedItem &&
|
||
forgeActionKey !== `${selectedItem.id}:reforge`
|
||
? 'text-white'
|
||
: 'text-zinc-600'
|
||
}`}
|
||
style={getNineSliceStyle(UI_CHROME.choiceButton, {
|
||
paddingX: 14,
|
||
paddingY: 8,
|
||
})}
|
||
>
|
||
{forgeActionKey === `${selectedItem.id}:reforge`
|
||
? '重铸中...'
|
||
: '重铸'}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
disabled={
|
||
!canEquipSelectedItem ||
|
||
equipmentActionKey === selectedItem.id
|
||
}
|
||
onClick={async () => {
|
||
setEquipmentActionKey(selectedItem.id);
|
||
const equipped = await onEquipItem(selectedItem.id);
|
||
setEquipmentActionKey(null);
|
||
if (equipped) {
|
||
setSelectedItem(null);
|
||
}
|
||
}}
|
||
className={`pixel-nine-slice pixel-pressable px-4 py-2 text-xs ${
|
||
canEquipSelectedItem && equipmentActionKey !== selectedItem.id
|
||
? 'text-white'
|
||
: 'text-zinc-600'
|
||
}`}
|
||
style={getNineSliceStyle(UI_CHROME.choiceButton, {
|
||
paddingX: 14,
|
||
paddingY: 8,
|
||
})}
|
||
>
|
||
{equipmentActionKey === selectedItem.id
|
||
? '装备中...'
|
||
: selectedItemEquipSlot
|
||
? `装备到 ${getEquipmentSlotLabel(selectedItemEquipSlot)}`
|
||
: '不可装备'}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => setSelectedItem(null)}
|
||
className="pixel-nine-slice pixel-pressable px-4 py-2 text-xs text-zinc-200"
|
||
style={getNineSliceStyle(UI_CHROME.choiceButton, {
|
||
paddingX: 14,
|
||
paddingY: 8,
|
||
})}
|
||
>
|
||
关闭
|
||
</button>
|
||
<button
|
||
type="button"
|
||
disabled={!canUseSelectedItem || isUsingItem}
|
||
onClick={async () => {
|
||
setIsUsingItem(true);
|
||
const used = await onUseItem(selectedItem.id);
|
||
setIsUsingItem(false);
|
||
if (used) {
|
||
setSelectedItem(null);
|
||
}
|
||
}}
|
||
className={`pixel-nine-slice pixel-pressable px-4 py-2 text-xs ${canUseSelectedItem && !isUsingItem ? 'text-white' : 'text-zinc-600'}`}
|
||
style={getNineSliceStyle(UI_CHROME.choiceButton, {
|
||
paddingX: 14,
|
||
paddingY: 8,
|
||
})}
|
||
>
|
||
{isUsingItem ? '使用中...' : '使用'}
|
||
</button>
|
||
</div>
|
||
) : undefined
|
||
}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|