186 lines
6.6 KiB
TypeScript
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,
|
|
};
|
|
}
|