This commit is contained in:
2026-04-29 11:51:04 +08:00
parent e191619ab3
commit 412279ae11
89 changed files with 3966 additions and 491 deletions

View File

@@ -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;