578 lines
18 KiB
TypeScript
578 lines
18 KiB
TypeScript
import type {
|
||
Dispatch,
|
||
SetStateAction,
|
||
} from 'react';
|
||
import { useState } from 'react';
|
||
|
||
import {
|
||
getCharacterById,
|
||
} from '../../data/characterPresets';
|
||
import {
|
||
buildNpcGiftModalState,
|
||
buildNpcRecruitModalState,
|
||
buildNpcTradeModalIntroText,
|
||
} from '../../data/functionCatalog';
|
||
import {
|
||
buildNpcTradeTransactionActionText,
|
||
} from '../../data/npcInteractions';
|
||
import { streamNpcRecruitDialogue } from '../../services/aiService';
|
||
import type { StoryGenerationContext } from '../../services/aiTypes';
|
||
import type {
|
||
Character,
|
||
Encounter,
|
||
GameState,
|
||
InventoryItem,
|
||
RuntimeNpcTradeItemView,
|
||
StoryMoment,
|
||
StoryOption,
|
||
} from '../../types';
|
||
import { resolveRpgRuntimeChoice } from '.';
|
||
import type {
|
||
GiftModalState,
|
||
RecruitModalState,
|
||
StoryGenerationNpcUi,
|
||
TradeModalState,
|
||
} from './uiTypes';
|
||
|
||
type GenerateStoryForState = (params: {
|
||
state: GameState;
|
||
character: Character;
|
||
history: StoryMoment[];
|
||
choice?: string;
|
||
lastFunctionId?: string | null;
|
||
optionCatalog?: StoryOption[] | null;
|
||
}) => Promise<StoryMoment>;
|
||
|
||
type StoryNpcInteractionRuntime = {
|
||
currentStory: StoryMoment | null;
|
||
setCurrentStory: Dispatch<SetStateAction<StoryMoment | null>>;
|
||
setAiError: Dispatch<SetStateAction<string | null>>;
|
||
setIsLoading: Dispatch<SetStateAction<boolean>>;
|
||
buildStoryContextFromState: (
|
||
state: GameState,
|
||
extras?: {
|
||
lastFunctionId?: string | null;
|
||
encounterNpcStateOverride?: GameState['npcStates'][string] | null;
|
||
},
|
||
) => StoryGenerationContext;
|
||
buildFallbackStoryForState: (
|
||
state: GameState,
|
||
character: Character,
|
||
fallbackText?: string,
|
||
) => StoryMoment;
|
||
buildDialogueStoryMoment: (
|
||
npcName: string,
|
||
text: string,
|
||
options: StoryOption[],
|
||
streaming?: boolean,
|
||
) => StoryMoment;
|
||
generateStoryForState: GenerateStoryForState;
|
||
getStoryGenerationHostileNpcs: (state: GameState) => GameState['sceneHostileNpcs'];
|
||
getTypewriterDelay: (char: string) => number;
|
||
};
|
||
|
||
function buildOfflineRecruitDialogue(encounter: Encounter, releasedCompanionName?: string | null) {
|
||
const releaseLine = releasedCompanionName
|
||
? `你:如果你愿意加入,我会先让${releasedCompanionName}暂时离队,把位置腾给你。`
|
||
: '你:如果你愿意加入,我希望接下来能和你并肩行动。';
|
||
|
||
return [
|
||
'你:我不是一时起意,我想正式邀请你加入我的队伍。',
|
||
`${encounter.npcName}:你这话说得够直接,不过我更在意,你是否真的清楚自己接下来要面对什么。`,
|
||
releaseLine,
|
||
`${encounter.npcName}:既然你已经想清楚了,那我就跟你走这一程,看看你能把这条路走到哪里。`,
|
||
].join('\n');
|
||
}
|
||
|
||
function normalizeRecruitDialogue(
|
||
encounter: Encounter,
|
||
dialogueText: string,
|
||
releasedCompanionName?: string | null,
|
||
) {
|
||
const rawLines = dialogueText
|
||
.replace(/\r/g, '')
|
||
.split('\n')
|
||
.map(line => line.trim())
|
||
.filter(Boolean);
|
||
const refusalPattern = /拒绝|不加入|不答应|不能加入|不会加入|暂不加入|以后再说|改天再说|再考虑|观望|算了|不同路/u;
|
||
const sanitizedLines = rawLines.filter(line => !refusalPattern.test(line));
|
||
const npcPrefix = `${encounter.npcName}:`;
|
||
const playerPrefix = '你:';
|
||
const releaseLine = releasedCompanionName
|
||
? `${playerPrefix}我会先让${releasedCompanionName}暂时离队,把位置腾给你。`
|
||
: `${playerPrefix}我会把接下来的路安排好,和你并肩前行。`;
|
||
const defaultLines = [
|
||
`${playerPrefix}我是真心邀请你加入队伍,不是随口一提。`,
|
||
`${npcPrefix}你的意思我已经听明白了,我更在意你是否真的准备好了。`,
|
||
releaseLine,
|
||
`${npcPrefix}好,我答应加入你的队伍。从现在开始,我就与你一同行动。`,
|
||
];
|
||
|
||
const workingLines = sanitizedLines.length > 0 ? sanitizedLines.slice(0, 5) : defaultLines.slice(0, 3);
|
||
if (!workingLines.some(line => line.startsWith(playerPrefix))) {
|
||
const firstDefaultLine = defaultLines[0];
|
||
if (firstDefaultLine) {
|
||
workingLines.unshift(firstDefaultLine);
|
||
}
|
||
}
|
||
if (!workingLines.some(line => line.startsWith(npcPrefix))) {
|
||
const secondDefaultLine = defaultLines[1];
|
||
if (secondDefaultLine) {
|
||
workingLines.push(secondDefaultLine);
|
||
}
|
||
}
|
||
|
||
const acceptanceLine = `${npcPrefix}好,我答应加入你的队伍。从现在开始,我就与你一同行动。`;
|
||
const lastWorkingLine = workingLines[workingLines.length - 1];
|
||
if (
|
||
workingLines.length === 0
|
||
|| !lastWorkingLine?.startsWith(npcPrefix)
|
||
|| refusalPattern.test(lastWorkingLine)
|
||
) {
|
||
workingLines.push(acceptanceLine);
|
||
} else {
|
||
workingLines[workingLines.length - 1] = acceptanceLine;
|
||
}
|
||
|
||
const compactLines = workingLines.slice(0, 5);
|
||
if (compactLines[compactLines.length - 1] !== acceptanceLine) {
|
||
compactLines.push(acceptanceLine);
|
||
}
|
||
|
||
return compactLines.slice(0, 6).join('\n');
|
||
}
|
||
|
||
function normalizeTradeQuantity(quantity: number) {
|
||
return Math.max(1, Math.floor(Number.isFinite(quantity) ? quantity : 1));
|
||
}
|
||
|
||
export function useStoryNpcInteractionFlow({
|
||
gameState,
|
||
setGameState,
|
||
getNpcEncounterKey,
|
||
getResolvedNpcState: _getResolvedNpcState,
|
||
updateNpcState: _updateNpcState,
|
||
cloneInventoryItemForOwner: _cloneInventoryItemForOwner,
|
||
runtime,
|
||
}: {
|
||
gameState: GameState;
|
||
setGameState: Dispatch<SetStateAction<GameState>>;
|
||
getNpcEncounterKey: (encounter: Encounter) => string;
|
||
getResolvedNpcState: (state: GameState, encounter: Encounter) => GameState['npcStates'][string];
|
||
updateNpcState: (
|
||
state: GameState,
|
||
encounter: Encounter,
|
||
updater: (npcState: GameState['npcStates'][string]) => GameState['npcStates'][string],
|
||
) => GameState;
|
||
cloneInventoryItemForOwner: (
|
||
item: InventoryItem,
|
||
owner: 'player' | 'npc',
|
||
quantity?: number,
|
||
) => InventoryItem;
|
||
runtime: StoryNpcInteractionRuntime;
|
||
}) {
|
||
const [tradeModal, setTradeModal] = useState<TradeModalState | null>(null);
|
||
const [giftModal, setGiftModal] = useState<GiftModalState | null>(null);
|
||
const [recruitModal, setRecruitModal] = useState<RecruitModalState | null>(null);
|
||
|
||
const resolveRecruitmentOnServer = async (params: {
|
||
encounter: Encounter;
|
||
actionText: string;
|
||
releasedNpcId?: string | null;
|
||
preludeText?: string | null;
|
||
}) => {
|
||
const playerCharacter = gameState.playerCharacter;
|
||
if (
|
||
!playerCharacter ||
|
||
!gameState.worldType ||
|
||
gameState.currentScene !== 'Story'
|
||
) {
|
||
return false;
|
||
}
|
||
|
||
try {
|
||
const { hydratedSnapshot, nextStory } = await resolveRpgRuntimeChoice({
|
||
gameState,
|
||
currentStory: runtime.currentStory,
|
||
option: {
|
||
functionId: 'npc_recruit',
|
||
actionText: params.actionText,
|
||
interaction: {
|
||
kind: 'npc',
|
||
npcId: params.encounter.id ?? getNpcEncounterKey(params.encounter),
|
||
action: 'recruit',
|
||
},
|
||
},
|
||
payload: {
|
||
...(params.releasedNpcId
|
||
? {
|
||
releaseNpcId: params.releasedNpcId,
|
||
}
|
||
: {}),
|
||
...(params.preludeText
|
||
? {
|
||
preludeText: params.preludeText,
|
||
}
|
||
: {}),
|
||
},
|
||
});
|
||
|
||
setGameState(hydratedSnapshot.gameState);
|
||
runtime.setCurrentStory(nextStory);
|
||
return true;
|
||
} catch (error) {
|
||
console.error('Failed to resolve npc recruit action on the server:', error);
|
||
runtime.setAiError(
|
||
error instanceof Error ? error.message : 'NPC 招募执行失败',
|
||
);
|
||
if (!runtime.currentStory) {
|
||
runtime.setCurrentStory(
|
||
runtime.buildFallbackStoryForState(gameState, playerCharacter),
|
||
);
|
||
}
|
||
return false;
|
||
} finally {
|
||
runtime.setIsLoading(false);
|
||
}
|
||
};
|
||
|
||
const startRecruitmentSequence = async (
|
||
encounter: Encounter,
|
||
actionText: string,
|
||
releasedNpcId?: string | null,
|
||
) => {
|
||
if (!gameState.playerCharacter) return;
|
||
|
||
const releasedCompanionName = releasedNpcId
|
||
? (() => {
|
||
const releasedCompanion = gameState.companions.find(item => item.npcId === releasedNpcId);
|
||
return releasedCompanion?.characterId
|
||
? getCharacterById(releasedCompanion.characterId)?.name ?? null
|
||
: null;
|
||
})()
|
||
: null;
|
||
const provisionalState = {
|
||
...gameState,
|
||
};
|
||
const recruitPromptSummary = releasedCompanionName
|
||
? `如果对方答应加入,你会先让${releasedCompanionName}离队,为新同伴腾出位置。`
|
||
: '如果对方答应加入,你们将立刻结伴同行。';
|
||
|
||
setRecruitModal(null);
|
||
runtime.setAiError(null);
|
||
runtime.setIsLoading(true);
|
||
runtime.setCurrentStory(runtime.buildDialogueStoryMoment(encounter.npcName, '', [], true));
|
||
|
||
let dialogueText = '';
|
||
let streamedTargetText = '';
|
||
let displayedText = '';
|
||
let streamCompleted = false;
|
||
|
||
const typewriterPromise = (async () => {
|
||
while (!streamCompleted || displayedText.length < streamedTargetText.length) {
|
||
if (displayedText.length >= streamedTargetText.length) {
|
||
await new Promise(resolve => window.setTimeout(resolve, 40));
|
||
continue;
|
||
}
|
||
|
||
const nextChar = streamedTargetText[displayedText.length];
|
||
if (!nextChar) {
|
||
await new Promise(resolve => window.setTimeout(resolve, 40));
|
||
continue;
|
||
}
|
||
displayedText += nextChar;
|
||
runtime.setCurrentStory(runtime.buildDialogueStoryMoment(encounter.npcName, displayedText, [], true));
|
||
await new Promise(resolve => window.setTimeout(resolve, runtime.getTypewriterDelay(nextChar)));
|
||
}
|
||
})();
|
||
|
||
try {
|
||
dialogueText = await streamNpcRecruitDialogue(
|
||
gameState.worldType!,
|
||
gameState.playerCharacter,
|
||
encounter,
|
||
runtime.getStoryGenerationHostileNpcs(provisionalState),
|
||
gameState.storyHistory,
|
||
runtime.buildStoryContextFromState(provisionalState, {
|
||
lastFunctionId: 'npc_recruit',
|
||
}),
|
||
actionText,
|
||
recruitPromptSummary,
|
||
{
|
||
onUpdate: text => {
|
||
streamedTargetText = text;
|
||
},
|
||
},
|
||
);
|
||
streamedTargetText = dialogueText;
|
||
streamCompleted = true;
|
||
await typewriterPromise;
|
||
} catch (error) {
|
||
streamCompleted = true;
|
||
await typewriterPromise;
|
||
console.error('Failed to stream recruit dialogue:', error);
|
||
dialogueText = displayedText || buildOfflineRecruitDialogue(encounter, releasedCompanionName);
|
||
runtime.setAiError(error instanceof Error ? error.message : '未知智能生成错误');
|
||
}
|
||
|
||
const finalDialogueText = normalizeRecruitDialogue(
|
||
encounter,
|
||
dialogueText || displayedText || buildOfflineRecruitDialogue(encounter, releasedCompanionName),
|
||
releasedCompanionName,
|
||
);
|
||
runtime.setCurrentStory(runtime.buildDialogueStoryMoment(encounter.npcName, finalDialogueText, [], false));
|
||
await new Promise(resolve => window.setTimeout(resolve, 260));
|
||
await resolveRecruitmentOnServer({
|
||
encounter,
|
||
actionText,
|
||
releasedNpcId,
|
||
preludeText: finalDialogueText,
|
||
});
|
||
};
|
||
|
||
const getRuntimeTradeItems = (
|
||
mode: 'buy' | 'sell',
|
||
): RuntimeNpcTradeItemView[] =>
|
||
mode === 'buy'
|
||
? gameState.runtimeNpcInteraction?.trade.buyItems ?? []
|
||
: gameState.runtimeNpcInteraction?.trade.sellItems ?? [];
|
||
|
||
const findRuntimeTradeItem = (modal: TradeModalState) => {
|
||
const itemId =
|
||
modal.mode === 'buy'
|
||
? modal.selectedNpcItemId
|
||
: modal.selectedPlayerItemId;
|
||
if (!itemId) return null;
|
||
|
||
return (
|
||
getRuntimeTradeItems(modal.mode).find((item) => item.itemId === itemId) ??
|
||
null
|
||
);
|
||
};
|
||
|
||
const findRuntimeGiftItem = (itemId: string | null) => {
|
||
if (!itemId) return null;
|
||
return (
|
||
gameState.runtimeNpcInteraction?.gift.items.find(
|
||
(item) => item.itemId === itemId,
|
||
) ?? null
|
||
);
|
||
};
|
||
|
||
const openTradeModal = (encounter: Encounter, actionText: string) => {
|
||
setTradeModal(
|
||
{
|
||
encounter,
|
||
actionText,
|
||
introText: buildNpcTradeModalIntroText(encounter),
|
||
mode: 'buy',
|
||
selectedNpcItemId:
|
||
gameState.runtimeNpcInteraction?.trade.buyItems.find(
|
||
(item) => item.canSubmit,
|
||
)?.itemId ??
|
||
gameState.runtimeNpcInteraction?.trade.buyItems[0]?.itemId ??
|
||
null,
|
||
selectedPlayerItemId:
|
||
gameState.runtimeNpcInteraction?.trade.sellItems.find(
|
||
(item) => item.canSubmit,
|
||
)?.itemId ??
|
||
gameState.runtimeNpcInteraction?.trade.sellItems[0]?.itemId ??
|
||
null,
|
||
selectedQuantity: 1,
|
||
},
|
||
);
|
||
};
|
||
|
||
const openGiftModal = (encounter: Encounter, actionText: string) => {
|
||
const selectedItemId =
|
||
gameState.runtimeNpcInteraction?.gift.items.find((item) => item.canSubmit)
|
||
?.itemId ??
|
||
gameState.runtimeNpcInteraction?.gift.items[0]?.itemId ??
|
||
null;
|
||
|
||
setGiftModal(
|
||
buildNpcGiftModalState(
|
||
encounter,
|
||
actionText,
|
||
selectedItemId,
|
||
),
|
||
);
|
||
};
|
||
|
||
const openRecruitModal = (encounter: Encounter, actionText: string) => {
|
||
setRecruitModal(buildNpcRecruitModalState(gameState, encounter, actionText));
|
||
};
|
||
|
||
const clearNpcInteractionUi = () => {
|
||
setTradeModal(null);
|
||
setGiftModal(null);
|
||
setRecruitModal(null);
|
||
};
|
||
|
||
const resolveServerNpcAction = async (params: {
|
||
encounter: Encounter;
|
||
actionText: string;
|
||
functionId: string;
|
||
action: 'trade' | 'gift' | 'quest_accept' | 'quest_turn_in';
|
||
payload?: Record<string, unknown>;
|
||
}) => {
|
||
const playerCharacter = gameState.playerCharacter;
|
||
if (
|
||
!playerCharacter ||
|
||
!gameState.worldType ||
|
||
gameState.currentScene !== 'Story'
|
||
) {
|
||
return false;
|
||
}
|
||
|
||
runtime.setAiError(null);
|
||
runtime.setIsLoading(true);
|
||
|
||
try {
|
||
const { hydratedSnapshot, nextStory } = await resolveRpgRuntimeChoice({
|
||
gameState,
|
||
currentStory: runtime.currentStory,
|
||
option: {
|
||
functionId: params.functionId,
|
||
actionText: params.actionText,
|
||
interaction: {
|
||
kind: 'npc',
|
||
npcId: params.encounter.id ?? getNpcEncounterKey(params.encounter),
|
||
action: params.action,
|
||
},
|
||
},
|
||
payload: params.payload,
|
||
});
|
||
|
||
setGameState(hydratedSnapshot.gameState);
|
||
runtime.setCurrentStory(nextStory);
|
||
return true;
|
||
} catch (error) {
|
||
console.error('Failed to resolve npc runtime action on the server:', error);
|
||
runtime.setAiError(
|
||
error instanceof Error ? error.message : 'NPC 交互执行失败',
|
||
);
|
||
if (!runtime.currentStory) {
|
||
runtime.setCurrentStory(
|
||
runtime.buildFallbackStoryForState(gameState, playerCharacter),
|
||
);
|
||
}
|
||
return false;
|
||
} finally {
|
||
runtime.setIsLoading(false);
|
||
}
|
||
};
|
||
|
||
const confirmTrade = () => {
|
||
if (!tradeModal || !gameState.playerCharacter) return;
|
||
|
||
const encounter = tradeModal.encounter;
|
||
const quantity = normalizeTradeQuantity(tradeModal.selectedQuantity);
|
||
const tradeItem = findRuntimeTradeItem(tradeModal);
|
||
if (!tradeItem) return;
|
||
|
||
setTradeModal(null);
|
||
void resolveServerNpcAction({
|
||
encounter,
|
||
actionText: buildNpcTradeTransactionActionText({
|
||
encounter,
|
||
mode: tradeModal.mode,
|
||
item: tradeItem.item,
|
||
quantity,
|
||
}),
|
||
functionId: 'npc_trade',
|
||
action: 'trade',
|
||
payload: {
|
||
mode: tradeModal.mode,
|
||
itemId: tradeItem.itemId,
|
||
quantity,
|
||
},
|
||
});
|
||
};
|
||
|
||
const confirmGift = () => {
|
||
if (!giftModal || !gameState.playerCharacter) return;
|
||
|
||
const encounter = giftModal.encounter;
|
||
const giftItem = findRuntimeGiftItem(giftModal.selectedItemId);
|
||
if (!giftItem) return;
|
||
|
||
setGiftModal(null);
|
||
void resolveServerNpcAction({
|
||
encounter,
|
||
actionText: `把${giftItem.item.name}赠给${encounter.npcName}`,
|
||
functionId: 'npc_gift',
|
||
action: 'gift',
|
||
payload: {
|
||
itemId: giftItem.itemId,
|
||
},
|
||
});
|
||
};
|
||
|
||
return {
|
||
npcUi: {
|
||
tradeModal,
|
||
giftModal,
|
||
recruitModal,
|
||
setTradeMode: (mode: 'buy' | 'sell') => setTradeModal(current => {
|
||
if (!current) return current;
|
||
return {
|
||
...current,
|
||
mode,
|
||
selectedNpcItemId:
|
||
current.selectedNpcItemId ??
|
||
gameState.runtimeNpcInteraction?.trade.buyItems[0]?.itemId ??
|
||
null,
|
||
selectedPlayerItemId:
|
||
current.selectedPlayerItemId ??
|
||
gameState.runtimeNpcInteraction?.trade.sellItems[0]?.itemId ??
|
||
null,
|
||
selectedQuantity: 1,
|
||
};
|
||
}),
|
||
selectTradeNpcItem: (itemId: string) => setTradeModal(current => {
|
||
if (!current) return current;
|
||
return {
|
||
...current,
|
||
selectedNpcItemId: itemId,
|
||
selectedQuantity: 1,
|
||
};
|
||
}),
|
||
selectTradePlayerItem: (itemId: string) => setTradeModal(current => {
|
||
if (!current) return current;
|
||
return {
|
||
...current,
|
||
selectedPlayerItemId: itemId,
|
||
selectedQuantity: 1,
|
||
};
|
||
}),
|
||
setTradeQuantity: (quantity: number) => setTradeModal(current => current
|
||
? {
|
||
...current,
|
||
selectedQuantity: normalizeTradeQuantity(quantity),
|
||
}
|
||
: current),
|
||
closeTradeModal: () => setTradeModal(null),
|
||
confirmTrade,
|
||
selectGiftItem: (itemId: string) => setGiftModal(current => current ? { ...current, selectedItemId: itemId } : current),
|
||
closeGiftModal: () => setGiftModal(null),
|
||
confirmGift,
|
||
selectRecruitRelease: (npcId: string) => setRecruitModal(current => current ? { ...current, selectedReleaseNpcId: npcId } : current),
|
||
closeRecruitModal: () => setRecruitModal(null),
|
||
confirmRecruit: () => {
|
||
if (!recruitModal) return;
|
||
void startRecruitmentSequence(
|
||
recruitModal.encounter,
|
||
recruitModal.actionText,
|
||
recruitModal.selectedReleaseNpcId,
|
||
);
|
||
},
|
||
} satisfies StoryGenerationNpcUi,
|
||
openTradeModal,
|
||
openGiftModal,
|
||
openRecruitModal,
|
||
startRecruitmentSequence,
|
||
clearNpcInteractionUi,
|
||
};
|
||
}
|