1758 lines
66 KiB
TypeScript
1758 lines
66 KiB
TypeScript
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>
|
||
</>
|
||
);
|
||
}
|