Implement scene-based chapter quest progression
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-08 11:58:47 +08:00
parent 9d2fc9e4b8
commit bd9fdcbe31
170 changed files with 18259 additions and 1049 deletions

View File

@@ -14,20 +14,32 @@ 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/useStoryGeneration';
import { sortQuestsForGoalPanel } from '../../services/storyEngine/goalDirector';
import type {
CampEvent,
ChapterState,
EquipmentSlotId,
GoalHandoff,
GoalPulseEvent,
GoalStackState,
InventoryItem,
JourneyBeat,
QuestLogEntry,
SetpieceDirective,
WorldType,
} from '../../types';
import {CHROME_ICONS, getInventoryCategoryIcon, getNineSliceStyle, UI_CHROME} from '../../uiAssets';
import {
CHROME_ICONS,
getInventoryItemVisualSrc,
getNineSliceStyle,
UI_CHROME,
} from '../../uiAssets';
import {HostileNpcAnimator} from '../HostileNpcAnimator';
import {PixelIcon} from '../PixelIcon';
@@ -66,8 +78,8 @@ interface AdventurePanelOverlaysProps {
onMusicVolumeChange: (value: number) => void;
onSaveAndExit: () => void;
saveAndExitDisabled: boolean;
isChapterPanelOpen: boolean;
setIsChapterPanelOpen: (open: boolean) => void;
isGoalPanelOpen: boolean;
setIsGoalPanelOpen: (open: boolean) => void;
isQuestPanelOpen: boolean;
setIsQuestPanelOpen: (open: boolean) => void;
isSettingsPanelOpen: boolean;
@@ -76,15 +88,17 @@ interface AdventurePanelOverlaysProps {
setIsStatsPanelOpen: (open: boolean) => void;
chapterState: ChapterState | null;
journeyBeat: JourneyBeat | null;
recentChronicleSummary: string | null;
currentCampEvent: CampEvent | null;
setpieceDirective: SetpieceDirective | 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;
@@ -98,87 +112,402 @@ interface AdventurePanelOverlaysProps {
getQuestStatusLabel: (status: QuestLogEntry['status']) => string;
}
function getChapterStageLabel(stage: ChapterState['stage'] | null | undefined) {
switch (stage) {
case 'opening':
return '开篇';
case 'expansion':
return '展开';
case 'turning_point':
return '转折';
case 'climax':
return '高潮';
case 'aftermath':
return '余波';
default:
return '进行中';
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 getJourneyBeatLabel(beatType: JourneyBeat['beatType'] | null | undefined) {
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 '接近';
return {
title: `前往${sceneLabel}`,
description: `${sceneLabel} 一带出现了值得跟进的新线索,继续靠近,看看那里到底发生了什么。`,
condition: `前往 ${sceneName},确认新的线索。`,
progress: '靠近线索',
};
case 'investigation':
return '调查';
return {
title: `调查${sceneLabel}`,
description: `${sceneLabel} 出现了新的异常和痕迹,继续调查,查清这里到底隐藏着什么。`,
condition: `${sceneName} 调查线索或异常。`,
progress: '调查进行中',
};
case 'camp':
return '休整';
return {
title: '回营整备',
description: '先整理队伍、资源和状态,再决定下一段任务的推进方式。',
condition: '返回营地,整理队伍或与同伴交谈。',
progress: '整备中',
};
case 'conflict':
return '冲突';
return {
title: `处理${sceneLabel}`,
description: `${sceneLabel} 的冲突已经浮出水面,需要继续推进并正面处理。`,
condition: `${sceneName} 处理当前冲突。`,
progress: '冲突处理中',
};
case 'boss_prelude':
return '决战前奏';
return {
title: `备战${sceneLabel}`,
description: '关键战斗已经逼近,先把线索和状态准备好。',
condition: fallbackCondition,
progress: '战前准备',
};
case 'climax':
return '高潮';
return {
title: `决断${sceneLabel}`,
description: '决定结果的对峙已经临近,继续推进到最终现场。',
condition: fallbackCondition,
progress: '决战临近',
};
case 'recovery':
return '恢复';
return {
title: '收束结果',
description: '刚结束的事件还在留下影响,先整理结果,再决定下一步去向。',
condition: fallbackCondition,
progress: '结果收束中',
};
default:
return '旅程';
return {
title: '继续推进',
description: `${sceneLabel} 一带还有没查清的事,继续推进当前线索。`,
condition: fallbackCondition,
progress: '推进中',
};
}
}
function getCampEventLabel(eventType: CampEvent['eventType'] | null | undefined) {
switch (eventType) {
case 'private_talk':
return '私话';
case 'party_banter':
return '同行插话';
case 'conflict':
return '争执';
case 'comfort':
return '安抚';
case 'reveal':
return '透露';
case 'decision':
return '抉择';
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 '营地事件';
return activeStep?.revealText ?? quest.summary;
}
}
function getSetpieceLabel(setpieceType: SetpieceDirective['setpieceType'] | null | undefined) {
switch (setpieceType) {
case 'boss_prelude':
return '决战前奏';
case 'showdown':
return '对峙';
case 'climax':
return '高潮';
case 'aftermath':
return '余波';
default:
return '剧情节点';
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.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) {
if (item.iconSrc) return item.iconSrc;
if (item.tags.includes('weapon')) return '/UI/Icon_Eq_Weapon.png';
if (item.tags.includes('armor')) return '/UI/Icon_Eq_Chest.png';
if (item.tags.includes('relic')) return '/Icons/47_treasure.png';
if (item.tags.includes('healing')) return '/Icons/12_potion.png';
if (item.tags.includes('mana')) return '/UI/Hud_icon_magic.png';
if (item.tags.includes('material')) return '/Icons/45_crystal.png';
return getInventoryCategoryIcon(item.category);
return getInventoryItemVisualSrc(item);
}
function getRewardItemFrameClass(rarity: InventoryItem['rarity']) {
@@ -197,21 +526,7 @@ function getRewardItemFrameClass(rarity: InventoryItem['rarity']) {
}
function buildRewardItemDescription(item: InventoryItem) {
if (item.description?.trim()) return item.description;
const traits: string[] = [];
if (item.tags.includes('healing')) traits.push('在冒险中恢复生命值');
if (item.tags.includes('mana')) traits.push('恢复法力值或技能节奏');
if (item.tags.includes('weapon')) traits.push('适合进攻型构筑');
if (item.tags.includes('armor')) traits.push('适合防御型构筑');
if (item.tags.includes('relic')) traits.push('作为稀有遗物奖励');
if (item.tags.includes('material')) traits.push('可用于制作');
if (traits.length === 0) {
return `${item.name}${item.category} 奖励物品,可用于后续路线规划、交易或构筑规划。`;
}
return `${item.name}${item.category} 奖励物品,${traits.join('')}`;
return buildInventoryItemDescription(item);
}
function getQuestObjectivePresentation(quest: QuestLogEntry, worldType: WorldType | null, sceneName: string) {
@@ -437,7 +752,7 @@ function QuestObjectiveCard({
<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="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">
@@ -481,8 +796,8 @@ export function AdventurePanelOverlays({
onMusicVolumeChange,
onSaveAndExit,
saveAndExitDisabled,
isChapterPanelOpen,
setIsChapterPanelOpen,
isGoalPanelOpen,
setIsGoalPanelOpen,
isQuestPanelOpen,
setIsQuestPanelOpen,
isSettingsPanelOpen,
@@ -491,15 +806,17 @@ export function AdventurePanelOverlays({
setIsStatsPanelOpen,
chapterState,
journeyBeat,
recentChronicleSummary,
currentCampEvent,
setpieceDirective,
goalStack,
goalPulse,
onDismissGoalPulse,
selectedQuest,
setSelectedQuestId,
completionNoticeQuest,
setCompletionNoticeQuestId,
rewardQuest,
setRewardQuestId,
rewardQuestHandoff,
setRewardQuestHandoff,
selectedRewardItemQuestId,
setSelectedRewardItemQuestId,
selectedRewardItemId,
@@ -513,117 +830,90 @@ export function AdventurePanelOverlays({
getQuestStatusLabel,
}: AdventurePanelOverlaysProps) {
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 closeGoalPanel = () => {
setIsGoalPanelOpen(false);
onDismissGoalPulse();
};
return (
<>
<AnimatePresence>
{isChapterPanelOpen && (
{shouldShowQuestUpdateModal && (
<motion.div
initial={{opacity: 0}}
animate={{opacity: 1}}
exit={{opacity: 0}}
className="fixed inset-0 z-[76] flex items-center justify-center bg-black/72 p-3 backdrop-blur-sm sm:p-4"
onClick={() => setIsChapterPanelOpen(false)}
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(86vh,40rem)] w-full max-w-lg flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.55)]"
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">
{chapterState?.title ?? '当前章节'}
{goalPulse ? '任务更新' : '当前任务'}
</div>
<div className="mt-1 text-[11px] text-zinc-500">
</div>
</div>
<button
type="button"
onClick={() => setIsChapterPanelOpen(false)}
onClick={closeGoalPanel}
className="absolute right-4 top-3 p-1 text-zinc-400 transition-colors hover:text-white sm:right-5 sm:top-4"
>
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
</button>
</div>
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto p-4 scrollbar-hide">
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3.5">
<div className="text-[10px] tracking-[0.24em] text-zinc-500">
</div>
<div className="mt-2 text-lg font-semibold text-white">
{chapterState?.title ?? '旅程推进中'}
</div>
{chapterState && (
<div className="mt-2 inline-flex rounded-full border border-white/10 bg-white/5 px-2.5 py-1 text-[10px] text-zinc-300">
{getChapterStageLabel(chapterState.stage)}
</div>
)}
{chapterState?.chapterSummary && (
<div className="mt-3 text-sm leading-relaxed text-zinc-300">
{chapterState.chapterSummary}
</div>
)}
</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>
{journeyBeat && (
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3.5">
<div className="text-[10px] tracking-[0.24em] text-zinc-500">
</div>
<div className="mt-2 text-sm font-semibold text-white">
{getJourneyBeatLabel(journeyBeat.beatType)} · {journeyBeat.title}
</div>
<div className="mt-2 text-sm leading-relaxed text-zinc-300">
{journeyBeat.emotionalGoal}
</div>
</div>
)}
{currentCampEvent && (
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3.5">
<div className="text-[10px] tracking-[0.24em] text-zinc-500">
</div>
<div className="mt-2 text-sm font-semibold text-white">
{getCampEventLabel(currentCampEvent.eventType)} · {currentCampEvent.title}
</div>
<div className="mt-2 text-sm leading-relaxed text-zinc-300">
{currentCampEvent.triggerReason}
</div>
</div>
)}
{setpieceDirective && (
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3.5">
<div className="text-[10px] tracking-[0.24em] text-zinc-500">
</div>
<div className="mt-2 text-sm font-semibold text-white">
{getSetpieceLabel(setpieceDirective.setpieceType)} · {setpieceDirective.title}
</div>
<div className="mt-2 text-sm leading-relaxed text-zinc-300">
{setpieceDirective.dramaticQuestion}
</div>
</div>
)}
{recentChronicleSummary && (
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3.5">
<div className="text-[10px] tracking-[0.24em] text-zinc-500">
</div>
<div className="mt-2 whitespace-pre-wrap text-sm leading-relaxed text-zinc-300">
{recentChronicleSummary}
</div>
</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>
@@ -845,20 +1135,53 @@ export function AdventurePanelOverlays({
</div>
<div className="flex-1 overflow-y-auto p-3 scrollbar-hide">
{(() => {
if (!activeGoalQuest) {
return null;
}
const currentTaskCard = buildCurrentTaskCardCopy({
goalStack,
goalPulse,
journeyBeat,
sceneName: statistics.currentSceneName,
});
if (!currentTaskCard) {
return null;
}
return (
<TaskTemplateCard
eyebrow={currentTaskCard.eyebrow}
title={currentTaskCard.title}
description={currentTaskCard.description}
condition={currentTaskCard.condition}
progress={currentTaskCard.progress}
reward={activeGoalQuest?.reward ?? null}
onRewardItemSelect={
activeGoalQuest
? itemId => selectQuestRewardItem(activeGoalQuest, itemId)
: null
}
tone="main"
/>
);
})()}
{quests.length > 0 ? (
<div className="space-y-2">
{quests.map(quest => (
<div className={`${activeGoalQuest ? 'mt-3' : ''} space-y-2`}>
{sortedQuests.map(quest => (
<button
key={quest.id}
type="button"
onClick={() => setSelectedQuestId(quest.id)}
className="w-full rounded-xl border border-white/8 bg-black/20 px-3 py-2.5 text-left transition hover:border-white/15"
className="w-full rounded-2xl border border-white/8 bg-black/20 px-3 py-3 text-left transition hover:border-white/15"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold text-white">{quest.title}</div>
<div className="mt-1 text-[11px] text-zinc-500">{quest.issuerNpcName}</div>
<div className="mt-1 text-xs leading-relaxed text-zinc-400">{quest.summary}</div>
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-[10px] tracking-[0.2em] text-zinc-500">
{goalStack.activeGoal?.sourceKind === 'quest' && goalStack.activeGoal.sourceId === quest.id
? '当前主任务'
: '任务'}
</div>
<span className={`rounded-full px-2 py-0.5 text-[10px] ${
isQuestReadyToClaim(quest)
@@ -870,6 +1193,25 @@ export function AdventurePanelOverlays({
{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>
<QuestRewardIconStrip
reward={quest.reward}
onSelectItem={itemId => selectQuestRewardItem(quest, itemId)}
/>
</button>
))}
</div>
@@ -917,10 +1259,16 @@ export function AdventurePanelOverlays({
</div>
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto p-4 scrollbar-hide sm:p-5">
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3.5">
<div className="text-[10px] tracking-[0.24em] text-zinc-500"></div>
<div className="mt-2 text-sm leading-relaxed text-zinc-300">{selectedQuest.description}</div>
</div>
<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
@@ -950,6 +1298,7 @@ export function AdventurePanelOverlays({
if (!claimed) return;
setSelectedBattleRewardItemId(null);
setRewardQuestId(selectedQuest.id);
setRewardQuestHandoff(claimed.handoff);
setSelectedRewardItemQuestId(selectedQuest.id);
setSelectedRewardItemId(selectedQuest.reward.items[0]?.id ?? null);
}}
@@ -992,7 +1341,9 @@ export function AdventurePanelOverlays({
<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
@@ -1023,6 +1374,7 @@ export function AdventurePanelOverlays({
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);
}}
@@ -1045,6 +1397,7 @@ export function AdventurePanelOverlays({
type="button"
onClick={() => {
setRewardQuestId(null);
setRewardQuestHandoff(null);
setSelectedRewardItemId(null);
setSelectedRewardItemQuestId(null);
}}
@@ -1055,6 +1408,19 @@ export function AdventurePanelOverlays({
</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}
@@ -1223,7 +1589,7 @@ export function AdventurePanelOverlays({
)}
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-3 text-xs text-zinc-300">
: {selectedRewardItem.tags.join(' / ') || '无'}
: {getInventoryTagLabels(selectedRewardItem.tags).join(' / ') || '无'}
</div>
</div>
</motion.div>