1222 lines
41 KiB
TypeScript
1222 lines
41 KiB
TypeScript
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<string, number>;
|
||
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 (
|
||
<div className="fixed inset-0 z-[74] flex items-center justify-center bg-black/72 p-4 backdrop-blur-sm">
|
||
<div
|
||
className="pixel-nine-slice pixel-modal-shell flex min-h-32 w-full max-w-sm items-center justify-center px-5 py-6 text-center text-[11px] uppercase tracking-[0.24em] text-zinc-400 shadow-[0_24px_80px_rgba(0,0,0,0.55)]"
|
||
style={getNineSliceStyle(UI_CHROME.modalPanel)}
|
||
>
|
||
正在载入冒险面板
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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<StoryMoment['dialogue']>[number],
|
||
) {
|
||
if (turn.speaker === 'system') {
|
||
return 'justify-center';
|
||
}
|
||
|
||
return turn.speaker === 'player' ? 'justify-end' : 'justify-start';
|
||
}
|
||
|
||
function getDialogueTurnBubbleClass(
|
||
turn: NonNullable<StoryMoment['dialogue']>[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<StoryMoment['dialogue']>[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<StoryMoment['dialogue']>[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 (
|
||
<div className="mt-3 rounded-xl border border-dashed border-white/10 bg-black/20 px-3 py-4 text-center text-xs text-zinc-500">
|
||
{emptyText}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="mt-3 flex flex-wrap gap-2.5">
|
||
{items.map((item) => (
|
||
<button
|
||
key={item.id}
|
||
type="button"
|
||
onClick={() => onSelectItem(item.id)}
|
||
aria-label={`查看奖励物品 ${item.name}`}
|
||
title={item.name}
|
||
className={`group relative flex h-16 w-16 items-center justify-center overflow-hidden rounded-2xl border transition hover:-translate-y-0.5 hover:border-white/20 sm:h-[4.5rem] sm:w-[4.5rem] ${getRewardItemFrameClass(
|
||
item.rarity,
|
||
)} ${
|
||
selectedItemId === item.id
|
||
? 'ring-1 ring-amber-300/55 ring-offset-0'
|
||
: ''
|
||
}`}
|
||
>
|
||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.12),transparent_55%)] opacity-0 transition group-hover:opacity-100" />
|
||
<PixelIcon
|
||
src={getQuestRewardItemIcon(item)}
|
||
alt={item.name}
|
||
className="relative z-[1] h-8 w-8 drop-shadow-[0_6px_10px_rgba(0,0,0,0.35)] sm:h-9 sm:w-9"
|
||
/>
|
||
<span className="absolute bottom-1 right-1 rounded-full border border-black/35 bg-black/70 px-1.5 py-0.5 text-[10px] font-semibold text-white">
|
||
{item.quantity}
|
||
</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div
|
||
className="grid gap-1.5"
|
||
style={{ gridTemplateColumns: `repeat(${safeTotal}, minmax(0, 1fr))` }}
|
||
>
|
||
{Array.from({ length: safeTotal }, (_, index) => (
|
||
<div
|
||
key={`quest-progress-${safeTotal}-${index}`}
|
||
className={`h-2 rounded-full border ${
|
||
index < progress ? activeClassName : 'border-white/8 bg-black/20'
|
||
}`}
|
||
/>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function QuestRewardGrid({
|
||
quest,
|
||
worldType,
|
||
selectedItemId,
|
||
onSelectItem,
|
||
}: {
|
||
quest: QuestLogEntry;
|
||
worldType: WorldType | null;
|
||
selectedItemId: string | null;
|
||
onSelectItem: (itemId: string) => void;
|
||
}) {
|
||
return (
|
||
<div className="rounded-2xl border border-amber-300/15 bg-[radial-gradient(circle_at_top,rgba(251,191,36,0.14),transparent_65%),rgba(0,0,0,0.24)] p-3">
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div>
|
||
<div className="text-[10px] tracking-[0.24em] text-amber-200/80">
|
||
奖励物品
|
||
</div>
|
||
<div className="mt-1 text-[11px] leading-relaxed text-zinc-500">
|
||
点击物品图标查看详情。
|
||
</div>
|
||
</div>
|
||
<ScrollText className="h-4 w-4 text-amber-200/70" />
|
||
</div>
|
||
|
||
<div className="mt-3 grid grid-cols-2 gap-2">
|
||
<div className="rounded-xl border border-rose-300/18 bg-rose-500/10 px-3 py-2.5 text-rose-50">
|
||
<div className="flex items-center gap-2">
|
||
<Heart className="h-4 w-4" />
|
||
<span className="text-sm font-semibold">
|
||
+{quest.reward.affinityBonus}
|
||
</span>
|
||
</div>
|
||
<div className="mt-1 text-[10px] uppercase tracking-[0.2em] text-rose-100/70">
|
||
好感度
|
||
</div>
|
||
</div>
|
||
<div className="rounded-xl border border-amber-300/18 bg-amber-500/10 px-3 py-2.5 text-amber-50">
|
||
<div className="flex items-center gap-2">
|
||
<Coins className="h-4 w-4" />
|
||
<span className="text-sm font-semibold">
|
||
{formatCurrency(quest.reward.currency, worldType)}
|
||
</span>
|
||
</div>
|
||
<div className="mt-1 text-[10px] uppercase tracking-[0.2em] text-amber-100/70">
|
||
货币
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<RewardItemIconGrid
|
||
items={quest.reward.items}
|
||
selectedItemId={selectedItemId}
|
||
onSelectItem={onSelectItem}
|
||
emptyText="本任务无物品奖励。"
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div
|
||
className={`overflow-hidden rounded-2xl border border-white/10 bg-gradient-to-br ${presentation.accentClass}`}
|
||
>
|
||
<div className="flex items-center justify-between gap-3 border-b border-white/10 px-3 py-3">
|
||
<div>
|
||
<div className="text-[10px] tracking-[0.24em] text-white/70">
|
||
{presentation.eyebrow}
|
||
</div>
|
||
<div className="mt-1 text-sm font-semibold text-white">
|
||
{presentation.primaryLabel}
|
||
</div>
|
||
</div>
|
||
<div
|
||
className={`rounded-full border px-2.5 py-1 text-[10px] tracking-[0.16em] ${presentation.labelClass}`}
|
||
>
|
||
{statusLabel}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid gap-3 p-3 sm:grid-cols-[minmax(0,0.95fr)_minmax(0,1.05fr)]">
|
||
<div className="relative overflow-hidden rounded-2xl border border-white/10 bg-black/30 p-3">
|
||
<div className="absolute inset-x-0 bottom-0 h-12 bg-gradient-to-t from-black/40 to-transparent" />
|
||
<div className="flex h-36 items-end justify-center">
|
||
{presentation.hostileNpcPreset ? (
|
||
<div className="rounded-full bg-[radial-gradient(circle,rgba(248,113,113,0.18),transparent_70%)] p-4">
|
||
<HostileNpcAnimator
|
||
hostileNpc={presentation.hostileNpcPreset}
|
||
animation={
|
||
presentation.hostileNpcPreset.animations.move
|
||
? 'move'
|
||
: 'idle'
|
||
}
|
||
className="origin-bottom scale-150 sm:scale-[1.85]"
|
||
/>
|
||
</div>
|
||
) : (
|
||
<motion.div
|
||
animate={{ y: [0, -4, 0], scale: [1, 1.03, 1] }}
|
||
transition={{
|
||
duration: 2.8,
|
||
ease: 'easeInOut',
|
||
repeat: Infinity,
|
||
}}
|
||
className="flex h-24 w-24 items-center justify-center rounded-3xl border border-white/10 bg-black/35"
|
||
>
|
||
{presentation.iconSrc ? (
|
||
<PixelIcon
|
||
src={presentation.iconSrc}
|
||
alt={presentation.primaryLabel}
|
||
className="h-14 w-14"
|
||
/>
|
||
) : (
|
||
<AccentIcon className="h-11 w-11 text-white/85" />
|
||
)}
|
||
</motion.div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-3">
|
||
<div className="grid gap-2 sm:grid-cols-2">
|
||
<div className="rounded-xl border border-white/10 bg-black/25 px-3 py-2.5 text-zinc-100">
|
||
<div className="flex items-center gap-2 text-[10px] uppercase tracking-[0.18em] text-zinc-500">
|
||
<AccentIcon className="h-3.5 w-3.5" />
|
||
Objective
|
||
</div>
|
||
<div className="mt-2 text-sm font-medium text-white">
|
||
{presentation.primaryLabel}
|
||
</div>
|
||
</div>
|
||
<div className="rounded-xl border border-white/10 bg-black/25 px-3 py-2.5 text-zinc-100">
|
||
<div className="flex items-center gap-2 text-[10px] uppercase tracking-[0.18em] text-zinc-500">
|
||
<MapPinned className="h-3.5 w-3.5" />
|
||
Area
|
||
</div>
|
||
<div className="mt-2 text-sm font-medium text-white">
|
||
{presentation.secondaryLabel}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="rounded-xl border border-white/10 bg-black/25 px-3 py-3">
|
||
<div className="flex items-center justify-between gap-3 text-[10px] uppercase tracking-[0.18em] text-zinc-500">
|
||
<span>Progress</span>
|
||
<span className="text-white/70">
|
||
{quest.progress}/{quest.objective.requiredCount}
|
||
</span>
|
||
</div>
|
||
<div className="mt-3">
|
||
<QuestProgressPips
|
||
progress={quest.progress}
|
||
total={quest.objective.requiredCount}
|
||
activeClassName="border-emerald-300/30 bg-emerald-400/85"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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<HTMLDivElement | null>(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<string | null>(null);
|
||
const [pendingAcceptedQuestId, setPendingAcceptedQuestId] = useState<
|
||
string | null
|
||
>(null);
|
||
const [completionNoticeQuestId, setCompletionNoticeQuestId] = useState<
|
||
string | null
|
||
>(null);
|
||
const [rewardQuestId, setRewardQuestId] = useState<string | null>(null);
|
||
const [rewardQuestHandoff, setRewardQuestHandoff] = useState<GoalHandoff | null>(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<string | null>(null);
|
||
const lastAutoOpenedPulseRef = useRef<string | null>(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 (
|
||
<div className="relative flex min-h-0 flex-1 flex-col">
|
||
<button
|
||
type="button"
|
||
onClick={() => setIsSettingsPanelOpen(true)}
|
||
aria-label="打开设置"
|
||
title="打开设置"
|
||
className="fixed z-[26] flex min-h-[2.75rem] min-w-[2.75rem] items-center justify-center border-0 bg-transparent p-0 shadow-none transition duration-150 hover:-translate-y-px hover:brightness-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-200/60"
|
||
style={{
|
||
top: 'calc(env(safe-area-inset-top, 0px) + 0.75rem)',
|
||
right: 'calc(clamp(0.75rem, 2vw, 1rem) - 5px)',
|
||
}}
|
||
>
|
||
<PixelIcon
|
||
src={CHROME_ICONS.settings}
|
||
className="h-[1.8rem] w-[1.8rem] drop-shadow-[0_4px_10px_rgba(0,0,0,0.45)]"
|
||
/>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setIsQuestPanelOpen(true);
|
||
onDismissGoalPulse();
|
||
}}
|
||
className="fixed right-0 z-[26] flex min-w-[3.1rem] flex-col items-center gap-1 rounded-l-xl border border-r-0 border-white/10 bg-black/78 pl-2 pr-1.5 py-2 text-[10px] text-zinc-200 shadow-[0_8px_18px_rgba(0,0,0,0.35)] backdrop-blur-md transition hover:text-white"
|
||
style={{ top: 'calc(env(safe-area-inset-top, 0px) + 7vh)' }}
|
||
>
|
||
{(hasCompletedQuest || goalPulse) && (
|
||
<span
|
||
aria-hidden="true"
|
||
className={`absolute -left-1 top-1 h-3.5 w-3.5 rounded-full border shadow-[0_0_14px_rgba(245,158,11,0.5)] ${
|
||
hasCompletedQuest
|
||
? 'border-red-200/45 bg-red-500 shadow-[0_0_14px_rgba(239,68,68,0.55)]'
|
||
: 'border-amber-200/45 bg-amber-500'
|
||
}`}
|
||
/>
|
||
)}
|
||
<PixelIcon src={CHROME_ICONS.map} className="h-4 w-4" />
|
||
<span className="leading-none">任务</span>
|
||
{primaryQuestGoal?.title ? (
|
||
<span className="max-w-[3.6rem] truncate text-[9px] text-zinc-400">
|
||
{primaryQuestGoal.title}
|
||
</span>
|
||
) : (
|
||
<span className="rounded-full border border-white/10 bg-white/10 px-1.5 py-0.5 text-[9px] text-white">
|
||
{quests.length}
|
||
</span>
|
||
)}
|
||
</button>
|
||
|
||
{aiError && (
|
||
<div
|
||
className="pixel-nine-slice pixel-panel mb-3 text-xs text-amber-100"
|
||
style={getNineSliceStyle(UI_CHROME.panel)}
|
||
>
|
||
{aiError}
|
||
</div>
|
||
)}
|
||
|
||
<div
|
||
ref={storyScrollContainerRef}
|
||
className="pixel-nine-slice pixel-panel mb-3 min-h-0 flex-1 overflow-y-auto pr-1 scrollbar-hide"
|
||
style={getNineSliceStyle(UI_CHROME.storyPanel)}
|
||
>
|
||
{isDialogueStory ? (
|
||
<div className="space-y-3">
|
||
{dialogueTurns.length > 0 ? (
|
||
dialogueTurns.map((turn, index) => (
|
||
<div
|
||
key={`${turn.speaker}-${turn.speakerName ?? 'default'}-${index}-${turn.text}`}
|
||
className={`flex ${getDialogueTurnAlignmentClass(turn)}`}
|
||
>
|
||
<div
|
||
className={`max-w-[88%] border px-3 py-2 text-sm leading-relaxed ${getDialogueTurnBubbleClass(turn)} ${getDialogueTurnBubbleShapeClass(turn)}`}
|
||
>
|
||
<div className="sr-only">
|
||
{turn.speaker === 'player' ? '你' : '对方'}
|
||
</div>
|
||
<div className="mb-1 text-[10px] tracking-[0.18em] text-zinc-400">
|
||
{getDialogueTurnLabel(turn)}
|
||
</div>
|
||
{turn.text}
|
||
</div>
|
||
</div>
|
||
))
|
||
) : (
|
||
<div className="flex justify-start">
|
||
<div className="rounded-2xl border border-white/10 bg-black/20 px-3 py-2 text-sm text-zinc-400">
|
||
对话正在展开...
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<p className="font-serif text-sm italic leading-relaxed text-zinc-300">
|
||
{currentStory.text}
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
<div className="mt-auto shrink-0 pb-2">
|
||
<div className="mb-2 flex items-center justify-between gap-3">
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={onOpenCharacter}
|
||
aria-label="打开队伍"
|
||
className="inline-flex h-8 items-center gap-1.5 rounded-md border border-white/10 bg-black/20 px-2 text-zinc-300 transition-colors hover:text-white"
|
||
>
|
||
<PixelIcon src={TAB_ICONS.character.active} className="h-4 w-4" />
|
||
<span className="text-xs leading-none">队伍</span>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={onOpenInventory}
|
||
aria-label="打开背包"
|
||
className="inline-flex h-8 items-center gap-1.5 rounded-md border border-white/10 bg-black/20 px-2 text-zinc-300 transition-colors hover:text-white"
|
||
>
|
||
<PixelIcon src={TAB_ICONS.inventory.active} className="h-4 w-4" />
|
||
<span className="text-xs leading-none">背包</span>
|
||
</button>
|
||
</div>
|
||
|
||
{canRefreshOptions && !shouldHideChoiceUi && (
|
||
<button
|
||
type="button"
|
||
onClick={onRefreshOptions}
|
||
aria-label="换一换选项"
|
||
className="inline-flex h-8 items-center gap-1.5 rounded-md border border-white/10 bg-black/20 px-2 text-zinc-300 transition-colors hover:text-white"
|
||
>
|
||
<PixelIcon
|
||
src={CHROME_ICONS.refreshOptions}
|
||
className="h-4 w-4"
|
||
/>
|
||
<span className="text-xs leading-none">换一换</span>
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
{isLoading && !isStoryStreaming ? (
|
||
<div className="flex items-center justify-center space-x-2 p-4 text-zinc-600">
|
||
<Loader2 className="h-4 w-4 animate-spin" />
|
||
<span className="text-xs uppercase tracking-widest">
|
||
剧情推演中...
|
||
</span>
|
||
</div>
|
||
) : isStoryStreaming ? (
|
||
<div className="flex items-center justify-center p-4 text-[11px] tracking-[0.18em] text-zinc-500">
|
||
对话进行中
|
||
</div>
|
||
) : shouldHideChoiceUi ? (
|
||
<div className="p-4" aria-hidden="true" />
|
||
) : (
|
||
displayedOptions.map((option, index) => {
|
||
const optionImpactSummary = getOptionImpactSummary(
|
||
option,
|
||
playerCharacter,
|
||
playerHp,
|
||
playerMaxHp,
|
||
playerMana,
|
||
playerMaxMana,
|
||
playerSkillCooldowns,
|
||
currentNpcBattleMode,
|
||
);
|
||
const isDeferredContinueOption =
|
||
hasDeferredAdventureOptions &&
|
||
isContinueAdventureOption(option);
|
||
|
||
if (isDeferredContinueOption) {
|
||
return (
|
||
<motion.button
|
||
key={`${option.functionId}-${option.actionText}-${index}`}
|
||
type="button"
|
||
initial={{ opacity: 0, y: 22 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
transition={{ duration: 0.24, ease: 'easeOut' }}
|
||
onClick={() => handleOptionChoice(option)}
|
||
className="pixel-nine-slice pixel-pressable pixel-choice-button group w-full text-left"
|
||
style={getNineSliceStyle(UI_CHROME.choiceButton)}
|
||
>
|
||
<div className="flex items-center justify-between">
|
||
<span
|
||
className={`text-xs ${getOptionActionTextClass(option)}`}
|
||
>
|
||
{option.actionText}
|
||
</span>
|
||
<PixelIcon
|
||
src={CHROME_ICONS.optionArrow}
|
||
className="h-3 w-3 opacity-70 transition-opacity group-hover:opacity-100"
|
||
/>
|
||
</div>
|
||
<div className="mt-1 text-[10px] leading-relaxed text-zinc-500">
|
||
剧情推理完成,继续后显示新的冒险选项
|
||
</div>
|
||
</motion.button>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<button
|
||
key={`${option.functionId}-${option.actionText}-${index}`}
|
||
type="button"
|
||
onClick={() => handleOptionChoice(option)}
|
||
className="pixel-nine-slice pixel-pressable pixel-choice-button group w-full text-left"
|
||
style={getNineSliceStyle(UI_CHROME.choiceButton)}
|
||
>
|
||
<div className="flex items-center justify-between">
|
||
<span
|
||
className={`text-xs ${getOptionActionTextClass(option)}`}
|
||
>
|
||
{option.actionText}
|
||
</span>
|
||
<PixelIcon
|
||
src={CHROME_ICONS.optionArrow}
|
||
className="h-3 w-3 opacity-70 transition-opacity group-hover:opacity-100"
|
||
/>
|
||
</div>
|
||
{getCompactOptionDetailText(option) && (
|
||
<div className="mt-1 text-[10px] leading-relaxed text-zinc-500">
|
||
{getCompactOptionDetailText(option)}
|
||
</div>
|
||
)}
|
||
{option.goalAffordance?.label && (
|
||
<div className={`mt-1 text-[10px] ${getOptionGoalAffordanceClass(option)}`}>
|
||
{option.goalAffordance.label}
|
||
</div>
|
||
)}
|
||
{optionImpactSummary && (
|
||
<div className="mt-1 text-[10px] text-zinc-500">
|
||
{optionImpactSummary}
|
||
</div>
|
||
)}
|
||
</button>
|
||
);
|
||
})
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{shouldMountAdventureOverlays && (
|
||
<Suspense fallback={<AdventurePanelOverlayLoadingFallback />}>
|
||
<AdventurePanelOverlays
|
||
worldType={worldType}
|
||
quests={quests}
|
||
questUi={questUi}
|
||
battleRewardUi={battleRewardUi}
|
||
statistics={statistics}
|
||
statisticsCards={statisticsCards}
|
||
musicVolume={musicVolume}
|
||
onMusicVolumeChange={onMusicVolumeChange}
|
||
onSaveAndExit={onSaveAndExit}
|
||
saveAndExitDisabled={saveAndExitDisabled}
|
||
isGoalPanelOpen={isGoalPanelOpen}
|
||
setIsGoalPanelOpen={setIsGoalPanelOpen}
|
||
isQuestPanelOpen={isQuestPanelOpen}
|
||
setIsQuestPanelOpen={setIsQuestPanelOpen}
|
||
isSettingsPanelOpen={isSettingsPanelOpen}
|
||
setIsSettingsPanelOpen={setIsSettingsPanelOpen}
|
||
isStatsPanelOpen={isStatsPanelOpen}
|
||
setIsStatsPanelOpen={setIsStatsPanelOpen}
|
||
chapterState={chapterState}
|
||
journeyBeat={journeyBeat}
|
||
goalStack={goalStack}
|
||
goalPulse={goalPulse}
|
||
onDismissGoalPulse={handleDismissGoalPanel}
|
||
selectedQuest={selectedQuest}
|
||
setSelectedQuestId={setSelectedQuestId}
|
||
completionNoticeQuest={completionNoticeQuest}
|
||
setCompletionNoticeQuestId={setCompletionNoticeQuestId}
|
||
rewardQuest={rewardQuest}
|
||
setRewardQuestId={setRewardQuestId}
|
||
rewardQuestHandoff={rewardQuestHandoff}
|
||
setRewardQuestHandoff={setRewardQuestHandoff}
|
||
selectedRewardItemQuestId={selectedRewardItemQuestId}
|
||
setSelectedRewardItemQuestId={setSelectedRewardItemQuestId}
|
||
selectedRewardItemId={selectedRewardItemId}
|
||
setSelectedRewardItemId={setSelectedRewardItemId}
|
||
selectedBattleRewardItemId={selectedBattleRewardItemId}
|
||
setSelectedBattleRewardItemId={setSelectedBattleRewardItemId}
|
||
selectedRewardItem={selectedRewardItem}
|
||
selectedRewardUseEffect={selectedRewardUseEffect}
|
||
selectedRewardEquipSlot={selectedRewardEquipSlot}
|
||
selectedQuestSceneName={selectedQuestSceneName}
|
||
getQuestStatusLabel={getQuestStatusLabel}
|
||
/>
|
||
</Suspense>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|