1
This commit is contained in:
@@ -1,20 +1,13 @@
|
||||
import type {
|
||||
Dispatch,
|
||||
SetStateAction,
|
||||
} from 'react';
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import {
|
||||
getCharacterById,
|
||||
} from '../../data/characterPresets';
|
||||
import { getCharacterById } from '../../data/characterPresets';
|
||||
import {
|
||||
buildNpcGiftModalState,
|
||||
buildNpcRecruitModalState,
|
||||
buildNpcTradeModalIntroText,
|
||||
} from '../../data/functionCatalog';
|
||||
import {
|
||||
buildNpcTradeTransactionActionText,
|
||||
} from '../../data/npcInteractions';
|
||||
import { buildNpcTradeTransactionActionText } from '../../data/npcInteractions';
|
||||
import { streamNpcRecruitDialogue } from '../../services/aiService';
|
||||
import type { StoryGenerationContext } from '../../services/aiTypes';
|
||||
import type {
|
||||
@@ -52,6 +45,7 @@ type StoryNpcInteractionRuntime = {
|
||||
state: GameState,
|
||||
extras?: {
|
||||
lastFunctionId?: string | null;
|
||||
currentStory?: StoryMoment | null;
|
||||
encounterNpcStateOverride?: GameState['npcStates'][string] | null;
|
||||
},
|
||||
) => StoryGenerationContext;
|
||||
@@ -67,11 +61,16 @@ type StoryNpcInteractionRuntime = {
|
||||
streaming?: boolean,
|
||||
) => StoryMoment;
|
||||
generateStoryForState: GenerateStoryForState;
|
||||
getStoryGenerationHostileNpcs: (state: GameState) => GameState['sceneHostileNpcs'];
|
||||
getStoryGenerationHostileNpcs: (
|
||||
state: GameState,
|
||||
) => GameState['sceneHostileNpcs'];
|
||||
getTypewriterDelay: (char: string) => number;
|
||||
};
|
||||
|
||||
function buildOfflineRecruitDialogue(encounter: Encounter, releasedCompanionName?: string | null) {
|
||||
function buildOfflineRecruitDialogue(
|
||||
encounter: Encounter,
|
||||
releasedCompanionName?: string | null,
|
||||
) {
|
||||
const releaseLine = releasedCompanionName
|
||||
? `你:如果你愿意加入,我会先让${releasedCompanionName}暂时离队,把位置腾给你。`
|
||||
: '你:如果你愿意加入,我希望接下来能和你并肩行动。';
|
||||
@@ -92,10 +91,11 @@ function normalizeRecruitDialogue(
|
||||
const rawLines = dialogueText
|
||||
.replace(/\r/g, '')
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
const refusalPattern = /拒绝|不加入|不答应|不能加入|不会加入|暂不加入|以后再说|改天再说|再考虑|观望|算了|不同路/u;
|
||||
const sanitizedLines = rawLines.filter(line => !refusalPattern.test(line));
|
||||
const refusalPattern =
|
||||
/拒绝|不加入|不答应|不能加入|不会加入|暂不加入|以后再说|改天再说|再考虑|观望|算了|不同路/u;
|
||||
const sanitizedLines = rawLines.filter((line) => !refusalPattern.test(line));
|
||||
const npcPrefix = `${encounter.npcName}:`;
|
||||
const playerPrefix = '你:';
|
||||
const releaseLine = releasedCompanionName
|
||||
@@ -108,14 +108,17 @@ function normalizeRecruitDialogue(
|
||||
`${npcPrefix}好,我答应加入你的队伍。从现在开始,我就与你一同行动。`,
|
||||
];
|
||||
|
||||
const workingLines = sanitizedLines.length > 0 ? sanitizedLines.slice(0, 5) : defaultLines.slice(0, 3);
|
||||
if (!workingLines.some(line => line.startsWith(playerPrefix))) {
|
||||
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))) {
|
||||
if (!workingLines.some((line) => line.startsWith(npcPrefix))) {
|
||||
const secondDefaultLine = defaultLines[1];
|
||||
if (secondDefaultLine) {
|
||||
workingLines.push(secondDefaultLine);
|
||||
@@ -125,9 +128,9 @@ function normalizeRecruitDialogue(
|
||||
const acceptanceLine = `${npcPrefix}好,我答应加入你的队伍。从现在开始,我就与你一同行动。`;
|
||||
const lastWorkingLine = workingLines[workingLines.length - 1];
|
||||
if (
|
||||
workingLines.length === 0
|
||||
|| !lastWorkingLine?.startsWith(npcPrefix)
|
||||
|| refusalPattern.test(lastWorkingLine)
|
||||
workingLines.length === 0 ||
|
||||
!lastWorkingLine?.startsWith(npcPrefix) ||
|
||||
refusalPattern.test(lastWorkingLine)
|
||||
) {
|
||||
workingLines.push(acceptanceLine);
|
||||
} else {
|
||||
@@ -158,11 +161,16 @@ export function useStoryNpcInteractionFlow({
|
||||
gameState: GameState;
|
||||
setGameState: Dispatch<SetStateAction<GameState>>;
|
||||
getNpcEncounterKey: (encounter: Encounter) => string;
|
||||
getResolvedNpcState: (state: GameState, encounter: Encounter) => GameState['npcStates'][string];
|
||||
getResolvedNpcState: (
|
||||
state: GameState,
|
||||
encounter: Encounter,
|
||||
) => GameState['npcStates'][string];
|
||||
updateNpcState: (
|
||||
state: GameState,
|
||||
encounter: Encounter,
|
||||
updater: (npcState: GameState['npcStates'][string]) => GameState['npcStates'][string],
|
||||
updater: (
|
||||
npcState: GameState['npcStates'][string],
|
||||
) => GameState['npcStates'][string],
|
||||
) => GameState;
|
||||
cloneInventoryItemForOwner: (
|
||||
item: InventoryItem,
|
||||
@@ -173,7 +181,9 @@ export function useStoryNpcInteractionFlow({
|
||||
}) {
|
||||
const [tradeModal, setTradeModal] = useState<TradeModalState | null>(null);
|
||||
const [giftModal, setGiftModal] = useState<GiftModalState | null>(null);
|
||||
const [recruitModal, setRecruitModal] = useState<RecruitModalState | null>(null);
|
||||
const [recruitModal, setRecruitModal] = useState<RecruitModalState | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const resolveRecruitmentOnServer = async (params: {
|
||||
encounter: Encounter;
|
||||
@@ -221,7 +231,10 @@ export function useStoryNpcInteractionFlow({
|
||||
runtime.setCurrentStory(nextStory);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to resolve npc recruit action on the server:', error);
|
||||
console.error(
|
||||
'Failed to resolve npc recruit action on the server:',
|
||||
error,
|
||||
);
|
||||
runtime.setAiError(
|
||||
error instanceof Error ? error.message : 'NPC 招募执行失败',
|
||||
);
|
||||
@@ -245,9 +258,11 @@ export function useStoryNpcInteractionFlow({
|
||||
|
||||
const releasedCompanionName = releasedNpcId
|
||||
? (() => {
|
||||
const releasedCompanion = gameState.companions.find(item => item.npcId === releasedNpcId);
|
||||
const releasedCompanion = gameState.companions.find(
|
||||
(item) => item.npcId === releasedNpcId,
|
||||
);
|
||||
return releasedCompanion?.characterId
|
||||
? getCharacterById(releasedCompanion.characterId)?.name ?? null
|
||||
? (getCharacterById(releasedCompanion.characterId)?.name ?? null)
|
||||
: null;
|
||||
})()
|
||||
: null;
|
||||
@@ -261,7 +276,9 @@ export function useStoryNpcInteractionFlow({
|
||||
setRecruitModal(null);
|
||||
runtime.setAiError(null);
|
||||
runtime.setIsLoading(true);
|
||||
runtime.setCurrentStory(runtime.buildDialogueStoryMoment(encounter.npcName, '', [], true));
|
||||
runtime.setCurrentStory(
|
||||
runtime.buildDialogueStoryMoment(encounter.npcName, '', [], true),
|
||||
);
|
||||
|
||||
let dialogueText = '';
|
||||
let streamedTargetText = '';
|
||||
@@ -269,20 +286,32 @@ export function useStoryNpcInteractionFlow({
|
||||
let streamCompleted = false;
|
||||
|
||||
const typewriterPromise = (async () => {
|
||||
while (!streamCompleted || displayedText.length < streamedTargetText.length) {
|
||||
while (
|
||||
!streamCompleted ||
|
||||
displayedText.length < streamedTargetText.length
|
||||
) {
|
||||
if (displayedText.length >= streamedTargetText.length) {
|
||||
await new Promise(resolve => window.setTimeout(resolve, 40));
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 40));
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextChar = streamedTargetText[displayedText.length];
|
||||
if (!nextChar) {
|
||||
await new Promise(resolve => window.setTimeout(resolve, 40));
|
||||
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)));
|
||||
runtime.setCurrentStory(
|
||||
runtime.buildDialogueStoryMoment(
|
||||
encounter.npcName,
|
||||
displayedText,
|
||||
[],
|
||||
true,
|
||||
),
|
||||
);
|
||||
await new Promise((resolve) =>
|
||||
window.setTimeout(resolve, runtime.getTypewriterDelay(nextChar)),
|
||||
);
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -294,12 +323,13 @@ export function useStoryNpcInteractionFlow({
|
||||
runtime.getStoryGenerationHostileNpcs(provisionalState),
|
||||
gameState.storyHistory,
|
||||
runtime.buildStoryContextFromState(provisionalState, {
|
||||
currentStory: runtime.currentStory,
|
||||
lastFunctionId: 'npc_recruit',
|
||||
}),
|
||||
actionText,
|
||||
recruitPromptSummary,
|
||||
{
|
||||
onUpdate: text => {
|
||||
onUpdate: (text) => {
|
||||
streamedTargetText = text;
|
||||
},
|
||||
},
|
||||
@@ -311,17 +341,30 @@ export function useStoryNpcInteractionFlow({
|
||||
streamCompleted = true;
|
||||
await typewriterPromise;
|
||||
console.error('Failed to stream recruit dialogue:', error);
|
||||
dialogueText = displayedText || buildOfflineRecruitDialogue(encounter, releasedCompanionName);
|
||||
runtime.setAiError(error instanceof Error ? error.message : '未知智能生成错误');
|
||||
dialogueText =
|
||||
displayedText ||
|
||||
buildOfflineRecruitDialogue(encounter, releasedCompanionName);
|
||||
runtime.setAiError(
|
||||
error instanceof Error ? error.message : '未知智能生成错误',
|
||||
);
|
||||
}
|
||||
|
||||
const finalDialogueText = normalizeRecruitDialogue(
|
||||
encounter,
|
||||
dialogueText || displayedText || buildOfflineRecruitDialogue(encounter, releasedCompanionName),
|
||||
dialogueText ||
|
||||
displayedText ||
|
||||
buildOfflineRecruitDialogue(encounter, releasedCompanionName),
|
||||
releasedCompanionName,
|
||||
);
|
||||
runtime.setCurrentStory(runtime.buildDialogueStoryMoment(encounter.npcName, finalDialogueText, [], false));
|
||||
await new Promise(resolve => window.setTimeout(resolve, 260));
|
||||
runtime.setCurrentStory(
|
||||
runtime.buildDialogueStoryMoment(
|
||||
encounter.npcName,
|
||||
finalDialogueText,
|
||||
[],
|
||||
false,
|
||||
),
|
||||
);
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 260));
|
||||
await resolveRecruitmentOnServer({
|
||||
encounter,
|
||||
actionText,
|
||||
@@ -334,8 +377,8 @@ export function useStoryNpcInteractionFlow({
|
||||
mode: 'buy' | 'sell',
|
||||
): RuntimeNpcTradeItemView[] =>
|
||||
mode === 'buy'
|
||||
? gameState.runtimeNpcInteraction?.trade.buyItems ?? []
|
||||
: gameState.runtimeNpcInteraction?.trade.sellItems ?? [];
|
||||
? (gameState.runtimeNpcInteraction?.trade.buyItems ?? [])
|
||||
: (gameState.runtimeNpcInteraction?.trade.sellItems ?? []);
|
||||
|
||||
const findRuntimeTradeItem = (modal: TradeModalState) => {
|
||||
const itemId =
|
||||
@@ -360,27 +403,25 @@ export function useStoryNpcInteractionFlow({
|
||||
};
|
||||
|
||||
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,
|
||||
},
|
||||
);
|
||||
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) => {
|
||||
@@ -390,17 +431,13 @@ export function useStoryNpcInteractionFlow({
|
||||
gameState.runtimeNpcInteraction?.gift.items[0]?.itemId ??
|
||||
null;
|
||||
|
||||
setGiftModal(
|
||||
buildNpcGiftModalState(
|
||||
encounter,
|
||||
actionText,
|
||||
selectedItemId,
|
||||
),
|
||||
);
|
||||
setGiftModal(buildNpcGiftModalState(encounter, actionText, selectedItemId));
|
||||
};
|
||||
|
||||
const openRecruitModal = (encounter: Encounter, actionText: string) => {
|
||||
setRecruitModal(buildNpcRecruitModalState(gameState, encounter, actionText));
|
||||
setRecruitModal(
|
||||
buildNpcRecruitModalState(gameState, encounter, actionText),
|
||||
);
|
||||
};
|
||||
|
||||
const clearNpcInteractionUi = () => {
|
||||
@@ -448,7 +485,10 @@ export function useStoryNpcInteractionFlow({
|
||||
runtime.setCurrentStory(nextStory);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to resolve npc runtime action on the server:', error);
|
||||
console.error(
|
||||
'Failed to resolve npc runtime action on the server:',
|
||||
error,
|
||||
);
|
||||
runtime.setAiError(
|
||||
error instanceof Error ? error.message : 'NPC 交互执行失败',
|
||||
);
|
||||
@@ -514,50 +554,62 @@ export function useStoryNpcInteractionFlow({
|
||||
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
|
||||
? {
|
||||
setTradeMode: (mode: 'buy' | 'sell') =>
|
||||
setTradeModal((current) => {
|
||||
if (!current) return current;
|
||||
return {
|
||||
...current,
|
||||
selectedQuantity: normalizeTradeQuantity(quantity),
|
||||
}
|
||||
: 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),
|
||||
selectGiftItem: (itemId: string) =>
|
||||
setGiftModal((current) =>
|
||||
current ? { ...current, selectedItemId: itemId } : current,
|
||||
),
|
||||
closeGiftModal: () => setGiftModal(null),
|
||||
confirmGift,
|
||||
selectRecruitRelease: (npcId: string) => setRecruitModal(current => current ? { ...current, selectedReleaseNpcId: npcId } : current),
|
||||
selectRecruitRelease: (npcId: string) =>
|
||||
setRecruitModal((current) =>
|
||||
current ? { ...current, selectedReleaseNpcId: npcId } : current,
|
||||
),
|
||||
closeRecruitModal: () => setRecruitModal(null),
|
||||
confirmRecruit: () => {
|
||||
if (!recruitModal) return;
|
||||
|
||||
Reference in New Issue
Block a user