Implement scene-based chapter quest progression
Some checks failed
CI / verify (push) Has been cancelled
Some checks failed
CI / verify (push) Has been cancelled
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user