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:
@@ -30,14 +30,15 @@ import { getScenePresetById } from '../data/scenePresets';
|
||||
import { getOptionImpactSummary } from '../hooks/combatStoryUtils';
|
||||
import type { BattleRewardUi, QuestFlowUi } from '../hooks/useStoryGeneration';
|
||||
import type {
|
||||
CampEvent,
|
||||
ChapterState,
|
||||
Character,
|
||||
GoalHandoff,
|
||||
GoalPulseEvent,
|
||||
GoalStackState,
|
||||
InventoryItem,
|
||||
JourneyBeat,
|
||||
NpcBattleMode,
|
||||
QuestLogEntry,
|
||||
SetpieceDirective,
|
||||
StoryMoment,
|
||||
StoryOption,
|
||||
WorldType,
|
||||
@@ -67,6 +68,9 @@ interface AdventurePanelProps {
|
||||
worldType: WorldType | null;
|
||||
quests: QuestLogEntry[];
|
||||
questUi: QuestFlowUi;
|
||||
goalStack: GoalStackState;
|
||||
goalPulse: GoalPulseEvent | null;
|
||||
onDismissGoalPulse: () => void;
|
||||
battleRewardUi: BattleRewardUi;
|
||||
playerHp: number;
|
||||
playerMaxHp: number;
|
||||
@@ -95,9 +99,6 @@ interface AdventurePanelProps {
|
||||
onSaveAndExit: () => void;
|
||||
chapterState?: ChapterState | null;
|
||||
journeyBeat?: JourneyBeat | null;
|
||||
recentChronicleSummary?: string | null;
|
||||
currentCampEvent?: CampEvent | null;
|
||||
setpieceDirective?: SetpieceDirective | null;
|
||||
}
|
||||
|
||||
const AdventurePanelOverlays = lazy(async () => {
|
||||
@@ -250,6 +251,19 @@ function formatPlayTime(playTimeMs: number) {
|
||||
return `${minutes}分${String(seconds).padStart(2, '0')}秒`;
|
||||
}
|
||||
|
||||
function getOptionGoalAffordanceClass(option: StoryOption) {
|
||||
switch (option.goalAffordance?.relation) {
|
||||
case 'advance':
|
||||
return 'text-amber-200/85';
|
||||
case 'support':
|
||||
return 'text-sky-200/80';
|
||||
case 'detour':
|
||||
return 'text-zinc-400';
|
||||
default:
|
||||
return 'text-zinc-500';
|
||||
}
|
||||
}
|
||||
|
||||
function RewardItemIconGrid({
|
||||
items,
|
||||
selectedItemId,
|
||||
@@ -589,6 +603,9 @@ export function AdventurePanel({
|
||||
worldType,
|
||||
quests,
|
||||
questUi,
|
||||
goalStack,
|
||||
goalPulse,
|
||||
onDismissGoalPulse,
|
||||
battleRewardUi,
|
||||
playerHp,
|
||||
playerMaxHp,
|
||||
@@ -602,9 +619,6 @@ export function AdventurePanel({
|
||||
onSaveAndExit,
|
||||
chapterState = null,
|
||||
journeyBeat = null,
|
||||
recentChronicleSummary = null,
|
||||
currentCampEvent = null,
|
||||
setpieceDirective = null,
|
||||
}: AdventurePanelProps) {
|
||||
const isDialogueStory = currentStory.displayMode === 'dialogue';
|
||||
const dialogueTurns = currentStory.dialogue ?? [];
|
||||
@@ -615,7 +629,12 @@ export function AdventurePanel({
|
||||
currentStory.deferredOptions?.length,
|
||||
);
|
||||
const saveAndExitDisabled = isLoading || isStoryStreaming;
|
||||
const [isChapterPanelOpen, setIsChapterPanelOpen] = useState(false);
|
||||
const primaryQuestGoal = goalStack.activeGoal?.sourceKind === 'quest'
|
||||
? goalStack.activeGoal
|
||||
: goalStack.immediateStepGoal?.sourceKind === 'quest'
|
||||
? goalStack.immediateStepGoal
|
||||
: null;
|
||||
const [isGoalPanelOpen, setIsGoalPanelOpen] = useState(false);
|
||||
const [isQuestPanelOpen, setIsQuestPanelOpen] = useState(false);
|
||||
const [isSettingsPanelOpen, setIsSettingsPanelOpen] = useState(false);
|
||||
const [isStatsPanelOpen, setIsStatsPanelOpen] = useState(false);
|
||||
@@ -627,6 +646,7 @@ export function AdventurePanel({
|
||||
string | null
|
||||
>(null);
|
||||
const [rewardQuestId, setRewardQuestId] = useState<string | null>(null);
|
||||
const [rewardQuestHandoff, setRewardQuestHandoff] = useState<GoalHandoff | null>(null);
|
||||
const [selectedRewardItemQuestId, setSelectedRewardItemQuestId] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
@@ -636,6 +656,8 @@ export function AdventurePanel({
|
||||
const [selectedBattleRewardItemId, setSelectedBattleRewardItemId] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const lastAutoOpenedGoalRef = useRef<string | null>(null);
|
||||
const lastAutoOpenedPulseRef = useRef<string | null>(null);
|
||||
const battleReward = battleRewardUi.reward;
|
||||
const hasCompletedQuest = useMemo(
|
||||
() => quests.some((quest) => isQuestReadyToClaim(quest)),
|
||||
@@ -712,6 +734,32 @@ export function AdventurePanel({
|
||||
setSelectedBattleRewardItemId(null);
|
||||
}, [battleReward]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!primaryQuestGoal) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (lastAutoOpenedGoalRef.current === primaryQuestGoal.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastAutoOpenedGoalRef.current = primaryQuestGoal.id;
|
||||
setIsGoalPanelOpen(true);
|
||||
}, [primaryQuestGoal]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!goalPulse) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (lastAutoOpenedPulseRef.current === goalPulse.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastAutoOpenedPulseRef.current = goalPulse.id;
|
||||
setIsGoalPanelOpen(true);
|
||||
}, [goalPulse]);
|
||||
|
||||
useEffect(() => {
|
||||
const container = storyScrollContainerRef.current;
|
||||
if (!container) return;
|
||||
@@ -813,7 +861,7 @@ export function AdventurePanel({
|
||||
[statistics],
|
||||
);
|
||||
const shouldMountAdventureOverlays =
|
||||
isChapterPanelOpen ||
|
||||
isGoalPanelOpen ||
|
||||
isSettingsPanelOpen ||
|
||||
isStatsPanelOpen ||
|
||||
isQuestPanelOpen ||
|
||||
@@ -834,6 +882,11 @@ export function AdventurePanel({
|
||||
onChoice(option);
|
||||
};
|
||||
|
||||
const handleDismissGoalPanel = () => {
|
||||
setIsGoalPanelOpen(false);
|
||||
onDismissGoalPulse();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-0 flex-1 flex-col">
|
||||
<button
|
||||
@@ -854,35 +907,34 @@ export function AdventurePanel({
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsChapterPanelOpen(true)}
|
||||
onClick={() => {
|
||||
setIsQuestPanelOpen(true);
|
||||
onDismissGoalPulse();
|
||||
}}
|
||||
className="fixed right-0 z-[26] flex min-w-[3.1rem] flex-col items-center gap-1 rounded-l-xl border border-r-0 border-white/10 bg-black/78 pl-2 pr-1.5 py-2 text-[10px] text-zinc-200 shadow-[0_8px_18px_rgba(0,0,0,0.35)] backdrop-blur-md transition hover:text-white"
|
||||
style={{ top: 'calc(env(safe-area-inset-top, 0px) + 7vh)' }}
|
||||
>
|
||||
<ScrollText className="h-4 w-4" />
|
||||
<span className="leading-none">章节</span>
|
||||
{chapterState?.title ? (
|
||||
<span className="max-w-[3.6rem] truncate text-[9px] text-zinc-400">
|
||||
{chapterState.title}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsQuestPanelOpen(true)}
|
||||
className="fixed right-0 z-[26] flex min-w-[3.1rem] flex-col items-center gap-1 rounded-l-xl border border-r-0 border-white/10 bg-black/78 pl-2 pr-1.5 py-2 text-[10px] text-zinc-200 shadow-[0_8px_18px_rgba(0,0,0,0.35)] backdrop-blur-md transition hover:text-white"
|
||||
style={{ top: 'calc(env(safe-area-inset-top, 0px) + 14.5vh)' }}
|
||||
>
|
||||
{hasCompletedQuest && (
|
||||
{(hasCompletedQuest || goalPulse) && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute -left-1 top-1 h-3.5 w-3.5 rounded-full border border-red-200/45 bg-red-500 shadow-[0_0_14px_rgba(239,68,68,0.55)]"
|
||||
className={`absolute -left-1 top-1 h-3.5 w-3.5 rounded-full border shadow-[0_0_14px_rgba(245,158,11,0.5)] ${
|
||||
hasCompletedQuest
|
||||
? 'border-red-200/45 bg-red-500 shadow-[0_0_14px_rgba(239,68,68,0.55)]'
|
||||
: 'border-amber-200/45 bg-amber-500'
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
<PixelIcon src={CHROME_ICONS.map} className="h-4 w-4" />
|
||||
<span className="leading-none">任务</span>
|
||||
<span className="rounded-full border border-white/10 bg-white/10 px-1.5 py-0.5 text-[9px] text-white">
|
||||
{quests.length}
|
||||
</span>
|
||||
{primaryQuestGoal?.title ? (
|
||||
<span className="max-w-[3.6rem] truncate text-[9px] text-zinc-400">
|
||||
{primaryQuestGoal.title}
|
||||
</span>
|
||||
) : (
|
||||
<span className="rounded-full border border-white/10 bg-white/10 px-1.5 py-0.5 text-[9px] text-white">
|
||||
{quests.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{aiError && (
|
||||
@@ -1058,6 +1110,11 @@ export function AdventurePanel({
|
||||
{getCompactOptionDetailText(option)}
|
||||
</div>
|
||||
)}
|
||||
{option.goalAffordance?.label && (
|
||||
<div className={`mt-1 text-[10px] ${getOptionGoalAffordanceClass(option)}`}>
|
||||
{option.goalAffordance.label}
|
||||
</div>
|
||||
)}
|
||||
{optionImpactSummary && (
|
||||
<div className="mt-1 text-[10px] text-zinc-500">
|
||||
{optionImpactSummary}
|
||||
@@ -1083,8 +1140,8 @@ export function AdventurePanel({
|
||||
onMusicVolumeChange={onMusicVolumeChange}
|
||||
onSaveAndExit={onSaveAndExit}
|
||||
saveAndExitDisabled={saveAndExitDisabled}
|
||||
isChapterPanelOpen={isChapterPanelOpen}
|
||||
setIsChapterPanelOpen={setIsChapterPanelOpen}
|
||||
isGoalPanelOpen={isGoalPanelOpen}
|
||||
setIsGoalPanelOpen={setIsGoalPanelOpen}
|
||||
isQuestPanelOpen={isQuestPanelOpen}
|
||||
setIsQuestPanelOpen={setIsQuestPanelOpen}
|
||||
isSettingsPanelOpen={isSettingsPanelOpen}
|
||||
@@ -1093,15 +1150,17 @@ export function AdventurePanel({
|
||||
setIsStatsPanelOpen={setIsStatsPanelOpen}
|
||||
chapterState={chapterState}
|
||||
journeyBeat={journeyBeat}
|
||||
recentChronicleSummary={recentChronicleSummary}
|
||||
currentCampEvent={currentCampEvent}
|
||||
setpieceDirective={setpieceDirective}
|
||||
goalStack={goalStack}
|
||||
goalPulse={goalPulse}
|
||||
onDismissGoalPulse={handleDismissGoalPanel}
|
||||
selectedQuest={selectedQuest}
|
||||
setSelectedQuestId={setSelectedQuestId}
|
||||
completionNoticeQuest={completionNoticeQuest}
|
||||
setCompletionNoticeQuestId={setCompletionNoticeQuestId}
|
||||
rewardQuest={rewardQuest}
|
||||
setRewardQuestId={setRewardQuestId}
|
||||
rewardQuestHandoff={rewardQuestHandoff}
|
||||
setRewardQuestHandoff={setRewardQuestHandoff}
|
||||
selectedRewardItemQuestId={selectedRewardItemQuestId}
|
||||
setSelectedRewardItemQuestId={setSelectedRewardItemQuestId}
|
||||
selectedRewardItemId={selectedRewardItemId}
|
||||
|
||||
Reference in New Issue
Block a user