Files
Genarrative/src/components/rpg-runtime-panels/RpgAdventurePanelOverlays.tsx
2026-04-29 20:56:59 +08:00

1758 lines
66 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 {
BarChart3,
Coins,
Heart,
LogOut,
type LucideIcon,
MapPinned,
PackageOpen,
ScrollText,
Volume2,
} from 'lucide-react';
import { AnimatePresence, motion } from 'motion/react';
import { formatCurrency } from '../../data/economy';
import { getHostileNpcPresetById } from '../../data/hostileNpcPresets';
import {
type InventoryUseEffect,
isInventoryItemUsable,
} from '../../data/inventoryEffects';
import {
buildInventoryItemDescription,
getInventoryTagLabels,
} from '../../data/itemPresentation';
import { getRarityLabel } from '../../data/npcInteractions';
import { isQuestReadyToClaim } from '../../data/questFlow';
import { getScenePresetById } from '../../data/scenePresets';
import type {
BattleRewardUi,
QuestFlowUi,
} from '../../hooks/rpg-runtime-story';
import { sortQuestsForGoalPanel } from '../../services/storyEngine/goalDirector';
import type {
ChapterState,
EquipmentSlotId,
GoalHandoff,
GoalPulseEvent,
GoalStackState,
InventoryItem,
JourneyBeat,
QuestLogEntry,
WorldType,
} from '../../types';
import {
getInventoryItemVisualSrc,
getNineSliceStyle,
UI_CHROME,
} from '../../uiAssets';
import { HostileNpcAnimator } from '../HostileNpcAnimator';
import { PixelCloseButton } from '../PixelCloseButton';
import { PixelIcon } from '../PixelIcon';
type AdventureStatisticCard = {
key: string;
label: string;
value: string;
detail: string;
icon: LucideIcon;
};
type AdventureStatistics = {
playTimeMs: number;
hostileNpcsDefeated: number;
questsAccepted: number;
questsCompleted: number;
questsTurnedIn: number;
itemsUsed: number;
scenesTraveled: number;
currentSceneName: string;
playerCurrency: number;
inventoryItemCount: number;
inventoryStackCount: number;
activeCompanionCount: number;
rosterCompanionCount: number;
};
interface RpgAdventurePanelOverlaysProps {
worldType: WorldType | null;
quests: QuestLogEntry[];
questUi: QuestFlowUi;
battleRewardUi: BattleRewardUi;
statistics: AdventureStatistics;
statisticsCards: AdventureStatisticCard[];
musicVolume: number;
onMusicVolumeChange: (value: number) => void;
onSaveAndExit: () => void;
saveAndExitDisabled: boolean;
isGoalPanelOpen: boolean;
setIsGoalPanelOpen: (open: boolean) => void;
isQuestPanelOpen: boolean;
setIsQuestPanelOpen: (open: boolean) => void;
isSettingsPanelOpen: boolean;
setIsSettingsPanelOpen: (open: boolean) => void;
isStatsPanelOpen: boolean;
setIsStatsPanelOpen: (open: boolean) => void;
chapterState: ChapterState | null;
journeyBeat: JourneyBeat | null;
goalStack: GoalStackState;
goalPulse: GoalPulseEvent | null;
onDismissGoalPulse: () => void;
selectedQuest: QuestLogEntry | null;
setSelectedQuestId: (questId: string | null) => void;
completionNoticeQuest: QuestLogEntry | null;
setCompletionNoticeQuestId: (questId: string | null) => void;
rewardQuest: QuestLogEntry | null;
setRewardQuestId: (questId: string | null) => void;
rewardQuestHandoff: GoalHandoff | null;
setRewardQuestHandoff: (handoff: GoalHandoff | null) => void;
selectedRewardItemQuestId: string | null;
setSelectedRewardItemQuestId: (questId: string | null) => void;
selectedRewardItemId: string | null;
setSelectedRewardItemId: (itemId: string | null) => void;
selectedBattleRewardItemId: string | null;
setSelectedBattleRewardItemId: (itemId: string | null) => void;
selectedRewardItem: InventoryItem | null;
selectedRewardUseEffect: InventoryUseEffect | null;
selectedRewardEquipSlot: EquipmentSlotId | null;
selectedQuestSceneName: string;
getQuestStatusLabel: (status: QuestLogEntry['status']) => string;
pendingNpcQuestOffer: QuestLogEntry | null;
onAcceptPendingNpcQuestOffer: () => string | null;
}
function compactSceneTaskLabel(
sceneName: string | null | undefined,
fallback: string,
) {
if (!sceneName?.trim()) {
return fallback;
}
const cleaned = sceneName.replace(/["']/gu, '').trim();
return cleaned.length > 8 ? cleaned.slice(0, 8) : cleaned;
}
function formatTaskTitle(title: string, fallback = '当前任务') {
const cleaned = title
.replace(/["']/gu, '')
.replace(/[·|:].*$/u, '')
.replace(/[,.!?;].*$/u, '')
.trim();
if (!cleaned) {
return fallback;
}
return cleaned.length > 12 ? cleaned.slice(0, 10) : cleaned;
}
function buildJourneyTaskCardCopy(params: {
beatType: JourneyBeat['beatType'] | null | undefined;
sceneName: string;
fallbackCondition: string;
}) {
const { beatType, sceneName, fallbackCondition } = params;
const sceneLabel = compactSceneTaskLabel(sceneName, '前方区域');
switch (beatType) {
case 'approach':
return {
title: `前往${sceneLabel}`,
description: `${sceneLabel} 一带出现了值得跟进的新线索,继续靠近,看看那里到底发生了什么。`,
condition: `前往 ${sceneName},确认新的线索。`,
progress: '靠近线索',
};
case 'investigation':
return {
title: `调查${sceneLabel}`,
description: `${sceneLabel} 出现了新的异常和痕迹,继续调查,查清这里到底隐藏着什么。`,
condition: `${sceneName} 调查线索或异常。`,
progress: '调查进行中',
};
case 'camp':
return {
title: '回营整备',
description: '先整理队伍、资源和状态,再决定下一段任务的推进方式。',
condition: '返回营地,整理队伍或与同伴交谈。',
progress: '整备中',
};
case 'conflict':
return {
title: `处理${sceneLabel}`,
description: `${sceneLabel} 的冲突已经浮出水面,需要继续推进并正面处理。`,
condition: `${sceneName} 处理当前冲突。`,
progress: '冲突处理中',
};
case 'boss_prelude':
return {
title: `备战${sceneLabel}`,
description: '关键战斗已经逼近,先把线索和状态准备好。',
condition: fallbackCondition,
progress: '战前准备',
};
case 'climax':
return {
title: `决断${sceneLabel}`,
description: '决定结果的对峙已经临近,继续推进到最终现场。',
condition: fallbackCondition,
progress: '决战临近',
};
case 'recovery':
return {
title: '收束结果',
description: '刚结束的事件还在留下影响,先整理结果,再决定下一步去向。',
condition: fallbackCondition,
progress: '结果收束中',
};
default:
return {
title: '继续推进',
description: `${sceneLabel} 一带还有没查清的事,继续推进当前线索。`,
condition: fallbackCondition,
progress: '推进中',
};
}
}
function buildCurrentTaskCardCopy(params: {
goalStack: GoalStackState;
goalPulse: GoalPulseEvent | null;
journeyBeat: JourneyBeat | null;
sceneName: string;
}) {
const { goalStack, goalPulse, journeyBeat, sceneName } = params;
const primaryGoal = goalStack.activeGoal ?? goalStack.northStarGoal;
const stepGoal = goalStack.immediateStepGoal ?? primaryGoal;
if (!primaryGoal || !stepGoal) {
return null;
}
if (primaryGoal.sourceKind === 'quest') {
const description = primaryGoal.whyNow || primaryGoal.promiseText;
return {
eyebrow: goalPulse?.title ?? '当前任务',
title: formatTaskTitle(primaryGoal.title, '当前任务'),
description,
condition: stepGoal.nextStepText,
progress: stepGoal.progressLabel ?? primaryGoal.progressLabel ?? '推进中',
pulseNote:
goalPulse?.detail &&
goalPulse.detail !== description &&
goalPulse.detail !== stepGoal.nextStepText
? goalPulse.detail
: null,
};
}
const journeyCopy = buildJourneyTaskCardCopy({
beatType: journeyBeat?.beatType,
sceneName,
fallbackCondition: stepGoal.nextStepText,
});
return {
eyebrow: goalPulse?.title ?? '当前任务',
title: journeyCopy.title,
description: journeyCopy.description,
condition: journeyCopy.condition,
progress: journeyCopy.progress,
pulseNote:
goalPulse?.detail &&
goalPulse.detail !== journeyCopy.description &&
goalPulse.detail !== journeyCopy.condition
? goalPulse.detail
: null,
};
}
function getQuestSceneName(quest: QuestLogEntry, worldType: WorldType | null) {
if (!quest.sceneId) {
return '当前区域';
}
if (!worldType) {
return quest.sceneId;
}
return getScenePresetById(worldType, quest.sceneId)?.name ?? quest.sceneId;
}
function getQuestHostileNpcName(
quest: QuestLogEntry,
worldType: WorldType | null,
) {
if (!quest.objective.targetHostileNpcId) {
return null;
}
return worldType
? (getHostileNpcPresetById(worldType, quest.objective.targetHostileNpcId)
?.name ?? quest.objective.targetHostileNpcId)
: quest.objective.targetHostileNpcId;
}
function buildQuestConditionText(
quest: QuestLogEntry,
worldType: WorldType | null,
) {
if (isQuestReadyToClaim(quest)) {
return `返回找 ${quest.issuerNpcName} 交付任务并领取奖励。`;
}
if (quest.status === 'turned_in') {
return '任务已经交付。';
}
const activeStep =
quest.steps?.find((step) => step.id === quest.activeStepId) ??
quest.steps?.find((step) => step.progress < step.requiredCount) ??
null;
const objective = activeStep ?? quest.objective;
const sceneName = getQuestSceneName(quest, worldType);
switch (objective.kind) {
case 'defeat_hostile_npc':
return `击败 ${getQuestHostileNpcName(quest, worldType) ?? '指定敌人'}`;
case 'inspect_treasure':
return `${sceneName} 调查宝藏或异常线索。`;
case 'spar_with_npc':
return `${quest.issuerNpcName} 完成一场切磋。`;
case 'talk_to_npc':
return `返回找 ${quest.issuerNpcName} 对话。`;
case 'reach_scene':
return `前往 ${objective.targetSceneId ?? sceneName}`;
case 'deliver_item':
return `把指定物品交给 ${quest.issuerNpcName}`;
default:
return activeStep?.revealText ?? quest.summary;
}
}
function getQuestProgressText(quest: QuestLogEntry) {
if (isQuestReadyToClaim(quest)) {
return '待交付';
}
if (quest.status === 'turned_in') {
return '已交付';
}
const activeStep =
quest.steps?.find((step) => step.id === quest.activeStepId) ??
quest.steps?.find((step) => step.progress < step.requiredCount) ??
null;
const progressSource = activeStep ?? quest;
const requiredCount =
'requiredCount' in progressSource
? progressSource.requiredCount
: quest.objective.requiredCount;
return `${progressSource.progress}/${requiredCount}`;
}
function TaskTemplateCard({
eyebrow,
title,
description,
condition,
progress,
reward,
onRewardItemSelect,
tone = 'default',
}: {
eyebrow: string;
title: string;
description: string;
condition: string;
progress?: string | null;
reward?: QuestLogEntry['reward'] | null;
onRewardItemSelect?: ((itemId: string) => void) | null;
tone?: 'default' | 'main';
}) {
if (!title.trim() && !description.trim() && !condition.trim()) {
return null;
}
return (
<div
className={`rounded-2xl border px-4 py-3.5 ${
tone === 'main'
? 'border-amber-300/15 bg-[radial-gradient(circle_at_top,rgba(245,158,11,0.13),transparent_65%),rgba(0,0,0,0.24)]'
: 'border-white/8 bg-black/20'
}`}
>
<div className="min-w-0">
<div
className={`text-[10px] tracking-[0.24em] ${tone === 'main' ? 'text-amber-200/80' : 'text-zinc-500'}`}
>
{eyebrow}
</div>
<div className="mt-2 text-sm font-semibold text-white">
{formatTaskTitle(title)}
</div>
</div>
<div className="mt-3 text-xs leading-relaxed text-zinc-400">
<span className="text-zinc-500"></span>
{description}
</div>
<div className="mt-2 text-xs leading-relaxed text-zinc-300">
<span className="text-zinc-500"></span>
{condition}
</div>
{progress ? (
<div className="mt-2 text-[11px] leading-relaxed text-zinc-500">
{progress}
</div>
) : null}
{reward ? (
<QuestRewardIconStrip
reward={reward}
onSelectItem={onRewardItemSelect ?? undefined}
/>
) : null}
</div>
);
}
function QuestRewardIconStrip({
reward,
onSelectItem,
}: {
reward: QuestLogEntry['reward'];
onSelectItem?: (itemId: string) => void;
}) {
const hasItems = reward.items.length > 0;
return (
<div className="mt-3 rounded-xl border border-amber-300/10 bg-black/18 px-3 py-2">
<div className="flex items-center justify-between gap-3">
<div className="text-[10px] tracking-[0.2em] text-amber-200/75">
</div>
<div className="text-[10px] text-zinc-500">
+{reward.affinityBonus}
{(reward.experience ?? 0) > 0 ? ` · 经验 +${reward.experience}` : ''}
{` · ${reward.currency}`}
</div>
</div>
{hasItems ? (
<div className="mt-2 flex flex-wrap gap-2">
{reward.items.map((item) => (
<button
key={item.id}
type="button"
onClick={() => onSelectItem?.(item.id)}
className="group relative flex h-9 w-9 items-center justify-center rounded-lg border border-white/10 bg-black/30 transition hover:border-amber-200/30"
title={item.name}
aria-label={`查看任务奖励 ${item.name}`}
>
<PixelIcon
src={getQuestRewardItemIcon(item)}
alt={item.name}
className="h-6 w-6"
/>
<span className="absolute -bottom-1 -right-1 rounded-full border border-black/40 bg-black/75 px-1 text-[9px] text-white">
{item.quantity}
</span>
</button>
))}
</div>
) : (
<div className="mt-2 text-[11px] text-zinc-500"></div>
)}
</div>
);
}
function GoalFocusCard({
goalStack,
goalPulse,
journeyBeat,
sceneName,
}: {
goalStack: GoalStackState;
goalPulse: GoalPulseEvent | null;
journeyBeat: JourneyBeat | null;
sceneName: string;
}) {
const cardCopy = buildCurrentTaskCardCopy({
goalStack,
goalPulse,
journeyBeat,
sceneName,
});
const primaryGoal = goalStack.activeGoal ?? goalStack.northStarGoal;
if (!cardCopy || primaryGoal?.sourceKind !== 'quest') {
return null;
}
return (
<div className="space-y-4">
{cardCopy.pulseNote ? (
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3 text-[11px] leading-relaxed text-zinc-300">
{cardCopy.pulseNote}
</div>
) : null}
<TaskTemplateCard
eyebrow={cardCopy.eyebrow}
title={cardCopy.title}
description={cardCopy.description}
condition={cardCopy.condition}
progress={cardCopy.progress}
tone="main"
/>
{(goalStack.immediateStepGoal ?? goalStack.activeGoal)?.sceneHint ||
(goalStack.immediateStepGoal ?? goalStack.activeGoal)?.npcHint ? (
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3 text-[11px] leading-relaxed text-zinc-400">
{(goalStack.immediateStepGoal ?? goalStack.activeGoal)?.sceneHint
? `地点:${(goalStack.immediateStepGoal ?? goalStack.activeGoal)?.sceneHint}`
: null}
{(goalStack.immediateStepGoal ?? goalStack.activeGoal)?.sceneHint &&
(goalStack.immediateStepGoal ?? goalStack.activeGoal)?.npcHint
? ' · '
: null}
{(goalStack.immediateStepGoal ?? goalStack.activeGoal)?.npcHint
? `相关人物:${(goalStack.immediateStepGoal ?? goalStack.activeGoal)?.npcHint}`
: null}
</div>
) : null}
</div>
);
}
function getQuestRewardItemIcon(item: InventoryItem) {
return getInventoryItemVisualSrc(item);
}
function getRewardItemFrameClass(rarity: InventoryItem['rarity']) {
switch (rarity) {
case 'legendary':
return 'border-amber-300/45 bg-gradient-to-br from-amber-500/18 via-orange-500/10 to-transparent shadow-[0_0_28px_rgba(245,158,11,0.12)]';
case 'epic':
return 'border-fuchsia-300/40 bg-gradient-to-br from-fuchsia-500/16 via-indigo-500/10 to-transparent shadow-[0_0_26px_rgba(217,70,239,0.1)]';
case 'rare':
return 'border-sky-300/40 bg-gradient-to-br from-sky-500/16 via-cyan-500/10 to-transparent shadow-[0_0_24px_rgba(56,189,248,0.08)]';
case 'uncommon':
return 'border-emerald-300/35 bg-gradient-to-br from-emerald-500/14 via-lime-500/8 to-transparent';
default:
return 'border-white/10 bg-white/[0.04]';
}
}
function buildRewardItemDescription(item: InventoryItem) {
return buildInventoryItemDescription(item);
}
function getQuestObjectivePresentation(
quest: QuestLogEntry,
worldType: WorldType | null,
sceneName: string,
) {
const hostileNpcPreset =
worldType && quest.objective.targetHostileNpcId
? getHostileNpcPresetById(worldType, quest.objective.targetHostileNpcId)
: null;
switch (quest.objective.kind) {
case 'defeat_hostile_npc':
return {
eyebrow: '悬赏目标',
accentClass: 'from-rose-500/20 via-orange-500/10 to-transparent',
labelClass: 'border-rose-300/25 bg-rose-500/12 text-rose-50',
primaryLabel:
hostileNpcPreset?.name ??
quest.objective.targetHostileNpcId ??
'未知敌对角色',
secondaryLabel: sceneName,
hostileNpcPreset,
iconSrc: null as string | null,
};
case 'inspect_treasure':
return {
eyebrow: '宝藏踪迹',
accentClass: 'from-amber-500/20 via-yellow-500/10 to-transparent',
labelClass: 'border-amber-300/25 bg-amber-500/12 text-amber-50',
primaryLabel: sceneName,
secondaryLabel: '探索隐藏的奖励地点',
hostileNpcPreset: null,
iconSrc: '/Icons/47_treasure.png',
};
case 'spar_with_npc':
default:
return {
eyebrow: '切磋会话',
accentClass: 'from-sky-500/20 via-cyan-500/10 to-transparent',
labelClass: 'border-sky-300/25 bg-sky-500/12 text-sky-50',
primaryLabel: quest.issuerNpcName,
secondaryLabel: sceneName,
hostileNpcPreset: null,
iconSrc: '/UI/1_weapon.png',
};
}
}
function RewardItemIconGrid({
items,
selectedItemId,
onSelectItem,
emptyText,
}: {
items: InventoryItem[];
selectedItemId: string | null;
onSelectItem: (itemId: string) => void;
emptyText: string;
}) {
if (items.length === 0) {
return (
<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 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 sm:grid-cols-3">
<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 className="rounded-xl border border-sky-300/18 bg-sky-500/10 px-3 py-2.5 text-sky-50">
<div className="flex items-center gap-2">
<BarChart3 className="h-4 w-4" />
<span className="text-sm font-semibold">
+{quest.reward.experience ?? 0}
</span>
</div>
<div className="mt-1 text-[10px] uppercase tracking-[0.2em] text-sky-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,
);
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"
/>
)}
</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="text-[10px] uppercase tracking-[0.18em] text-zinc-500">
</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" />
</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></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>
);
}
export function RpgAdventurePanelOverlays({
worldType,
quests,
questUi,
battleRewardUi,
statistics,
statisticsCards,
musicVolume,
onMusicVolumeChange,
onSaveAndExit,
saveAndExitDisabled,
isGoalPanelOpen,
setIsGoalPanelOpen,
isQuestPanelOpen,
setIsQuestPanelOpen,
isSettingsPanelOpen,
setIsSettingsPanelOpen,
isStatsPanelOpen,
setIsStatsPanelOpen,
journeyBeat,
goalStack,
goalPulse,
onDismissGoalPulse,
selectedQuest,
setSelectedQuestId,
completionNoticeQuest,
setCompletionNoticeQuestId,
rewardQuest,
setRewardQuestId,
rewardQuestHandoff,
setRewardQuestHandoff,
selectedRewardItemQuestId,
setSelectedRewardItemQuestId,
selectedRewardItemId,
setSelectedRewardItemId,
selectedBattleRewardItemId,
setSelectedBattleRewardItemId,
selectedRewardItem,
selectedRewardUseEffect,
selectedRewardEquipSlot,
selectedQuestSceneName,
getQuestStatusLabel,
pendingNpcQuestOffer,
onAcceptPendingNpcQuestOffer,
}: RpgAdventurePanelOverlaysProps) {
const battleReward = battleRewardUi.reward;
const sortedQuests = sortQuestsForGoalPanel(quests, goalStack);
const activeGoalQuest =
goalStack.activeGoal?.sourceKind === 'quest'
? (quests.find((quest) => quest.id === goalStack.activeGoal?.sourceId) ??
null)
: null;
const shouldShowQuestUpdateModal = Boolean(
activeGoalQuest && isGoalPanelOpen,
);
const selectQuestRewardItem = (quest: QuestLogEntry, itemId: string) => {
setSelectedBattleRewardItemId(null);
setSelectedRewardItemQuestId(quest.id);
setSelectedRewardItemId(itemId);
};
const isPendingSelectedQuest = Boolean(
selectedQuest && pendingNpcQuestOffer?.id === selectedQuest.id,
);
const closeGoalPanel = () => {
setIsGoalPanelOpen(false);
onDismissGoalPulse();
};
return (
<>
<AnimatePresence>
{shouldShowQuestUpdateModal && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[77] flex items-center justify-center bg-black/72 p-3 backdrop-blur-sm sm:p-4"
onClick={closeGoalPanel}
>
<motion.div
initial={{ opacity: 0, scale: 0.96, y: 8 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.96, y: 8 }}
transition={{ duration: 0.18, ease: 'easeOut' }}
className="pixel-nine-slice pixel-modal-shell flex max-h-[min(84vh,34rem)] w-full max-w-md flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.55)]"
style={getNineSliceStyle(UI_CHROME.modalPanel)}
onClick={(event) => event.stopPropagation()}
>
<div className="relative border-b border-white/10 px-4 py-3 sm:px-5 sm:py-4">
<div className="min-w-0 pr-10">
<div className="text-sm font-semibold text-white">
{goalPulse ? '任务更新' : '当前任务'}
</div>
<div className="mt-1 text-[11px] text-zinc-500">
</div>
</div>
<PixelCloseButton onClick={closeGoalPanel} label="关闭任务更新" />
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-4 scrollbar-hide">
<GoalFocusCard
goalStack={goalStack}
goalPulse={goalPulse}
journeyBeat={journeyBeat}
sceneName={statistics.currentSceneName}
/>
</div>
<div className="flex items-center justify-end gap-2 border-t border-white/10 px-4 py-3 sm:px-5">
{goalStack.activeGoal?.sourceKind === 'quest' ||
goalStack.immediateStepGoal?.sourceKind === 'quest' ? (
<button
type="button"
onClick={() => {
closeGoalPanel();
setIsQuestPanelOpen(true);
}}
className="rounded-full border border-white/12 bg-black/20 px-3 py-1.5 text-[11px] text-zinc-200 transition hover:text-white"
>
</button>
) : null}
<button
type="button"
onClick={closeGoalPanel}
className="pixel-nine-slice pixel-pressable px-4 py-2 text-xs text-white"
style={getNineSliceStyle(UI_CHROME.choiceButton, {
paddingX: 14,
paddingY: 8,
})}
>
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
<AnimatePresence>
{isSettingsPanelOpen && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[75] flex items-center justify-center bg-black/72 p-3 backdrop-blur-sm sm:p-4"
onClick={() => setIsSettingsPanelOpen(false)}
>
<motion.div
initial={{ opacity: 0, scale: 0.96, y: 8 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.96, y: 8 }}
transition={{ duration: 0.18, ease: 'easeOut' }}
className="pixel-nine-slice pixel-modal-shell flex max-h-[min(88vh,36rem)] w-full max-w-md flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.55)]"
style={getNineSliceStyle(UI_CHROME.modalPanel)}
onClick={(event) => event.stopPropagation()}
>
<div className="relative border-b border-white/10 px-4 py-3 sm:px-5 sm:py-4">
<div className="min-w-0 pr-10">
<div className="text-sm font-semibold text-white">
</div>
<div className="mt-1 text-[11px] text-zinc-500">
退
</div>
</div>
<PixelCloseButton
onClick={() => setIsSettingsPanelOpen(false)}
label="关闭冒险设置"
/>
</div>
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto p-4">
<div className="rounded-2xl border border-white/10 bg-[radial-gradient(circle_at_top,rgba(56,189,248,0.14),transparent_65%),rgba(0,0,0,0.24)] p-4">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-[10px] tracking-[0.24em] text-sky-200/80">
</div>
<div className="mt-2 text-sm font-semibold text-white">
</div>
</div>
<Volume2 className="h-4 w-4 text-sky-200/75" />
</div>
<div className="mt-4 flex items-center gap-3">
<input
type="range"
min={0}
max={100}
step={1}
value={Math.round(musicVolume * 100)}
onChange={(event) =>
onMusicVolumeChange(
Number(event.currentTarget.value) / 100,
)
}
className="h-2 w-full cursor-pointer accent-sky-400"
/>
<div className="min-w-[3rem] rounded-full border border-white/10 bg-black/30 px-2 py-1 text-center text-xs text-white">
{Math.round(musicVolume * 100)}%
</div>
</div>
</div>
<button
type="button"
onClick={() => setIsStatsPanelOpen(true)}
className="w-full rounded-2xl border border-white/10 bg-black/25 px-4 py-3 text-left transition hover:border-white/20"
>
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-sm font-semibold text-white">
</div>
<div className="mt-1 text-[11px] text-zinc-500">
</div>
</div>
<BarChart3 className="h-4 w-4 text-amber-200/75" />
</div>
</button>
<button
type="button"
disabled={saveAndExitDisabled}
onClick={() => {
if (saveAndExitDisabled) return;
setIsStatsPanelOpen(false);
setIsSettingsPanelOpen(false);
onSaveAndExit();
}}
className={`w-full rounded-2xl border px-4 py-3 text-left transition ${
saveAndExitDisabled
? 'border-white/8 bg-black/20 text-zinc-500'
: 'border-rose-300/18 bg-rose-500/10 text-white hover:border-rose-300/30'
}`}
>
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-sm font-semibold">退</div>
<div className="mt-1 text-[11px] text-zinc-400">
</div>
</div>
<LogOut className="h-4 w-4" />
</div>
</button>
{saveAndExitDisabled && (
<div className="rounded-xl border border-dashed border-white/10 bg-black/18 px-3 py-3 text-[11px] leading-relaxed text-zinc-500">
</div>
)}
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
<AnimatePresence>
{isStatsPanelOpen && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[76] flex items-center justify-center bg-black/76 p-3 backdrop-blur-sm sm:p-4"
onClick={() => setIsStatsPanelOpen(false)}
>
<motion.div
initial={{ opacity: 0, scale: 0.96, y: 8 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.96, y: 8 }}
transition={{ duration: 0.18, ease: 'easeOut' }}
className="pixel-nine-slice pixel-modal-shell flex max-h-[min(90vh,42rem)] w-full max-w-2xl flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.55)]"
style={getNineSliceStyle(UI_CHROME.modalPanel)}
onClick={(event) => event.stopPropagation()}
>
<div className="relative border-b border-white/10 px-4 py-3 sm:px-5 sm:py-4">
<div className="min-w-0 pr-10">
<div className="text-sm font-semibold text-white">
</div>
<div className="mt-1 text-[11px] text-zinc-500">
: {statistics.currentSceneName}
</div>
</div>
<PixelCloseButton
onClick={() => setIsStatsPanelOpen(false)}
label="关闭冒险统计"
/>
</div>
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto p-4">
<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-4">
<div className="text-[10px] tracking-[0.24em] text-amber-200/80">
</div>
<div className="mt-2 text-sm leading-relaxed text-zinc-300">
{statistics.hostileNpcsDefeated} {' '}
{statistics.inventoryItemCount} {' '}
{statistics.scenesTraveled}
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
{statisticsCards.map((card) => {
const Icon = card.icon;
return (
<div
key={card.key}
className="rounded-2xl border border-white/10 bg-black/22 px-4 py-3"
>
<div className="flex items-center justify-between gap-3 text-zinc-400">
<span className="text-[10px] tracking-[0.18em]">
{card.label}
</span>
<Icon className="h-4 w-4" />
</div>
<div className="mt-3 text-lg font-semibold text-white">
{card.value}
</div>
<div className="mt-1 text-[11px] leading-relaxed text-zinc-500">
{card.detail}
</div>
</div>
);
})}
</div>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
<AnimatePresence>
{isQuestPanelOpen && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[68] flex items-center justify-center bg-black/72 p-3 backdrop-blur-sm sm:p-4"
onClick={() => {
setIsQuestPanelOpen(false);
setSelectedQuestId(null);
}}
>
<motion.div
initial={{ opacity: 0, scale: 0.96, y: 8 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.96, y: 8 }}
transition={{ duration: 0.18, ease: 'easeOut' }}
className="pixel-nine-slice pixel-modal-shell flex max-h-[min(82vh,38rem)] w-full max-w-sm flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.55)]"
style={getNineSliceStyle(UI_CHROME.modalPanel)}
onClick={(event) => event.stopPropagation()}
>
<div className="relative border-b border-white/10 px-4 py-3 sm:px-5 sm:py-4">
<div className="min-w-0 pr-10">
<div className="text-sm font-semibold text-white">
</div>
<div className="mt-1 text-[11px] text-zinc-500">
: {quests.length}
</div>
</div>
<PixelCloseButton
onClick={() => {
setIsQuestPanelOpen(false);
setSelectedQuestId(null);
}}
label="关闭任务日志"
/>
</div>
<div className="flex-1 overflow-y-auto p-3 scrollbar-hide">
{quests.length > 0 ? (
<div className="space-y-2">
{sortedQuests.map((quest) => (
<div
key={quest.id}
className="rounded-2xl border border-white/8 bg-black/20 transition hover:border-white/15 focus-within:border-white/15"
>
<button
type="button"
onClick={() => setSelectedQuestId(quest.id)}
className="w-full px-3 pt-3 text-left focus:outline-none"
>
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-[10px] tracking-[0.2em] text-zinc-500">
</div>
<span
className={`rounded-full px-2 py-0.5 text-[10px] ${
isQuestReadyToClaim(quest)
? 'border border-emerald-400/20 bg-emerald-500/10 text-emerald-100'
: quest.status === 'turned_in'
? 'border border-white/10 bg-white/8 text-zinc-200'
: 'border border-sky-400/20 bg-sky-500/10 text-sky-100'
}`}
>
{getQuestStatusLabel(quest.status)}
</span>
</div>
<div className="mt-2 text-sm font-semibold text-white">
{formatTaskTitle(quest.title)}
</div>
<div className="mt-2 text-xs leading-relaxed text-zinc-400">
<span className="text-zinc-500"></span>
{quest.description ||
quest.narrativeBinding?.playerHook ||
quest.summary}
</div>
<div className="mt-1 text-xs leading-relaxed text-zinc-300">
<span className="text-zinc-500"></span>
{buildQuestConditionText(quest, worldType)}
</div>
<div className="mt-2 text-[11px] leading-relaxed text-zinc-500">
{quest.issuerNpcName}
{` · 任务进度:${getQuestProgressText(quest)}`}
</div>
</button>
<div className="px-3 pb-3">
<QuestRewardIconStrip
reward={quest.reward}
onSelectItem={(itemId) =>
selectQuestRewardItem(quest, itemId)
}
/>
</div>
</div>
))}
</div>
) : (
<div className="rounded-xl border border-dashed border-white/10 bg-black/20 px-4 py-6 text-center text-sm text-zinc-500">
</div>
)}
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
<AnimatePresence>
{selectedQuest && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[69] flex items-center justify-center bg-black/72 p-3 backdrop-blur-sm sm:p-4"
onClick={() => setSelectedQuestId(null)}
>
<motion.div
initial={{ opacity: 0, scale: 0.96, y: 8 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.96, y: 8 }}
transition={{ duration: 0.18, ease: 'easeOut' }}
className="pixel-nine-slice pixel-modal-shell flex max-h-[min(86vh,48rem)] w-full max-w-2xl flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.55)]"
style={getNineSliceStyle(UI_CHROME.modalPanel)}
onClick={(event) => event.stopPropagation()}
>
<div className="relative border-b border-white/10 px-4 py-3 sm:px-5 sm:py-4">
<div className="min-w-0 pr-10">
<div className="truncate text-sm font-semibold text-white">
{selectedQuest.title}
</div>
<div className="mt-1 text-[11px] text-zinc-500">
{selectedQuest.issuerNpcName}
</div>
</div>
<PixelCloseButton
onClick={() => setSelectedQuestId(null)}
label="关闭任务详情"
/>
</div>
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto p-4 scrollbar-hide sm:p-5">
<TaskTemplateCard
eyebrow="任务详情"
title={selectedQuest.title}
description={
selectedQuest.description ||
selectedQuest.narrativeBinding?.playerHook ||
selectedQuest.summary
}
condition={buildQuestConditionText(selectedQuest, worldType)}
progress={getQuestProgressText(selectedQuest)}
reward={selectedQuest.reward}
onRewardItemSelect={(itemId) =>
selectQuestRewardItem(selectedQuest, itemId)
}
tone="main"
/>
<div className="grid gap-3 lg:grid-cols-[minmax(0,1.25fr)_minmax(0,0.75fr)]">
<QuestObjectiveCard
quest={selectedQuest}
worldType={worldType}
sceneName={selectedQuestSceneName}
statusLabel={
isPendingSelectedQuest
? '待领取'
: getQuestStatusLabel(selectedQuest.status)
}
/>
<QuestRewardGrid
quest={selectedQuest}
worldType={worldType}
selectedItemId={
selectedRewardItemQuestId === selectedQuest.id
? selectedRewardItemId
: null
}
onSelectItem={(itemId) => {
setSelectedBattleRewardItemId(null);
setSelectedRewardItemQuestId(selectedQuest.id);
setSelectedRewardItemId(itemId);
}}
/>
</div>
{isPendingSelectedQuest && (
<div className="flex justify-end">
<button
type="button"
onClick={() => {
const acceptedQuestId = onAcceptPendingNpcQuestOffer();
if (!acceptedQuestId) return;
setSelectedRewardItemQuestId(null);
setSelectedRewardItemId(null);
setSelectedBattleRewardItemId(null);
}}
className="pixel-nine-slice pixel-pressable px-4 py-2 text-xs text-white"
style={getNineSliceStyle(UI_CHROME.choiceButton, {
paddingX: 14,
paddingY: 8,
})}
>
</button>
</div>
)}
{isQuestReadyToClaim(selectedQuest) &&
!isPendingSelectedQuest && (
<div className="flex justify-end">
<button
type="button"
onClick={() => setSelectedQuestId(null)}
className="pixel-nine-slice pixel-pressable px-4 py-2 text-xs text-white"
style={getNineSliceStyle(UI_CHROME.choiceButton, {
paddingX: 14,
paddingY: 8,
})}
>
</button>
</div>
)}
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
<AnimatePresence>
{completionNoticeQuest && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[70] flex items-center justify-center bg-black/70 p-4 backdrop-blur-sm"
onClick={() => {
questUi.acknowledgeQuestCompletion(completionNoticeQuest.id);
setCompletionNoticeQuestId(null);
}}
>
<motion.div
initial={{ opacity: 0, scale: 0.94, y: 10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.94, y: 10 }}
transition={{ duration: 0.18, ease: 'easeOut' }}
className="pixel-nine-slice pixel-modal-shell flex max-h-[min(92vh,32rem)] w-full max-w-sm flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.55)]"
style={getNineSliceStyle(UI_CHROME.modalPanel)}
onClick={(event) => event.stopPropagation()}
>
<div className="min-h-0 flex-1 space-y-3 overflow-y-auto px-5 py-5 text-center">
<div className="text-[11px] tracking-[0.24em] text-emerald-300">
</div>
<div className="text-lg font-semibold text-white">
</div>
<div className="text-sm text-zinc-300">
{completionNoticeQuest.title}
</div>
<div className="rounded-xl border border-emerald-400/15 bg-emerald-500/10 px-3 py-3 text-sm text-emerald-50">
{goalStack.immediateStepGoal?.sourceKind === 'quest'
? goalStack.immediateStepGoal.nextStepText
: '可前往任务日志领取奖励。'}
</div>
<div className="flex justify-center">
<button
type="button"
onClick={() => {
questUi.acknowledgeQuestCompletion(
completionNoticeQuest.id,
);
setCompletionNoticeQuestId(null);
setIsQuestPanelOpen(true);
}}
className="pixel-nine-slice pixel-pressable px-4 py-2 text-xs text-white"
style={getNineSliceStyle(UI_CHROME.choiceButton, {
paddingX: 14,
paddingY: 8,
})}
>
</button>
</div>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
<AnimatePresence>
{rewardQuest && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[71] flex items-center justify-center bg-black/72 p-3 backdrop-blur-sm sm:p-4"
onClick={() => {
setRewardQuestId(null);
setRewardQuestHandoff(null);
setSelectedRewardItemId(null);
setSelectedRewardItemQuestId(null);
}}
>
<motion.div
initial={{ opacity: 0, scale: 0.96, y: 8 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.96, y: 8 }}
transition={{ duration: 0.18, ease: 'easeOut' }}
className="pixel-nine-slice pixel-modal-shell flex max-h-[min(92vh,42rem)] w-full max-w-lg flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.55)]"
style={getNineSliceStyle(UI_CHROME.modalPanel)}
onClick={(event) => event.stopPropagation()}
>
<div className="relative border-b border-white/10 px-4 py-3 sm:px-5 sm:py-4">
<div className="min-w-0 pr-10">
<div className="text-sm font-semibold text-white">
</div>
<div className="mt-1 text-[11px] text-zinc-500">
{rewardQuest.title}
</div>
</div>
<PixelCloseButton
onClick={() => {
setRewardQuestId(null);
setRewardQuestHandoff(null);
setSelectedRewardItemId(null);
setSelectedRewardItemQuestId(null);
}}
label="关闭任务奖励"
/>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-4">
{rewardQuestHandoff ? (
<div className="mb-4 rounded-2xl border border-violet-300/15 bg-[radial-gradient(circle_at_top,rgba(139,92,246,0.14),transparent_65%),rgba(0,0,0,0.24)] p-3">
<div className="text-[10px] tracking-[0.24em] text-violet-200/80">
</div>
<div className="mt-2 text-sm font-semibold text-white">
{rewardQuestHandoff.title}
</div>
<div className="mt-2 text-sm leading-relaxed text-zinc-300">
{rewardQuestHandoff.detail}
</div>
</div>
) : null}
<QuestRewardGrid
quest={rewardQuest}
worldType={worldType}
selectedItemId={
selectedRewardItemQuestId === rewardQuest.id
? selectedRewardItemId
: null
}
onSelectItem={(itemId) => {
setSelectedBattleRewardItemId(null);
setSelectedRewardItemQuestId(rewardQuest.id);
setSelectedRewardItemId(itemId);
}}
/>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
<AnimatePresence>
{battleReward && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[73] flex items-center justify-center bg-black/72 p-3 backdrop-blur-sm sm:p-4"
onClick={() => {
battleRewardUi.dismiss();
setSelectedBattleRewardItemId(null);
}}
>
<motion.div
initial={{ opacity: 0, scale: 0.96, y: 8 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.96, y: 8 }}
transition={{ duration: 0.18, ease: 'easeOut' }}
className="pixel-nine-slice pixel-modal-shell flex max-h-[min(92vh,44rem)] w-full max-w-lg flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.55)]"
style={getNineSliceStyle(UI_CHROME.modalPanel)}
onClick={(event) => event.stopPropagation()}
>
<div className="relative border-b border-white/10 px-4 py-3 sm:px-5 sm:py-4">
<div className="min-w-0 pr-10">
<div className="text-sm font-semibold text-white">
</div>
<div className="mt-1 text-[11px] text-zinc-500">
: {battleReward.defeatedHostileNpcs.length}
</div>
</div>
<PixelCloseButton
onClick={() => {
battleRewardUi.dismiss();
setSelectedBattleRewardItemId(null);
}}
label="关闭战斗奖励"
/>
</div>
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto p-4">
<div className="rounded-2xl border border-emerald-300/15 bg-[radial-gradient(circle_at_top,rgba(16,185,129,0.14),transparent_65%),rgba(0,0,0,0.24)] p-3">
<div className="text-[10px] tracking-[0.24em] text-emerald-200/80">
</div>
<div className="mt-2 text-sm leading-relaxed text-zinc-300">
</div>
<div className="mt-3 flex flex-wrap gap-2">
{battleReward.defeatedHostileNpcs.map((hostileNpc) => (
<span
key={`${battleReward.id}-${hostileNpc.renderKey ?? hostileNpc.id}`}
className="rounded-full border border-emerald-300/20 bg-emerald-500/10 px-3 py-1 text-xs text-emerald-50"
>
{hostileNpc.name}
</span>
))}
</div>
</div>
<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">
{battleReward.items.length > 0
? '点击物品图标查看详情。'
: '本次战斗无可用战利品。'}
</div>
</div>
<PackageOpen className="h-4 w-4 text-amber-200/70" />
</div>
<RewardItemIconGrid
items={battleReward.items}
selectedItemId={selectedBattleRewardItemId}
onSelectItem={(itemId) => {
setSelectedRewardItemQuestId(null);
setSelectedRewardItemId(null);
setSelectedBattleRewardItemId(itemId);
}}
emptyText="本次战斗无战利品掉落。"
/>
</div>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
<AnimatePresence>
{selectedRewardItem && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[74] flex items-center justify-center bg-black/72 p-3 backdrop-blur-sm sm:p-4"
onClick={() => {
setSelectedRewardItemId(null);
setSelectedRewardItemQuestId(null);
setSelectedBattleRewardItemId(null);
}}
>
<motion.div
initial={{ opacity: 0, scale: 0.96, y: 8 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.96, y: 8 }}
transition={{ duration: 0.18, ease: 'easeOut' }}
className="pixel-nine-slice pixel-modal-shell flex max-h-[min(92vh,36rem)] w-full max-w-sm flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.55)]"
style={getNineSliceStyle(UI_CHROME.modalPanel)}
onClick={(event) => event.stopPropagation()}
>
<div className="relative border-b border-white/10 px-4 py-3 sm:px-5 sm:py-4">
<div className="min-w-0 pr-10">
<div className="truncate text-sm font-semibold text-white">
{selectedRewardItem.name}
</div>
<div className="mt-1 text-[11px] text-zinc-500">
{selectedRewardItem.category}
</div>
</div>
<PixelCloseButton
onClick={() => {
setSelectedRewardItemId(null);
setSelectedRewardItemQuestId(null);
setSelectedBattleRewardItemId(null);
}}
label="关闭奖励物品"
/>
</div>
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto p-4">
<div className="flex items-center gap-3">
<div
className={`flex h-[4.5rem] w-[4.5rem] items-center justify-center rounded-2xl border bg-black/25 ${getRewardItemFrameClass(selectedRewardItem.rarity)}`}
>
<PixelIcon
src={getQuestRewardItemIcon(selectedRewardItem)}
className="h-10 w-10"
/>
</div>
<div className="min-w-0 flex-1 space-y-1 text-sm text-zinc-300">
<div>
: {getRarityLabel(selectedRewardItem.rarity)}
</div>
<div>: {selectedRewardItem.quantity}</div>
<div>
{selectedRewardEquipSlot
? `装备槽: ${selectedRewardEquipSlot}`
: '不可装备'}
</div>
<div>
{isInventoryItemUsable(selectedRewardItem)
? '可直接使用'
: '被动/非即时生效物品'}
</div>
</div>
</div>
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-3 text-sm leading-relaxed text-zinc-300">
{buildRewardItemDescription(selectedRewardItem)}
</div>
{selectedRewardUseEffect && (
<div className="rounded-xl border border-emerald-400/15 bg-emerald-500/10 px-3 py-3 text-xs text-emerald-50">
+{selectedRewardUseEffect.hpRestore} / +
{selectedRewardUseEffect.manaRestore} / -
{selectedRewardUseEffect.cooldownReduction}
</div>
)}
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-3 text-xs text-zinc-300">
:{' '}
{getInventoryTagLabels(selectedRewardItem.tags).join(' / ') ||
'无'}
</div>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</>
);
}