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 { isContinueAdventureOption } 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, NpcChatQuestOfferUi, QuestFlowUi, } from '../../hooks/rpg-runtime-story'; 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'; const BATTLE_OPTION_ROW_MIN_HEIGHT = 58; const BATTLE_OPTION_ROW_GAP = 6; const DEFAULT_BATTLE_VISIBLE_OPTION_COUNT = 4; export interface RpgAdventurePanelProps { 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; npcChatQuestOfferUi: NpcChatQuestOfferUi; 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; playerLevel?: number; playerCurrentLevelXp?: number; playerXpToNextLevel?: number; playerTotalXp?: number; inventoryItemCount: number; inventoryStackCount: number; activeCompanionCount: number; rosterCompanionCount: number; }; musicVolume: number; onMusicVolumeChange: (value: number) => void; onSaveAndExit: () => void; chapterState?: ChapterState | null; journeyBeat?: JourneyBeat | null; currentSceneActTitle?: string | null; currentSceneActIndex?: number | null; currentSceneActCount?: number | null; } const RpgAdventurePanelOverlays = lazy(async () => { const module = await import('./RpgAdventurePanelOverlays'); return { default: module.RpgAdventurePanelOverlays, }; }); function AdventurePanelOverlayLoadingFallback() { return (
正在载入冒险面板
); } 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 getBattleVisibleOptionCount(availableHeight: number, total: number) { if (total <= 0) return 0; if (!Number.isFinite(availableHeight) || availableHeight <= 0) { return Math.min(total, DEFAULT_BATTLE_VISIBLE_OPTION_COUNT); } return Math.max( 1, Math.min( total, Math.floor( (availableHeight + BATTLE_OPTION_ROW_GAP) / (BATTLE_OPTION_ROW_MIN_HEIGHT + BATTLE_OPTION_ROW_GAP), ), ), ); } function useMeasuredElementHeight(enabled: boolean) { const elementRef = useRef(null); const [height, setHeight] = useState(0); useEffect(() => { if (!enabled) { setHeight(0); return; } const element = elementRef.current; if (!element) return; const updateHeight = () => { setHeight(element.getBoundingClientRect().height); }; updateHeight(); if (typeof ResizeObserver !== 'undefined') { const observer = new ResizeObserver(updateHeight); observer.observe(element); return () => observer.disconnect(); } window.addEventListener('resize', updateHeight); return () => window.removeEventListener('resize', updateHeight); }, [enabled]); return [elementRef, height] as const; } function getOptionFunctionTagText(option: StoryOption) { const tagByFunctionId: Record = { battle_all_in_crush: '战斗', battle_attack_basic: '战斗', battle_escape_breakout: '逃跑', battle_feint_step: '战斗', battle_finisher_window: '战斗', battle_guard_break: '战斗', battle_probe_pressure: '战斗', battle_recover_breath: '调息', battle_use_skill: '技能', camp_travel_home_scene: '场景', idle_call_out: '试探', idle_explore_forward: '探索', idle_follow_clue: '线索', idle_observe_signs: '观察', idle_rest_focus: '调息', idle_travel_next_scene: '场景', npc_chat: '聊天', npc_fight: '战斗', npc_gift: '送礼', npc_help: '求助', npc_leave: '离开', npc_preview_talk: '聊天', npc_quest_accept: '任务', npc_quest_turn_in: '任务', npc_recruit: '招募', npc_spar: '切磋', npc_trade: '交易', story_continue_adventure: '继续', treasure_inspect: '探查', treasure_leave: '离开', treasure_secure: '收取', }; if (option.functionId.startsWith('npc_chat_quest_offer_')) { return '任务'; } return tagByFunctionId[option.functionId] ?? null; } function RpgOptionActionLabel({ option }: { option: StoryOption }) { const tagText = getOptionFunctionTagText(option); return ( {tagText ? ( {tagText} ) : null} {option.actionText} ); } 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 '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 '系统'; } if (turn.speaker === 'player') { return '你'; } if (turn.speaker === 'companion') { return turn.speakerName?.trim() || '同伴'; } return turn.speakerName?.trim() || '对方'; } 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)}
货币
+{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, ); const AccentIcon = presentation.icon; return (
{presentation.eyebrow}
{presentation.primaryLabel}
{statusLabel}
{presentation.hostileNpcPreset ? (
) : ( {presentation.iconSrc ? ( ) : ( )} )}
Objective
{presentation.primaryLabel}
Area
{presentation.secondaryLabel}
当前进度
{quest.progress}/{quest.objective.requiredCount}
任务描述
{quest.description}
); } function RpgAdventureStorySection(props: { currentSceneActTitle: string | null; currentSceneActIndex: number | null; currentSceneActCount: number | null; limitedNpcChatRemainingTurns: number | null; storyScrollContainerRef: React.RefObject; isDialogueStory: boolean; dialogueTurns: NonNullable; isNpcChatMode: boolean; isStoryStreaming: boolean; currentStory: StoryMoment; }) { const { currentSceneActTitle, currentSceneActIndex, currentSceneActCount, limitedNpcChatRemainingTurns, storyScrollContainerRef, isDialogueStory, dialogueTurns, isNpcChatMode, isStoryStreaming, currentStory, } = props; const storyPanelClassName = isNpcChatMode ? 'flex-[1.18] sm:min-h-[15rem]' : 'flex-1 sm:min-h-[14rem]'; return (
{(currentSceneActTitle || limitedNpcChatRemainingTurns !== null) && (
{currentSceneActTitle ? (
当前幕 {currentSceneActTitle} {currentSceneActIndex && currentSceneActCount ? ( {currentSceneActIndex}/{currentSceneActCount} ) : null}
) : null} {limitedNpcChatRemainingTurns !== null ? (
剩余交谈 {limitedNpcChatRemainingTurns} 轮
) : null}
)} {isDialogueStory ? (
{dialogueTurns.length > 0 ? ( dialogueTurns.map((turn, index) => (
{turn.speaker === 'player' ? '你' : '对方'}
{getDialogueTurnLabel(turn)}
{turn.text}
)) ) : isNpcChatMode && !isStoryStreaming ? ( ) : (

{currentStory.text}

)}
); } function RpgAdventureChoiceSection(props: { isNpcChatMode: boolean; canRefreshOptions: boolean; shouldHideChoiceUi: boolean; onRefreshOptions: () => void; onOpenCharacter: () => void; onOpenInventory: () => void; onExitNpcChat?: () => boolean; isLoading: boolean; isStoryStreaming: boolean; displayedOptions: StoryOption[]; playerCharacter: Character; playerHp: number; playerMaxHp: number; playerMana: number; playerMaxMana: number; playerSkillCooldowns: Record; currentNpcBattleMode: NpcBattleMode | null; hasDeferredAdventureOptions: boolean; handleOptionChoice: (option: StoryOption) => void; isNpcQuestOfferMode: boolean; npcChatDraft: string; setNpcChatDraft: (value: string) => void; npcChatPlaceholder: string; submitNpcChatDraft: () => void; inBattle: boolean; }) { const { isNpcChatMode, canRefreshOptions, shouldHideChoiceUi, onRefreshOptions, onOpenCharacter, onOpenInventory, onExitNpcChat, isLoading, isStoryStreaming, displayedOptions, playerCharacter, playerHp, playerMaxHp, playerMana, playerMaxMana, playerSkillCooldowns, currentNpcBattleMode, hasDeferredAdventureOptions, handleOptionChoice, isNpcQuestOfferMode, npcChatDraft, setNpcChatDraft, npcChatPlaceholder, submitNpcChatDraft, inBattle, } = props; const [battleChoiceViewportRef, battleChoiceViewportHeight] = useMeasuredElementHeight( inBattle && !isNpcChatMode && !shouldHideChoiceUi, ); const visibleDisplayedOptions = inBattle && !isNpcChatMode && !shouldHideChoiceUi ? displayedOptions.slice( 0, getBattleVisibleOptionCount( battleChoiceViewportHeight, displayedOptions.length, ), ) : displayedOptions; return (
{/* 让底部操作区整体更贴近屏幕底部,同时把上方聊天区腾出更稳定的展示高度。 */}
{isNpcChatMode && canRefreshOptions && !shouldHideChoiceUi ? ( ) : !isNpcChatMode && canRefreshOptions && !shouldHideChoiceUi ? ( ) : null} {isNpcChatMode ? ( ) : null}
{isLoading && !isStoryStreaming ? (
{inBattle ? '战斗结算中...' : '剧情推演中...'}
) : isStoryStreaming ? (
对话进行中
) : shouldHideChoiceUi ? (
); } function RpgAdventureOverlaySection(props: { shouldMountAdventureOverlays: boolean; worldType: WorldType | null; quests: QuestLogEntry[]; questUi: QuestFlowUi; battleRewardUi: BattleRewardUi; statistics: RpgAdventurePanelProps['statistics']; statisticsCards: Array<{ key: string; label: string; value: string; detail: string; icon: typeof Clock3; }>; musicVolume: number; onMusicVolumeChange: (value: number) => void; onSaveAndExit: () => void; saveAndExitDisabled: boolean; isGoalPanelOpen: boolean; setIsGoalPanelOpen: (value: boolean) => void; isQuestPanelOpen: boolean; setIsQuestPanelOpen: (value: boolean) => void; isSettingsPanelOpen: boolean; setIsSettingsPanelOpen: (value: boolean) => void; isStatsPanelOpen: boolean; setIsStatsPanelOpen: (value: 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: ReturnType | null; selectedRewardEquipSlot: ReturnType | null; selectedQuestSceneName: string; getQuestStatusLabel: (status: QuestLogEntry['status']) => string; pendingNpcQuestOffer: QuestLogEntry | null; onAcceptPendingNpcQuestOffer: () => string | null; }) { if (!props.shouldMountAdventureOverlays) { return null; } return ( }> ); } const LEGACY_ADVENTURE_PANEL_HELPERS = { getQuestRewardItemIcon, getRewardItemFrameClass, buildRewardItemDescription, RewardItemIconGrid, getQuestObjectivePresentation, QuestProgressPips, QuestRewardGrid, QuestObjectiveCard, }; void LEGACY_ADVENTURE_PANEL_HELPERS; /** * RPG 冒险主面板。 * 维持原有视觉与交互,只把真实实现迁到 `rpg-runtime-panels`, * 并把 story / choice / overlay 三个主 section 显式拆开。 */ export function RpgAdventurePanel({ aiError, currentStory, isLoading, displayedOptions, hideOptions, canRefreshOptions, onRefreshOptions, onChoice, onSubmitNpcChatInput, onExitNpcChat, onOpenCharacter, onOpenInventory, playerCharacter, worldType, quests, questUi, npcChatQuestOfferUi, goalStack, goalPulse, onDismissGoalPulse, battleRewardUi, playerHp, playerMaxHp, playerMana, playerMaxMana, playerSkillCooldowns, inBattle, currentNpcBattleMode, statistics, musicVolume, onMusicVolumeChange, onSaveAndExit, chapterState = null, journeyBeat = null, currentSceneActTitle = null, currentSceneActIndex = null, currentSceneActCount = null, }: RpgAdventurePanelProps) { const isDialogueStory = currentStory.displayMode === 'dialogue'; const dialogueTurns = currentStory.dialogue ?? []; const npcChatState = currentStory.npcChatState ?? null; const isNpcChatMode = Boolean(npcChatState); const pendingNpcQuestOffer = npcChatState?.pendingQuestOffer?.quest ?? null; const isNpcQuestOfferMode = Boolean(pendingNpcQuestOffer); 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) ?? (pendingNpcQuestOffer?.id === selectedQuestId ? pendingNpcQuestOffer : null), [pendingNpcQuestOffer, 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 limitedNpcChatRemainingTurns = npcChatState?.turnLimit && npcChatState.limitReason === 'negative_affinity' ? Math.max(0, npcChatState.remainingTurns ?? 0) : null; const shouldMountAdventureOverlays = isGoalPanelOpen || isSettingsPanelOpen || isStatsPanelOpen || isQuestPanelOpen || Boolean(selectedQuest) || Boolean(completionNoticeQuest) || Boolean(rewardQuest) || Boolean(battleReward) || Boolean(selectedRewardItem); const handleOptionChoice = (option: StoryOption) => { const pendingQuestAction = typeof option.runtimePayload?.npcChatQuestOfferAction === 'string' ? option.runtimePayload.npcChatQuestOfferAction : null; if (pendingQuestAction && pendingNpcQuestOffer) { if (pendingQuestAction === 'view') { setSelectedQuestId(pendingNpcQuestOffer.id); return; } if (pendingQuestAction === 'replace') { void npcChatQuestOfferUi.replacePendingOffer(); return; } if (pendingQuestAction === 'abandon') { npcChatQuestOfferUi.abandonPendingOffer(); return; } } 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}
)} {!inBattle ? ( ) : null} { const acceptedQuestId = npcChatQuestOfferUi.acceptPendingOffer(); if (!acceptedQuestId) return null; // 中文注释:待领取任务详情弹层走的是异步服务端接取链路, // 这里先记录 questId,等 quest 真正进入日志后再由 effect 统一收口面板状态。 setPendingAcceptedQuestId(acceptedQuestId); setSelectedQuestId(null); return acceptedQuestId; }} />
); } export default RpgAdventurePanel;