Files
Genarrative/src/components/AdventurePanel.tsx
高物 54b3d3c490
Some checks failed
CI / verify (push) Has been cancelled
1
2026-04-18 17:28:23 +08:00

1222 lines
41 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
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>
);
}