Files
Genarrative/src/components/NpcModals.tsx
kdletters 193e4f0e96 继续收口首页导航行与暗色弹窗底栏
将首页排行行与分类行切换为 PlatformNavigableListItem
将 NPC 交易详情与地图切场确认底栏收口到 PlatformDarkModalFooter
补充首页与弹窗回归测试并更新 PlatformUiKit 收口计划与共享决策记录
2026-06-11 05:06:07 +08:00

804 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 { PlatformActionButton } from './common/PlatformActionButton';
import { PlatformDarkModalFooter } from './common/PlatformDarkModalFooter';
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>
) : (
<PlatformEmptyState
surface="editorDark"
size="compact"
tone="soft"
className="px-2 py-8 text-center"
>
</PlatformEmptyState>
)}
</PlatformSubpanel>
</div>
</div>
</div>
<PlatformDarkModalFooter data-testid="npc-trade-footer">
<PlatformActionButton
surface="editorDark"
tone="secondary"
size="xs"
onClick={npcUi.closeTradeModal}
>
</PlatformActionButton>
<PlatformActionButton
surface="editorDark"
tone="primary"
size="xs"
disabled={!canConfirmTrade}
onClick={npcUi.confirmTrade}
>
{tradeMode === 'buy' ? '确认购买' : '确认出售'}
</PlatformActionButton>
</PlatformDarkModalFooter>
</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>
<PlatformDarkModalFooter
padding="roomy"
data-testid="npc-trade-detail-footer"
>
<PlatformActionButton
surface="editorDark"
tone="secondary"
size="xs"
onClick={() => setTradeDetail(null)}
>
</PlatformActionButton>
</PlatformDarkModalFooter>
</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>
<PlatformDarkModalFooter
bordered={false}
padding="bottom"
data-testid="npc-gift-footer"
>
<PlatformActionButton
surface="editorDark"
tone="secondary"
size="xs"
onClick={npcUi.closeGiftModal}
>
</PlatformActionButton>
<PlatformActionButton
surface="editorDark"
tone="primary"
size="xs"
disabled={!activeGiftView?.canSubmit}
onClick={npcUi.confirmGift}
>
</PlatformActionButton>
</PlatformDarkModalFooter>
</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>
<PlatformDarkModalFooter
bordered={false}
padding="bottom"
data-testid="npc-recruit-footer"
>
<PlatformActionButton
surface="editorDark"
tone="secondary"
size="xs"
onClick={npcUi.closeRecruitModal}
>
</PlatformActionButton>
<PlatformActionButton
surface="editorDark"
tone="primary"
size="xs"
disabled={!npcUi.recruitModal.selectedReleaseNpcId}
onClick={npcUi.confirmRecruit}
>
</PlatformActionButton>
</PlatformDarkModalFooter>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}