255 lines
8.4 KiB
TypeScript
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,
|
|
};
|
|
}
|