import { Backpack, Clock3, Coins, Crosshair, Footprints, Heart, Loader2, MapPinned, PackageOpen, ScrollText, Skull, Swords, Users, } from 'lucide-react'; import { motion } from 'motion/react'; import { lazy, Suspense, useEffect, useMemo, useRef, useState } from 'react'; import { formatCurrency } from '../data/economy'; import { getEquipmentSlotFromItem } from '../data/equipmentEffects'; import { getFunctionDocumentationById, isContinueAdventureOption, NPC_CHAT_FUNCTION, } from '../data/functionCatalog'; import { getHostileNpcPresetById } from '../data/hostileNpcPresets'; import { resolveInventoryItemUseEffect } from '../data/inventoryEffects'; import { isQuestReadyToClaim } from '../data/questFlow'; import { getScenePresetById } from '../data/scenePresets'; import { getOptionImpactSummary } from '../hooks/combatStoryUtils'; import type { BattleRewardUi, QuestFlowUi } from '../hooks/useStoryGeneration'; import type { ChapterState, Character, GoalHandoff, GoalPulseEvent, GoalStackState, InventoryItem, JourneyBeat, NpcBattleMode, QuestLogEntry, StoryMoment, StoryOption, WorldType, } from '../types'; import { CHROME_ICONS, getInventoryCategoryIcon, getNineSliceStyle, TAB_ICONS, UI_CHROME, } from '../uiAssets'; import { HostileNpcAnimator } from './HostileNpcAnimator'; import { PixelIcon } from './PixelIcon'; interface AdventurePanelProps { aiError: string | null; currentStory: StoryMoment; isLoading: boolean; displayedOptions: StoryOption[]; hideOptions: boolean; canRefreshOptions: boolean; onRefreshOptions: () => void; onChoice: (option: StoryOption) => void; onSubmitNpcChatInput?: (input: string) => boolean; onExitNpcChat?: () => boolean; onOpenCharacter: () => void; onOpenInventory: () => void; playerCharacter: Character; worldType: WorldType | null; quests: QuestLogEntry[]; questUi: QuestFlowUi; goalStack: GoalStackState; goalPulse: GoalPulseEvent | null; onDismissGoalPulse: () => void; battleRewardUi: BattleRewardUi; playerHp: number; playerMaxHp: number; playerMana: number; playerMaxMana: number; playerSkillCooldowns: Record; inBattle: boolean; currentNpcBattleMode: NpcBattleMode | null; statistics: { 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; }; musicVolume: number; onMusicVolumeChange: (value: number) => void; onSaveAndExit: () => void; chapterState?: ChapterState | null; journeyBeat?: JourneyBeat | null; } const AdventurePanelOverlays = lazy(async () => { const module = await import('./adventure-panel/AdventurePanelOverlays'); return { default: module.AdventurePanelOverlays, }; }); function AdventurePanelOverlayLoadingFallback() { return (
正在载入冒险面板
); } function getCompactOptionDetailText(option: StoryOption) { if (option.functionId === NPC_CHAT_FUNCTION.id) { return ( option.detailText || getFunctionDocumentationById(option.functionId)?.runtime ?.compactDetailText || '聊聊并试探口风' ); } return ( getFunctionDocumentationById(option.functionId)?.runtime ?.compactDetailText || option.detailText ); } function getOptionActionTextClass(option: StoryOption) { if ((option.priority ?? 1) >= 3) return 'text-fuchsia-200 group-hover:text-fuchsia-100'; if ((option.priority ?? 1) >= 2) return 'text-sky-200 group-hover:text-sky-100'; return 'text-zinc-300 group-hover:text-white'; } function getDialogueTurnAlignmentClass( turn: NonNullable[number], ) { if (turn.speaker === 'system') { return 'justify-center'; } return turn.speaker === 'player' ? 'justify-end' : 'justify-start'; } function getDialogueTurnBubbleClass( turn: NonNullable[number], ) { if (turn.speaker === 'system') { return turn.affinityDelta && turn.affinityDelta > 0 ? 'border-rose-400/30 bg-rose-500/12 text-rose-50' : 'border-white/12 bg-white/[0.06] text-zinc-100'; } if (turn.speaker === 'player') { return 'border-sky-400/20 bg-sky-500/10 text-sky-50'; } if (turn.speaker === 'companion') { return 'border-emerald-400/20 bg-emerald-500/10 text-emerald-50'; } return 'border-amber-400/20 bg-amber-500/10 text-amber-50'; } function getDialogueTurnBubbleShapeClass( turn: NonNullable[number], ) { if (turn.speaker === 'system') { return 'rounded-full'; } if (turn.speaker === 'player') { return 'rounded-2xl rounded-br-none'; } if (turn.speaker === 'companion') { return 'rounded-2xl'; } return 'rounded-2xl rounded-bl-none'; } function getDialogueTurnLabel( turn: NonNullable[number], ) { if (turn.speaker === 'system') { return turn.affinityDelta && turn.affinityDelta > 0 ? '关系变化' : '系统'; } if (turn.speaker === 'player') { return '\u4f60'; } if (turn.speaker === 'companion') { return turn.speakerName?.trim() || '\u540c\u4f34'; } return turn.speakerName?.trim() || '\u5bf9\u65b9'; } function getQuestRewardItemIcon(item: InventoryItem) { if (item.iconSrc) return item.iconSrc; if (item.tags.includes('weapon')) return '/UI/Icon_Eq_Weapon.png'; if (item.tags.includes('armor')) return '/UI/Icon_Eq_Chest.png'; if (item.tags.includes('relic')) return '/Icons/47_treasure.png'; if (item.tags.includes('healing')) return '/Icons/12_potion.png'; if (item.tags.includes('mana')) return '/UI/Hud_icon_magic.png'; if (item.tags.includes('material')) return '/Icons/45_crystal.png'; return getInventoryCategoryIcon(item.category); } 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) { if (item.description?.trim()) return item.description; const traits: string[] = []; if (item.tags.includes('healing')) traits.push('冒险中可恢复生命'); if (item.tags.includes('mana')) traits.push('可恢复内力或保持法术节奏'); if (item.tags.includes('weapon')) traits.push('适合进攻型构筑'); if (item.tags.includes('armor')) traits.push('适合防御型构筑'); if (item.tags.includes('relic')) traits.push('可作为稀有饰品级奖励'); if (item.tags.includes('material')) traits.push('可作为制作材料'); if (traits.length === 0) { return '任务奖励物品,可用于后续路线、交易或构筑规划'; } return item.name + ' 奖励物品'; } function formatPlayTime(playTimeMs: number) { const totalSeconds = Math.max(0, Math.floor(playTimeMs / 1000)); const hours = Math.floor(totalSeconds / 3600); const minutes = Math.floor((totalSeconds % 3600) / 60); const seconds = totalSeconds % 60; if (hours > 0) { return `${hours}小时 ${String(minutes).padStart(2, '0')}分${String(seconds).padStart(2, '0')}秒`; } return `${minutes}分${String(seconds).padStart(2, '0')}秒`; } function getOptionGoalAffordanceClass(option: StoryOption) { switch (option.goalAffordance?.relation) { case 'advance': return 'text-amber-200/85'; case 'support': return 'text-sky-200/80'; case 'detour': return 'text-zinc-400'; default: return 'text-zinc-500'; } } 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 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', icon: Crosshair, 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', icon: PackageOpen, 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', icon: Swords, primaryLabel: quest.issuerNpcName, secondaryLabel: sceneName, hostileNpcPreset: null, iconSrc: '/UI/1_weapon.png', }; } } 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)}
货币
); } function QuestObjectiveCard({ quest, worldType, sceneName, statusLabel, }: { quest: QuestLogEntry; worldType: WorldType | null; sceneName: string; statusLabel: string; }) { const presentation = getQuestObjectivePresentation( quest, worldType, sceneName, ); const AccentIcon = presentation.icon; return (
{presentation.eyebrow}
{presentation.primaryLabel}
{statusLabel}
{presentation.hostileNpcPreset ? (
) : ( {presentation.iconSrc ? ( ) : ( )} )}
Objective
{presentation.primaryLabel}
Area
{presentation.secondaryLabel}
Progress {quest.progress}/{quest.objective.requiredCount}
); } const LEGACY_ADVENTURE_PANEL_HELPERS = { getQuestRewardItemIcon, getRewardItemFrameClass, buildRewardItemDescription, RewardItemIconGrid, getQuestObjectivePresentation, QuestProgressPips, QuestRewardGrid, QuestObjectiveCard, }; void LEGACY_ADVENTURE_PANEL_HELPERS; export function AdventurePanel({ aiError, currentStory, isLoading, displayedOptions, hideOptions, canRefreshOptions, onRefreshOptions, onChoice, onSubmitNpcChatInput, onExitNpcChat, onOpenCharacter, onOpenInventory, playerCharacter, worldType, quests, questUi, goalStack, goalPulse, onDismissGoalPulse, battleRewardUi, playerHp, playerMaxHp, playerMana, playerMaxMana, playerSkillCooldowns, currentNpcBattleMode, statistics, musicVolume, onMusicVolumeChange, onSaveAndExit, chapterState = null, journeyBeat = null, }: AdventurePanelProps) { const isDialogueStory = currentStory.displayMode === 'dialogue'; const dialogueTurns = currentStory.dialogue ?? []; const npcChatState = currentStory.npcChatState ?? null; const isNpcChatMode = Boolean(npcChatState); const isStoryStreaming = Boolean(currentStory.streaming); const shouldHideChoiceUi = hideOptions; const storyScrollContainerRef = useRef(null); const hasDeferredAdventureOptions = Boolean( currentStory.deferredOptions?.length, ); const saveAndExitDisabled = isLoading || isStoryStreaming; const primaryQuestGoal = goalStack.activeGoal?.sourceKind === 'quest' ? goalStack.activeGoal : goalStack.immediateStepGoal?.sourceKind === 'quest' ? goalStack.immediateStepGoal : null; const [isGoalPanelOpen, setIsGoalPanelOpen] = useState(false); const [isQuestPanelOpen, setIsQuestPanelOpen] = useState(false); const [isSettingsPanelOpen, setIsSettingsPanelOpen] = useState(false); const [isStatsPanelOpen, setIsStatsPanelOpen] = useState(false); const [selectedQuestId, setSelectedQuestId] = useState(null); const [pendingAcceptedQuestId, setPendingAcceptedQuestId] = useState< string | null >(null); const [completionNoticeQuestId, setCompletionNoticeQuestId] = useState< string | null >(null); const [rewardQuestId, setRewardQuestId] = useState(null); const [rewardQuestHandoff, setRewardQuestHandoff] = useState(null); const [selectedRewardItemQuestId, setSelectedRewardItemQuestId] = useState< string | null >(null); const [selectedRewardItemId, setSelectedRewardItemId] = useState< string | null >(null); const [selectedBattleRewardItemId, setSelectedBattleRewardItemId] = useState< string | null >(null); const [npcChatDraft, setNpcChatDraft] = useState(''); const lastAutoOpenedGoalRef = useRef(null); const lastAutoOpenedPulseRef = useRef(null); const battleReward = battleRewardUi.reward; const hasCompletedQuest = useMemo( () => quests.some((quest) => isQuestReadyToClaim(quest)), [quests], ); const selectedQuest = useMemo( () => quests.find((quest) => quest.id === selectedQuestId) ?? null, [quests, selectedQuestId], ); const rewardQuest = useMemo( () => quests.find((quest) => quest.id === rewardQuestId) ?? null, [quests, rewardQuestId], ); const selectedRewardItemQuest = useMemo( () => quests.find((quest) => quest.id === selectedRewardItemQuestId) ?? null, [quests, selectedRewardItemQuestId], ); const selectedBattleRewardItem = useMemo( () => battleReward?.items.find( (item) => item.id === selectedBattleRewardItemId, ) ?? null, [battleReward, selectedBattleRewardItemId], ); const selectedRewardItem = useMemo( () => selectedRewardItemQuest?.reward.items.find( (item) => item.id === selectedRewardItemId, ) ?? selectedBattleRewardItem, [selectedBattleRewardItem, selectedRewardItemId, selectedRewardItemQuest], ); const getQuestStatusLabel = (status: QuestLogEntry['status']) => { if (status === 'ready_to_turn_in' || status === 'completed') return '待交付'; if (status === 'turned_in') return '已交付'; if (status === 'discovered') return '已发现'; if (status === 'failed') return '已失败'; if (status === 'expired') return '已过期'; return '进行中'; }; useEffect(() => { if (completionNoticeQuestId) return; const pendingNoticeQuest = quests.find( (quest) => isQuestReadyToClaim(quest) && !quest.completionNotified, ); if (pendingNoticeQuest) { setCompletionNoticeQuestId(pendingNoticeQuest.id); } }, [completionNoticeQuestId, quests]); useEffect(() => { if (!pendingAcceptedQuestId) return; const acceptedQuest = quests.find( (quest) => quest.id === pendingAcceptedQuestId, ); if (!acceptedQuest) return; setIsQuestPanelOpen(false); setSelectedQuestId(acceptedQuest.id); setSelectedRewardItemQuestId(null); setSelectedRewardItemId(null); setSelectedBattleRewardItemId(null); setPendingAcceptedQuestId(null); }, [pendingAcceptedQuestId, quests]); useEffect(() => { if (battleReward) return; setSelectedBattleRewardItemId(null); }, [battleReward]); useEffect(() => { setNpcChatDraft(''); }, [npcChatState?.npcId, npcChatState?.turnCount]); useEffect(() => { if (!primaryQuestGoal) { return; } if (lastAutoOpenedGoalRef.current === primaryQuestGoal.id) { return; } lastAutoOpenedGoalRef.current = primaryQuestGoal.id; setIsGoalPanelOpen(true); }, [primaryQuestGoal]); useEffect(() => { if (!goalPulse) { return; } if (lastAutoOpenedPulseRef.current === goalPulse.id) { return; } lastAutoOpenedPulseRef.current = goalPulse.id; setIsGoalPanelOpen(true); }, [goalPulse]); useEffect(() => { const container = storyScrollContainerRef.current; if (!container) return; const animationFrameId = window.requestAnimationFrame(() => { container.scrollTo({ top: container.scrollHeight, behavior: 'auto', }); }); return () => window.cancelAnimationFrame(animationFrameId); }, [ currentStory.displayMode, currentStory.text, dialogueTurns.length, displayedOptions.length, isLoading, isStoryStreaming, ]); const completionNoticeQuest = completionNoticeQuestId ? (quests.find((quest) => quest.id === completionNoticeQuestId) ?? null) : null; const selectedRewardUseEffect = selectedRewardItem ? resolveInventoryItemUseEffect(selectedRewardItem, playerCharacter) : null; const selectedRewardEquipSlot = selectedRewardItem ? getEquipmentSlotFromItem(selectedRewardItem) : null; const selectedQuestSceneName = useMemo(() => { if (!selectedQuest) return '当前区域'; if (!worldType) return selectedQuest.sceneId ?? '当前区域'; return ( getScenePresetById(worldType, selectedQuest.sceneId)?.name ?? selectedQuest.sceneId ?? '当前区域' ); }, [selectedQuest, worldType]); const statisticsCards = useMemo( () => [ { key: 'play-time', label: '游戏时长', value: formatPlayTime(statistics.playTimeMs), detail: '累计冒险时间', icon: Clock3, }, { key: 'hostile-npc-defeats', label: '击杀怪物', value: `${statistics.hostileNpcsDefeated}`, detail: '累计战斗击杀', icon: Skull, }, { key: 'quests-completed', label: '完成任务', value: `${statistics.questsCompleted}`, detail: `已接 ${statistics.questsAccepted} / 已交 ${statistics.questsTurnedIn}`, icon: ScrollText, }, { key: 'travel-count', label: '场景移动', value: `${statistics.scenesTraveled}`, detail: statistics.currentSceneName, icon: Footprints, }, { key: 'currency', label: '当前货币', value: `${statistics.playerCurrency}`, detail: '可用于交易与奖励结算', icon: Coins, }, { key: 'inventory', label: '背包物品', value: `${statistics.inventoryItemCount}`, detail: `${statistics.inventoryStackCount} 组物品 / 使用 ${statistics.itemsUsed} 次`, icon: Backpack, }, { key: 'companions', label: '同伴人数', value: `${statistics.activeCompanionCount + statistics.rosterCompanionCount}`, detail: `出战 ${statistics.activeCompanionCount} / 营地 ${statistics.rosterCompanionCount}`, icon: Users, }, { key: 'scene', label: '当前区域', value: statistics.currentSceneName, detail: '本次冒险所在地', icon: MapPinned, }, ], [statistics], ); const shouldMountAdventureOverlays = isGoalPanelOpen || isSettingsPanelOpen || isStatsPanelOpen || isQuestPanelOpen || Boolean(selectedQuest) || Boolean(completionNoticeQuest) || Boolean(rewardQuest) || Boolean(battleReward) || Boolean(selectedRewardItem); const handleOptionChoice = (option: StoryOption) => { if ( option.interaction?.kind === 'npc' && option.interaction.action === 'quest_accept' ) { setPendingAcceptedQuestId(option.interaction.questId ?? null); } onChoice(option); }; const handleDismissGoalPanel = () => { setIsGoalPanelOpen(false); onDismissGoalPulse(); }; const submitNpcChatDraft = () => { const nextInput = npcChatDraft.trim(); if (!nextInput || !onSubmitNpcChatInput) { return; } const submitted = onSubmitNpcChatInput(nextInput); if (submitted) { setNpcChatDraft(''); } }; return (
{aiError && (
{aiError}
)}
{isDialogueStory ? (
{dialogueTurns.length > 0 ? ( dialogueTurns.map((turn, index) => (
{turn.speaker === 'player' ? '你' : '对方'}
{getDialogueTurnLabel(turn)}
{turn.text}
)) ) : (
对话正在展开...
)}
) : (

{currentStory.text}

)}
{canRefreshOptions && !shouldHideChoiceUi && ( )}
{isLoading && !isStoryStreaming ? (
剧情推演中...
) : isStoryStreaming ? (
对话进行中
) : shouldHideChoiceUi ? (
{shouldMountAdventureOverlays && ( }> )}
); }