This commit is contained in:
2026-05-05 14:40:41 +08:00
parent e847fcea6f
commit 07e777fef8
76 changed files with 4246 additions and 444 deletions
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type { GameState, StoryMoment } from '../../types';
import type { GameState, StoryMoment, StoryOption } from '../../types';
export type SceneTransitionPhase = 'idle' | 'exiting' | 'entering';
export type SceneTransitionTriggerMode = 'scene-change' | 'content-change';
@@ -18,6 +18,7 @@ const DEFAULT_SCENE_SWITCH_ENTRY_MS = 5930;
export const SCENE_TRANSITION_FUNCTION_MODES: Partial<
Record<string, SceneTransitionTriggerMode>
> = {
story_continue_adventure: 'content-change',
idle_travel_next_scene: 'scene-change',
camp_travel_home_scene: 'scene-change',
idle_explore_forward: 'content-change',
@@ -29,6 +30,9 @@ function buildSceneTransitionContentKey(
currentStory: StoryMoment | null,
) {
const sceneId = gameState.currentScenePreset?.id ?? 'scene:none';
const sceneActId =
gameState.storyEngineMemory?.currentSceneActState?.currentActId ??
'act:none';
const encounterKey = gameState.currentEncounter
? `${gameState.currentEncounter.kind}:${gameState.currentEncounter.id ?? gameState.currentEncounter.npcName ?? 'unknown'}`
: 'encounter:none';
@@ -39,9 +43,9 @@ function buildSceneTransitionContentKey(
)
.join('|');
const storyKey = currentStory
? `${currentStory.displayMode ?? 'story'}:${currentStory.text ?? ''}:${currentStory.dialogue?.length ?? 0}`
? `${currentStory.displayMode ?? 'story'}:${currentStory.text ?? ''}:${currentStory.dialogue?.length ?? 0}:${currentStory.options.map((option) => option.functionId).join('|')}:${currentStory.deferredAutoChoice?.functionId ?? 'auto:none'}`
: 'story:none';
return [sceneId, encounterKey, monsterKey, storyKey].join('::');
return [sceneId, sceneActId, encounterKey, monsterKey, storyKey].join('::');
}
/**
@@ -52,8 +56,14 @@ export function useRpgSceneTransitionModel(params: {
gameState: GameState;
currentStory: StoryMoment | null;
openingCampSceneId: string | null;
onDeferredAutoChoice?: ((option: StoryOption) => void) | null;
}) {
const { gameState, currentStory, openingCampSceneId } = params;
const {
gameState,
currentStory,
openingCampSceneId,
onDeferredAutoChoice = null,
} = params;
const [renderGameState, setRenderGameState] = useState(gameState);
const [renderCurrentStory, setRenderCurrentStory] = useState(currentStory);
const [sceneTransitionPhase, setSceneTransitionPhase] =
@@ -73,6 +83,13 @@ export function useRpgSceneTransitionModel(params: {
});
const sceneTransitionTimerIdsRef = useRef<number[]>([]);
const sceneTransitionRequestRef = useRef<SceneTransitionRequest | null>(null);
const pendingDeferredAutoChoiceRef =
useRef<StoryOption | null>(null);
const onDeferredAutoChoiceRef = useRef(onDeferredAutoChoice);
useEffect(() => {
onDeferredAutoChoiceRef.current = onDeferredAutoChoice;
}, [onDeferredAutoChoice]);
useEffect(() => {
return () => {
@@ -81,6 +98,7 @@ export function useRpgSceneTransitionModel(params: {
);
sceneTransitionTimerIdsRef.current = [];
sceneTransitionRequestRef.current = null;
pendingDeferredAutoChoiceRef.current = null;
};
}, []);
@@ -98,6 +116,15 @@ export function useRpgSceneTransitionModel(params: {
const entryTimerId = window.setTimeout(() => {
setSceneTransitionPhase('idle');
const autoChoice =
payload.currentStory?.deferredAutoChoice ??
pendingDeferredAutoChoiceRef.current;
if (autoChoice) {
pendingDeferredAutoChoiceRef.current = null;
// 中文注释:入场计时器可能跨过一次 currentStory/gameState 更新,
// 必须读取最新回调,避免用点击“继续冒险”前的旧状态自动开聊。
onDeferredAutoChoiceRef.current?.(autoChoice);
}
}, sceneTransitionDurations.entryMs);
sceneTransitionTimerIdsRef.current.push(entryTimerId);
},
@@ -109,6 +136,7 @@ export function useRpgSceneTransitionModel(params: {
if (sceneTransitionPhase !== 'idle') return;
pendingScenePayloadRef.current = { gameState, currentStory };
pendingDeferredAutoChoiceRef.current = null;
sceneTransitionTimerIdsRef.current.forEach((timerId) =>
window.clearTimeout(timerId),
);
@@ -170,6 +198,8 @@ export function useRpgSceneTransitionModel(params: {
: buildSceneTransitionContentKey(gameState, currentStory) !==
request.baselineContentKey;
if (isReady) {
pendingDeferredAutoChoiceRef.current =
currentStory?.deferredAutoChoice ?? null;
startSceneEntering({ gameState, currentStory });
}
return;