Files
Genarrative/src/hooks/story/openingAdventure.ts
2026-04-08 16:41:29 +08:00

329 lines
9.0 KiB
TypeScript

import type { Dispatch, SetStateAction } from 'react';
import {
hasEncounterEntity,
interpolateEncounterTransitionState,
} from '../../data/encounterTransition';
import { STORY_OPENING_CAMP_DIALOGUE_FUNCTION } from '../../data/functionCatalog';
import {
CALL_OUT_ENTRY_X_METERS,
RESOLVED_ENTITY_X_METERS,
} from '../../data/sceneEncounterPreviews';
import { getWorldCampScenePreset } from '../../data/scenePresets';
import { sortStoryOptionsByPriority } from '../../data/stateFunctions';
import { generateNextStep } from '../../services/aiService';
import type { StoryGenerationContext } from '../../services/aiTypes';
import { createHistoryMoment } from '../../services/storyHistory';
import type {
Character,
Encounter,
GameState,
StoryMoment,
StoryOption,
} from '../../types';
const ENCOUNTER_ENTRY_DURATION_MS = 1800;
const ENCOUNTER_ENTRY_TICK_MS = 180;
const OPENING_CAMP_DIALOGUE_FUNCTION_ID =
STORY_OPENING_CAMP_DIALOGUE_FUNCTION.id;
export type PreparedOpeningAdventure = {
encounterKey: string;
actionText: string;
resultText: string;
fallbackText: string;
openingOptions: StoryOption[];
};
export function buildPreparedOpeningAdventure({
state,
character,
getNpcEncounterKey,
appendHistory,
buildCampCompanionOpeningOptions,
buildCampCompanionOpeningResultText,
buildInitialCompanionDialogueText,
}: {
state: GameState;
character: Character;
getNpcEncounterKey: (encounter: Encounter) => string;
appendHistory: (
state: GameState,
actionText: string,
resultText: string,
) => GameState['storyHistory'];
buildCampCompanionOpeningOptions: (
state: GameState,
character: Character,
encounter: Encounter,
) => StoryOption[];
buildCampCompanionOpeningResultText: (
character: Character,
encounter: Encounter,
worldType: GameState['worldType'],
) => string;
buildInitialCompanionDialogueText: (
character: Character,
encounter: Encounter,
worldType: GameState['worldType'],
) => string;
}): PreparedOpeningAdventure | null {
const encounter = state.currentEncounter;
if (
!encounter ||
encounter.kind !== 'npc' ||
encounter.specialBehavior !== 'initial_companion'
) {
return null;
}
const campScene = state.worldType
? getWorldCampScenePreset(state.worldType)
: null;
const actionText = '开始冒险';
const resultText = buildCampCompanionOpeningResultText(
character,
encounter,
state.worldType,
);
const dialogueText = buildInitialCompanionDialogueText(
character,
encounter,
state.worldType,
);
const resolvedEncounter: Encounter = {
...encounter,
specialBehavior: 'camp_companion',
xMeters: RESOLVED_ENTITY_X_METERS,
};
const resolvedState: GameState = {
...state,
currentScenePreset: campScene ?? state.currentScenePreset,
currentEncounter: resolvedEncounter,
npcInteractionActive: false,
};
const nextHistory = appendHistory(state, actionText, resultText);
const stateWithHistory: GameState = {
...resolvedState,
storyHistory: nextHistory,
};
return {
encounterKey: getNpcEncounterKey(encounter),
actionText,
resultText,
fallbackText: dialogueText,
openingOptions: buildCampCompanionOpeningOptions(
stateWithHistory,
character,
resolvedEncounter,
),
};
}
export async function playOpeningAdventureSequence({
gameState,
character,
encounter,
preparedStory,
setGameState,
setCurrentStory,
setAiError,
setIsLoading,
buildDialogueStoryMoment,
buildStoryContextFromState,
getStoryGenerationHostileNpcs,
hasRenderableDialogueTurns,
inferOpeningCampFollowupOptions,
getTypewriterDelay,
}: {
gameState: GameState;
character: Character;
encounter: Encounter;
preparedStory: PreparedOpeningAdventure;
setGameState: Dispatch<SetStateAction<GameState>>;
setCurrentStory: Dispatch<SetStateAction<StoryMoment | null>>;
setAiError: Dispatch<SetStateAction<string | null>>;
setIsLoading: Dispatch<SetStateAction<boolean>>;
buildDialogueStoryMoment: (
npcName: string,
text: string,
options: StoryOption[],
streaming?: boolean,
) => StoryMoment;
buildStoryContextFromState: (
state: GameState,
extras?: { lastFunctionId?: string | null },
) => StoryGenerationContext;
getStoryGenerationHostileNpcs: (
state: GameState,
) => GameState['sceneHostileNpcs'];
hasRenderableDialogueTurns: (text: string, npcName: string) => boolean;
inferOpeningCampFollowupOptions: (
state: GameState,
character: Character,
baseOptions: StoryOption[],
openingBackground: string,
openingDialogue: string,
) => Promise<StoryOption[]>;
getTypewriterDelay: (char: string) => number;
}) {
const {
fallbackText,
openingOptions,
resultText: openingBackground,
} = preparedStory;
const actionText = `在营地与 ${encounter.npcName} 交换开场判断`;
const campScene = gameState.worldType
? getWorldCampScenePreset(gameState.worldType)
: null;
const entryState: GameState = {
...gameState,
currentScenePreset: campScene ?? gameState.currentScenePreset,
currentEncounter: {
...encounter,
xMeters: encounter.xMeters ?? CALL_OUT_ENTRY_X_METERS,
},
};
const resolvedEncounter: Encounter = {
...encounter,
xMeters: RESOLVED_ENTITY_X_METERS,
};
const storyEncounter: Encounter = {
...resolvedEncounter,
specialBehavior: 'camp_companion',
};
const resolvedState: GameState = {
...gameState,
currentScenePreset: campScene ?? gameState.currentScenePreset,
currentEncounter: resolvedEncounter,
npcInteractionActive: false,
};
setGameState(entryState);
setAiError(null);
setIsLoading(true);
try {
if (hasEncounterEntity(resolvedState)) {
const runTicks = Math.max(
1,
Math.ceil(ENCOUNTER_ENTRY_DURATION_MS / ENCOUNTER_ENTRY_TICK_MS),
);
const tickDurationMs = Math.max(
1,
Math.round(ENCOUNTER_ENTRY_DURATION_MS / runTicks),
);
for (let tick = 1; tick <= runTicks; tick += 1) {
const progress = tick / runTicks;
setGameState(
interpolateEncounterTransitionState(
entryState,
resolvedState,
progress,
),
);
await new Promise((resolve) =>
window.setTimeout(resolve, tickDurationMs),
);
}
}
const storyState: GameState = {
...resolvedState,
currentEncounter: storyEncounter,
npcInteractionActive: false,
};
setGameState(storyState);
setCurrentStory(buildDialogueStoryMoment(encounter.npcName, '', [], true));
let openingText = fallbackText;
let resolvedOpeningOptions = sortStoryOptionsByPriority(openingOptions);
try {
const response = await generateNextStep(
gameState.worldType!,
character,
getStoryGenerationHostileNpcs(storyState),
gameState.storyHistory,
actionText,
buildStoryContextFromState(storyState, {
lastFunctionId: OPENING_CAMP_DIALOGUE_FUNCTION_ID,
}),
{
availableOptions: openingOptions,
},
);
const generatedText = response.storyText.trim();
if (
generatedText &&
hasRenderableDialogueTurns(generatedText, encounter.npcName)
) {
openingText = generatedText;
}
if (response.options.length > 0) {
resolvedOpeningOptions = sortStoryOptionsByPriority(response.options);
}
} catch (error) {
console.error('Failed to infer opening camp dialogue:', error);
setAiError(error instanceof Error ? error.message : '未知智能生成错误');
}
const finalHistory = [
...gameState.storyHistory,
createHistoryMoment(actionText, 'action'),
createHistoryMoment(openingText, 'result', openingOptions),
];
const finalState: GameState = {
...storyState,
storyHistory: finalHistory,
};
setGameState(finalState);
const openingOptionsPromise = inferOpeningCampFollowupOptions(
finalState,
character,
resolvedOpeningOptions,
openingBackground,
openingText,
);
let displayedText = '';
for (const nextChar of openingText) {
displayedText += nextChar;
setCurrentStory(
buildDialogueStoryMoment(encounter.npcName, displayedText, [], true),
);
await new Promise((resolve) =>
window.setTimeout(resolve, getTypewriterDelay(nextChar)),
);
}
const finalOpeningOptions = await openingOptionsPromise;
setCurrentStory(
buildDialogueStoryMoment(
encounter.npcName,
openingText,
finalOpeningOptions,
false,
),
);
} catch (error) {
console.error('Failed to play opening adventure sequence:', error);
setAiError(error instanceof Error ? error.message : '未知智能生成错误');
setCurrentStory(
buildDialogueStoryMoment(
encounter.npcName,
fallbackText,
openingOptions,
false,
),
);
} finally {
setIsLoading(false);
}
}