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

@@ -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}