@@ -62,6 +62,8 @@ interface AdventurePanelProps {
|
||||
canRefreshOptions: boolean;
|
||||
onRefreshOptions: () => void;
|
||||
onChoice: (option: StoryOption) => void;
|
||||
onSubmitNpcChatInput?: (input: string) => boolean;
|
||||
onExitNpcChat?: () => boolean;
|
||||
onOpenCharacter: () => void;
|
||||
onOpenInventory: () => void;
|
||||
playerCharacter: Character;
|
||||
@@ -149,12 +151,22 @@ function getOptionActionTextClass(option: StoryOption) {
|
||||
function getDialogueTurnAlignmentClass(
|
||||
turn: NonNullable<StoryMoment['dialogue']>[number],
|
||||
) {
|
||||
if (turn.speaker === 'system') {
|
||||
return 'justify-center';
|
||||
}
|
||||
|
||||
return turn.speaker === 'player' ? 'justify-end' : 'justify-start';
|
||||
}
|
||||
|
||||
function getDialogueTurnBubbleClass(
|
||||
turn: NonNullable<StoryMoment['dialogue']>[number],
|
||||
) {
|
||||
if (turn.speaker === 'system') {
|
||||
return turn.affinityDelta && turn.affinityDelta > 0
|
||||
? 'border-rose-400/30 bg-rose-500/12 text-rose-50'
|
||||
: 'border-white/12 bg-white/[0.06] text-zinc-100';
|
||||
}
|
||||
|
||||
if (turn.speaker === 'player') {
|
||||
return 'border-sky-400/20 bg-sky-500/10 text-sky-50';
|
||||
}
|
||||
@@ -169,6 +181,10 @@ function getDialogueTurnBubbleClass(
|
||||
function getDialogueTurnBubbleShapeClass(
|
||||
turn: NonNullable<StoryMoment['dialogue']>[number],
|
||||
) {
|
||||
if (turn.speaker === 'system') {
|
||||
return 'rounded-full';
|
||||
}
|
||||
|
||||
if (turn.speaker === 'player') {
|
||||
return 'rounded-2xl rounded-br-none';
|
||||
}
|
||||
@@ -183,6 +199,10 @@ function getDialogueTurnBubbleShapeClass(
|
||||
function getDialogueTurnLabel(
|
||||
turn: NonNullable<StoryMoment['dialogue']>[number],
|
||||
) {
|
||||
if (turn.speaker === 'system') {
|
||||
return turn.affinityDelta && turn.affinityDelta > 0 ? '关系变化' : '系统';
|
||||
}
|
||||
|
||||
if (turn.speaker === 'player') {
|
||||
return '\u4f60';
|
||||
}
|
||||
@@ -597,6 +617,8 @@ export function AdventurePanel({
|
||||
canRefreshOptions,
|
||||
onRefreshOptions,
|
||||
onChoice,
|
||||
onSubmitNpcChatInput,
|
||||
onExitNpcChat,
|
||||
onOpenCharacter,
|
||||
onOpenInventory,
|
||||
playerCharacter,
|
||||
@@ -622,6 +644,8 @@ export function AdventurePanel({
|
||||
}: AdventurePanelProps) {
|
||||
const isDialogueStory = currentStory.displayMode === 'dialogue';
|
||||
const dialogueTurns = currentStory.dialogue ?? [];
|
||||
const npcChatState = currentStory.npcChatState ?? null;
|
||||
const isNpcChatMode = Boolean(npcChatState);
|
||||
const isStoryStreaming = Boolean(currentStory.streaming);
|
||||
const shouldHideChoiceUi = hideOptions;
|
||||
const storyScrollContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -656,6 +680,7 @@ export function AdventurePanel({
|
||||
const [selectedBattleRewardItemId, setSelectedBattleRewardItemId] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [npcChatDraft, setNpcChatDraft] = useState('');
|
||||
const lastAutoOpenedGoalRef = useRef<string | null>(null);
|
||||
const lastAutoOpenedPulseRef = useRef<string | null>(null);
|
||||
const battleReward = battleRewardUi.reward;
|
||||
@@ -734,6 +759,10 @@ export function AdventurePanel({
|
||||
setSelectedBattleRewardItemId(null);
|
||||
}, [battleReward]);
|
||||
|
||||
useEffect(() => {
|
||||
setNpcChatDraft('');
|
||||
}, [npcChatState?.npcId, npcChatState?.turnCount]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!primaryQuestGoal) {
|
||||
return;
|
||||
@@ -887,6 +916,18 @@ export function AdventurePanel({
|
||||
onDismissGoalPulse();
|
||||
};
|
||||
|
||||
const submitNpcChatDraft = () => {
|
||||
const nextInput = npcChatDraft.trim();
|
||||
if (!nextInput || !onSubmitNpcChatInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
const submitted = onSubmitNpcChatInput(nextInput);
|
||||
if (submitted) {
|
||||
setNpcChatDraft('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-0 flex-1 flex-col">
|
||||
<button
|
||||
|
||||
@@ -45,6 +45,8 @@ interface GameShellStoryProps {
|
||||
canRefreshOptions: boolean;
|
||||
handleRefreshOptions: () => void;
|
||||
handleChoice: (option: StoryOption) => void;
|
||||
handleNpcChatInput: (input: string) => boolean;
|
||||
exitNpcChat: () => boolean;
|
||||
handleMapTravelToScene: (sceneId: string) => boolean;
|
||||
npcUi: StoryGenerationNpcUi;
|
||||
characterChatUi: CharacterChatUi;
|
||||
@@ -200,6 +202,8 @@ export function GameShell({session, story, entry, companions, audio}: GameShellP
|
||||
canRefreshOptions,
|
||||
handleRefreshOptions,
|
||||
handleChoice,
|
||||
handleNpcChatInput,
|
||||
exitNpcChat,
|
||||
handleMapTravelToScene,
|
||||
npcUi,
|
||||
characterChatUi,
|
||||
@@ -533,6 +537,8 @@ export function GameShell({session, story, entry, companions, audio}: GameShellP
|
||||
canRefreshOptions={canRefreshOptions}
|
||||
onRefreshOptions={handleRefreshOptions}
|
||||
onChoice={handleSceneTransitionChoice}
|
||||
onSubmitNpcChatInput={handleNpcChatInput}
|
||||
onExitNpcChat={exitNpcChat}
|
||||
onOpenCharacter={() => openOverlayPanel('character')}
|
||||
onOpenInventory={() => openOverlayPanel('inventory')}
|
||||
playerCharacter={visibleGameState.playerCharacter}
|
||||
|
||||
@@ -74,6 +74,8 @@ export function GameShellMainContent({
|
||||
canRefreshOptions,
|
||||
handleRefreshOptions,
|
||||
handleSceneTransitionChoice,
|
||||
handleNpcChatInput,
|
||||
exitNpcChat,
|
||||
characterChatUi,
|
||||
inventoryUi,
|
||||
battleRewardUi,
|
||||
@@ -112,6 +114,8 @@ export function GameShellMainContent({
|
||||
canRefreshOptions: boolean;
|
||||
handleRefreshOptions: () => void;
|
||||
handleSceneTransitionChoice: (option: StoryOption) => void;
|
||||
handleNpcChatInput: (input: string) => boolean;
|
||||
exitNpcChat: () => boolean;
|
||||
characterChatUi: CharacterChatUi;
|
||||
inventoryUi: InventoryFlowUi;
|
||||
battleRewardUi: BattleRewardUi;
|
||||
@@ -198,6 +202,8 @@ export function GameShellMainContent({
|
||||
canRefreshOptions={canRefreshOptions}
|
||||
handleRefreshOptions={handleRefreshOptions}
|
||||
handleSceneTransitionChoice={handleSceneTransitionChoice}
|
||||
handleNpcChatInput={handleNpcChatInput}
|
||||
exitNpcChat={exitNpcChat}
|
||||
characterChatUi={characterChatUi}
|
||||
inventoryUi={inventoryUi}
|
||||
battleRewardUi={battleRewardUi}
|
||||
|
||||
@@ -34,6 +34,8 @@ export function GameShellRuntime({session, story, entry, companions, audio}: Gam
|
||||
displayedOptions,
|
||||
canRefreshOptions,
|
||||
handleRefreshOptions,
|
||||
handleNpcChatInput,
|
||||
exitNpcChat,
|
||||
handleMapTravelToScene,
|
||||
npcUi,
|
||||
characterChatUi,
|
||||
@@ -151,6 +153,8 @@ export function GameShellRuntime({session, story, entry, companions, audio}: Gam
|
||||
canRefreshOptions={canRefreshOptions}
|
||||
handleRefreshOptions={handleRefreshOptions}
|
||||
handleSceneTransitionChoice={handleSceneTransitionChoice}
|
||||
handleNpcChatInput={handleNpcChatInput}
|
||||
exitNpcChat={exitNpcChat}
|
||||
characterChatUi={characterChatUi}
|
||||
inventoryUi={inventoryUi}
|
||||
battleRewardUi={battleRewardUi}
|
||||
|
||||
@@ -51,6 +51,8 @@ export function GameShellStoryPanels({
|
||||
canRefreshOptions,
|
||||
handleRefreshOptions,
|
||||
handleSceneTransitionChoice,
|
||||
handleNpcChatInput,
|
||||
exitNpcChat,
|
||||
characterChatUi,
|
||||
inventoryUi,
|
||||
battleRewardUi,
|
||||
@@ -77,6 +79,8 @@ export function GameShellStoryPanels({
|
||||
canRefreshOptions: boolean;
|
||||
handleRefreshOptions: () => void;
|
||||
handleSceneTransitionChoice: (option: StoryOption) => void;
|
||||
handleNpcChatInput: (input: string) => boolean;
|
||||
exitNpcChat: () => boolean;
|
||||
characterChatUi: CharacterChatUi;
|
||||
inventoryUi: InventoryFlowUi;
|
||||
battleRewardUi: BattleRewardUi;
|
||||
@@ -181,6 +185,8 @@ export function GameShellStoryPanels({
|
||||
canRefreshOptions={canRefreshOptions}
|
||||
onRefreshOptions={handleRefreshOptions}
|
||||
onChoice={handleSceneTransitionChoice}
|
||||
onSubmitNpcChatInput={handleNpcChatInput}
|
||||
onExitNpcChat={exitNpcChat}
|
||||
onOpenCharacter={() => openOverlayPanel('character')}
|
||||
onOpenInventory={() => openOverlayPanel('inventory')}
|
||||
playerCharacter={playerCharacter}
|
||||
|
||||
@@ -33,6 +33,8 @@ export interface GameShellStoryProps {
|
||||
canRefreshOptions: boolean;
|
||||
handleRefreshOptions: () => void;
|
||||
handleChoice: (option: StoryOption) => void;
|
||||
handleNpcChatInput: (input: string) => boolean;
|
||||
exitNpcChat: () => boolean;
|
||||
handleMapTravelToScene: (sceneId: string) => boolean;
|
||||
npcUi: StoryGenerationNpcUi;
|
||||
characterChatUi: CharacterChatUi;
|
||||
|
||||
@@ -40,7 +40,11 @@ import {
|
||||
resolveSceneEncounterPreview,
|
||||
} from '../../data/sceneEncounterPreviews';
|
||||
import { applyStoryReasoningRecovery } from '../../data/storyRecovery';
|
||||
import { generateNextStep, streamNpcChatDialogue } from '../../services/aiService';
|
||||
import {
|
||||
generateNextStep,
|
||||
streamNpcChatDialogue,
|
||||
streamNpcChatTurn,
|
||||
} from '../../services/aiService';
|
||||
import type { StoryGenerationContext } from '../../services/aiTypes';
|
||||
import { generateQuestForNpcEncounter } from '../../services/questDirector';
|
||||
import {
|
||||
@@ -572,6 +576,249 @@ export function createStoryNpcEncounterActions({
|
||||
}
|
||||
};
|
||||
|
||||
const buildNpcChatTurnOptions = (
|
||||
encounter: Encounter,
|
||||
suggestions: string[],
|
||||
): StoryOption[] =>
|
||||
suggestions.slice(0, 3).map((suggestion) => ({
|
||||
functionId: 'npc_chat',
|
||||
actionText: suggestion,
|
||||
text: suggestion,
|
||||
detailText: '',
|
||||
visuals: {
|
||||
playerAnimation: AnimationState.IDLE,
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
interaction: {
|
||||
kind: 'npc',
|
||||
npcId: encounter.id ?? encounter.npcName,
|
||||
action: 'chat',
|
||||
},
|
||||
}));
|
||||
|
||||
const buildFallbackNpcChatSuggestions = (playerMessage: string) => {
|
||||
const topic = playerMessage.trim() || '刚才那句话';
|
||||
return [
|
||||
`顺着“${topic}”继续追问`,
|
||||
'先表明你的判断,再看对方反应',
|
||||
'换个更轻松的语气把话接下去',
|
||||
];
|
||||
};
|
||||
|
||||
const buildNpcChatStoryMoment = (params: {
|
||||
encounter: Encounter;
|
||||
dialogue: NonNullable<StoryMoment['dialogue']>;
|
||||
options: StoryOption[];
|
||||
streaming: boolean;
|
||||
turnCount: number;
|
||||
}): StoryMoment => ({
|
||||
text: params.dialogue.map((turn) => turn.text).join('\n'),
|
||||
options: params.options,
|
||||
displayMode: 'dialogue',
|
||||
dialogue: params.dialogue,
|
||||
streaming: params.streaming,
|
||||
npcChatState: {
|
||||
npcId: params.encounter.id ?? params.encounter.npcName,
|
||||
npcName: params.encounter.npcName,
|
||||
turnCount: params.turnCount,
|
||||
customInputPlaceholder: '输入你想对 TA 说的话',
|
||||
},
|
||||
});
|
||||
|
||||
const handleNpcChatTurn = async (
|
||||
encounter: Encounter,
|
||||
playerMessage: string,
|
||||
) => {
|
||||
const playerCharacter = gameState.playerCharacter;
|
||||
if (!playerCharacter || !gameState.worldType) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const npcState = getResolvedNpcState(gameState, encounter);
|
||||
const currentNpcChatState =
|
||||
currentStory?.npcChatState?.npcId === (encounter.id ?? encounter.npcName)
|
||||
? currentStory.npcChatState
|
||||
: null;
|
||||
const existingDialogue =
|
||||
currentStory?.dialogue && currentNpcChatState
|
||||
? [...currentStory.dialogue]
|
||||
: [];
|
||||
const dialogueWithPlayer = [
|
||||
...existingDialogue,
|
||||
{
|
||||
speaker: 'player' as const,
|
||||
text: playerMessage,
|
||||
},
|
||||
];
|
||||
const nextTurnCount = (currentNpcChatState?.turnCount ?? 0) + 1;
|
||||
const openingCampContext = buildOpeningCampChatContext(
|
||||
gameState,
|
||||
playerCharacter,
|
||||
encounter,
|
||||
);
|
||||
|
||||
setAiError(null);
|
||||
setIsLoading(true);
|
||||
setCurrentStory(
|
||||
buildNpcChatStoryMoment({
|
||||
encounter,
|
||||
dialogue: dialogueWithPlayer,
|
||||
options: [],
|
||||
streaming: true,
|
||||
turnCount: nextTurnCount,
|
||||
}),
|
||||
);
|
||||
|
||||
try {
|
||||
const chatTurn = await streamNpcChatTurn(
|
||||
gameState.worldType,
|
||||
playerCharacter,
|
||||
encounter,
|
||||
getStoryGenerationHostileNpcs(gameState),
|
||||
gameState.storyHistory,
|
||||
buildStoryContextFromState(gameState, {
|
||||
lastFunctionId: 'npc_chat',
|
||||
...openingCampContext,
|
||||
encounterNpcStateOverride: npcState,
|
||||
}),
|
||||
existingDialogue,
|
||||
playerMessage,
|
||||
{
|
||||
affinity: npcState.affinity,
|
||||
chattedCount: npcState.chattedCount,
|
||||
recruited: npcState.recruited,
|
||||
},
|
||||
{
|
||||
onReplyUpdate: (text) => {
|
||||
setCurrentStory(
|
||||
buildNpcChatStoryMoment({
|
||||
encounter,
|
||||
dialogue: [
|
||||
...dialogueWithPlayer,
|
||||
{
|
||||
speaker: 'npc',
|
||||
speakerName: encounter.npcName,
|
||||
text,
|
||||
},
|
||||
],
|
||||
options: [],
|
||||
streaming: true,
|
||||
turnCount: nextTurnCount,
|
||||
}),
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
let nextAffinity = npcState.affinity;
|
||||
const nextState = updateNpcState(
|
||||
gameState,
|
||||
encounter,
|
||||
(currentNpcState) => {
|
||||
nextAffinity = currentNpcState.affinity + chatTurn.affinityDelta;
|
||||
return {
|
||||
...markNpcFirstMeaningfulContactResolved(currentNpcState),
|
||||
affinity: nextAffinity,
|
||||
relationState: buildRelationState(nextAffinity),
|
||||
chattedCount: currentNpcState.chattedCount + 1,
|
||||
stanceProfile: applyStoryChoiceToStanceProfile(
|
||||
currentNpcState.stanceProfile,
|
||||
'npc_chat',
|
||||
{ affinityGain: chatTurn.affinityDelta },
|
||||
),
|
||||
};
|
||||
},
|
||||
);
|
||||
const finalHistory = appendHistory(
|
||||
gameState,
|
||||
playerMessage,
|
||||
chatTurn.npcReply,
|
||||
);
|
||||
const finalState = {
|
||||
...nextState,
|
||||
quests: applyQuestProgressFromNpcTalk(
|
||||
nextState.quests,
|
||||
encounter.id ?? encounter.npcName,
|
||||
),
|
||||
storyHistory: finalHistory,
|
||||
};
|
||||
setGameState(finalState);
|
||||
|
||||
const affinityTurn =
|
||||
chatTurn.affinityDelta !== 0
|
||||
? [
|
||||
{
|
||||
speaker: 'system' as const,
|
||||
text:
|
||||
chatTurn.affinityDelta > 0
|
||||
? `${chatTurn.affinityText} 好感 +${chatTurn.affinityDelta}`
|
||||
: chatTurn.affinityText,
|
||||
affinityDelta: chatTurn.affinityDelta,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
setCurrentStory(
|
||||
buildNpcChatStoryMoment({
|
||||
encounter,
|
||||
dialogue: [
|
||||
...dialogueWithPlayer,
|
||||
{
|
||||
speaker: 'npc',
|
||||
speakerName: encounter.npcName,
|
||||
text: chatTurn.npcReply,
|
||||
},
|
||||
...affinityTurn,
|
||||
],
|
||||
options: buildNpcChatTurnOptions(
|
||||
encounter,
|
||||
chatTurn.suggestions.length > 0
|
||||
? chatTurn.suggestions
|
||||
: buildFallbackNpcChatSuggestions(playerMessage),
|
||||
),
|
||||
streaming: false,
|
||||
turnCount: nextTurnCount,
|
||||
}),
|
||||
);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to stream npc chat turn:', error);
|
||||
setAiError(
|
||||
error instanceof Error ? error.message : 'NPC 聊天续写失败',
|
||||
);
|
||||
setCurrentStory(
|
||||
buildNpcChatStoryMoment({
|
||||
encounter,
|
||||
dialogue: dialogueWithPlayer,
|
||||
options: buildNpcChatTurnOptions(
|
||||
encounter,
|
||||
buildFallbackNpcChatSuggestions(playerMessage),
|
||||
),
|
||||
streaming: false,
|
||||
turnCount: nextTurnCount,
|
||||
}),
|
||||
);
|
||||
return false;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const exitNpcChat = () => {
|
||||
const playerCharacter = gameState.playerCharacter;
|
||||
if (!playerCharacter || !isNpcEncounter(gameState.currentEncounter)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
setAiError(null);
|
||||
setCurrentStory(buildFallbackStoryForState(gameState, playerCharacter));
|
||||
return true;
|
||||
};
|
||||
|
||||
const enterNpcInteraction = (encounter: Encounter, actionText: string) => {
|
||||
const playerCharacter = gameState.playerCharacter;
|
||||
if (!playerCharacter) return false;
|
||||
@@ -824,57 +1071,7 @@ export function createStoryNpcEncounterActions({
|
||||
return true;
|
||||
}
|
||||
case 'chat': {
|
||||
const chatOutcome = getChatAffinityOutcome({
|
||||
playerCharacter,
|
||||
encounter,
|
||||
npcState,
|
||||
actionText: option.actionText,
|
||||
worldType: gameState.worldType,
|
||||
customWorldProfile: gameState.customWorldProfile,
|
||||
});
|
||||
const affinityGain = chatOutcome.affinityGain;
|
||||
const attributeSummary = chatOutcome.summary;
|
||||
let nextAffinity = npcState.affinity;
|
||||
const nextState = updateNpcState(
|
||||
gameState,
|
||||
encounter,
|
||||
(currentNpcState) => {
|
||||
nextAffinity = currentNpcState.affinity + affinityGain;
|
||||
return {
|
||||
...markNpcFirstMeaningfulContactResolved(currentNpcState),
|
||||
affinity: nextAffinity,
|
||||
relationState: buildRelationState(nextAffinity),
|
||||
chattedCount: currentNpcState.chattedCount + 1,
|
||||
stanceProfile: applyStoryChoiceToStanceProfile(
|
||||
currentNpcState.stanceProfile,
|
||||
'npc_chat',
|
||||
{ affinityGain },
|
||||
),
|
||||
};
|
||||
},
|
||||
);
|
||||
void commitNpcChatState(
|
||||
nextState,
|
||||
playerCharacter,
|
||||
encounter,
|
||||
option.actionText,
|
||||
npcState.recruited
|
||||
? buildCampCompanionChatResultText(
|
||||
encounter,
|
||||
affinityGain,
|
||||
nextAffinity,
|
||||
)
|
||||
: buildNpcChatResultText(
|
||||
encounter,
|
||||
affinityGain,
|
||||
nextAffinity,
|
||||
attributeSummary,
|
||||
),
|
||||
option.functionId,
|
||||
{
|
||||
contextNpcStateOverride: npcState,
|
||||
},
|
||||
);
|
||||
void handleNpcChatTurn(encounter, option.actionText);
|
||||
return true;
|
||||
}
|
||||
case 'quest_accept': {
|
||||
@@ -1029,5 +1226,7 @@ export function createStoryNpcEncounterActions({
|
||||
enterNpcInteraction,
|
||||
handleNpcInteraction,
|
||||
finalizeNpcBattleResult,
|
||||
handleNpcChatTurn,
|
||||
exitNpcChat,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -137,6 +137,8 @@ export function useStoryFlowCoordinator({
|
||||
npcUi,
|
||||
inventoryUi,
|
||||
clearStoryInteractionUi,
|
||||
handleNpcChatInput,
|
||||
exitNpcChat,
|
||||
} = useStoryInteractionCoordinator({
|
||||
gameState,
|
||||
isLoading,
|
||||
@@ -186,5 +188,7 @@ export function useStoryFlowCoordinator({
|
||||
goalUi,
|
||||
npcUi,
|
||||
inventoryUi,
|
||||
handleNpcChatInput,
|
||||
exitNpcChat,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -101,11 +101,16 @@ export function useStoryInteractionCoordinator({
|
||||
const npcInteractionFlow = useStoryNpcInteractionFlow(
|
||||
interactionConfig.npcInteractionFlow,
|
||||
);
|
||||
const { enterNpcInteraction, handleNpcInteraction, finalizeNpcBattleResult } =
|
||||
createStoryNpcEncounterActions({
|
||||
...interactionConfig.npcEncounterActions,
|
||||
npcInteractionFlow,
|
||||
});
|
||||
const {
|
||||
enterNpcInteraction,
|
||||
handleNpcInteraction,
|
||||
finalizeNpcBattleResult,
|
||||
handleNpcChatTurn,
|
||||
exitNpcChat,
|
||||
} = createStoryNpcEncounterActions({
|
||||
...interactionConfig.npcEncounterActions,
|
||||
npcInteractionFlow,
|
||||
});
|
||||
const choiceRuntimeController: Parameters<
|
||||
typeof useStoryChoiceCoordinator
|
||||
>[0]['runtimeController'] = {
|
||||
@@ -199,5 +204,15 @@ export function useStoryInteractionCoordinator({
|
||||
npcUi: npcInteractionFlow.npcUi,
|
||||
inventoryUi,
|
||||
clearStoryInteractionUi,
|
||||
handleNpcChatInput: (input: string) => {
|
||||
const encounter = interactionConfig.npcEncounterActions.gameState.currentEncounter;
|
||||
if (!encounter || encounter.kind !== 'npc') {
|
||||
return false;
|
||||
}
|
||||
|
||||
void handleNpcChatTurn(encounter, input);
|
||||
return true;
|
||||
},
|
||||
exitNpcChat,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -148,6 +148,8 @@ export function useGameShellRuntime(): GameShellProps {
|
||||
canRefreshOptions: storyFlow.canRefreshOptions,
|
||||
handleRefreshOptions: storyFlow.handleRefreshOptions,
|
||||
handleChoice: storyFlow.handleChoice,
|
||||
handleNpcChatInput: storyFlow.handleNpcChatInput,
|
||||
exitNpcChat: storyFlow.exitNpcChat,
|
||||
handleMapTravelToScene: storyFlow.travelToSceneFromMap,
|
||||
npcUi: storyFlow.npcUi,
|
||||
characterChatUi: storyFlow.characterChatUi,
|
||||
|
||||
@@ -97,6 +97,8 @@ export function useStoryGeneration({
|
||||
goalUi,
|
||||
npcUi,
|
||||
inventoryUi,
|
||||
handleNpcChatInput,
|
||||
exitNpcChat,
|
||||
} = useStoryFlowCoordinator({
|
||||
gameState,
|
||||
setGameState,
|
||||
@@ -141,5 +143,7 @@ export function useStoryGeneration({
|
||||
npcUi,
|
||||
characterChatUi,
|
||||
inventoryUi,
|
||||
handleNpcChatInput,
|
||||
exitNpcChat,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@ import type {
|
||||
CharacterChatSuggestionsRequest,
|
||||
CharacterChatSummaryRequest,
|
||||
NpcChatDialogueRequest,
|
||||
NpcChatTurnRequest,
|
||||
NpcChatTurnResult,
|
||||
NpcRecruitDialogueRequest,
|
||||
PlainTextResponse,
|
||||
} from '../../packages/shared/src/contracts/story';
|
||||
@@ -156,6 +158,37 @@ async function requestPlainTextStream(
|
||||
return accumulatedText.trim();
|
||||
}
|
||||
|
||||
type ParsedSseEvent = {
|
||||
event: string | null;
|
||||
data: string;
|
||||
};
|
||||
|
||||
function parseSseEventBlock(eventBlock: string): ParsedSseEvent | null {
|
||||
let eventName: string | null = null;
|
||||
const dataLines: string[] = [];
|
||||
|
||||
for (const rawLine of eventBlock.split(/\r?\n/u)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line) continue;
|
||||
if (line.startsWith('event:')) {
|
||||
eventName = line.slice(6).trim() || null;
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith('data:')) {
|
||||
dataLines.push(line.slice(5).trim());
|
||||
}
|
||||
}
|
||||
|
||||
if (dataLines.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
event: eventName,
|
||||
data: dataLines.join('\n'),
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateInitialStory(
|
||||
world: WorldType,
|
||||
character: Character,
|
||||
@@ -893,6 +926,109 @@ export async function streamNpcChatDialogue(
|
||||
return dialogue.trim();
|
||||
}
|
||||
|
||||
export async function streamNpcChatTurn(
|
||||
world: WorldType,
|
||||
character: Character,
|
||||
encounter: Encounter,
|
||||
monsters: SceneHostileNpc[],
|
||||
history: StoryMoment[],
|
||||
context: StoryGenerationContext,
|
||||
conversationHistory: StoryMoment['dialogue'],
|
||||
playerMessage: string,
|
||||
npcState: Record<string, unknown>,
|
||||
options: {
|
||||
onReplyUpdate?: (text: string) => void;
|
||||
} = {},
|
||||
) {
|
||||
const payload = {
|
||||
worldType: world,
|
||||
character,
|
||||
encounter,
|
||||
monsters,
|
||||
history,
|
||||
context,
|
||||
conversationHistory: conversationHistory ?? [],
|
||||
playerMessage,
|
||||
npcState,
|
||||
} satisfies NpcChatTurnRequest;
|
||||
|
||||
const response = await fetchWithApiAuth(`${RUNTIME_API_BASE}/chat/npc/turn/stream`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const responseText = await response.text();
|
||||
throw new Error(parseApiErrorMessage(responseText, 'NPC 聊天续写失败'));
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error('streaming response body is unavailable');
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
let buffer = '';
|
||||
let accumulatedReply = '';
|
||||
let completedResult: NpcChatTurnResult | null = null;
|
||||
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
while (buffer.includes('\n\n')) {
|
||||
const boundary = buffer.indexOf('\n\n');
|
||||
const eventBlock = buffer.slice(0, boundary);
|
||||
buffer = buffer.slice(boundary + 2);
|
||||
|
||||
const parsedEvent = parseSseEventBlock(eventBlock);
|
||||
if (!parsedEvent) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (parsedEvent.data === '[DONE]') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (parsedEvent.event === 'reply_delta') {
|
||||
const payloadRecord = JSON.parse(parsedEvent.data) as Record<string, unknown>;
|
||||
const nextText =
|
||||
typeof payloadRecord.text === 'string' ? payloadRecord.text : '';
|
||||
accumulatedReply = nextText;
|
||||
options.onReplyUpdate?.(accumulatedReply);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (parsedEvent.event === 'complete') {
|
||||
completedResult = JSON.parse(parsedEvent.data) as NpcChatTurnResult;
|
||||
accumulatedReply = completedResult.npcReply;
|
||||
options.onReplyUpdate?.(accumulatedReply);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (parsedEvent.event === 'error') {
|
||||
const payloadRecord = JSON.parse(parsedEvent.data) as Record<string, unknown>;
|
||||
throw new Error(
|
||||
typeof payloadRecord.message === 'string'
|
||||
? payloadRecord.message
|
||||
: 'NPC 聊天续写失败',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!completedResult) {
|
||||
throw new Error('NPC 聊天续写结果为空');
|
||||
}
|
||||
|
||||
return completedResult;
|
||||
}
|
||||
|
||||
export async function streamNpcRecruitDialogue(
|
||||
world: WorldType,
|
||||
character: Character,
|
||||
|
||||
@@ -101,9 +101,17 @@ export interface QuestLogEntry {
|
||||
}
|
||||
|
||||
export interface StoryDialogueTurn {
|
||||
speaker: 'player' | 'npc' | 'companion';
|
||||
speaker: 'player' | 'npc' | 'companion' | 'system';
|
||||
speakerName?: string;
|
||||
text: string;
|
||||
affinityDelta?: number;
|
||||
}
|
||||
|
||||
export interface StoryNpcChatState {
|
||||
npcId: string;
|
||||
npcName: string;
|
||||
turnCount: number;
|
||||
customInputPlaceholder?: string;
|
||||
}
|
||||
|
||||
export interface CharacterChatTurn {
|
||||
@@ -127,6 +135,7 @@ export interface StoryMoment {
|
||||
streaming?: boolean;
|
||||
deferredOptions?: StoryOption[];
|
||||
historyRole?: StoryHistoryRole;
|
||||
npcChatState?: StoryNpcChatState;
|
||||
}
|
||||
|
||||
export type StoryOptionInteraction =
|
||||
|
||||
Reference in New Issue
Block a user