215 lines
7.3 KiB
TypeScript
215 lines
7.3 KiB
TypeScript
import { useEffect } from 'react';
|
||
|
||
import { DEFAULT_MUSIC_VOLUME } from '../../../packages/shared/src/contracts/runtime';
|
||
import { useAuthUi } from '../../components/auth/AuthUiContext';
|
||
import type { CustomWorldRuntimeLaunchOptions } from '../../components/platform-entry/platformEntryTypes';
|
||
import type { RpgRuntimeShellProps } from '../../components/rpg-runtime-shell/types';
|
||
import {
|
||
activateRosterCompanion,
|
||
benchActiveCompanion,
|
||
} from '../../data/companionRoster';
|
||
import { syncGameStatePlayTime } from '../../data/runtimeStats';
|
||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||
import { useBackgroundMusic } from '../useBackgroundMusic';
|
||
import { useCombatFlow } from '../useCombatFlow';
|
||
import { useNpcInteractionFlow } from '../useNpcInteractionFlow';
|
||
import { useRpgRuntimeStory } from '../rpg-runtime-story/useRpgRuntimeStory';
|
||
import { useRpgSessionBootstrap } from './useRpgSessionBootstrap';
|
||
import { useRpgSessionPersistence } from './useRpgSessionPersistence';
|
||
|
||
/**
|
||
* RPG 主运行态装配器真实实现。
|
||
* 工作包 C 起主链改为组合 `rpg-session` 下的 bootstrap / persistence 新入口。
|
||
*/
|
||
export function useRpgRuntimeSession(): RpgRuntimeShellProps {
|
||
const authUi = useAuthUi();
|
||
const {
|
||
gameState,
|
||
setGameState,
|
||
bottomTab,
|
||
setBottomTab,
|
||
isMapOpen,
|
||
setIsMapOpen,
|
||
resetGame,
|
||
handleCustomWorldSelect: selectCustomWorld,
|
||
handleBackToWorldSelect: backToWorldSelect,
|
||
handleCharacterSelect: selectCharacter,
|
||
} = useRpgSessionBootstrap();
|
||
|
||
// 中文注释:战斗播放与结算仍然沿用独立 combat flow;
|
||
// runtime session 只消费它暴露出来的“选项结算结果”和“动画播放入口”。
|
||
const combatFlow = useCombatFlow({
|
||
setGameState,
|
||
});
|
||
|
||
// 中文注释:剧情流是运行时主链的另一半。
|
||
// 这里把 GameState 交给 runtime story,由它负责剧情文本、选项、NPC 交互与任务 UI。
|
||
const storyFlow = useRpgRuntimeStory({
|
||
gameState,
|
||
setGameState,
|
||
buildResolvedChoiceState: combatFlow.buildResolvedChoiceState,
|
||
playResolvedChoice: combatFlow.playResolvedChoice,
|
||
});
|
||
|
||
const { companionRenderStates, buildCompanionRenderStates } =
|
||
useNpcInteractionFlow(gameState);
|
||
// 中文注释:持久化层统一负责继续游戏、自动存档与退出保存,
|
||
// session 只把当前运行态快照和 story 水位传进去。
|
||
const persistence = useRpgSessionPersistence({
|
||
authenticatedUserId: authUi?.user?.id ?? null,
|
||
gameState,
|
||
bottomTab,
|
||
currentStory: storyFlow.currentStory,
|
||
isLoading: storyFlow.isLoading,
|
||
setGameState,
|
||
setBottomTab,
|
||
hydrateStoryState: storyFlow.hydrateStoryState,
|
||
resetStoryState: storyFlow.resetStoryState,
|
||
});
|
||
|
||
useBackgroundMusic({
|
||
active: Boolean(
|
||
gameState.playerCharacter && gameState.currentScene === 'Story',
|
||
),
|
||
volume: authUi?.musicVolume ?? DEFAULT_MUSIC_VOLUME,
|
||
});
|
||
|
||
useEffect(() => {
|
||
if (!gameState.playerCharacter || gameState.currentScene !== 'Story') {
|
||
return;
|
||
}
|
||
|
||
// 中文注释:游玩时长统计不跟每一帧绑定,而是固定 15 秒增量同步,
|
||
// 这样既能累计活跃时长,也不会因为高频 setState 拉高运行态噪音。
|
||
const intervalId = window.setInterval(() => {
|
||
setGameState((currentState) => {
|
||
if (
|
||
!currentState.playerCharacter ||
|
||
currentState.currentScene !== 'Story'
|
||
) {
|
||
return currentState;
|
||
}
|
||
|
||
return syncGameStatePlayTime(currentState);
|
||
});
|
||
}, 15000);
|
||
|
||
return () => window.clearInterval(intervalId);
|
||
}, [gameState.currentScene, gameState.playerCharacter, setGameState]);
|
||
|
||
const handleCustomWorldSelect = (
|
||
customWorldProfile: Parameters<typeof selectCustomWorld>[0],
|
||
options?: CustomWorldRuntimeLaunchOptions,
|
||
) => {
|
||
// 中文注释:切换世界前先清空上一局 story 控制器,
|
||
// 避免旧世界的 currentStory / 选项残留到新开局。
|
||
storyFlow.resetStoryState();
|
||
selectCustomWorld(customWorldProfile, {
|
||
mode: options?.mode,
|
||
disablePersistence: options?.disablePersistence,
|
||
});
|
||
};
|
||
|
||
const handleCharacterSelect = (
|
||
character: Parameters<typeof selectCharacter>[0],
|
||
) => {
|
||
// 中文注释:角色确认意味着正式进入新 run,
|
||
// 这里同样先清理 story 层,保证开场剧情重新按当前角色生成。
|
||
storyFlow.resetStoryState();
|
||
selectCharacter(character);
|
||
};
|
||
|
||
const handleBackToWorldSelect = () => {
|
||
storyFlow.resetStoryState();
|
||
backToWorldSelect();
|
||
};
|
||
|
||
const handleContinueGame = (snapshot?: HydratedSavedGameSnapshot | null) => {
|
||
// 中文注释:继续游戏是异步恢复链,内部会重新向服务端刷新 runtime story,
|
||
// 所以这里显式丢给 persistence 异步执行。
|
||
void persistence.continueSavedGame(snapshot);
|
||
};
|
||
|
||
const handleStartNewGame = () => {
|
||
void persistence.clearSavedGame();
|
||
storyFlow.resetStoryState();
|
||
resetGame();
|
||
};
|
||
|
||
const handleSaveAndExit = () => {
|
||
// 中文注释:退出保存只请求服务端基于已存快照创建 checkpoint;
|
||
// 游玩时长的最终刷新由后端 checkpoint 负责,不再上传本地同步后的 GameState。
|
||
void persistence.saveCurrentGame({
|
||
bottomTab,
|
||
currentStory: storyFlow.currentStory,
|
||
});
|
||
storyFlow.resetStoryState();
|
||
resetGame();
|
||
};
|
||
|
||
const handleBenchCompanion = (npcId: string) => {
|
||
setGameState((currentState) => benchActiveCompanion(currentState, npcId));
|
||
};
|
||
|
||
const handleActivateRosterCompanion = (
|
||
npcId: string,
|
||
swapNpcId?: string | null,
|
||
) => {
|
||
setGameState((currentState) =>
|
||
activateRosterCompanion(currentState, npcId, swapNpcId),
|
||
);
|
||
};
|
||
|
||
return {
|
||
session: {
|
||
gameState,
|
||
currentStory: storyFlow.currentStory,
|
||
isLoading: storyFlow.isLoading,
|
||
aiError: storyFlow.aiError,
|
||
bottomTab,
|
||
setBottomTab,
|
||
isMapOpen,
|
||
setIsMapOpen,
|
||
},
|
||
story: {
|
||
displayedOptions: storyFlow.displayedOptions,
|
||
canRefreshOptions: storyFlow.canRefreshOptions,
|
||
handleRefreshOptions: storyFlow.handleRefreshOptions,
|
||
handleChoice: storyFlow.handleChoice,
|
||
handleNpcChatInput: storyFlow.handleNpcChatInput,
|
||
refreshNpcChatOptions: storyFlow.refreshNpcChatOptions,
|
||
exitNpcChat: storyFlow.exitNpcChat,
|
||
handleMapTravelToScene: storyFlow.travelToSceneFromMap,
|
||
npcUi: storyFlow.npcUi,
|
||
characterChatUi: storyFlow.characterChatUi,
|
||
inventoryUi: storyFlow.inventoryUi,
|
||
battleRewardUi: storyFlow.battleRewardUi,
|
||
questUi: storyFlow.questUi,
|
||
npcChatQuestOfferUi: storyFlow.npcChatQuestOfferUi,
|
||
goalUi: storyFlow.goalUi,
|
||
},
|
||
entry: {
|
||
hasSavedGame: persistence.hasSavedGame,
|
||
savedSnapshot: persistence.savedSnapshot,
|
||
handleContinueGame,
|
||
handleStartNewGame,
|
||
handleSaveAndExit,
|
||
handleCustomWorldSelect,
|
||
handleBackToWorldSelect,
|
||
handleCharacterSelect,
|
||
},
|
||
companions: {
|
||
companionRenderStates,
|
||
buildCompanionRenderStates,
|
||
onBenchCompanion: handleBenchCompanion,
|
||
onActivateRosterCompanion: handleActivateRosterCompanion,
|
||
},
|
||
audio: {
|
||
musicVolume: authUi?.musicVolume ?? DEFAULT_MUSIC_VOLUME,
|
||
onMusicVolumeChange: authUi?.setMusicVolume ?? (() => {}),
|
||
},
|
||
};
|
||
}
|
||
|
||
export type RpgRuntimeSessionResult = ReturnType<typeof useRpgRuntimeSession>;
|