329 lines
9.0 KiB
TypeScript
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);
|
|
}
|
|
}
|