1
This commit is contained in:
12
src/hooks/rpg-session/index.ts
Normal file
12
src/hooks/rpg-session/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export { type RpgSessionBootstrapResult, useRpgSessionBootstrap } from './useRpgSessionBootstrap';
|
||||
export type { BottomTab } from './rpgSessionTypes';
|
||||
export {
|
||||
type RpgRuntimeSessionResult,
|
||||
useRpgRuntimeSession,
|
||||
} from './useRpgRuntimeSession';
|
||||
export {
|
||||
type RpgSessionPersistenceResult,
|
||||
type UseRpgSessionPersistenceParams,
|
||||
useRpgSessionPersistence,
|
||||
} from './useRpgSessionPersistence';
|
||||
export type { BottomTab as RpgBottomTab } from './rpgSessionTypes';
|
||||
3
src/hooks/rpg-session/rpgSessionTypes.ts
Normal file
3
src/hooks/rpg-session/rpgSessionTypes.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import type { BottomTab } from '../../types/navigation';
|
||||
|
||||
export type { BottomTab };
|
||||
191
src/hooks/rpg-session/useRpgRuntimeSession.ts
Normal file
191
src/hooks/rpg-session/useRpgRuntimeSession.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { DEFAULT_MUSIC_VOLUME } from '../../../packages/shared/src/contracts/runtime';
|
||||
import { useAuthUi } from '../../components/auth/AuthUiContext';
|
||||
import type { RpgRuntimeShellProps } from '../../components/rpg-runtime-shell';
|
||||
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';
|
||||
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();
|
||||
|
||||
const combatFlow = useCombatFlow({
|
||||
setGameState,
|
||||
});
|
||||
|
||||
const storyFlow = useRpgRuntimeStory({
|
||||
gameState,
|
||||
setGameState,
|
||||
buildResolvedChoiceState: combatFlow.buildResolvedChoiceState,
|
||||
playResolvedChoice: combatFlow.playResolvedChoice,
|
||||
});
|
||||
|
||||
const { companionRenderStates, buildCompanionRenderStates } =
|
||||
useNpcInteractionFlow(gameState);
|
||||
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;
|
||||
}
|
||||
|
||||
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],
|
||||
) => {
|
||||
storyFlow.resetStoryState();
|
||||
selectCustomWorld(customWorldProfile);
|
||||
};
|
||||
|
||||
const handleCharacterSelect = (
|
||||
character: Parameters<typeof selectCharacter>[0],
|
||||
) => {
|
||||
storyFlow.resetStoryState();
|
||||
selectCharacter(character);
|
||||
};
|
||||
|
||||
const handleBackToWorldSelect = () => {
|
||||
storyFlow.resetStoryState();
|
||||
backToWorldSelect();
|
||||
};
|
||||
|
||||
const handleContinueGame = (snapshot?: HydratedSavedGameSnapshot | null) => {
|
||||
void persistence.continueSavedGame(snapshot);
|
||||
};
|
||||
|
||||
const handleStartNewGame = () => {
|
||||
void persistence.clearSavedGame();
|
||||
storyFlow.resetStoryState();
|
||||
resetGame();
|
||||
};
|
||||
|
||||
const handleSaveAndExit = () => {
|
||||
const syncedGameState = syncGameStatePlayTime(gameState);
|
||||
void persistence.saveCurrentGame({
|
||||
gameState: syncedGameState,
|
||||
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,
|
||||
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>;
|
||||
416
src/hooks/rpg-session/useRpgSessionBootstrap.ts
Normal file
416
src/hooks/rpg-session/useRpgSessionBootstrap.ts
Normal file
@@ -0,0 +1,416 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import {
|
||||
buildCustomWorldRuntimeCharacters,
|
||||
createCharacterSkillCooldowns,
|
||||
getCharacterMaxHp,
|
||||
getCharacterMaxMana,
|
||||
setRuntimeCharacterOverrides,
|
||||
} from '../../data/characterPresets';
|
||||
import { setRuntimeCustomWorldProfile } from '../../data/customWorldRuntime';
|
||||
import { getInitialPlayerCurrency } from '../../data/economy';
|
||||
import {
|
||||
applyEquipmentLoadoutToState,
|
||||
buildInitialEquipmentLoadout,
|
||||
createEmptyEquipmentLoadout,
|
||||
} from '../../data/equipmentEffects';
|
||||
import {
|
||||
buildInitialNpcState,
|
||||
buildInitialPlayerInventory,
|
||||
} from '../../data/npcInteractions';
|
||||
import { createInitialPlayerProgressionState } from '../../data/playerProgression';
|
||||
import { createInitialGameRuntimeStats } from '../../data/runtimeStats';
|
||||
import {
|
||||
ensureSceneEncounterPreview,
|
||||
RESOLVED_ENTITY_X_METERS,
|
||||
} from '../../data/sceneEncounterPreviews';
|
||||
import { getScenePreset, getWorldCampScenePreset } from '../../data/scenePresets';
|
||||
import { createEmptyStoryEngineMemoryState } from '../../services/storyEngine/visibilityEngine';
|
||||
import {
|
||||
AnimationState,
|
||||
Character,
|
||||
CustomWorldProfile,
|
||||
Encounter,
|
||||
EquipmentLoadout,
|
||||
GameState,
|
||||
InventoryItem,
|
||||
SceneNpc,
|
||||
WorldType,
|
||||
} from '../../types';
|
||||
import type { BottomTab } from './rpgSessionTypes';
|
||||
|
||||
const PLAYER_BASE_MAX_HP = 180;
|
||||
|
||||
function mergeStarterInventoryItems<
|
||||
T extends { category: string; name: string },
|
||||
>(explicitItems: T[], fallbackItems: T[]) {
|
||||
const merged = new Map<string, T>();
|
||||
|
||||
[...explicitItems, ...fallbackItems].forEach((item) => {
|
||||
merged.set(`${item.category}:${item.name}`, item);
|
||||
});
|
||||
|
||||
return [...merged.values()];
|
||||
}
|
||||
|
||||
function normalizeExplicitStarterCategory(category: string) {
|
||||
const normalized = category.trim();
|
||||
return normalized === '专属物' ? '专属物品' : normalized;
|
||||
}
|
||||
|
||||
function inferExplicitStarterSlot(category: string) {
|
||||
const normalized = normalizeExplicitStarterCategory(category);
|
||||
if (normalized === '武器') return 'weapon' as const;
|
||||
if (normalized === '护甲') return 'armor' as const;
|
||||
if (
|
||||
normalized === '饰品' ||
|
||||
normalized === '稀有品' ||
|
||||
normalized === '专属物品'
|
||||
) {
|
||||
return 'relic' as const;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildExplicitCustomWorldRoleStarterState(
|
||||
profile: CustomWorldProfile,
|
||||
character: Character,
|
||||
) {
|
||||
const role =
|
||||
profile.playableNpcs.find((entry) => entry.id === character.id) ??
|
||||
profile.storyNpcs.find((entry) => entry.id === character.id) ??
|
||||
profile.playableNpcs.find(
|
||||
(entry) => entry.templateCharacterId === character.id,
|
||||
) ??
|
||||
profile.playableNpcs.find((entry) => entry.name === character.name) ??
|
||||
profile.storyNpcs.find((entry) => entry.name === character.name) ??
|
||||
null;
|
||||
|
||||
const inventory = role
|
||||
? role.initialItems.map((item, index) => {
|
||||
const category = normalizeExplicitStarterCategory(item.category);
|
||||
return {
|
||||
id: `custom-role-item:${role.id}:${index + 1}`,
|
||||
category,
|
||||
name: item.name,
|
||||
quantity: Math.max(1, item.quantity),
|
||||
rarity: item.rarity,
|
||||
tags: [...item.tags],
|
||||
description: item.description,
|
||||
equipmentSlotId: inferExplicitStarterSlot(category),
|
||||
runtimeMetadata: {
|
||||
origin: 'ai_compiled' as const,
|
||||
generationChannel: 'discovery' as const,
|
||||
seedKey: `${role.id}:${index + 1}`,
|
||||
relationAnchor: {
|
||||
type: 'npc' as const,
|
||||
npcId: role.id,
|
||||
npcName: role.name,
|
||||
roleText: role.role,
|
||||
},
|
||||
sourceReason: `${role.name}在自定义世界开局时自带的初始物品。`,
|
||||
},
|
||||
} satisfies InventoryItem;
|
||||
})
|
||||
: [];
|
||||
|
||||
const equipment: EquipmentLoadout = createEmptyEquipmentLoadout();
|
||||
inventory.forEach((item) => {
|
||||
const slot = item.equipmentSlotId;
|
||||
if (!slot || equipment[slot]) {
|
||||
return;
|
||||
}
|
||||
equipment[slot] = item;
|
||||
});
|
||||
|
||||
return {
|
||||
inventory,
|
||||
equipment,
|
||||
};
|
||||
}
|
||||
|
||||
function createInitialCampEncounter(
|
||||
worldType: WorldType | null,
|
||||
playerCharacter: Character,
|
||||
): Encounter | null {
|
||||
if (!worldType) return null;
|
||||
|
||||
const campScenePreset =
|
||||
getWorldCampScenePreset(worldType) ?? getScenePreset(worldType, 0);
|
||||
const npcCandidates = (campScenePreset?.npcs ?? [])
|
||||
.filter((npc: SceneNpc) => Boolean(npc.characterId))
|
||||
.filter((npc: SceneNpc) => npc.characterId !== playerCharacter.id);
|
||||
if (npcCandidates.length === 0) return null;
|
||||
|
||||
const npc =
|
||||
npcCandidates[Math.floor(Math.random() * npcCandidates.length)] ?? null;
|
||||
if (!npc) return null;
|
||||
|
||||
return {
|
||||
id: npc.id,
|
||||
kind: 'npc',
|
||||
characterId: npc.characterId,
|
||||
npcName: npc.name,
|
||||
npcDescription: npc.description,
|
||||
npcAvatar: npc.avatar,
|
||||
context: npc.role,
|
||||
gender: npc.gender,
|
||||
xMeters: RESOLVED_ENTITY_X_METERS,
|
||||
};
|
||||
}
|
||||
|
||||
function createInitialGameState(): GameState {
|
||||
return {
|
||||
worldType: null,
|
||||
customWorldProfile: null,
|
||||
playerCharacter: null,
|
||||
runtimeStats: createInitialGameRuntimeStats(),
|
||||
playerProgression: createInitialPlayerProgressionState(),
|
||||
currentScene: 'Selection',
|
||||
storyHistory: [],
|
||||
storyEngineMemory: createEmptyStoryEngineMemoryState(),
|
||||
chapterState: null,
|
||||
campaignState: null,
|
||||
activeScenarioPackId: null,
|
||||
activeCampaignPackId: null,
|
||||
characterChats: {},
|
||||
lastObserveSignsSceneId: null,
|
||||
lastObserveSignsReport: null,
|
||||
animationState: AnimationState.IDLE,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
currentScenePreset: null,
|
||||
sceneHostileNpcs: [],
|
||||
playerX: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
playerActionMode: 'idle',
|
||||
scrollWorld: false,
|
||||
inBattle: false,
|
||||
playerHp: PLAYER_BASE_MAX_HP,
|
||||
playerMaxHp: PLAYER_BASE_MAX_HP,
|
||||
playerMana: 0,
|
||||
playerMaxMana: 0,
|
||||
playerSkillCooldowns: {},
|
||||
activeBuildBuffs: [],
|
||||
activeCombatEffects: [],
|
||||
playerCurrency: 0,
|
||||
playerInventory: [],
|
||||
playerEquipment: createEmptyEquipmentLoadout(),
|
||||
npcStates: {},
|
||||
quests: [],
|
||||
roster: [],
|
||||
companions: [],
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* RPG session bootstrap 主实现。
|
||||
* 工作包 C 起由新域 hook 承载世界选择、选角确认与新开局初始化。
|
||||
*/
|
||||
export function useRpgSessionBootstrap() {
|
||||
const [gameState, setGameState] = useState<GameState>(() =>
|
||||
createInitialGameState(),
|
||||
);
|
||||
const [bottomTab, setBottomTab] = useState<BottomTab>('adventure');
|
||||
const [isMapOpen, setIsMapOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setRuntimeCustomWorldProfile(gameState.customWorldProfile);
|
||||
setRuntimeCharacterOverrides(
|
||||
gameState.customWorldProfile
|
||||
? buildCustomWorldRuntimeCharacters(gameState.customWorldProfile)
|
||||
: null,
|
||||
);
|
||||
}, [gameState.customWorldProfile]);
|
||||
|
||||
const resetGame = () => {
|
||||
setBottomTab('adventure');
|
||||
setIsMapOpen(false);
|
||||
setGameState(createInitialGameState());
|
||||
};
|
||||
|
||||
const handleCustomWorldSelect = (customWorldProfile: CustomWorldProfile) => {
|
||||
const resolvedWorldType = WorldType.CUSTOM;
|
||||
setRuntimeCustomWorldProfile(customWorldProfile);
|
||||
setRuntimeCharacterOverrides(
|
||||
buildCustomWorldRuntimeCharacters(customWorldProfile),
|
||||
);
|
||||
const initialScenePreset = getScenePreset(resolvedWorldType, 0) ?? null;
|
||||
setIsMapOpen(false);
|
||||
setGameState((prev) =>
|
||||
ensureSceneEncounterPreview({
|
||||
...prev,
|
||||
worldType: resolvedWorldType,
|
||||
customWorldProfile,
|
||||
currentScenePreset: initialScenePreset,
|
||||
sceneHostileNpcs: [],
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
playerProgression: createInitialPlayerProgressionState(),
|
||||
storyEngineMemory: createEmptyStoryEngineMemoryState(),
|
||||
chapterState: null,
|
||||
campaignState: null,
|
||||
activeScenarioPackId: customWorldProfile?.scenarioPackId ?? null,
|
||||
activeCampaignPackId: customWorldProfile?.campaignPackId ?? null,
|
||||
lastObserveSignsSceneId: null,
|
||||
lastObserveSignsReport: null,
|
||||
playerActionMode: 'idle',
|
||||
activeCombatEffects: [],
|
||||
activeBuildBuffs: [],
|
||||
inBattle: false,
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const handleBackToWorldSelect = () => {
|
||||
setBottomTab('adventure');
|
||||
setIsMapOpen(false);
|
||||
setGameState(createInitialGameState());
|
||||
};
|
||||
|
||||
const handleCharacterSelect = (character: Character) => {
|
||||
setBottomTab('adventure');
|
||||
setIsMapOpen(false);
|
||||
|
||||
setGameState((prev) => {
|
||||
const resolvedWorldType = prev.worldType;
|
||||
const resolvedCustomWorldProfile = prev.customWorldProfile;
|
||||
const initialScenePreset = resolvedWorldType
|
||||
? (getWorldCampScenePreset(resolvedWorldType) ??
|
||||
getScenePreset(resolvedWorldType, 0))
|
||||
: null;
|
||||
const initialEncounter = createInitialCampEncounter(
|
||||
resolvedWorldType,
|
||||
character,
|
||||
);
|
||||
const initialNpcState = initialEncounter
|
||||
? buildInitialNpcState(initialEncounter, resolvedWorldType, prev)
|
||||
: null;
|
||||
const initialEquipment = buildInitialEquipmentLoadout(
|
||||
character,
|
||||
resolvedCustomWorldProfile,
|
||||
);
|
||||
const explicitStarterItems =
|
||||
resolvedWorldType === WorldType.CUSTOM
|
||||
? buildExplicitCustomWorldRoleStarterState(
|
||||
resolvedCustomWorldProfile!,
|
||||
character,
|
||||
)
|
||||
: null;
|
||||
const mergedStarterEquipment = {
|
||||
weapon:
|
||||
explicitStarterItems?.equipment.weapon ?? initialEquipment.weapon,
|
||||
armor: explicitStarterItems?.equipment.armor ?? initialEquipment.armor,
|
||||
relic: explicitStarterItems?.equipment.relic ?? initialEquipment.relic,
|
||||
};
|
||||
const playerMaxHp = getCharacterMaxHp(
|
||||
character,
|
||||
resolvedWorldType,
|
||||
resolvedCustomWorldProfile,
|
||||
);
|
||||
|
||||
return ensureSceneEncounterPreview(
|
||||
applyEquipmentLoadoutToState(
|
||||
{
|
||||
...prev,
|
||||
playerCharacter: character,
|
||||
runtimeStats: createInitialGameRuntimeStats({ isActiveRun: true }),
|
||||
playerProgression: createInitialPlayerProgressionState(),
|
||||
currentScene: 'Story',
|
||||
storyHistory: [],
|
||||
storyEngineMemory: createEmptyStoryEngineMemoryState(),
|
||||
chapterState: null,
|
||||
campaignState: null,
|
||||
activeScenarioPackId:
|
||||
prev.customWorldProfile?.scenarioPackId ?? null,
|
||||
activeCampaignPackId:
|
||||
prev.customWorldProfile?.campaignPackId ?? null,
|
||||
characterChats: {},
|
||||
currentEncounter: initialEncounter,
|
||||
npcInteractionActive: false,
|
||||
currentScenePreset: initialScenePreset,
|
||||
lastObserveSignsSceneId: null,
|
||||
lastObserveSignsReport: null,
|
||||
animationState: AnimationState.IDLE,
|
||||
sceneHostileNpcs: [],
|
||||
playerX: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
playerActionMode: 'idle',
|
||||
scrollWorld: false,
|
||||
inBattle: false,
|
||||
playerHp: playerMaxHp,
|
||||
playerMaxHp: playerMaxHp,
|
||||
playerMana: getCharacterMaxMana(character),
|
||||
playerMaxMana: getCharacterMaxMana(character),
|
||||
playerSkillCooldowns: createCharacterSkillCooldowns(character),
|
||||
activeBuildBuffs: [],
|
||||
activeCombatEffects: [],
|
||||
playerCurrency: getInitialPlayerCurrency(
|
||||
resolvedWorldType,
|
||||
resolvedCustomWorldProfile,
|
||||
),
|
||||
playerInventory: mergeStarterInventoryItems<InventoryItem>(
|
||||
explicitStarterItems?.inventory ?? [],
|
||||
buildInitialPlayerInventory(
|
||||
character,
|
||||
resolvedWorldType,
|
||||
resolvedCustomWorldProfile,
|
||||
),
|
||||
),
|
||||
playerEquipment: createEmptyEquipmentLoadout(),
|
||||
npcStates:
|
||||
initialEncounter && initialNpcState
|
||||
? {
|
||||
[initialEncounter.id!]: initialNpcState,
|
||||
}
|
||||
: {},
|
||||
quests: [],
|
||||
roster: [],
|
||||
companions: [],
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
},
|
||||
mergedStarterEquipment,
|
||||
),
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
gameState,
|
||||
setGameState,
|
||||
bottomTab,
|
||||
setBottomTab,
|
||||
isMapOpen,
|
||||
setIsMapOpen,
|
||||
resetGame,
|
||||
handleCustomWorldSelect,
|
||||
handleBackToWorldSelect,
|
||||
handleCharacterSelect,
|
||||
};
|
||||
}
|
||||
|
||||
export type RpgSessionBootstrapResult = ReturnType<
|
||||
typeof useRpgSessionBootstrap
|
||||
>;
|
||||
348
src/hooks/rpg-session/useRpgSessionPersistence.ts
Normal file
348
src/hooks/rpg-session/useRpgSessionPersistence.ts
Normal file
@@ -0,0 +1,348 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
import { isAbortError } from '../../services/apiClient';
|
||||
import { rpgSnapshotClient } from '../../services/rpg-runtime';
|
||||
import type { GameState, StoryMoment } from '../../types';
|
||||
import { resumeServerRuntimeStory } from '../rpg-runtime-story/runtimeStoryCoordinator';
|
||||
import type { BottomTab } from './rpgSessionTypes';
|
||||
|
||||
const AUTO_SAVE_DELAY_MS = 400;
|
||||
|
||||
function canPersistSnapshot(gameState: GameState, story: StoryMoment | null) {
|
||||
return (
|
||||
gameState.currentScene === 'Story' &&
|
||||
Boolean(gameState.worldType) &&
|
||||
Boolean(gameState.playerCharacter) &&
|
||||
story?.streaming !== true
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeBottomTab(bottomTab: string | null | undefined): BottomTab {
|
||||
if (bottomTab === 'character' || bottomTab === 'inventory') {
|
||||
return bottomTab;
|
||||
}
|
||||
|
||||
return 'adventure';
|
||||
}
|
||||
|
||||
function resolveRemoteSnapshotState(snapshot: HydratedSavedGameSnapshot) {
|
||||
return {
|
||||
gameState: snapshot.gameState,
|
||||
currentStory: snapshot.currentStory ?? null,
|
||||
bottomTab: normalizeBottomTab(snapshot.bottomTab),
|
||||
};
|
||||
}
|
||||
|
||||
export type UseRpgSessionPersistenceParams = {
|
||||
authenticatedUserId: string | null;
|
||||
gameState: GameState;
|
||||
bottomTab: BottomTab;
|
||||
currentStory: StoryMoment | null;
|
||||
isLoading: boolean;
|
||||
setGameState: (state: GameState) => void;
|
||||
setBottomTab: (tab: BottomTab) => void;
|
||||
hydrateStoryState: (story: StoryMoment | null) => void;
|
||||
resetStoryState: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* RPG session persistence 主实现。
|
||||
* 工作包 C 起由新域 hook 负责自动存档、继续游戏恢复与运行态 story 恢复刷新。
|
||||
*/
|
||||
export function useRpgSessionPersistence({
|
||||
authenticatedUserId,
|
||||
gameState,
|
||||
bottomTab,
|
||||
currentStory,
|
||||
isLoading,
|
||||
setGameState,
|
||||
setBottomTab,
|
||||
hydrateStoryState,
|
||||
resetStoryState,
|
||||
}: UseRpgSessionPersistenceParams) {
|
||||
const [hasSavedGame, setHasSavedGame] = useState(false);
|
||||
const [savedSnapshot, setSavedSnapshot] =
|
||||
useState<HydratedSavedGameSnapshot | null>(null);
|
||||
const [isHydratingSnapshot, setIsHydratingSnapshot] = useState(true);
|
||||
const [isPersistingSnapshot, setIsPersistingSnapshot] = useState(false);
|
||||
const [persistenceError, setPersistenceError] = useState<string | null>(null);
|
||||
const hydrateControllerRef = useRef<AbortController | null>(null);
|
||||
const saveControllerRef = useRef<AbortController | null>(null);
|
||||
const saveRequestIdRef = useRef(0);
|
||||
|
||||
const abortActiveSave = useCallback(() => {
|
||||
saveControllerRef.current?.abort();
|
||||
saveControllerRef.current = null;
|
||||
setIsPersistingSnapshot(false);
|
||||
}, []);
|
||||
|
||||
const persistSnapshot = useCallback(
|
||||
async (params: {
|
||||
payload: {
|
||||
gameState: GameState;
|
||||
bottomTab: BottomTab;
|
||||
currentStory: StoryMoment | null;
|
||||
};
|
||||
logLabel: string;
|
||||
}) => {
|
||||
if (!authenticatedUserId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
abortActiveSave();
|
||||
|
||||
const requestId = saveRequestIdRef.current + 1;
|
||||
saveRequestIdRef.current = requestId;
|
||||
const controller = new AbortController();
|
||||
saveControllerRef.current = controller;
|
||||
setIsPersistingSnapshot(true);
|
||||
setPersistenceError(null);
|
||||
|
||||
try {
|
||||
const snapshot = await rpgSnapshotClient.putSnapshot(
|
||||
{
|
||||
gameState: params.payload.gameState,
|
||||
bottomTab: params.payload.bottomTab,
|
||||
currentStory: params.payload.currentStory,
|
||||
},
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
|
||||
if (saveRequestIdRef.current !== requestId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
setSavedSnapshot(snapshot);
|
||||
setHasSavedGame(true);
|
||||
return snapshot;
|
||||
} catch (error) {
|
||||
if (isAbortError(error)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const message =
|
||||
error instanceof Error ? error.message : '远端存档同步失败';
|
||||
if (saveRequestIdRef.current === requestId) {
|
||||
setPersistenceError(message);
|
||||
}
|
||||
console.warn(`[useRpgSessionPersistence] ${params.logLabel}`, error);
|
||||
return null;
|
||||
} finally {
|
||||
if (saveControllerRef.current === controller) {
|
||||
saveControllerRef.current = null;
|
||||
setIsPersistingSnapshot(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[abortActiveSave, authenticatedUserId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
hydrateControllerRef.current?.abort();
|
||||
hydrateControllerRef.current = null;
|
||||
abortActiveSave();
|
||||
|
||||
if (!authenticatedUserId) {
|
||||
setSavedSnapshot(null);
|
||||
setHasSavedGame(false);
|
||||
setPersistenceError(null);
|
||||
setIsHydratingSnapshot(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
hydrateControllerRef.current = controller;
|
||||
setIsHydratingSnapshot(true);
|
||||
|
||||
void rpgSnapshotClient
|
||||
.getSnapshot({ signal: controller.signal })
|
||||
.then((snapshot) => {
|
||||
setSavedSnapshot(snapshot);
|
||||
setHasSavedGame(Boolean(snapshot));
|
||||
setPersistenceError(null);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (isAbortError(error)) {
|
||||
return;
|
||||
}
|
||||
const message =
|
||||
error instanceof Error ? error.message : '读取远端存档失败';
|
||||
setPersistenceError(message);
|
||||
console.warn(
|
||||
'[useRpgSessionPersistence] failed to load remote snapshot',
|
||||
error,
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
if (hydrateControllerRef.current === controller) {
|
||||
hydrateControllerRef.current = null;
|
||||
setIsHydratingSnapshot(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
controller.abort();
|
||||
if (hydrateControllerRef.current === controller) {
|
||||
hydrateControllerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [abortActiveSave, authenticatedUserId]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
hydrateControllerRef.current?.abort();
|
||||
saveControllerRef.current?.abort();
|
||||
saveControllerRef.current = null;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const canPersist =
|
||||
!isLoading && canPersistSnapshot(gameState, currentStory);
|
||||
|
||||
if (!canPersist) return;
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
void persistSnapshot({
|
||||
payload: {
|
||||
gameState,
|
||||
bottomTab,
|
||||
currentStory,
|
||||
},
|
||||
logLabel: 'failed to autosave remote snapshot',
|
||||
});
|
||||
}, AUTO_SAVE_DELAY_MS);
|
||||
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [bottomTab, currentStory, gameState, isLoading, persistSnapshot]);
|
||||
|
||||
const saveCurrentGame = useCallback(
|
||||
async (override?: {
|
||||
gameState?: GameState;
|
||||
bottomTab?: BottomTab;
|
||||
currentStory?: StoryMoment | null;
|
||||
}) => {
|
||||
const nextGameState = override?.gameState ?? gameState;
|
||||
const nextBottomTab = override?.bottomTab ?? bottomTab;
|
||||
const nextStory = override?.currentStory ?? currentStory;
|
||||
|
||||
if (!canPersistSnapshot(nextGameState, nextStory)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const snapshot = await persistSnapshot({
|
||||
payload: {
|
||||
gameState: nextGameState,
|
||||
bottomTab: nextBottomTab,
|
||||
currentStory: nextStory,
|
||||
},
|
||||
logLabel: 'failed to save remote snapshot',
|
||||
});
|
||||
|
||||
return Boolean(snapshot);
|
||||
},
|
||||
[bottomTab, currentStory, gameState, persistSnapshot],
|
||||
);
|
||||
|
||||
const clearSavedGame = useCallback(async () => {
|
||||
abortActiveSave();
|
||||
|
||||
if (!authenticatedUserId) {
|
||||
setSavedSnapshot(null);
|
||||
setHasSavedGame(false);
|
||||
setPersistenceError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await rpgSnapshotClient.deleteSnapshot();
|
||||
setPersistenceError(null);
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'[useRpgSessionPersistence] failed to delete remote snapshot',
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
setSavedSnapshot(null);
|
||||
setHasSavedGame(false);
|
||||
}, [abortActiveSave, authenticatedUserId]);
|
||||
|
||||
const continueSavedGame = useCallback(
|
||||
async (snapshotOverride?: HydratedSavedGameSnapshot | null) => {
|
||||
if (!authenticatedUserId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const snapshot =
|
||||
snapshotOverride ??
|
||||
savedSnapshot ??
|
||||
(await rpgSnapshotClient.getSnapshot().catch((error) => {
|
||||
if (!isAbortError(error)) {
|
||||
console.warn(
|
||||
'[useRpgSessionPersistence] failed to refetch remote snapshot',
|
||||
error,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}));
|
||||
if (!snapshot) {
|
||||
setSavedSnapshot(null);
|
||||
setHasSavedGame(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
resetStoryState();
|
||||
const fallbackHydration = resolveRemoteSnapshotState(snapshot);
|
||||
|
||||
const resumedState = await resumeServerRuntimeStory(snapshot).catch(
|
||||
(error) => {
|
||||
if (!isAbortError(error)) {
|
||||
console.warn(
|
||||
'[useRpgSessionPersistence] failed to refresh runtime story state from server',
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
hydratedSnapshot: fallbackHydration,
|
||||
nextStory: fallbackHydration.currentStory,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
setGameState(resumedState.hydratedSnapshot.gameState);
|
||||
setBottomTab(normalizeBottomTab(resumedState.hydratedSnapshot.bottomTab));
|
||||
hydrateStoryState(resumedState.nextStory);
|
||||
setSavedSnapshot(snapshot);
|
||||
setHasSavedGame(true);
|
||||
setPersistenceError(null);
|
||||
return true;
|
||||
},
|
||||
[
|
||||
authenticatedUserId,
|
||||
hydrateStoryState,
|
||||
resetStoryState,
|
||||
savedSnapshot,
|
||||
setBottomTab,
|
||||
setGameState,
|
||||
],
|
||||
);
|
||||
|
||||
return {
|
||||
hasSavedGame,
|
||||
savedSnapshot,
|
||||
isHydratingSnapshot,
|
||||
isPersistingSnapshot,
|
||||
persistenceError,
|
||||
saveCurrentGame,
|
||||
continueSavedGame,
|
||||
clearSavedGame,
|
||||
};
|
||||
}
|
||||
|
||||
export type RpgSessionPersistenceResult = ReturnType<
|
||||
typeof useRpgSessionPersistence
|
||||
>;
|
||||
Reference in New Issue
Block a user