import { BarChart3, Coins, Heart, LogOut, type LucideIcon, MapPinned, PackageOpen, ScrollText, Volume2, } from 'lucide-react'; import { AnimatePresence, motion } from 'motion/react'; import { formatCurrency } from '../../data/economy'; import { getHostileNpcPresetById } from '../../data/hostileNpcPresets'; import { type InventoryUseEffect, isInventoryItemUsable, } from '../../data/inventoryEffects'; import { buildInventoryItemDescription, getInventoryTagLabels, } from '../../data/itemPresentation'; import { getRarityLabel } from '../../data/npcInteractions'; import { isQuestReadyToClaim } from '../../data/questFlow'; import { getScenePresetById } from '../../data/scenePresets'; import type { BattleRewardUi, QuestFlowUi, } from '../../hooks/rpg-runtime-story'; import { sortQuestsForGoalPanel } from '../../services/storyEngine/goalDirector'; import type { ChapterState, EquipmentSlotId, GoalHandoff, GoalPulseEvent, GoalStackState, InventoryItem, JourneyBeat, QuestLogEntry, WorldType, } from '../../types'; import { CHROME_ICONS, getInventoryItemVisualSrc, getNineSliceStyle, UI_CHROME, } from '../../uiAssets'; import { HostileNpcAnimator } from '../HostileNpcAnimator'; import { PixelIcon } from '../PixelIcon'; type AdventureStatisticCard = { key: string; label: string; value: string; detail: string; icon: LucideIcon; }; type AdventureStatistics = { playTimeMs: number; hostileNpcsDefeated: number; questsAccepted: number; questsCompleted: number; questsTurnedIn: number; itemsUsed: number; scenesTraveled: number; currentSceneName: string; playerCurrency: number; inventoryItemCount: number; inventoryStackCount: number; activeCompanionCount: number; rosterCompanionCount: number; }; interface RpgAdventurePanelOverlaysProps { worldType: WorldType | null; quests: QuestLogEntry[]; questUi: QuestFlowUi; battleRewardUi: BattleRewardUi; statistics: AdventureStatistics; statisticsCards: AdventureStatisticCard[]; musicVolume: number; onMusicVolumeChange: (value: number) => void; onSaveAndExit: () => void; saveAndExitDisabled: boolean; isGoalPanelOpen: boolean; setIsGoalPanelOpen: (open: boolean) => void; isQuestPanelOpen: boolean; setIsQuestPanelOpen: (open: boolean) => void; isSettingsPanelOpen: boolean; setIsSettingsPanelOpen: (open: boolean) => void; isStatsPanelOpen: boolean; setIsStatsPanelOpen: (open: boolean) => void; chapterState: ChapterState | null; journeyBeat: JourneyBeat | null; goalStack: GoalStackState; goalPulse: GoalPulseEvent | null; onDismissGoalPulse: () => void; selectedQuest: QuestLogEntry | null; setSelectedQuestId: (questId: string | null) => void; completionNoticeQuest: QuestLogEntry | null; setCompletionNoticeQuestId: (questId: string | null) => void; rewardQuest: QuestLogEntry | null; setRewardQuestId: (questId: string | null) => void; rewardQuestHandoff: GoalHandoff | null; setRewardQuestHandoff: (handoff: GoalHandoff | null) => void; selectedRewardItemQuestId: string | null; setSelectedRewardItemQuestId: (questId: string | null) => void; selectedRewardItemId: string | null; setSelectedRewardItemId: (itemId: string | null) => void; selectedBattleRewardItemId: string | null; setSelectedBattleRewardItemId: (itemId: string | null) => void; selectedRewardItem: InventoryItem | null; selectedRewardUseEffect: InventoryUseEffect | null; selectedRewardEquipSlot: EquipmentSlotId | null; selectedQuestSceneName: string; getQuestStatusLabel: (status: QuestLogEntry['status']) => string; pendingNpcQuestOffer: QuestLogEntry | null; onAcceptPendingNpcQuestOffer: () => string | null; } function compactSceneTaskLabel( sceneName: string | null | undefined, fallback: string, ) { if (!sceneName?.trim()) { return fallback; } const cleaned = sceneName.replace(/[《》「」“”"']/gu, '').trim(); return cleaned.length > 8 ? cleaned.slice(0, 8) : cleaned; } function formatTaskTitle(title: string, fallback = '当前任务') { const cleaned = title .replace(/[《》「」“”"']/gu, '') .replace(/[·||::].*$/u, '') .replace(/[,。!?;,.!?;].*$/u, '') .trim(); if (!cleaned) { return fallback; } return cleaned.length > 12 ? cleaned.slice(0, 10) : cleaned; } function buildJourneyTaskCardCopy(params: { beatType: JourneyBeat['beatType'] | null | undefined; sceneName: string; fallbackCondition: string; }) { const { beatType, sceneName, fallbackCondition } = params; const sceneLabel = compactSceneTaskLabel(sceneName, '前方区域'); switch (beatType) { case 'approach': return { title: `前往${sceneLabel}`, description: `${sceneLabel} 一带出现了值得跟进的新线索,继续靠近,看看那里到底发生了什么。`, condition: `前往 ${sceneName},确认新的线索。`, progress: '靠近线索', }; case 'investigation': return { title: `调查${sceneLabel}`, description: `${sceneLabel} 出现了新的异常和痕迹,继续调查,查清这里到底隐藏着什么。`, condition: `在 ${sceneName} 调查线索或异常。`, progress: '调查进行中', }; case 'camp': return { title: '回营整备', description: '先整理队伍、资源和状态,再决定下一段任务的推进方式。', condition: '返回营地,整理队伍或与同伴交谈。', progress: '整备中', }; case 'conflict': return { title: `处理${sceneLabel}`, description: `${sceneLabel} 的冲突已经浮出水面,需要继续推进并正面处理。`, condition: `在 ${sceneName} 处理当前冲突。`, progress: '冲突处理中', }; case 'boss_prelude': return { title: `备战${sceneLabel}`, description: '关键战斗已经逼近,先把线索和状态准备好。', condition: fallbackCondition, progress: '战前准备', }; case 'climax': return { title: `决断${sceneLabel}`, description: '决定结果的对峙已经临近,继续推进到最终现场。', condition: fallbackCondition, progress: '决战临近', }; case 'recovery': return { title: '收束结果', description: '刚结束的事件还在留下影响,先整理结果,再决定下一步去向。', condition: fallbackCondition, progress: '结果收束中', }; default: return { title: '继续推进', description: `${sceneLabel} 一带还有没查清的事,继续推进当前线索。`, condition: fallbackCondition, progress: '推进中', }; } } function buildCurrentTaskCardCopy(params: { goalStack: GoalStackState; goalPulse: GoalPulseEvent | null; journeyBeat: JourneyBeat | null; sceneName: string; }) { const { goalStack, goalPulse, journeyBeat, sceneName } = params; const primaryGoal = goalStack.activeGoal ?? goalStack.northStarGoal; const stepGoal = goalStack.immediateStepGoal ?? primaryGoal; if (!primaryGoal || !stepGoal) { return null; } if (primaryGoal.sourceKind === 'quest') { const description = primaryGoal.whyNow || primaryGoal.promiseText; return { eyebrow: goalPulse?.title ?? '当前任务', title: formatTaskTitle(primaryGoal.title, '当前任务'), description, condition: stepGoal.nextStepText, progress: stepGoal.progressLabel ?? primaryGoal.progressLabel ?? '推进中', pulseNote: goalPulse?.detail && goalPulse.detail !== description && goalPulse.detail !== stepGoal.nextStepText ? goalPulse.detail : null, }; } const journeyCopy = buildJourneyTaskCardCopy({ beatType: journeyBeat?.beatType, sceneName, fallbackCondition: stepGoal.nextStepText, }); return { eyebrow: goalPulse?.title ?? '当前任务', title: journeyCopy.title, description: journeyCopy.description, condition: journeyCopy.condition, progress: journeyCopy.progress, pulseNote: goalPulse?.detail && goalPulse.detail !== journeyCopy.description && goalPulse.detail !== journeyCopy.condition ? goalPulse.detail : null, }; } function getQuestSceneName(quest: QuestLogEntry, worldType: WorldType | null) { if (!quest.sceneId) { return '当前区域'; } if (!worldType) { return quest.sceneId; } return getScenePresetById(worldType, quest.sceneId)?.name ?? quest.sceneId; } function getQuestHostileNpcName( quest: QuestLogEntry, worldType: WorldType | null, ) { if (!quest.objective.targetHostileNpcId) { return null; } return worldType ? (getHostileNpcPresetById(worldType, quest.objective.targetHostileNpcId) ?.name ?? quest.objective.targetHostileNpcId) : quest.objective.targetHostileNpcId; } function buildQuestConditionText( quest: QuestLogEntry, worldType: WorldType | null, ) { if (isQuestReadyToClaim(quest)) { return `返回找 ${quest.issuerNpcName} 交付任务并领取奖励。`; } if (quest.status === 'turned_in') { return '任务已经交付。'; } const activeStep = quest.steps?.find((step) => step.id === quest.activeStepId) ?? quest.steps?.find((step) => step.progress < step.requiredCount) ?? null; const objective = activeStep ?? quest.objective; const sceneName = getQuestSceneName(quest, worldType); switch (objective.kind) { case 'defeat_hostile_npc': return `击败 ${getQuestHostileNpcName(quest, worldType) ?? '指定敌人'}。`; case 'inspect_treasure': return `在 ${sceneName} 调查宝藏或异常线索。`; case 'spar_with_npc': return `与 ${quest.issuerNpcName} 完成一场切磋。`; case 'talk_to_npc': return `返回找 ${quest.issuerNpcName} 对话。`; case 'reach_scene': return `前往 ${objective.targetSceneId ?? sceneName}。`; case 'deliver_item': return `把指定物品交给 ${quest.issuerNpcName}。`; default: return activeStep?.revealText ?? quest.summary; } } function getQuestProgressText(quest: QuestLogEntry) { if (isQuestReadyToClaim(quest)) { return '待交付'; } if (quest.status === 'turned_in') { return '已交付'; } const activeStep = quest.steps?.find((step) => step.id === quest.activeStepId) ?? quest.steps?.find((step) => step.progress < step.requiredCount) ?? null; const progressSource = activeStep ?? quest; const requiredCount = 'requiredCount' in progressSource ? progressSource.requiredCount : quest.objective.requiredCount; return `${progressSource.progress}/${requiredCount}`; } function TaskTemplateCard({ eyebrow, title, description, condition, progress, reward, onRewardItemSelect, tone = 'default', }: { eyebrow: string; title: string; description: string; condition: string; progress?: string | null; reward?: QuestLogEntry['reward'] | null; onRewardItemSelect?: ((itemId: string) => void) | null; tone?: 'default' | 'main'; }) { if (!title.trim() && !description.trim() && !condition.trim()) { return null; } return (
{eyebrow}
{formatTaskTitle(title)}
任务描述: {description}
达成条件: {condition}
{progress ? (
任务进度:{progress}
) : null} {reward ? ( ) : null}
); } function QuestRewardIconStrip({ reward, onSelectItem, }: { reward: QuestLogEntry['reward']; onSelectItem?: (itemId: string) => void; }) { const hasItems = reward.items.length > 0; return (
任务奖励
好感 +{reward.affinityBonus} {(reward.experience ?? 0) > 0 ? ` · 经验 +${reward.experience}` : ''} {` · ${reward.currency}`}
{hasItems ? (
{reward.items.map((item) => ( ))}
) : (
无物品奖励
)}
); } function GoalFocusCard({ goalStack, goalPulse, journeyBeat, sceneName, }: { goalStack: GoalStackState; goalPulse: GoalPulseEvent | null; journeyBeat: JourneyBeat | null; sceneName: string; }) { const cardCopy = buildCurrentTaskCardCopy({ goalStack, goalPulse, journeyBeat, sceneName, }); const primaryGoal = goalStack.activeGoal ?? goalStack.northStarGoal; if (!cardCopy || primaryGoal?.sourceKind !== 'quest') { return null; } return (
{cardCopy.pulseNote ? (
{cardCopy.pulseNote}
) : null} {(goalStack.immediateStepGoal ?? goalStack.activeGoal)?.sceneHint || (goalStack.immediateStepGoal ?? goalStack.activeGoal)?.npcHint ? (
{(goalStack.immediateStepGoal ?? goalStack.activeGoal)?.sceneHint ? `地点:${(goalStack.immediateStepGoal ?? goalStack.activeGoal)?.sceneHint}` : null} {(goalStack.immediateStepGoal ?? goalStack.activeGoal)?.sceneHint && (goalStack.immediateStepGoal ?? goalStack.activeGoal)?.npcHint ? ' · ' : null} {(goalStack.immediateStepGoal ?? goalStack.activeGoal)?.npcHint ? `相关人物:${(goalStack.immediateStepGoal ?? goalStack.activeGoal)?.npcHint}` : null}
) : null}
); } function getQuestRewardItemIcon(item: InventoryItem) { return getInventoryItemVisualSrc(item); } function getRewardItemFrameClass(rarity: InventoryItem['rarity']) { switch (rarity) { case 'legendary': return 'border-amber-300/45 bg-gradient-to-br from-amber-500/18 via-orange-500/10 to-transparent shadow-[0_0_28px_rgba(245,158,11,0.12)]'; case 'epic': return 'border-fuchsia-300/40 bg-gradient-to-br from-fuchsia-500/16 via-indigo-500/10 to-transparent shadow-[0_0_26px_rgba(217,70,239,0.1)]'; case 'rare': return 'border-sky-300/40 bg-gradient-to-br from-sky-500/16 via-cyan-500/10 to-transparent shadow-[0_0_24px_rgba(56,189,248,0.08)]'; case 'uncommon': return 'border-emerald-300/35 bg-gradient-to-br from-emerald-500/14 via-lime-500/8 to-transparent'; default: return 'border-white/10 bg-white/[0.04]'; } } function buildRewardItemDescription(item: InventoryItem) { return buildInventoryItemDescription(item); } function getQuestObjectivePresentation( quest: QuestLogEntry, worldType: WorldType | null, sceneName: string, ) { const hostileNpcPreset = worldType && quest.objective.targetHostileNpcId ? getHostileNpcPresetById(worldType, quest.objective.targetHostileNpcId) : null; switch (quest.objective.kind) { case 'defeat_hostile_npc': return { eyebrow: '悬赏目标', accentClass: 'from-rose-500/20 via-orange-500/10 to-transparent', labelClass: 'border-rose-300/25 bg-rose-500/12 text-rose-50', primaryLabel: hostileNpcPreset?.name ?? quest.objective.targetHostileNpcId ?? '未知敌对角色', secondaryLabel: sceneName, hostileNpcPreset, iconSrc: null as string | null, }; case 'inspect_treasure': return { eyebrow: '宝藏踪迹', accentClass: 'from-amber-500/20 via-yellow-500/10 to-transparent', labelClass: 'border-amber-300/25 bg-amber-500/12 text-amber-50', primaryLabel: sceneName, secondaryLabel: '探索隐藏的奖励地点', hostileNpcPreset: null, iconSrc: '/Icons/47_treasure.png', }; case 'spar_with_npc': default: return { eyebrow: '切磋会话', accentClass: 'from-sky-500/20 via-cyan-500/10 to-transparent', labelClass: 'border-sky-300/25 bg-sky-500/12 text-sky-50', primaryLabel: quest.issuerNpcName, secondaryLabel: sceneName, hostileNpcPreset: null, iconSrc: '/UI/1_weapon.png', }; } } function RewardItemIconGrid({ items, selectedItemId, onSelectItem, emptyText, }: { items: InventoryItem[]; selectedItemId: string | null; onSelectItem: (itemId: string) => void; emptyText: string; }) { if (items.length === 0) { return (
{emptyText}
); } return (
{items.map((item) => ( ))}
); } function QuestProgressPips({ progress, total, activeClassName, }: { progress: number; total: number; activeClassName: string; }) { const safeTotal = Math.max(1, total); return (
{Array.from({ length: safeTotal }, (_, index) => (
))}
); } function QuestRewardGrid({ quest, worldType, selectedItemId, onSelectItem, }: { quest: QuestLogEntry; worldType: WorldType | null; selectedItemId: string | null; onSelectItem: (itemId: string) => void; }) { return (
奖励缓存
点击物品图标查看详细信息。
+{quest.reward.affinityBonus}
好感度
{formatCurrency(quest.reward.currency, worldType)}
货币
+{quest.reward.experience ?? 0}
经验
); } function QuestObjectiveCard({ quest, worldType, sceneName, statusLabel, }: { quest: QuestLogEntry; worldType: WorldType | null; sceneName: string; statusLabel: string; }) { const presentation = getQuestObjectivePresentation( quest, worldType, sceneName, ); return (
{presentation.eyebrow}
{presentation.primaryLabel}
{statusLabel}
{presentation.hostileNpcPreset ? (
) : ( {presentation.iconSrc && ( )} )}
任务目标
{presentation.primaryLabel}
区域
{presentation.secondaryLabel}
进度 {quest.progress}/{quest.objective.requiredCount}
); } export function RpgAdventurePanelOverlays({ worldType, quests, questUi, battleRewardUi, statistics, statisticsCards, musicVolume, onMusicVolumeChange, onSaveAndExit, saveAndExitDisabled, isGoalPanelOpen, setIsGoalPanelOpen, isQuestPanelOpen, setIsQuestPanelOpen, isSettingsPanelOpen, setIsSettingsPanelOpen, isStatsPanelOpen, setIsStatsPanelOpen, chapterState, journeyBeat, goalStack, goalPulse, onDismissGoalPulse, selectedQuest, setSelectedQuestId, completionNoticeQuest, setCompletionNoticeQuestId, rewardQuest, setRewardQuestId, rewardQuestHandoff, setRewardQuestHandoff, selectedRewardItemQuestId, setSelectedRewardItemQuestId, selectedRewardItemId, setSelectedRewardItemId, selectedBattleRewardItemId, setSelectedBattleRewardItemId, selectedRewardItem, selectedRewardUseEffect, selectedRewardEquipSlot, selectedQuestSceneName, getQuestStatusLabel, pendingNpcQuestOffer, onAcceptPendingNpcQuestOffer, }: RpgAdventurePanelOverlaysProps) { const battleReward = battleRewardUi.reward; const sortedQuests = sortQuestsForGoalPanel(quests, goalStack); const activeGoalQuest = goalStack.activeGoal?.sourceKind === 'quest' ? (quests.find((quest) => quest.id === goalStack.activeGoal?.sourceId) ?? null) : null; const shouldShowQuestUpdateModal = Boolean( activeGoalQuest && isGoalPanelOpen, ); const selectQuestRewardItem = (quest: QuestLogEntry, itemId: string) => { setSelectedBattleRewardItemId(null); setSelectedRewardItemQuestId(quest.id); setSelectedRewardItemId(itemId); }; const isPendingSelectedQuest = Boolean( selectedQuest && pendingNpcQuestOffer?.id === selectedQuest.id, ); const closeGoalPanel = () => { setIsGoalPanelOpen(false); onDismissGoalPulse(); }; return ( <> {shouldShowQuestUpdateModal && ( event.stopPropagation()} >
{goalPulse ? '任务更新' : '当前任务'}
只展示这一步推进最需要知道的信息
{goalStack.activeGoal?.sourceKind === 'quest' || goalStack.immediateStepGoal?.sourceKind === 'quest' ? ( ) : null}
)}
{isSettingsPanelOpen && ( setIsSettingsPanelOpen(false)} > event.stopPropagation()} >
冒险设置
调整音乐音量,查看统计数据,或保存并退出。
音频
音乐音量
onMusicVolumeChange( Number(event.currentTarget.value) / 100, ) } className="h-2 w-full cursor-pointer accent-sky-400" />
{Math.round(musicVolume * 100)}%
{saveAndExitDisabled && (
故事内容仍在加载或流式传输时,保存功能暂时禁用。
)}
)}
{isStatsPanelOpen && ( setIsStatsPanelOpen(false)} > event.stopPropagation()} >
冒险统计
当前区域: {statistics.currentSceneName}
冒险总览
已击败 {statistics.hostileNpcsDefeated} 个敌人,背包中有{' '} {statistics.inventoryItemCount} 件物品,已探索{' '} {statistics.scenesTraveled} 个场景。
{statisticsCards.map((card) => { const Icon = card.icon; return (
{card.label}
{card.value}
{card.detail}
); })}
)}
{isQuestPanelOpen && ( { setIsQuestPanelOpen(false); setSelectedQuestId(null); }} > event.stopPropagation()} >
任务日志
总任务数: {quests.length}
{quests.length > 0 ? (
{sortedQuests.map((quest) => (
selectQuestRewardItem(quest, itemId) } />
))}
) : (
暂无活跃任务。
)}
)}
{selectedQuest && ( setSelectedQuestId(null)} > event.stopPropagation()} >
{selectedQuest.title}
{selectedQuest.issuerNpcName}
selectQuestRewardItem(selectedQuest, itemId) } tone="main" />
{ setSelectedBattleRewardItemId(null); setSelectedRewardItemQuestId(selectedQuest.id); setSelectedRewardItemId(itemId); }} />
{isPendingSelectedQuest && (
)} {isQuestReadyToClaim(selectedQuest) && !isPendingSelectedQuest && (
)}
)}
{completionNoticeQuest && ( { questUi.acknowledgeQuestCompletion(completionNoticeQuest.id); setCompletionNoticeQuestId(null); }} > event.stopPropagation()} >
任务完成
奖励已准备
{completionNoticeQuest.title}
{goalStack.immediateStepGoal?.sourceKind === 'quest' ? goalStack.immediateStepGoal.nextStepText : '可前往任务日志领取奖励。'}
)}
{rewardQuest && ( { setRewardQuestId(null); setRewardQuestHandoff(null); setSelectedRewardItemId(null); setSelectedRewardItemQuestId(null); }} > event.stopPropagation()} >
任务奖励已领取
{rewardQuest.title}
{rewardQuestHandoff ? (
下一步建议
{rewardQuestHandoff.title}
{rewardQuestHandoff.detail}
) : null} { setSelectedBattleRewardItemId(null); setSelectedRewardItemQuestId(rewardQuest.id); setSelectedRewardItemId(itemId); }} />
)}
{battleReward && ( { battleRewardUi.dismiss(); setSelectedBattleRewardItemId(null); }} > event.stopPropagation()} >
战斗奖励
已击败敌人: {battleReward.defeatedHostileNpcs.length}
战斗结束
战利品已添加至背包。可查看下方掉落物品详情。
{battleReward.defeatedHostileNpcs.map((hostileNpc) => ( {hostileNpc.name} ))}
战利品
{battleReward.items.length > 0 ? '点击物品图标查看详情。' : '本次战斗无可用战利品。'}
{ setSelectedRewardItemQuestId(null); setSelectedRewardItemId(null); setSelectedBattleRewardItemId(itemId); }} emptyText="本次战斗无战利品掉落。" />
)}
{selectedRewardItem && ( { setSelectedRewardItemId(null); setSelectedRewardItemQuestId(null); setSelectedBattleRewardItemId(null); }} > event.stopPropagation()} >
{selectedRewardItem.name}
{selectedRewardItem.category}
稀有度: {getRarityLabel(selectedRewardItem.rarity)}
数量: {selectedRewardItem.quantity}
{selectedRewardEquipSlot ? `装备槽: ${selectedRewardEquipSlot}` : '不可装备'}
{isInventoryItemUsable(selectedRewardItem) ? '可直接使用' : '被动/非即时生效物品'}
{buildRewardItemDescription(selectedRewardItem)}
{selectedRewardUseEffect && (
效果预览:生命 +{selectedRewardUseEffect.hpRestore} / 灵力 + {selectedRewardUseEffect.manaRestore} / 冷却 - {selectedRewardUseEffect.cooldownReduction}
)}
标签:{' '} {getInventoryTagLabels(selectedRewardItem.tags).join(' / ') || '无'}
)}
); }