Files
Genarrative/src/components/rpg-runtime-shell/useRpgSceneTransitionModel.ts
T
2026-05-05 14:40:41 +08:00

255 lines
8.4 KiB
TypeScript

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<string, SceneTransitionTriggerMode>
> = {
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<SceneTransitionPhase>('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<number[]>([]);
const sceneTransitionRequestRef = useRef<SceneTransitionRequest | null>(null);
const pendingDeferredAutoChoiceRef =
useRef<StoryOption | null>(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,
};
}