601 lines
28 KiB
TypeScript
601 lines
28 KiB
TypeScript
import { AnimatePresence, motion } from 'motion/react';
|
||
import { useState } from 'react';
|
||
|
||
import { getCharacterById } from '../data/characterPresets';
|
||
import {
|
||
formatCurrency,
|
||
getCurrencyName,
|
||
getInventoryItemValue,
|
||
getNpcBuybackPrice,
|
||
getNpcPurchasePrice,
|
||
} from '../data/economy';
|
||
import {
|
||
getEquipmentSlotFromItem,
|
||
getEquipmentSlotLabel,
|
||
} from '../data/equipmentEffects';
|
||
import { isInventoryItemUsable, resolveInventoryItemUseEffect } from '../data/inventoryEffects';
|
||
import {
|
||
buildInventoryItemDescription,
|
||
getInventoryTagLabels,
|
||
} from '../data/itemPresentation';
|
||
import {
|
||
buildInitialNpcState,
|
||
getGiftCandidates,
|
||
getRarityLabel,
|
||
} from '../data/npcInteractions';
|
||
import { StoryGenerationNpcUi } from '../hooks/rpg-runtime-story';
|
||
import { GameState, InventoryItem } from '../types';
|
||
import { CHROME_ICONS, getInventoryItemVisualSrc, getNineSliceStyle, UI_CHROME } from '../uiAssets';
|
||
import { PixelIcon } from './PixelIcon';
|
||
|
||
interface NpcModalsProps {
|
||
gameState: GameState;
|
||
npcUi: StoryGenerationNpcUi;
|
||
}
|
||
|
||
type TradeDetailState = {
|
||
itemId: string;
|
||
source: 'buy' | 'sell';
|
||
} | null;
|
||
|
||
function getNpcEncounterKey(encounter: NonNullable<GameState['currentEncounter']>) {
|
||
return encounter.id ?? encounter.npcName;
|
||
}
|
||
|
||
function getItemVisualSrc(item: InventoryItem) {
|
||
return getInventoryItemVisualSrc(item);
|
||
}
|
||
|
||
function buildTradeUseEffectText(
|
||
effect: ReturnType<typeof resolveInventoryItemUseEffect> | null,
|
||
) {
|
||
if (!effect) return null;
|
||
|
||
const parts = [
|
||
effect.hpRestore > 0 ? `生命 +${effect.hpRestore}` : null,
|
||
effect.manaRestore > 0 ? `灵力 +${effect.manaRestore}` : null,
|
||
effect.cooldownReduction > 0 ? `冷却 -${effect.cooldownReduction}` : null,
|
||
].filter((part): part is string => Boolean(part));
|
||
|
||
return parts.join(' / ') || '无直接效果';
|
||
}
|
||
|
||
function TradeItemRow({
|
||
item,
|
||
selected,
|
||
unitPrice,
|
||
currencyName,
|
||
onClick,
|
||
}: {
|
||
item: InventoryItem;
|
||
selected: boolean;
|
||
unitPrice: number;
|
||
currencyName: string;
|
||
onClick: () => void;
|
||
}) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={onClick}
|
||
className={`w-full rounded-xl border px-3 py-2.5 text-left transition ${
|
||
selected
|
||
? 'border-emerald-400/45 bg-emerald-500/10'
|
||
: 'border-white/8 bg-black/20 hover:border-white/15'
|
||
}`}
|
||
>
|
||
<div className="flex items-center gap-3">
|
||
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-lg border border-white/10 bg-black/35">
|
||
<PixelIcon src={getItemVisualSrc(item)} className="h-7 w-7" />
|
||
</div>
|
||
<div className="min-w-0 flex-1">
|
||
<div className="truncate text-sm font-medium text-white">{item.name}</div>
|
||
<div className="mt-1 text-[10px] text-zinc-500">
|
||
{item.category} / {getRarityLabel(item.rarity)} / 单价 {unitPrice} {currencyName}
|
||
</div>
|
||
</div>
|
||
<div className="rounded-full border border-white/10 bg-black/25 px-2 py-0.5 text-[10px] text-white">
|
||
x{item.quantity}
|
||
</div>
|
||
</div>
|
||
</button>
|
||
);
|
||
}
|
||
|
||
function TradeQuantityStepper({
|
||
quantity,
|
||
maxQuantity,
|
||
onChange,
|
||
}: {
|
||
quantity: number;
|
||
maxQuantity: number;
|
||
onChange: (quantity: number) => void;
|
||
}) {
|
||
const safeMax = Math.max(1, maxQuantity);
|
||
return (
|
||
<div className="flex items-center justify-between rounded-xl border border-white/8 bg-black/20 px-3 py-2">
|
||
<div>
|
||
<div className="text-[10px] uppercase tracking-[0.18em] text-zinc-500">数量</div>
|
||
<div className="mt-1 text-xs text-zinc-400">最多 {safeMax}</div>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => onChange(quantity - 1)}
|
||
disabled={quantity <= 1}
|
||
className={`h-8 w-8 rounded-lg border text-sm ${
|
||
quantity > 1
|
||
? 'border-white/12 bg-white/6 text-white'
|
||
: 'border-white/8 bg-black/20 text-zinc-600'
|
||
}`}
|
||
>
|
||
-
|
||
</button>
|
||
<div className="min-w-[3rem] text-center text-sm font-semibold text-white">{quantity}</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => onChange(quantity + 1)}
|
||
disabled={quantity >= safeMax}
|
||
className={`h-8 w-8 rounded-lg border text-sm ${
|
||
quantity < safeMax
|
||
? 'border-white/12 bg-white/6 text-white'
|
||
: 'border-white/8 bg-black/20 text-zinc-600'
|
||
}`}
|
||
>
|
||
+
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function NpcModals({ gameState, npcUi }: NpcModalsProps) {
|
||
const [tradeDetail, setTradeDetail] = useState<TradeDetailState>(null);
|
||
const currencyName = getCurrencyName(
|
||
gameState.worldType,
|
||
gameState.customWorldProfile,
|
||
);
|
||
const tradeModal = npcUi.tradeModal;
|
||
const tradeNpcState = tradeModal
|
||
? gameState.npcStates[getNpcEncounterKey(tradeModal.encounter)]
|
||
?? buildInitialNpcState(tradeModal.encounter, gameState.worldType, gameState)
|
||
: null;
|
||
const selectedTradeNpcItem = tradeNpcState?.inventory.find(item => item.id === tradeModal?.selectedNpcItemId) ?? null;
|
||
const selectedTradePlayerItem = tradeModal?.selectedPlayerItemId
|
||
? gameState.playerInventory.find(item => item.id === tradeModal?.selectedPlayerItemId) ?? null
|
||
: null;
|
||
const tradeMode = tradeModal?.mode ?? 'buy';
|
||
const activeTradeItem = tradeMode === 'buy' ? selectedTradeNpcItem : selectedTradePlayerItem;
|
||
const activeTradeUnitPrice = tradeModal && activeTradeItem && tradeNpcState
|
||
? tradeMode === 'buy'
|
||
? getNpcPurchasePrice(activeTradeItem, tradeNpcState.affinity)
|
||
: getNpcBuybackPrice(activeTradeItem, tradeNpcState.affinity)
|
||
: 0;
|
||
const activeTradeMaxQuantity = activeTradeItem?.quantity ?? 0;
|
||
const activeTradeQuantity = tradeModal
|
||
? Math.max(1, Math.min(tradeModal.selectedQuantity, Math.max(1, activeTradeMaxQuantity)))
|
||
: 1;
|
||
const activeTradeTotalPrice = activeTradeUnitPrice * activeTradeQuantity;
|
||
const canConfirmTrade = Boolean(
|
||
activeTradeItem &&
|
||
activeTradeMaxQuantity > 0 &&
|
||
activeTradeQuantity >= 1 &&
|
||
activeTradeQuantity <= activeTradeMaxQuantity &&
|
||
(tradeMode === 'sell' || gameState.playerCurrency >= activeTradeTotalPrice)
|
||
);
|
||
const tradeItemList = tradeMode === 'buy'
|
||
? (tradeNpcState?.inventory ?? [])
|
||
: gameState.playerInventory;
|
||
const tradeDetailItem = tradeDetail
|
||
? (tradeDetail.source === 'buy' ? tradeNpcState?.inventory ?? [] : gameState.playerInventory)
|
||
.find(item => item.id === tradeDetail.itemId) ?? null
|
||
: null;
|
||
const tradeDetailUseEffect = tradeDetailItem && gameState.playerCharacter
|
||
? resolveInventoryItemUseEffect(tradeDetailItem, gameState.playerCharacter)
|
||
: null;
|
||
const tradeDetailEquipSlot = tradeDetailItem ? getEquipmentSlotFromItem(tradeDetailItem) : null;
|
||
const tradeDetailEffectText = buildTradeUseEffectText(tradeDetailUseEffect);
|
||
const giftCandidates = npcUi.giftModal
|
||
? getGiftCandidates(gameState.playerInventory, npcUi.giftModal.encounter, {
|
||
worldType: gameState.worldType,
|
||
customWorldProfile: gameState.customWorldProfile,
|
||
})
|
||
: [];
|
||
|
||
const handleTradeItemClick = (item: InventoryItem) => {
|
||
if (tradeMode === 'buy') {
|
||
npcUi.selectTradeNpcItem(item.id);
|
||
setTradeDetail({ itemId: item.id, source: 'buy' });
|
||
return;
|
||
}
|
||
|
||
npcUi.selectTradePlayerItem(item.id);
|
||
setTradeDetail({ itemId: item.id, source: 'sell' });
|
||
};
|
||
|
||
return (
|
||
<AnimatePresence>
|
||
{tradeModal && tradeNpcState && (
|
||
<motion.div
|
||
initial={{ opacity: 0 }}
|
||
animate={{ opacity: 1 }}
|
||
exit={{ opacity: 0 }}
|
||
className="fixed inset-0 z-[75] flex items-center justify-center bg-black/70 p-3 backdrop-blur-sm sm:p-4"
|
||
onClick={npcUi.closeTradeModal}
|
||
>
|
||
<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 }}
|
||
className="pixel-nine-slice pixel-modal-shell flex max-h-[min(92vh,48rem)] w-full max-w-4xl flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.55)]"
|
||
style={getNineSliceStyle(UI_CHROME.modalPanel)}
|
||
onClick={event => event.stopPropagation()}
|
||
>
|
||
<div className="flex items-center justify-between border-b border-white/10 px-4 py-3 sm:px-5 sm:py-4">
|
||
<div className="min-w-0">
|
||
<div className="text-sm font-semibold text-white">交易</div>
|
||
<div className="mt-1 text-xs text-zinc-500">
|
||
{tradeModal.encounter.npcName} / 你当前{currencyName}:{gameState.playerCurrency}
|
||
</div>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={npcUi.closeTradeModal}
|
||
className="rounded-full border border-white/10 bg-black/20 p-2 text-zinc-400 transition-colors hover:text-white"
|
||
>
|
||
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
|
||
</button>
|
||
</div>
|
||
|
||
<div className="flex-1 overflow-y-auto px-4 py-3 sm:px-5 sm:py-4">
|
||
{tradeModal.introText && (
|
||
<div className="mb-3 whitespace-pre-line rounded-xl border border-amber-300/15 bg-amber-500/10 px-3 py-2 text-xs leading-relaxed text-amber-50/90">
|
||
{tradeModal.introText}
|
||
</div>
|
||
)}
|
||
<div className="grid gap-3 lg:grid-cols-[minmax(0,1.2fr)_minmax(18rem,0.8fr)]">
|
||
<div className="min-h-0 space-y-3">
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => npcUi.setTradeMode('buy')}
|
||
className={`rounded-xl border px-3 py-2 text-sm transition ${
|
||
tradeMode === 'buy'
|
||
? 'border-emerald-400/45 bg-emerald-500/10 text-emerald-100'
|
||
: 'border-white/10 bg-black/20 text-zinc-300'
|
||
}`}
|
||
>
|
||
购买物品
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => npcUi.setTradeMode('sell')}
|
||
className={`rounded-xl border px-3 py-2 text-sm transition ${
|
||
tradeMode === 'sell'
|
||
? 'border-sky-400/45 bg-sky-500/10 text-sky-100'
|
||
: 'border-white/10 bg-black/20 text-zinc-300'
|
||
}`}
|
||
>
|
||
出售物品
|
||
</button>
|
||
</div>
|
||
|
||
<div className="flex items-center justify-between rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs text-zinc-400">
|
||
<span>{tradeMode === 'buy' ? '对方库存' : '你的背包'}</span>
|
||
<span>{tradeItemList.length} 件</span>
|
||
</div>
|
||
|
||
<div className="max-h-[42vh] space-y-2 overflow-y-auto pr-1 scrollbar-hide">
|
||
{tradeItemList.length > 0 ? tradeItemList.map(item => (
|
||
<div key={item.id}>
|
||
<TradeItemRow
|
||
item={item}
|
||
selected={tradeMode === 'buy'
|
||
? tradeModal.selectedNpcItemId === item.id
|
||
: tradeModal.selectedPlayerItemId === item.id}
|
||
unitPrice={tradeMode === 'buy'
|
||
? getNpcPurchasePrice(item, tradeNpcState.affinity)
|
||
: getNpcBuybackPrice(item, tradeNpcState.affinity)}
|
||
currencyName={currencyName}
|
||
onClick={() => handleTradeItemClick(item)}
|
||
/>
|
||
</div>
|
||
)) : (
|
||
<div className="rounded-xl border border-dashed border-white/10 bg-black/20 px-4 py-6 text-sm text-zinc-500">
|
||
{tradeMode === 'buy' ? '对方暂时没有可出售的物品。' : '你当前没有可出售的物品。'}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-3">
|
||
<div className="rounded-xl border border-white/8 bg-black/20 p-3">
|
||
{activeTradeItem ? (
|
||
<div className="space-y-3">
|
||
<TradeQuantityStepper
|
||
quantity={activeTradeQuantity}
|
||
maxQuantity={activeTradeMaxQuantity}
|
||
onChange={npcUi.setTradeQuantity}
|
||
/>
|
||
|
||
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-3 text-sm text-zinc-200">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<span>{tradeMode === 'buy' ? '购买总价' : '出售总价'}</span>
|
||
<span className="font-semibold text-white">
|
||
{formatCurrency(activeTradeTotalPrice, gameState.worldType)}
|
||
</span>
|
||
</div>
|
||
{tradeMode === 'buy' && gameState.playerCurrency < activeTradeTotalPrice && (
|
||
<div className="mt-2 text-xs text-rose-300">
|
||
当前货币不足,还差 {formatCurrency(activeTradeTotalPrice - gameState.playerCurrency, gameState.worldType)}。
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="px-2 py-8 text-center text-sm text-zinc-500">
|
||
请选择一件物品,右侧会显示数量、价格与详情。
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-center justify-end gap-3 border-t border-white/10 px-4 py-3 sm:px-5 sm:py-4">
|
||
<button
|
||
type="button"
|
||
onClick={npcUi.closeTradeModal}
|
||
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={!canConfirmTrade}
|
||
onClick={npcUi.confirmTrade}
|
||
className={`pixel-nine-slice pixel-pressable px-4 py-2 text-xs ${canConfirmTrade ? 'text-white' : 'text-zinc-600'}`}
|
||
style={getNineSliceStyle(UI_CHROME.choiceButton, { paddingX: 14, paddingY: 8 })}
|
||
>
|
||
{tradeMode === 'buy' ? '确认购买' : '确认出售'}
|
||
</button>
|
||
</div>
|
||
</motion.div>
|
||
</motion.div>
|
||
)}
|
||
|
||
{tradeModal && tradeDetail && tradeDetailItem && (
|
||
<motion.div
|
||
initial={{ opacity: 0 }}
|
||
animate={{ opacity: 1 }}
|
||
exit={{ opacity: 0 }}
|
||
className="fixed inset-0 z-[76] flex items-center justify-center bg-black/78 p-4 backdrop-blur-sm"
|
||
onClick={() => setTradeDetail(null)}
|
||
>
|
||
<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 }}
|
||
className="pixel-nine-slice pixel-modal-shell flex max-h-[min(92vh,42rem)] w-full max-w-lg flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.55)]"
|
||
style={getNineSliceStyle(UI_CHROME.modalPanel)}
|
||
onClick={event => event.stopPropagation()}
|
||
>
|
||
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
|
||
<div>
|
||
<div className="text-sm font-semibold text-white">物品详情</div>
|
||
<div className="mt-1 text-xs text-zinc-500">
|
||
{tradeDetail.source === 'buy' ? '对方物品' : '背包物品'}
|
||
</div>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => setTradeDetail(null)}
|
||
className="rounded-full border border-white/10 bg-black/20 p-2 text-zinc-400 transition-colors hover:text-white"
|
||
>
|
||
<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-start gap-4">
|
||
<div className="flex h-20 w-20 shrink-0 items-center justify-center rounded-2xl border border-white/10 bg-black/25">
|
||
<PixelIcon src={getItemVisualSrc(tradeDetailItem)} className="h-12 w-12" />
|
||
</div>
|
||
<div className="min-w-0 flex-1">
|
||
<div className="text-base font-semibold text-white">{tradeDetailItem.name}</div>
|
||
<div className="mt-1 text-xs text-zinc-500">
|
||
{tradeDetailItem.category} / {getRarityLabel(tradeDetailItem.rarity)}
|
||
</div>
|
||
<div className="mt-2 space-y-1 text-sm text-zinc-300">
|
||
<div>库存: {tradeDetailItem.quantity}</div>
|
||
<div>估值: {getInventoryItemValue(tradeDetailItem)}</div>
|
||
<div>
|
||
{tradeDetail.source === 'buy'
|
||
? `购买价格: ${formatCurrency(getNpcPurchasePrice(tradeDetailItem, tradeNpcState?.affinity ?? 0), gameState.worldType)}`
|
||
: `回收价格: ${formatCurrency(getNpcBuybackPrice(tradeDetailItem, tradeNpcState?.affinity ?? 0), gameState.worldType)}`}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<p className="text-sm leading-relaxed text-zinc-300">
|
||
{buildInventoryItemDescription(tradeDetailItem, tradeDetailUseEffect)}
|
||
</p>
|
||
|
||
<div className="grid grid-cols-2 gap-2 text-xs text-zinc-300">
|
||
<div className="rounded-lg border border-white/8 bg-black/20 px-3 py-2">
|
||
{tradeDetailEquipSlot ? `装备位:${getEquipmentSlotLabel(tradeDetailEquipSlot)}` : '不可装备'}
|
||
</div>
|
||
<div className="rounded-lg border border-white/8 bg-black/20 px-3 py-2">
|
||
{isInventoryItemUsable(tradeDetailItem) ? '可立即使用' : '不可即时使用'}
|
||
</div>
|
||
<div className="col-span-2 rounded-lg border border-white/8 bg-black/20 px-3 py-2">
|
||
标签:{getInventoryTagLabels(tradeDetailItem.tags).join(' / ') || '无'}
|
||
</div>
|
||
</div>
|
||
|
||
{tradeDetailEffectText && (
|
||
<div className="rounded-lg border border-emerald-400/15 bg-emerald-500/10 px-3 py-2 text-xs text-emerald-100">
|
||
使用效果:{tradeDetailEffectText}
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex justify-end">
|
||
<button
|
||
type="button"
|
||
onClick={() => setTradeDetail(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>
|
||
</div>
|
||
</div>
|
||
</motion.div>
|
||
</motion.div>
|
||
)}
|
||
|
||
{npcUi.giftModal && (
|
||
<motion.div
|
||
initial={{ opacity: 0 }}
|
||
animate={{ opacity: 1 }}
|
||
exit={{ opacity: 0 }}
|
||
className="fixed inset-0 z-[75] flex items-center justify-center bg-black/70 p-4 backdrop-blur-sm"
|
||
onClick={npcUi.closeGiftModal}
|
||
>
|
||
<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 }}
|
||
className="pixel-nine-slice pixel-modal-shell flex max-h-[min(92vh,48rem)] w-full max-w-3xl flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.55)]"
|
||
style={getNineSliceStyle(UI_CHROME.modalPanel)}
|
||
onClick={event => event.stopPropagation()}
|
||
>
|
||
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
|
||
<div>
|
||
<div className="text-sm font-semibold text-white">赠送礼物</div>
|
||
<div className="mt-1 text-xs text-zinc-500">{npcUi.giftModal.encounter.npcName}</div>
|
||
</div>
|
||
<button type="button" onClick={npcUi.closeGiftModal} className="rounded-full border border-white/10 bg-black/20 p-2 text-zinc-400 transition-colors hover:text-white">
|
||
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
|
||
</button>
|
||
</div>
|
||
|
||
<div className="min-h-0 flex-1 space-y-2 overflow-y-auto p-5">
|
||
{npcUi.giftModal.introText && (
|
||
<div className="whitespace-pre-line rounded-xl border border-rose-300/15 bg-rose-500/10 px-3 py-2 text-xs leading-relaxed text-rose-50/90">
|
||
{npcUi.giftModal.introText}
|
||
</div>
|
||
)}
|
||
{giftCandidates.length > 0 ? giftCandidates.map(candidate => (
|
||
<button
|
||
key={candidate.item.id}
|
||
type="button"
|
||
onClick={() => npcUi.selectGiftItem(candidate.item.id)}
|
||
className={`w-full rounded-lg border px-3 py-2 text-left transition-colors ${npcUi.giftModal?.selectedItemId === candidate.item.id ? 'border-rose-400/60 bg-rose-500/10' : 'border-white/5 bg-black/20 hover:border-white/15'}`}
|
||
>
|
||
<div className="flex items-center justify-between gap-3">
|
||
<div className="flex items-center gap-3">
|
||
<PixelIcon src={getItemVisualSrc(candidate.item)} className="h-8 w-8" />
|
||
<div>
|
||
<div className="text-sm text-white">{candidate.item.name}</div>
|
||
<div className="mt-1 text-[10px] text-zinc-500">{candidate.item.category} / {getRarityLabel(candidate.item.rarity)}</div>
|
||
{candidate.attributeInsight?.reasonText && (
|
||
<div className="mt-1 text-[10px] text-rose-200/80">
|
||
属性共振:{candidate.attributeInsight.reasonText}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<div className="rounded-full border border-rose-500/20 bg-rose-500/10 px-2 py-0.5 text-[10px] text-rose-100">
|
||
好感 +{candidate.affinityGain}
|
||
</div>
|
||
</div>
|
||
</button>
|
||
)) : (
|
||
<div className="rounded-xl border border-dashed border-white/10 bg-black/20 px-4 py-6 text-sm text-zinc-500">
|
||
当前没有适合送出的礼物。
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="flex justify-end gap-3 px-5 pb-5">
|
||
<button type="button" onClick={npcUi.closeGiftModal} 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={!npcUi.giftModal.selectedItemId} onClick={npcUi.confirmGift} className={`pixel-nine-slice pixel-pressable px-4 py-2 text-xs ${npcUi.giftModal.selectedItemId ? 'text-white' : 'text-zinc-600'}`} style={getNineSliceStyle(UI_CHROME.choiceButton, { paddingX: 14, paddingY: 8 })}>
|
||
确认赠礼
|
||
</button>
|
||
</div>
|
||
</motion.div>
|
||
</motion.div>
|
||
)}
|
||
|
||
{npcUi.recruitModal && (
|
||
<motion.div
|
||
initial={{ opacity: 0 }}
|
||
animate={{ opacity: 1 }}
|
||
exit={{ opacity: 0 }}
|
||
className="fixed inset-0 z-[75] flex items-center justify-center bg-black/70 p-4 backdrop-blur-sm"
|
||
onClick={npcUi.closeRecruitModal}
|
||
>
|
||
<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 }}
|
||
className="pixel-nine-slice pixel-modal-shell flex max-h-[min(92vh,42rem)] w-full max-w-2xl flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.55)]"
|
||
style={getNineSliceStyle(UI_CHROME.modalPanel)}
|
||
onClick={event => event.stopPropagation()}
|
||
>
|
||
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
|
||
<div>
|
||
<div className="text-sm font-semibold text-white">调整同行位置</div>
|
||
<div className="mt-1 text-xs text-zinc-500">队伍已满,请先选择一名当前同行角色离队,再邀请对方加入。</div>
|
||
</div>
|
||
<button type="button" onClick={npcUi.closeRecruitModal} className="rounded-full border border-white/10 bg-black/20 p-2 text-zinc-400 transition-colors hover:text-white">
|
||
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
|
||
</button>
|
||
</div>
|
||
|
||
<div className="min-h-0 flex-1 space-y-2 overflow-y-auto p-5">
|
||
{npcUi.recruitModal.introText && (
|
||
<div className="whitespace-pre-line rounded-xl border border-amber-300/15 bg-amber-500/10 px-3 py-2 text-xs leading-relaxed text-amber-50/90">
|
||
{npcUi.recruitModal.introText}
|
||
</div>
|
||
)}
|
||
{gameState.companions.length > 0 ? gameState.companions.map(companion => {
|
||
const character = getCharacterById(companion.characterId);
|
||
if (!character) return null;
|
||
return (
|
||
<button
|
||
key={companion.npcId}
|
||
type="button"
|
||
onClick={() => npcUi.selectRecruitRelease(companion.npcId)}
|
||
className={`w-full rounded-lg border px-3 py-2 text-left transition-colors ${npcUi.recruitModal?.selectedReleaseNpcId === companion.npcId ? 'border-amber-400/60 bg-amber-500/10' : 'border-white/5 bg-black/20 hover:border-white/15'}`}
|
||
>
|
||
<div className="text-sm text-white">{character.name}</div>
|
||
<div className="mt-1 text-[10px] text-zinc-500">{character.title}</div>
|
||
</button>
|
||
);
|
||
}) : (
|
||
<div className="rounded-xl border border-dashed border-white/10 bg-black/20 px-4 py-6 text-sm text-zinc-500">
|
||
当前没有可替换的同行角色。
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="flex justify-end gap-3 px-5 pb-5">
|
||
<button type="button" onClick={npcUi.closeRecruitModal} 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={!npcUi.recruitModal.selectedReleaseNpcId} onClick={npcUi.confirmRecruit} className={`pixel-nine-slice pixel-pressable px-4 py-2 text-xs ${npcUi.recruitModal.selectedReleaseNpcId ? 'text-white' : 'text-zinc-600'}`} style={getNineSliceStyle(UI_CHROME.choiceButton, { paddingX: 14, paddingY: 8 })}>
|
||
确认招募
|
||
</button>
|
||
</div>
|
||
</motion.div>
|
||
</motion.div>
|
||
)}
|
||
</AnimatePresence>
|
||
);
|
||
}
|