300 lines
9.8 KiB
TypeScript
300 lines
9.8 KiB
TypeScript
import { useCallback, useEffect } from 'react';
|
||
|
||
import type {
|
||
Character,
|
||
Encounter,
|
||
GameState,
|
||
StoryMoment,
|
||
StoryOption,
|
||
} from '../../types';
|
||
import type { EscapePlaybackSync as ResolvedChoicePlaybackSync } from '../combat/escapeFlow';
|
||
import type { ResolvedChoiceState } from '../combat/resolvedChoice';
|
||
import { useTreasureFlow } from '../useTreasureFlow';
|
||
import { useStoryInventoryActions } from './inventoryActions';
|
||
import { useStoryNpcInteractionFlow } from './npcInteraction';
|
||
import type {
|
||
ChoiceRuntimeController,
|
||
ChoiceRuntimeSupport,
|
||
StoryChoiceCoordinatorParams,
|
||
} from './storyChoiceCoordinator';
|
||
import type { StoryInteractionCoordinatorConfig } from './storyInteractionCoordinator';
|
||
import type { StoryRuntimeSupport } from './storyRuntimeSupport';
|
||
import { createRpgRuntimeNpcEncounterActions } from './useRpgRuntimeNpcInteraction';
|
||
import { useStoryChoiceCoordinator } from './useStoryChoiceCoordinator';
|
||
|
||
type RpgRuntimeInteractionFlowParams = {
|
||
gameState: GameState;
|
||
isLoading: boolean;
|
||
interactionConfig: StoryInteractionCoordinatorConfig;
|
||
runtimeSupport: StoryRuntimeSupport;
|
||
buildResolvedChoiceState: (
|
||
state: GameState,
|
||
option: StoryOption,
|
||
character: Character,
|
||
) => ResolvedChoiceState;
|
||
playResolvedChoice: (
|
||
state: GameState,
|
||
option: StoryOption,
|
||
character: Character,
|
||
resolvedChoice: ResolvedChoiceState,
|
||
sync?: ResolvedChoicePlaybackSync,
|
||
) => Promise<GameState>;
|
||
buildStoryFromResponse: ChoiceRuntimeController['buildStoryFromResponse'];
|
||
getResolvedSceneHostileNpcs: (
|
||
state: GameState,
|
||
) => GameState['sceneHostileNpcs'];
|
||
getCampCompanionTravelScene: StoryChoiceCoordinatorParams['runtimeController']['getCampCompanionTravelScene'];
|
||
isContinueAdventureOption: (option: StoryOption) => boolean;
|
||
isCampTravelHomeOption: (option: StoryOption) => boolean;
|
||
isRegularNpcEncounter: (
|
||
encounter: GameState['currentEncounter'],
|
||
) => encounter is Encounter;
|
||
isNpcEncounter: (
|
||
encounter: GameState['currentEncounter'],
|
||
) => encounter is Encounter;
|
||
npcPreviewTalkFunctionId: string;
|
||
fallbackCompanionName: string;
|
||
turnVisualMs: number;
|
||
};
|
||
|
||
export function createClearStoryInteractionUi(params: {
|
||
clearStoryChoiceUi: () => void;
|
||
clearNpcInteractionUi: () => void;
|
||
}) {
|
||
return () => {
|
||
// 中文注释:story 选择面板和 NPC 交互面板是两套独立 UI;
|
||
// 清理运行时交互态时必须同时重置,避免战斗/对话切换后残留旧弹层。
|
||
params.clearStoryChoiceUi();
|
||
params.clearNpcInteractionUi();
|
||
};
|
||
}
|
||
|
||
/**
|
||
* RPG runtime 交互分发层。
|
||
* 统一串起宝箱、背包、NPC 交互与 story choice 的正式分发。
|
||
*/
|
||
export function useRpgRuntimeInteractionFlow({
|
||
gameState,
|
||
isLoading,
|
||
interactionConfig,
|
||
runtimeSupport,
|
||
buildResolvedChoiceState,
|
||
playResolvedChoice,
|
||
buildStoryFromResponse,
|
||
getResolvedSceneHostileNpcs,
|
||
getCampCompanionTravelScene,
|
||
isContinueAdventureOption,
|
||
isCampTravelHomeOption,
|
||
isRegularNpcEncounter,
|
||
isNpcEncounter,
|
||
npcPreviewTalkFunctionId,
|
||
fallbackCompanionName,
|
||
turnVisualMs,
|
||
}: RpgRuntimeInteractionFlowParams) {
|
||
const { handleTreasureInteraction } = useTreasureFlow(
|
||
interactionConfig.treasureFlow,
|
||
);
|
||
const { inventoryUi } = useStoryInventoryActions(
|
||
interactionConfig.inventoryFlow,
|
||
);
|
||
const npcInteractionFlow = useStoryNpcInteractionFlow(
|
||
interactionConfig.npcInteractionFlow,
|
||
);
|
||
const {
|
||
enterNpcInteraction,
|
||
handleNpcInteraction,
|
||
finalizeNpcBattleResult,
|
||
reopenNpcChatAfterBattle,
|
||
handleNpcChatTurn,
|
||
exitNpcChat,
|
||
replacePendingNpcQuestOffer,
|
||
abandonPendingNpcQuestOffer,
|
||
acceptPendingNpcQuestOffer,
|
||
} = createRpgRuntimeNpcEncounterActions({
|
||
...interactionConfig.npcEncounterActions,
|
||
buildNpcStory: runtimeSupport.buildNpcStory,
|
||
npcInteractionFlow,
|
||
});
|
||
|
||
useEffect(() => {
|
||
if (isLoading || gameState.inBattle || gameState.npcInteractionActive) {
|
||
return;
|
||
}
|
||
|
||
if (isNpcEncounter(gameState.currentEncounter)) {
|
||
// 中文注释:当场景里已经解析出 NPC 遭遇,且当前不在战斗/加载中时,
|
||
// 自动进入 NPC 交互态,让开场相遇和旅行后遭遇都能无缝落到对话/互动面板。
|
||
enterNpcInteraction(
|
||
gameState.currentEncounter,
|
||
`与${gameState.currentEncounter.npcName}搭话`,
|
||
);
|
||
}
|
||
}, [
|
||
enterNpcInteraction,
|
||
gameState.currentEncounter,
|
||
gameState.inBattle,
|
||
gameState.npcInteractionActive,
|
||
isLoading,
|
||
isNpcEncounter,
|
||
]);
|
||
|
||
const choiceRuntimeController: Parameters<
|
||
typeof useStoryChoiceCoordinator
|
||
>[0]['runtimeController'] = {
|
||
currentStory: interactionConfig.npcEncounterActions.currentStory,
|
||
buildStoryContextFromState:
|
||
interactionConfig.npcEncounterActions.buildStoryContextFromState,
|
||
buildStoryFromResponse: (
|
||
state: GameState,
|
||
character: Character,
|
||
response: StoryMoment,
|
||
availableOptions: StoryOption[] | null,
|
||
optionCatalog?: StoryOption[] | null,
|
||
) =>
|
||
buildStoryFromResponse(
|
||
state,
|
||
character,
|
||
response,
|
||
availableOptions,
|
||
optionCatalog,
|
||
),
|
||
buildFallbackStoryForState:
|
||
interactionConfig.npcEncounterActions.buildFallbackStoryForState,
|
||
generateStoryForState: async (params) =>
|
||
interactionConfig.npcEncounterActions.generateStoryForState(params),
|
||
getAvailableOptionsForState:
|
||
interactionConfig.npcEncounterActions.getAvailableOptionsForState,
|
||
getCampCompanionTravelScene: (state, character) =>
|
||
getCampCompanionTravelScene(state, character),
|
||
commitGeneratedStateWithEncounterEntry: async (
|
||
entryState,
|
||
resolvedState,
|
||
character,
|
||
actionText,
|
||
resultText,
|
||
lastFunctionId,
|
||
) => {
|
||
await interactionConfig.npcEncounterActions.commitGeneratedStateWithEncounterEntry(
|
||
entryState,
|
||
resolvedState,
|
||
character,
|
||
actionText,
|
||
resultText,
|
||
lastFunctionId,
|
||
);
|
||
},
|
||
};
|
||
// 中文注释:choice coordinator 只关心“点下某个 story option 后怎么结算”,
|
||
// NPC 战斗结束后要不要回到对话态,则通过 runtimeSupport 在这里桥接进去。
|
||
const choiceRuntimeSupport: ChoiceRuntimeSupport = {
|
||
...runtimeSupport,
|
||
handleNpcBattleConversationContinuation: ({
|
||
nextState,
|
||
encounter,
|
||
actionText,
|
||
resultText,
|
||
battleMode,
|
||
}) =>
|
||
reopenNpcChatAfterBattle({
|
||
nextState,
|
||
encounter,
|
||
actionText,
|
||
resultText,
|
||
battleMode,
|
||
}),
|
||
};
|
||
const { handleChoice, battleRewardUi, clearStoryChoiceUi } =
|
||
useStoryChoiceCoordinator({
|
||
gameState,
|
||
isLoading,
|
||
setGameState: interactionConfig.npcEncounterActions.setGameState,
|
||
setCurrentStory: interactionConfig.npcEncounterActions.setCurrentStory,
|
||
setAiError: interactionConfig.npcEncounterActions.setAiError,
|
||
setIsLoading: interactionConfig.npcEncounterActions.setIsLoading,
|
||
buildResolvedChoiceState,
|
||
playResolvedChoice,
|
||
getStoryGenerationHostileNpcs:
|
||
interactionConfig.npcEncounterActions.getStoryGenerationHostileNpcs,
|
||
getResolvedSceneHostileNpcs,
|
||
runtimeController: choiceRuntimeController,
|
||
runtimeSupport: choiceRuntimeSupport,
|
||
enterNpcInteraction,
|
||
handleNpcInteraction,
|
||
handleTreasureInteraction,
|
||
finalizeNpcBattleResult,
|
||
sortOptions: interactionConfig.npcEncounterActions.sortOptions,
|
||
buildContinueAdventureOption:
|
||
interactionConfig.npcEncounterActions.buildContinueAdventureOption,
|
||
isContinueAdventureOption,
|
||
isCampTravelHomeOption,
|
||
isRegularNpcEncounter,
|
||
isNpcEncounter,
|
||
npcPreviewTalkFunctionId,
|
||
fallbackCompanionName,
|
||
turnVisualMs,
|
||
});
|
||
|
||
const clearStoryInteractionUi = useCallback(
|
||
createClearStoryInteractionUi({
|
||
clearStoryChoiceUi,
|
||
clearNpcInteractionUi: npcInteractionFlow.clearNpcInteractionUi,
|
||
}),
|
||
[clearStoryChoiceUi, npcInteractionFlow.clearNpcInteractionUi],
|
||
);
|
||
|
||
return {
|
||
handleChoice,
|
||
battleRewardUi,
|
||
npcUi: npcInteractionFlow.npcUi,
|
||
inventoryUi,
|
||
clearStoryInteractionUi,
|
||
handleNpcChatInput: (input: string) => {
|
||
const encounter = interactionConfig.npcEncounterActions.gameState.currentEncounter;
|
||
if (!encounter || encounter.kind !== 'npc') {
|
||
return false;
|
||
}
|
||
|
||
// 中文注释:聊天提交是 fire-and-forget,
|
||
// 调用方只需要知道“当前能不能发给 NPC”,不需要阻塞等待整轮对话结束。
|
||
void handleNpcChatTurn(encounter, input);
|
||
return true;
|
||
},
|
||
refreshNpcChatOptions: () => {
|
||
const story = interactionConfig.npcEncounterActions.currentStory;
|
||
const encounter = interactionConfig.npcEncounterActions.gameState.currentEncounter;
|
||
if (!story?.npcChatState || !story.options.length || !encounter || encounter.kind !== 'npc') {
|
||
return false;
|
||
}
|
||
|
||
const [firstOption, ...restOptions] = story.options;
|
||
if (!firstOption || restOptions.length === 0) {
|
||
return false;
|
||
}
|
||
|
||
// 中文注释:NPC 聊天的“换一组回应建议”当前通过轮转 options 实现,
|
||
// 不额外发请求,优先复用本轮已经拿到的候选动作。
|
||
interactionConfig.npcEncounterActions.setCurrentStory({
|
||
...story,
|
||
options: [...restOptions, firstOption],
|
||
});
|
||
return true;
|
||
},
|
||
exitNpcChat,
|
||
npcChatQuestOfferUi: {
|
||
replacePendingOffer: replacePendingNpcQuestOffer,
|
||
abandonPendingOffer: abandonPendingNpcQuestOffer,
|
||
acceptPendingOffer: acceptPendingNpcQuestOffer,
|
||
},
|
||
};
|
||
}
|
||
|
||
export type UseRpgRuntimeInteractionFlowParams = Parameters<
|
||
typeof useRpgRuntimeInteractionFlow
|
||
>[0];
|
||
export type RpgRuntimeInteractionFlowResult = ReturnType<
|
||
typeof useRpgRuntimeInteractionFlow
|
||
>;
|
||
|
||
export const createRpgRuntimeInteractionUiResetter =
|
||
createClearStoryInteractionUi;
|