185
src/components/game-shell/useSceneTransitionModel.ts
Normal file
185
src/components/game-shell/useSceneTransitionModel.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user