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> = { 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('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); 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, }; }