Files
Genarrative/src/components/NpcModals.tsx
kdletters 1ad25e30f8 收口前端平台组件库能力
新增 PlatformUiKit 通用弹窗、按钮、状态、空态、媒体、表单和标签等公共组件
迁移结果页、创作工作台、认证入口、RPG 暗色面板和运行态弹窗的重复 UI chrome
补充组件测试、页面回归测试、技术文档和 Hermes 共享决策记录
2026-06-10 10:24:18 +08:00

806 lines
30 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 { type ReactNode, useState } from 'react';
import { getCharacterById } from '../data/characterPresets';
import { formatCurrency, getInventoryItemValue } from '../data/economy';
import {
getEquipmentSlotFromItem,
getEquipmentSlotLabel,
} from '../data/equipmentEffects';
import {
isInventoryItemUsable,
resolveInventoryItemUseEffect,
} from '../data/inventoryEffects';
import {
buildInventoryItemDescription,
getInventoryTagLabels,
} from '../data/itemPresentation';
import { getRarityLabel } from '../data/npcInteractions';
import { StoryGenerationNpcUi } from '../hooks/rpg-runtime-story';
import {
GameState,
InventoryItem,
RuntimeNpcGiftItemView,
RuntimeNpcTradeItemView,
} from '../types';
import {
getInventoryItemVisualSrc,
getNineSliceStyle,
UI_CHROME,
} from '../uiAssets';
import { PlatformDarkOptionCard } from './common/PlatformDarkOptionCard';
import { PlatformEmptyState } from './common/PlatformEmptyState';
import { PlatformPillBadge } from './common/PlatformPillBadge';
import { PlatformStatusMessage } from './common/PlatformStatusMessage';
import { PlatformSubpanel } from './common/PlatformSubpanel';
import { PixelCloseButton } from './PixelCloseButton';
import { PixelIcon } from './PixelIcon';
interface NpcModalsProps {
gameState: GameState;
npcUi: StoryGenerationNpcUi;
}
type TradeDetailState = {
itemId: string;
source: 'buy' | 'sell';
} | null;
function getItemVisualSrc(item: InventoryItem) {
return getInventoryItemVisualSrc(item);
}
function NpcModalEmptyState({ children }: { children: ReactNode }) {
return (
<PlatformEmptyState
surface="editorDark"
size="compact"
tone="soft"
className="py-6"
>
{children}
</PlatformEmptyState>
);
}
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 (
<PlatformDarkOptionCard
selected={selected}
tone="emerald"
padding="md"
onClick={onClick}
className="w-full"
>
<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 ?? item.id}
</div>
<div className="mt-1 text-[10px] text-zinc-500">
{item.category} / {getRarityLabel(item.rarity)} / {unitPrice}{' '}
{currencyName}
</div>
</div>
<PlatformPillBadge
tone="darkNeutral"
size="xxs"
className="px-2 py-0.5 text-white"
>
x{item.quantity}
</PlatformPillBadge>
</div>
</PlatformDarkOptionCard>
);
}
function TradeQuantityStepper({
quantity,
maxQuantity,
onChange,
}: {
quantity: number;
maxQuantity: number;
onChange: (quantity: number) => void;
}) {
const safeMax = Math.max(1, maxQuantity);
return (
<PlatformSubpanel
as="div"
surface="dark"
radius="xs"
padding="row"
className="flex items-center justify-between"
data-testid="npc-trade-quantity-stepper"
>
<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>
</PlatformSubpanel>
);
}
export function NpcModals({ gameState, npcUi }: NpcModalsProps) {
const [tradeDetail, setTradeDetail] = useState<TradeDetailState>(null);
const npcInteraction = gameState.runtimeNpcInteraction ?? null;
const currencyName = npcInteraction?.currencyName ?? '钱币';
const tradeModal = npcUi.tradeModal;
const tradeMode = tradeModal?.mode ?? 'buy';
const tradeItemViews: RuntimeNpcTradeItemView[] =
tradeMode === 'buy'
? (npcInteraction?.trade.buyItems ?? [])
: (npcInteraction?.trade.sellItems ?? []);
const activeTradeView = tradeModal
? (tradeItemViews.find(
(view) =>
view.itemId ===
(tradeMode === 'buy'
? tradeModal.selectedNpcItemId
: tradeModal.selectedPlayerItemId),
) ?? null)
: null;
const activeTradeItem = activeTradeView?.item ?? null;
const activeTradeUnitPrice = activeTradeView?.unitPrice ?? 0;
const activeTradeMaxQuantity = activeTradeView?.maxQuantity ?? 0;
const activeTradeQuantity = tradeModal
? Math.max(
1,
Math.min(
tradeModal.selectedQuantity,
Math.max(1, activeTradeMaxQuantity),
),
)
: 1;
const activeTradeTotalPrice = activeTradeUnitPrice * activeTradeQuantity;
const canConfirmTrade = Boolean(
activeTradeView && activeTradeView.canSubmit && activeTradeQuantity >= 1,
);
const tradeItemList = tradeItemViews;
const tradeDetailItem = tradeDetail
? ((tradeDetail.source === 'buy'
? (npcInteraction?.trade.buyItems ?? [])
: (npcInteraction?.trade.sellItems ?? [])
).find((view) => view.itemId === tradeDetail.itemId)?.item ?? null)
: null;
const tradeDetailView = tradeDetail
? ((tradeDetail.source === 'buy'
? (npcInteraction?.trade.buyItems ?? [])
: (npcInteraction?.trade.sellItems ?? [])
).find((view) => view.itemId === 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: RuntimeNpcGiftItemView[] = npcUi.giftModal
? (npcInteraction?.gift.items ?? [])
: [];
const activeGiftView =
giftCandidates.find(
(item) => item.itemId === npcUi.giftModal?.selectedItemId,
) ?? null;
const handleTradeItemClick = (view: RuntimeNpcTradeItemView) => {
if (tradeMode === 'buy') {
npcUi.selectTradeNpcItem(view.itemId);
setTradeDetail({ itemId: view.itemId, source: 'buy' });
return;
}
npcUi.selectTradePlayerItem(view.itemId);
setTradeDetail({ itemId: view.itemId, source: 'sell' });
};
return (
<AnimatePresence>
{tradeModal && (
<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">
{npcInteraction?.npcName ?? tradeModal.encounter.npcName} /
{currencyName}
{npcInteraction?.playerCurrency ?? gameState.playerCurrency}
</div>
</div>
<PixelCloseButton
onClick={npcUi.closeTradeModal}
label="关闭交易"
placement="inline"
/>
</div>
<div className="flex-1 overflow-y-auto px-4 py-3 sm:px-5 sm:py-4">
{tradeModal.introText && (
<PlatformStatusMessage
tone="warning"
surface="editorDark"
size="xs"
className="mb-3 whitespace-pre-line leading-relaxed"
>
{tradeModal.introText}
</PlatformStatusMessage>
)}
<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">
<PlatformDarkOptionCard
selected={tradeMode === 'buy'}
tone="emerald"
onClick={() => npcUi.setTradeMode('buy')}
className="text-center text-sm"
>
</PlatformDarkOptionCard>
<PlatformDarkOptionCard
selected={tradeMode === 'sell'}
tone="sky"
onClick={() => npcUi.setTradeMode('sell')}
className="text-center text-sm"
>
</PlatformDarkOptionCard>
</div>
<PlatformSubpanel
as="div"
surface="dark"
radius="xs"
padding="row"
className="flex items-center justify-between text-xs text-zinc-400"
data-testid="npc-trade-list-summary"
>
<span>{tradeMode === 'buy' ? '对方库存' : '你的背包'}</span>
<span>{tradeItemList.length} </span>
</PlatformSubpanel>
<div className="max-h-[42vh] space-y-2 overflow-y-auto pr-1 scrollbar-hide">
{tradeItemList.length > 0 ? (
tradeItemList.map((view) => (
<div key={view.itemId}>
<TradeItemRow
item={view.item}
selected={
tradeMode === 'buy'
? tradeModal.selectedNpcItemId === view.itemId
: tradeModal.selectedPlayerItemId ===
view.itemId
}
unitPrice={view.unitPrice}
currencyName={currencyName}
onClick={() => handleTradeItemClick(view)}
/>
{!view.canSubmit && view.reason && (
<div className="mt-1 px-1 text-[10px] text-rose-300">
{view.reason}
</div>
)}
</div>
))
) : (
<NpcModalEmptyState>
{tradeMode === 'buy'
? '对方暂时没有可出售的物品。'
: '你当前没有可出售的物品。'}
</NpcModalEmptyState>
)}
</div>
</div>
<div className="space-y-3">
<PlatformSubpanel
surface="dark"
radius="xs"
padding="sm"
data-testid="npc-trade-detail-panel"
>
{activeTradeItem ? (
<div className="space-y-3">
<TradeQuantityStepper
quantity={activeTradeQuantity}
maxQuantity={activeTradeMaxQuantity}
onChange={npcUi.setTradeQuantity}
/>
<PlatformSubpanel
surface="dark"
radius="xs"
padding="sm"
className="text-sm text-zinc-200"
data-testid="npc-trade-total-panel"
>
<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>
{!activeTradeView?.canSubmit &&
activeTradeView?.reason && (
<div className="mt-2 text-xs text-rose-300">
{activeTradeView.reason}
</div>
)}
</PlatformSubpanel>
</div>
) : (
<div className="px-2 py-8 text-center text-sm text-zinc-500">
</div>
)}
</PlatformSubpanel>
</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>
<PixelCloseButton
onClick={() => setTradeDetail(null)}
label="关闭物品详情"
placement="inline"
/>
</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(tradeDetailView?.unitPrice ?? 0, gameState.worldType)}`
: `回收价格: ${formatCurrency(tradeDetailView?.unitPrice ?? 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">
<PlatformSubpanel
as="div"
surface="dark"
radius="xs"
padding="row"
>
{tradeDetailEquipSlot
? `装备位:${getEquipmentSlotLabel(tradeDetailEquipSlot)}`
: '不可装备'}
</PlatformSubpanel>
<PlatformSubpanel
as="div"
surface="dark"
radius="xs"
padding="row"
>
{isInventoryItemUsable(tradeDetailItem)
? '可立即使用'
: '不可即时使用'}
</PlatformSubpanel>
<PlatformSubpanel
as="div"
surface="dark"
radius="xs"
padding="row"
className="col-span-2"
>
{getInventoryTagLabels(tradeDetailItem.tags).join(' / ') ||
'无'}
</PlatformSubpanel>
</div>
{tradeDetailEffectText && (
<PlatformStatusMessage
tone="success"
surface="editorDark"
size="xs"
>
使{tradeDetailEffectText}
</PlatformStatusMessage>
)}
<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>
<PixelCloseButton
onClick={npcUi.closeGiftModal}
label="关闭赠礼"
placement="inline"
/>
</div>
<div className="min-h-0 flex-1 space-y-2 overflow-y-auto p-5">
{npcUi.giftModal.introText && (
<PlatformStatusMessage
tone="error"
surface="editorDark"
size="xs"
className="whitespace-pre-line leading-relaxed"
>
{npcUi.giftModal.introText}
</PlatformStatusMessage>
)}
{giftCandidates.length > 0 ? (
giftCandidates.map((candidate) => (
<PlatformDarkOptionCard
key={candidate.itemId}
selected={
npcUi.giftModal?.selectedItemId === candidate.itemId
}
tone="rose"
radius="sm"
onClick={() => npcUi.selectGiftItem(candidate.itemId)}
className="w-full"
>
<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.canSubmit && candidate.reason && (
<div className="mt-1 text-[10px] text-rose-200/80">
{candidate.reason}
</div>
)}
</div>
</div>
<PlatformPillBadge
tone="darkRose"
size="xxs"
className="px-2 py-0.5"
>
+{candidate.affinityGain}
</PlatformPillBadge>
</div>
</PlatformDarkOptionCard>
))
) : (
<NpcModalEmptyState>
</NpcModalEmptyState>
)}
</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={!activeGiftView?.canSubmit}
onClick={npcUi.confirmGift}
className={`pixel-nine-slice pixel-pressable px-4 py-2 text-xs ${activeGiftView?.canSubmit ? '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>
<PixelCloseButton
onClick={npcUi.closeRecruitModal}
label="关闭招募"
placement="inline"
/>
</div>
<div className="min-h-0 flex-1 space-y-2 overflow-y-auto p-5">
{npcUi.recruitModal.introText && (
<PlatformStatusMessage
tone="warning"
surface="editorDark"
size="xs"
className="whitespace-pre-line leading-relaxed"
>
{npcUi.recruitModal.introText}
</PlatformStatusMessage>
)}
{gameState.companions.length > 0 ? (
gameState.companions.map((companion) => {
const character = getCharacterById(companion.characterId);
if (!character) return null;
return (
<PlatformDarkOptionCard
key={companion.npcId}
selected={
npcUi.recruitModal?.selectedReleaseNpcId ===
companion.npcId
}
tone="amber"
radius="sm"
onClick={() =>
npcUi.selectRecruitRelease(companion.npcId)
}
className="w-full"
>
<div className="text-sm text-white">{character.name}</div>
<div className="mt-1 text-[10px] text-zinc-500">
{character.title}
</div>
</PlatformDarkOptionCard>
);
})
) : (
<NpcModalEmptyState>
</NpcModalEmptyState>
)}
</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>
);
}