import { useCallback, useEffect, useRef, useState } from 'react'; import type { GameState, StoryMoment, StoryOption } from '../../types'; export type SceneTransitionPhase = 'idle' | 'exiting' | 'entering'; export type SceneTransitionTriggerMode = 'scene-change' | 'content-change'; type SceneTransitionRequest = { mode: SceneTransitionTriggerMode; baselineSceneId: string | null; baselineContentKey: string; exitComplete: boolean; }; const DEFAULT_SCENE_SWITCH_EXIT_MS = 5000; const DEFAULT_SCENE_SWITCH_ENTRY_MS = 5930; export const SCENE_TRANSITION_FUNCTION_MODES: Partial< Record > = { story_continue_adventure: 'content-change', idle_travel_next_scene: 'scene-change', camp_travel_home_scene: 'scene-change', idle_explore_forward: 'content-change', idle_follow_clue: 'content-change', }; function buildSceneTransitionContentKey( gameState: GameState, 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'; const monsterKey = gameState.sceneHostileNpcs .map( (monster) => `${monster.id}:${monster.renderKind}:${monster.xMeters}:${monster.animation}`, ) .join('|'); const storyKey = currentStory ? `${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, sceneActId, encounterKey, monsterKey, storyKey].join('::'); } /** * RPG 运行态场景过场模型真实入口。 * 第三批收口后,`rpg-runtime-shell` 直接承载场景过场状态。 */ export function useRpgSceneTransitionModel(params: { gameState: GameState; currentStory: StoryMoment | null; openingCampSceneId: string | null; onDeferredAutoChoice?: ((option: StoryOption) => void) | null; }) { const { gameState, currentStory, openingCampSceneId, onDeferredAutoChoice = null, } = params; const [renderGameState, setRenderGameState] = useState(gameState); const [renderCurrentStory, setRenderCurrentStory] = useState(currentStory); const [sceneTransitionPhase, setSceneTransitionPhase] = useState('idle'); const [sceneTransitionToken, setSceneTransitionToken] = useState(0); const [sceneTransitionDurations, setSceneTransitionDurations] = useState({ exitMs: DEFAULT_SCENE_SWITCH_EXIT_MS, entryMs: DEFAULT_SCENE_SWITCH_ENTRY_MS, }); const pendingScenePayloadRef = useRef<{ gameState: GameState; currentStory: StoryMoment | null; }>({ gameState, currentStory, }); const sceneTransitionTimerIdsRef = useRef([]); const sceneTransitionRequestRef = useRef(null); const pendingDeferredAutoChoiceRef = useRef(null); const onDeferredAutoChoiceRef = useRef(onDeferredAutoChoice); useEffect(() => { onDeferredAutoChoiceRef.current = onDeferredAutoChoice; }, [onDeferredAutoChoice]); useEffect(() => { return () => { sceneTransitionTimerIdsRef.current.forEach((timerId) => window.clearTimeout(timerId), ); sceneTransitionTimerIdsRef.current = []; sceneTransitionRequestRef.current = null; pendingDeferredAutoChoiceRef.current = null; }; }, []); const startSceneEntering = useCallback( (payload: { gameState: GameState; currentStory: StoryMoment | null }) => { sceneTransitionTimerIdsRef.current.forEach((timerId) => window.clearTimeout(timerId), ); sceneTransitionTimerIdsRef.current = []; sceneTransitionRequestRef.current = null; setRenderGameState(payload.gameState); setRenderCurrentStory(payload.currentStory); setSceneTransitionToken((current) => current + 1); setSceneTransitionPhase('entering'); 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); }, [sceneTransitionDurations.entryMs], ); const beginSceneTransition = useCallback( (mode: SceneTransitionTriggerMode) => { if (sceneTransitionPhase !== 'idle') return; pendingScenePayloadRef.current = { gameState, currentStory }; pendingDeferredAutoChoiceRef.current = null; sceneTransitionTimerIdsRef.current.forEach((timerId) => window.clearTimeout(timerId), ); sceneTransitionTimerIdsRef.current = []; sceneTransitionRequestRef.current = { mode, baselineSceneId: renderGameState.currentScenePreset?.id ?? gameState.currentScenePreset?.id ?? null, baselineContentKey: buildSceneTransitionContentKey( renderGameState, renderCurrentStory, ), exitComplete: false, }; setSceneTransitionPhase('exiting'); const exitTimerId = window.setTimeout(() => { const request = sceneTransitionRequestRef.current; if (!request) return; request.exitComplete = true; const pendingPayload = pendingScenePayloadRef.current; const isReady = request.mode === 'scene-change' ? (pendingPayload.gameState.currentScenePreset?.id ?? null) !== request.baselineSceneId : buildSceneTransitionContentKey( pendingPayload.gameState, pendingPayload.currentStory, ) !== request.baselineContentKey; if (isReady) { startSceneEntering(pendingPayload); } }, sceneTransitionDurations.exitMs); sceneTransitionTimerIdsRef.current.push(exitTimerId); }, [ currentStory, gameState, renderCurrentStory, renderGameState, sceneTransitionDurations.exitMs, sceneTransitionPhase, startSceneEntering, ], ); useEffect(() => { pendingScenePayloadRef.current = { gameState, currentStory }; const request = sceneTransitionRequestRef.current; if (sceneTransitionPhase === 'exiting' && request?.exitComplete) { const isReady = request.mode === 'scene-change' ? (gameState.currentScenePreset?.id ?? null) !== request.baselineSceneId : buildSceneTransitionContentKey(gameState, currentStory) !== request.baselineContentKey; if (isReady) { pendingDeferredAutoChoiceRef.current = currentStory?.deferredAutoChoice ?? null; startSceneEntering({ gameState, currentStory }); } return; } if (sceneTransitionPhase !== 'exiting') { setRenderGameState(gameState); setRenderCurrentStory(currentStory); } }, [currentStory, gameState, sceneTransitionPhase, startSceneEntering]); useEffect(() => { if (sceneTransitionPhase !== 'idle') { return; } if (renderGameState.playerCharacter) { return; } if (!gameState.playerCharacter || gameState.currentScene !== 'Story') { return; } if (gameState.storyHistory.length > 0) { return; } if ( !openingCampSceneId || gameState.currentScenePreset?.id !== openingCampSceneId ) { return; } startSceneEntering({ gameState, currentStory }); }, [ currentStory, gameState, openingCampSceneId, renderGameState.playerCharacter, sceneTransitionPhase, startSceneEntering, ]); return { visibleGameState: sceneTransitionPhase === 'idle' ? gameState : renderGameState, visibleCurrentStory: sceneTransitionPhase === 'idle' ? currentStory : renderCurrentStory, sceneTransitionPhase, sceneTransitionToken, setSceneTransitionDurations, beginSceneTransition, }; }