Refine NPC interactions and runtime item generation
This commit is contained in:
309
src/components/InventoryItemViews.tsx
Normal file
309
src/components/InventoryItemViews.tsx
Normal file
@@ -0,0 +1,309 @@
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { formatCurrency, getInventoryItemValue } from '../data/economy';
|
||||
import {
|
||||
getEquipmentSlotFromItem,
|
||||
getEquipmentSlotLabel,
|
||||
isInventoryItemEquippable,
|
||||
} from '../data/equipmentEffects';
|
||||
import {
|
||||
isInventoryItemUsable,
|
||||
resolveInventoryItemUseEffect,
|
||||
} from '../data/inventoryEffects';
|
||||
import type { Character, InventoryItem, WorldType } from '../types';
|
||||
import {
|
||||
CHROME_ICONS,
|
||||
getInventoryCategoryIcon,
|
||||
getNineSliceStyle,
|
||||
UI_CHROME,
|
||||
} from '../uiAssets';
|
||||
import { PixelIcon } from './PixelIcon';
|
||||
|
||||
function getInventoryRarityClass(rarity: InventoryItem['rarity']) {
|
||||
switch (rarity) {
|
||||
case 'legendary':
|
||||
return 'border-amber-400/45 bg-gradient-to-br from-amber-500/16 to-orange-500/8';
|
||||
case 'epic':
|
||||
return 'border-fuchsia-400/40 bg-gradient-to-br from-fuchsia-500/14 to-purple-500/8';
|
||||
case 'rare':
|
||||
return 'border-sky-400/40 bg-gradient-to-br from-sky-500/14 to-cyan-500/8';
|
||||
case 'uncommon':
|
||||
return 'border-emerald-400/35 bg-gradient-to-br from-emerald-500/12 to-lime-500/8';
|
||||
default:
|
||||
return 'border-white/10 bg-white/[0.04]';
|
||||
}
|
||||
}
|
||||
|
||||
function getInventoryRarityLabel(rarity: InventoryItem['rarity']) {
|
||||
switch (rarity) {
|
||||
case 'legendary':
|
||||
return '传说';
|
||||
case 'epic':
|
||||
return '史诗';
|
||||
case 'rare':
|
||||
return '稀有';
|
||||
case 'uncommon':
|
||||
return '优秀';
|
||||
default:
|
||||
return '普通';
|
||||
}
|
||||
}
|
||||
|
||||
function getInventoryItemIcon(item: InventoryItem) {
|
||||
return item.iconSrc ?? getInventoryCategoryIcon(item.category);
|
||||
}
|
||||
|
||||
function buildInventoryItemSummary(
|
||||
item: InventoryItem,
|
||||
useEffect: ReturnType<typeof resolveInventoryItemUseEffect>,
|
||||
) {
|
||||
if (item.description?.trim()) return item.description;
|
||||
if (!useEffect)
|
||||
return `${item.name} 当前更适合作为交易、拆解或后续锻造素材使用。`;
|
||||
|
||||
const parts = [
|
||||
useEffect.hpRestore > 0 ? `恢复 ${useEffect.hpRestore} 点气血` : null,
|
||||
useEffect.manaRestore > 0 ? `恢复 ${useEffect.manaRestore} 点灵力` : null,
|
||||
useEffect.cooldownReduction > 0
|
||||
? `额外推进 ${useEffect.cooldownReduction} 回合冷却`
|
||||
: null,
|
||||
useEffect.buildBuffs.length > 0
|
||||
? `获得 ${useEffect.buildBuffs.map((buff) => buff.name).join('、')}`
|
||||
: null,
|
||||
].filter(Boolean);
|
||||
|
||||
return parts.length > 0
|
||||
? `${item.name} 可以立即使用,${parts.join(',')}。`
|
||||
: `${item.name} 当前更适合作为交易、拆解或后续锻造素材使用。`;
|
||||
}
|
||||
|
||||
function buildInventorySlots(items: InventoryItem[], minimumSlotCount: number) {
|
||||
const slotCount = Math.ceil(Math.max(items.length, minimumSlotCount) / 4) * 4;
|
||||
return [
|
||||
...items,
|
||||
...Array.from(
|
||||
{ length: Math.max(0, slotCount - items.length) },
|
||||
() => null,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
export function InventoryItemGrid({
|
||||
items,
|
||||
selectedItemId = null,
|
||||
minimumSlotCount = 16,
|
||||
onSelectItem,
|
||||
}: {
|
||||
items: InventoryItem[];
|
||||
selectedItemId?: string | null;
|
||||
minimumSlotCount?: number;
|
||||
onSelectItem: (item: InventoryItem) => void;
|
||||
}) {
|
||||
const inventorySlots = buildInventorySlots(items, minimumSlotCount);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-4 gap-2 sm:grid-cols-5 lg:grid-cols-6 xl:grid-cols-7">
|
||||
{inventorySlots.map((item, index) => {
|
||||
if (!item) {
|
||||
return (
|
||||
<div
|
||||
key={`empty-slot-${index}`}
|
||||
className="aspect-square rounded-xl border border-dashed border-white/8 bg-black/12"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const selected = selectedItemId === item.id;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => onSelectItem(item)}
|
||||
className={`relative aspect-square rounded-xl border p-1.5 transition-colors hover:border-white/25 ${getInventoryRarityClass(item.rarity)} ${selected ? 'ring-1 ring-amber-300/55' : ''}`}
|
||||
title={`${item.name} x${item.quantity}`}
|
||||
>
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<PixelIcon
|
||||
src={getInventoryItemIcon(item)}
|
||||
className="h-9 w-9 drop-shadow-[0_4px_8px_rgba(0,0,0,0.35)] sm:h-11 sm:w-11"
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute bottom-1 right-1 rounded-full border border-black/30 bg-black/65 px-1.5 py-0.5 text-[10px] font-semibold text-white">
|
||||
{item.quantity}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function InventoryItemDetailModal({
|
||||
item,
|
||||
playerCharacter,
|
||||
worldType,
|
||||
ownerLabel,
|
||||
onClose,
|
||||
footer,
|
||||
}: {
|
||||
item: InventoryItem | null;
|
||||
playerCharacter: Character;
|
||||
worldType: WorldType | null;
|
||||
ownerLabel?: string;
|
||||
onClose: () => void;
|
||||
footer?: ReactNode;
|
||||
}) {
|
||||
const selectedItemUseEffect = item
|
||||
? resolveInventoryItemUseEffect(item, playerCharacter)
|
||||
: null;
|
||||
const selectedItemEquipSlot = item ? getEquipmentSlotFromItem(item) : null;
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{item && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-[78] flex items-center justify-center bg-black/72 p-4 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.96, y: 8 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.96, y: 8 }}
|
||||
transition={{ duration: 0.18, ease: 'easeOut' }}
|
||||
className="pixel-nine-slice pixel-modal-shell flex max-h-[min(92vh,42rem)] w-full max-w-md flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.55)] sm:max-w-lg"
|
||||
style={getNineSliceStyle(UI_CHROME.modalPanel)}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="relative border-b border-white/10 px-4 py-3 sm:px-5 sm:py-4">
|
||||
<div className="min-w-0 pr-10">
|
||||
<div className="text-[10px] uppercase tracking-[0.2em] text-zinc-500">
|
||||
{item.category}
|
||||
</div>
|
||||
<div className="mt-1 truncate text-sm font-semibold text-white">
|
||||
{item.name}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="absolute right-4 top-3 p-1 text-zinc-400 transition-colors hover:text-white sm:right-5 sm:top-4"
|
||||
>
|
||||
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto p-5">
|
||||
<div className="flex items-center gap-4">
|
||||
<div
|
||||
className={`flex h-24 w-24 shrink-0 items-center justify-center rounded-2xl border ${getInventoryRarityClass(item.rarity)}`}
|
||||
>
|
||||
<PixelIcon
|
||||
src={getInventoryItemIcon(item)}
|
||||
className="h-14 w-14 drop-shadow-[0_6px_10px_rgba(0,0,0,0.35)]"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<div className="rounded-full border border-white/10 bg-white/6 px-2 py-1 text-[10px] uppercase tracking-[0.16em] text-zinc-200">
|
||||
{getInventoryRarityLabel(item.rarity)}
|
||||
</div>
|
||||
<div className="text-sm text-zinc-300">
|
||||
数量:{item.quantity}
|
||||
</div>
|
||||
<div className="text-sm text-zinc-300">
|
||||
持有者:{ownerLabel ?? playerCharacter.name}
|
||||
</div>
|
||||
<div className="text-sm text-zinc-300">
|
||||
可使用:{isInventoryItemUsable(item) ? '是' : '否'}
|
||||
</div>
|
||||
<div className="text-sm text-zinc-300">
|
||||
可装备:
|
||||
{selectedItemEquipSlot
|
||||
? getEquipmentSlotLabel(selectedItemEquipSlot)
|
||||
: '否'}
|
||||
</div>
|
||||
<div className="text-sm text-zinc-300">
|
||||
装备类型:
|
||||
{isInventoryItemEquippable(item)
|
||||
? '可装备物品'
|
||||
: '非装备物品'}
|
||||
</div>
|
||||
<div className="text-sm text-zinc-300">
|
||||
价值:
|
||||
{formatCurrency(getInventoryItemValue(item), worldType)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="pixel-nine-slice pixel-panel"
|
||||
style={getNineSliceStyle(UI_CHROME.infoPanel)}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm text-zinc-300">
|
||||
<div className="rounded-lg border border-white/6 bg-black/20 px-3 py-2">
|
||||
类型:{item.category}
|
||||
</div>
|
||||
<div className="rounded-lg border border-white/6 bg-black/20 px-3 py-2">
|
||||
标签:{item.tags.length}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 text-sm leading-relaxed text-zinc-300">
|
||||
{buildInventoryItemSummary(item, selectedItemUseEffect)}
|
||||
</div>
|
||||
{selectedItemUseEffect?.buildBuffs.length ? (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{selectedItemUseEffect.buildBuffs.map((buff) => (
|
||||
<span
|
||||
key={buff.id}
|
||||
className="rounded-full border border-sky-400/20 bg-sky-500/10 px-2 py-1 text-[10px] text-sky-100"
|
||||
>
|
||||
{buff.name} / {buff.tags.join('、')} /{' '}
|
||||
{buff.durationTurns} 回合
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{item.tags.length > 0 ? (
|
||||
item.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="rounded-full border border-white/10 bg-black/20 px-2 py-1 text-[10px] text-zinc-300"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span className="rounded-full border border-white/10 bg-black/20 px-2 py-1 text-[10px] text-zinc-300">
|
||||
无标签
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{footer ?? (
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
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>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user