Files
Genarrative/src/components/NpcModals.tsx
2026-04-21 18:27:46 +08:00

601 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}