Files
Genarrative/src/components/game-shell/useSceneTransitionModel.ts
高物 c49c64896a
Some checks failed
CI / verify (push) Has been cancelled
初始仓库迁移
2026-04-04 23:57:06 +08:00

186 lines
6.6 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from 'react';
import type {
GameState,
StoryMoment,
} 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>> = {
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 encounterKey = gameState.currentEncounter
? `${gameState.currentEncounter.kind}:${gameState.currentEncounter.id ?? gameState.currentEncounter.npcName ?? 'unknown'}`
: 'encounter:none';
const monsterKey = gameState.sceneMonsters
.map(monster => `${monster.id}:${monster.renderKind}:${monster.xMeters}:${monster.animation}`)
.join('|');
const storyKey = currentStory
? `${currentStory.displayMode ?? 'story'}:${currentStory.text ?? ''}:${currentStory.dialogue?.length ?? 0}`
: 'story:none';
return [sceneId, encounterKey, monsterKey, storyKey].join('::');
}
export function useSceneTransitionModel(params: {
gameState: GameState;
currentStory: StoryMoment | null;
openingCampSceneId: string | null;
}) {
const {
gameState,
currentStory,
openingCampSceneId,
} = 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);
useEffect(() => {
return () => {
sceneTransitionTimerIdsRef.current.forEach(timerId => window.clearTimeout(timerId));
sceneTransitionTimerIdsRef.current = [];
sceneTransitionRequestRef.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');
}, sceneTransitionDurations.entryMs);
sceneTransitionTimerIdsRef.current.push(entryTimerId);
}, [sceneTransitionDurations.entryMs]);
const beginSceneTransition = useCallback((mode: SceneTransitionTriggerMode) => {
if (sceneTransitionPhase !== 'idle') return;
pendingScenePayloadRef.current = { gameState, currentStory };
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) {
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,
};
}