1610 lines
46 KiB
TypeScript
1610 lines
46 KiB
TypeScript
import type { Dispatch, SetStateAction } from 'react';
|
||
import { useCallback, useEffect, useState } from 'react';
|
||
|
||
import {
|
||
getCharacterAdventureOpening,
|
||
getCharacterById,
|
||
getCharacterHomeSceneId,
|
||
} from '../data/characterPresets';
|
||
import {
|
||
buildCampTravelHomeOption,
|
||
buildContinueAdventureOption,
|
||
buildNpcPreviewTalkOption,
|
||
isCampTravelHomeOption,
|
||
isContinueAdventureOption,
|
||
NPC_CHAT_FUNCTION,
|
||
NPC_FIGHT_FUNCTION,
|
||
NPC_LEAVE_FUNCTION,
|
||
NPC_PREVIEW_TALK_FUNCTION,
|
||
NPC_RECRUIT_FUNCTION,
|
||
STORY_OPENING_CAMP_DIALOGUE_FUNCTION,
|
||
} from '../data/functionCatalog';
|
||
import {
|
||
buildInitialNpcState,
|
||
buildNpcEncounterStoryMoment,
|
||
describeNpcAffinityInWords,
|
||
getNpcConversationDirective,
|
||
isNpcFirstMeaningfulContact,
|
||
normalizeNpcPersistentState,
|
||
} from '../data/npcInteractions';
|
||
import { applyQuestProgressFromTreasure } from '../data/questFlow';
|
||
import { incrementGameRuntimeStats } from '../data/runtimeStats';
|
||
import {
|
||
buildSceneEntityCatalogText,
|
||
getForwardScenePreset,
|
||
getScenePresetById,
|
||
getTravelScenePreset,
|
||
getWorldCampScenePreset,
|
||
} from '../data/scenePresets';
|
||
import {
|
||
getDefaultFunctionIdsForContext,
|
||
resolveFunctionOption,
|
||
sortStoryOptionsByPriority,
|
||
} from '../data/stateFunctions';
|
||
import { generateInitialStory, generateNextStep } from '../services/ai';
|
||
import {
|
||
Character,
|
||
Encounter,
|
||
GameState,
|
||
InventoryItem,
|
||
StoryDialogueTurn,
|
||
StoryMoment,
|
||
StoryOption,
|
||
WorldType,
|
||
} from '../types';
|
||
import type { EscapePlaybackSync as ResolvedChoicePlaybackSync } from './combat/escapeFlow';
|
||
import type { ResolvedChoiceState } from './combat/resolvedChoice';
|
||
import {
|
||
buildFallbackStoryMoment,
|
||
normalizeSkillProbabilities,
|
||
} from './combatStoryUtils';
|
||
import {
|
||
getCharacterChatRecord,
|
||
useCharacterChatFlow,
|
||
} from './story/characterChat';
|
||
import { createStoryChoiceActions } from './story/choiceActions';
|
||
import { useStoryInventoryActions } from './story/inventoryActions';
|
||
import { createStoryNpcEncounterActions } from './story/npcEncounterActions';
|
||
import { useStoryNpcInteractionFlow } from './story/npcInteraction';
|
||
import {
|
||
buildPreparedOpeningAdventure as buildPreparedOpeningAdventureState,
|
||
playOpeningAdventureSequence,
|
||
type PreparedOpeningAdventure,
|
||
} from './story/openingAdventure';
|
||
import {
|
||
appendStoryHistory,
|
||
createStoryProgressionActions,
|
||
} from './story/progressionActions';
|
||
import { createStorySessionActions } from './story/sessionActions';
|
||
import { resolveNpcInteractionDecision } from './story/storyGenerationState';
|
||
import type {
|
||
BattleRewardSummary,
|
||
BattleRewardUi,
|
||
QuestFlowUi,
|
||
} from './story/uiTypes';
|
||
import { useStoryOptions } from './useStoryOptions';
|
||
import {
|
||
buildTreasureStory,
|
||
isTreasureEncounter,
|
||
useTreasureFlow,
|
||
} from './useTreasureFlow';
|
||
|
||
const MIN_OPTION_POOL_SIZE = 6;
|
||
const TURN_VISUAL_MS = 820;
|
||
const OPENING_CAMP_DIALOGUE_FUNCTION_ID =
|
||
STORY_OPENING_CAMP_DIALOGUE_FUNCTION.id;
|
||
const NPC_PREVIEW_TALK_FUNCTION_ID = NPC_PREVIEW_TALK_FUNCTION.id;
|
||
const FALLBACK_COMPANION_NAME = '同伴';
|
||
|
||
export type {
|
||
CharacterChatModalState,
|
||
CharacterChatTarget,
|
||
CharacterChatUi,
|
||
} from './story/characterChat';
|
||
export type {
|
||
BattleRewardSummary,
|
||
BattleRewardUi,
|
||
GiftModalState,
|
||
InventoryFlowUi,
|
||
QuestFlowUi,
|
||
RecruitModalState,
|
||
StoryGenerationNpcUi,
|
||
TradeModalState,
|
||
} from './story/uiTypes';
|
||
|
||
type CampCompanionEncounter = Encounter & {
|
||
specialBehavior: 'camp_companion';
|
||
};
|
||
|
||
function dedupeStoryOptions(options: StoryOption[]) {
|
||
const seen = new Set<string>();
|
||
|
||
return options.filter((option) => {
|
||
const identity = `${option.functionId}::${option.actionText}::${option.text}`;
|
||
if (seen.has(identity)) return false;
|
||
seen.add(identity);
|
||
return true;
|
||
});
|
||
}
|
||
|
||
function _buildLocalCharacterChatSummary(
|
||
character: Character,
|
||
history: Array<{ speaker: 'player' | 'character'; text: string }>,
|
||
previousSummary: string,
|
||
) {
|
||
const latestTurns = history
|
||
.slice(-4)
|
||
.map(
|
||
(turn) =>
|
||
`${turn.speaker === 'player' ? 'Player' : character.name}: ${turn.text}`,
|
||
)
|
||
.join(' ');
|
||
|
||
const currentSummary = latestTurns
|
||
? `${character.name} is becoming more open in private conversation. Recent exchange: ${latestTurns}`
|
||
: `${character.name} is willing to continue private conversation and gradually trusts the player more.`;
|
||
if (!previousSummary) {
|
||
return currentSummary.slice(0, 118);
|
||
}
|
||
|
||
return `${previousSummary} ${currentSummary}`.slice(0, 118);
|
||
}
|
||
|
||
function _buildLocalCharacterChatSuggestions(character: Character) {
|
||
return [
|
||
'我想听你把这件事再说得更明白一点。',
|
||
`${character.name},你现在真正担心的是什么?`,
|
||
'先把外面的局势放一放,我想更了解你一些。',
|
||
];
|
||
}
|
||
|
||
function buildPartyRelationshipNotes(state: GameState) {
|
||
const lines: string[] = [];
|
||
const seenCharacterIds = new Set<string>();
|
||
|
||
const appendNote = (characterId: string, roleLabel: string) => {
|
||
if (seenCharacterIds.has(characterId)) return;
|
||
const character = getCharacterById(characterId);
|
||
const summary = getCharacterChatRecord(state, characterId).summary.trim();
|
||
if (!character || !summary) return;
|
||
|
||
seenCharacterIds.add(characterId);
|
||
lines.push(
|
||
`- ${character.name} (${character.title} / ${roleLabel}): ${summary}`,
|
||
);
|
||
};
|
||
|
||
state.companions.forEach((companion) =>
|
||
appendNote(companion.characterId, '当前同行'),
|
||
);
|
||
state.roster.forEach((companion) =>
|
||
appendNote(companion.characterId, '营地待命'),
|
||
);
|
||
|
||
return lines.length > 0 ? lines.join('\n') : null;
|
||
}
|
||
|
||
function buildRecentConversationEventText(state: GameState) {
|
||
const recentText = state.storyHistory
|
||
.slice(-6)
|
||
.map((item) => item.text)
|
||
.join('\n');
|
||
if (
|
||
/击败|怪物|战斗|切磋|交手|脱身/u.test(recentText)
|
||
) {
|
||
return '你们刚经历过一场交锋或切磋,空气里的紧张感还没有完全散去。';
|
||
}
|
||
if (/携手|相助|帮你|并肩/u.test(recentText)) {
|
||
return '你们刚并肩配合过一次,彼此之间的距离感稍微淡了一些。';
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function inferConversationSituation(
|
||
state: GameState,
|
||
extras: {
|
||
lastFunctionId?: string | null;
|
||
openingCampDialogue?: string | null;
|
||
},
|
||
) {
|
||
if (state.inBattle) return 'shared_danger_coordination' as const;
|
||
if (extras.lastFunctionId === OPENING_CAMP_DIALOGUE_FUNCTION_ID)
|
||
return 'camp_first_contact' as const;
|
||
if (
|
||
state.currentEncounter?.specialBehavior === 'camp_companion' &&
|
||
extras.openingCampDialogue?.trim()
|
||
) {
|
||
return 'camp_followup' as const;
|
||
}
|
||
const recentText = state.storyHistory
|
||
.slice(-6)
|
||
.map((item) => item.text)
|
||
.join('\n');
|
||
if (
|
||
/击败|怪物|战斗|切磋|交手|脱身/u.test(recentText)
|
||
) {
|
||
return 'post_battle_breath' as const;
|
||
}
|
||
if (extras.lastFunctionId === NPC_CHAT_FUNCTION.id)
|
||
return 'private_followup' as const;
|
||
return 'first_contact_cautious' as const;
|
||
}
|
||
|
||
function inferConversationPressure(
|
||
state: GameState,
|
||
situation: ReturnType<typeof inferConversationSituation>,
|
||
) {
|
||
const hpRatio = state.playerHp / Math.max(state.playerMaxHp, 1);
|
||
if (state.inBattle || hpRatio < 0.35) return 'high' as const;
|
||
if (
|
||
situation === 'post_battle_breath' ||
|
||
situation === 'shared_danger_coordination'
|
||
)
|
||
return 'medium' as const;
|
||
if (situation === 'camp_first_contact' || situation === 'camp_followup')
|
||
return 'low' as const;
|
||
return 'medium' as const;
|
||
}
|
||
|
||
function describeConversationSituation(
|
||
situation: ReturnType<typeof inferConversationSituation>,
|
||
) {
|
||
switch (situation) {
|
||
case 'camp_first_contact':
|
||
return 'This is the first quiet moment at camp, so the tone should stay careful, observant, and lightly probing.';
|
||
case 'camp_followup':
|
||
return 'The first camp exchange already happened, so this can pick up the previous thread and go a little deeper.';
|
||
case 'post_battle_breath':
|
||
return 'A fight just ended. The immediate danger is lower, but both sides are still tense and catching their breath.';
|
||
case 'shared_danger_coordination':
|
||
return 'Danger is still active, so the conversation should stay short, direct, and practical.';
|
||
case 'private_followup':
|
||
return 'This is not a strict first meeting anymore. It works best as a continuation of something half-said a moment ago.';
|
||
default:
|
||
return 'They have only just met, and both sides are still deciding how much they can trust the other.';
|
||
}
|
||
}
|
||
|
||
function describeConversationTalkPriority(
|
||
situation: ReturnType<typeof inferConversationSituation>,
|
||
) {
|
||
switch (situation) {
|
||
case 'camp_first_contact':
|
||
return 'Start with immediate impressions, mutual attitude, and the atmosphere at camp instead of over-explaining motives.';
|
||
case 'camp_followup':
|
||
return 'Start by picking up the unresolved thread from the last exchange, then decide whether to press further.';
|
||
case 'post_battle_breath':
|
||
return 'Talk about the clash that just happened and how each side judged the other before moving deeper.';
|
||
case 'shared_danger_coordination':
|
||
return 'Focus on the most useful judgment, danger, and next step instead of expanding into long background exposition.';
|
||
case 'private_followup':
|
||
return 'Pick up the current thread and relationship shift instead of resetting the conversation back to a first meeting.';
|
||
default:
|
||
return 'Probe stance and现场 judgment first instead of fully exposing motive and secrets.';
|
||
}
|
||
}
|
||
|
||
function buildStoryContextFromState(
|
||
state: GameState,
|
||
extras: {
|
||
pendingSceneEncounter?: boolean;
|
||
lastFunctionId?: string | null;
|
||
observeSignsRequested?: boolean;
|
||
openingCampBackground?: string | null;
|
||
openingCampDialogue?: string | null;
|
||
encounterNpcStateOverride?: GameState['npcStates'][string] | null;
|
||
} = {},
|
||
) {
|
||
const conversationSituation = inferConversationSituation(state, extras);
|
||
const conversationPressure = inferConversationPressure(
|
||
state,
|
||
conversationSituation,
|
||
);
|
||
const recentSharedEvent = buildRecentConversationEventText(state);
|
||
const encounterNpcState =
|
||
state.currentEncounter?.kind === 'npc'
|
||
? (() => {
|
||
const encounter = state.currentEncounter;
|
||
return extras.encounterNpcStateOverride
|
||
?? state.npcStates[getNpcEncounterKey(encounter)]
|
||
?? buildInitialNpcState(encounter, state.worldType, state);
|
||
})()
|
||
: null;
|
||
const encounterDirective =
|
||
state.currentEncounter?.kind === 'npc'
|
||
? (() => {
|
||
const encounter = state.currentEncounter;
|
||
return encounterNpcState
|
||
? getNpcConversationDirective(encounter, encounterNpcState)
|
||
: null;
|
||
})()
|
||
: null;
|
||
const isFirstMeaningfulContact =
|
||
state.currentEncounter?.kind === 'npc'
|
||
? (() => {
|
||
const encounter = state.currentEncounter;
|
||
return encounterNpcState
|
||
? isNpcFirstMeaningfulContact(encounter, encounterNpcState)
|
||
: false;
|
||
})()
|
||
: false;
|
||
const firstContactRelationStance = (() => {
|
||
if (
|
||
!isFirstMeaningfulContact ||
|
||
!state.currentEncounter ||
|
||
state.currentEncounter.kind !== 'npc'
|
||
) {
|
||
return null;
|
||
}
|
||
|
||
const stance = encounterNpcState?.relationState?.stance ?? null;
|
||
if (
|
||
stance === 'guarded' ||
|
||
stance === 'neutral' ||
|
||
stance === 'cooperative' ||
|
||
stance === 'bonded'
|
||
) {
|
||
return stance;
|
||
}
|
||
return null;
|
||
})();
|
||
const encounterAffinityText =
|
||
state.currentEncounter?.kind === 'npc'
|
||
? (() => {
|
||
const encounter = state.currentEncounter;
|
||
return encounterNpcState
|
||
? describeNpcAffinityInWords(encounter, encounterNpcState.affinity, {
|
||
recruited: encounterNpcState.recruited,
|
||
})
|
||
: null;
|
||
})()
|
||
: null;
|
||
const baseSceneDescription = state.currentScenePreset?.description ?? null;
|
||
const observeSignsSceneDescription =
|
||
extras.observeSignsRequested && state.worldType
|
||
? [
|
||
baseSceneDescription,
|
||
'Observed entity pool:',
|
||
buildSceneEntityCatalogText(
|
||
state.worldType,
|
||
state.currentScenePreset?.id ?? null,
|
||
),
|
||
]
|
||
.filter(Boolean)
|
||
.join('\n')
|
||
: baseSceneDescription;
|
||
|
||
return {
|
||
playerHp: state.playerHp,
|
||
playerMaxHp: state.playerMaxHp,
|
||
playerMana: state.playerMana,
|
||
playerMaxMana: state.playerMaxMana,
|
||
inBattle: state.inBattle,
|
||
playerX: state.playerX,
|
||
playerFacing: state.playerFacing,
|
||
playerAnimation: state.animationState,
|
||
skillCooldowns: state.playerSkillCooldowns,
|
||
sceneId: state.currentScenePreset?.id ?? null,
|
||
sceneName: state.currentScenePreset?.name ?? null,
|
||
sceneDescription: observeSignsSceneDescription,
|
||
pendingSceneEncounter: extras.pendingSceneEncounter ?? false,
|
||
lastFunctionId: extras.lastFunctionId ?? null,
|
||
observeSignsRequested: extras.observeSignsRequested ?? false,
|
||
lastObserveSignsReport:
|
||
state.lastObserveSignsSceneId === (state.currentScenePreset?.id ?? null)
|
||
? (state.lastObserveSignsReport ?? null)
|
||
: null,
|
||
encounterKind: state.currentEncounter?.kind ?? null,
|
||
encounterName: state.currentEncounter?.npcName ?? null,
|
||
encounterDescription: state.currentEncounter?.npcDescription ?? null,
|
||
encounterContext: state.currentEncounter?.context ?? null,
|
||
encounterCharacterId: state.currentEncounter?.characterId ?? null,
|
||
encounterGender: state.currentEncounter?.gender ?? null,
|
||
encounterAffinity: encounterDirective?.affinity ?? null,
|
||
encounterAffinityText,
|
||
encounterConversationStyle: encounterDirective?.style ?? null,
|
||
encounterDisclosureStage: encounterDirective?.disclosureStage ?? null,
|
||
encounterWarmthStage: encounterDirective?.warmthStage ?? null,
|
||
encounterAnswerMode: encounterDirective?.answerMode ?? null,
|
||
encounterAllowedTopics: encounterDirective?.allowTopics ?? null,
|
||
encounterBlockedTopics: encounterDirective?.blockedTopics ?? null,
|
||
isFirstMeaningfulContact,
|
||
firstContactRelationStance,
|
||
conversationSituation,
|
||
conversationPressure,
|
||
recentSharedEvent:
|
||
recentSharedEvent ?? describeConversationSituation(conversationSituation),
|
||
talkPriority: describeConversationTalkPriority(conversationSituation),
|
||
encounterRelationshipSummary: state.currentEncounter?.characterId
|
||
? getCharacterChatRecord(state, state.currentEncounter.characterId)
|
||
.summary || null
|
||
: null,
|
||
partyRelationshipNotes: buildPartyRelationshipNotes(state),
|
||
customWorldProfile: state.customWorldProfile ?? null,
|
||
openingCampBackground: extras.openingCampBackground ?? null,
|
||
openingCampDialogue: extras.openingCampDialogue ?? null,
|
||
};
|
||
}
|
||
|
||
function buildNpcPreviewStory(
|
||
state: GameState,
|
||
character: Character,
|
||
encounter: Encounter,
|
||
overrideText?: string,
|
||
): StoryMoment {
|
||
if (!state.worldType) {
|
||
return {
|
||
text:
|
||
overrideText ??
|
||
`${encounter.npcName} waits ahead, as if letting you decide whether to engage first.`,
|
||
options: [buildNpcPreviewTalkOption(encounter)],
|
||
};
|
||
}
|
||
|
||
const functionContext = {
|
||
worldType: state.worldType,
|
||
playerCharacter: character,
|
||
inBattle: false,
|
||
currentSceneId: state.currentScenePreset?.id ?? null,
|
||
currentSceneName: state.currentScenePreset?.name ?? null,
|
||
monsters: [],
|
||
playerHp: state.playerHp,
|
||
playerMaxHp: state.playerMaxHp,
|
||
playerMana: state.playerMana,
|
||
playerMaxMana: state.playerMaxMana,
|
||
};
|
||
|
||
const locationOptions = getDefaultFunctionIdsForContext(functionContext)
|
||
.filter((functionId) => functionId !== 'idle_call_out')
|
||
.map((functionId) => resolveFunctionOption(functionId, functionContext))
|
||
.filter((option): option is StoryOption => Boolean(option));
|
||
|
||
return {
|
||
text:
|
||
overrideText ??
|
||
encounter.npcName +
|
||
' appears near ' +
|
||
(state.currentScenePreset?.name ?? 'the path ahead') +
|
||
', but you have not fully committed your attention to them yet.',
|
||
options: [buildNpcPreviewTalkOption(encounter), ...locationOptions],
|
||
};
|
||
}
|
||
|
||
function getStoryGenerationHostileNpcs(state: GameState) {
|
||
return state.inBattle ? getResolvedSceneHostileNpcs(state) : [];
|
||
}
|
||
|
||
function getResolvedSceneHostileNpcs(state: GameState) {
|
||
return state.sceneHostileNpcs ?? state.sceneMonsters;
|
||
}
|
||
|
||
function sanitizeOptions(
|
||
options: StoryOption[],
|
||
character: Character,
|
||
state: GameState,
|
||
) {
|
||
const normalizedOptions = dedupeStoryOptions(
|
||
options.map((option) => normalizeSkillProbabilities(option, character)),
|
||
);
|
||
|
||
if (normalizedOptions.length === 0) {
|
||
return buildFallbackStoryMoment(state, character).options;
|
||
}
|
||
|
||
if (normalizedOptions.length >= MIN_OPTION_POOL_SIZE) {
|
||
return normalizedOptions;
|
||
}
|
||
|
||
return sortStoryOptionsByPriority(
|
||
dedupeStoryOptions([
|
||
...normalizedOptions,
|
||
...buildFallbackStoryMoment(state, character).options,
|
||
]).slice(0, MIN_OPTION_POOL_SIZE),
|
||
);
|
||
}
|
||
|
||
function escapeRegExp(value: string) {
|
||
const specialChars = [
|
||
'\\',
|
||
'^',
|
||
'$',
|
||
'*',
|
||
'+',
|
||
'?',
|
||
'.',
|
||
'(',
|
||
')',
|
||
'|',
|
||
'[',
|
||
']',
|
||
'{',
|
||
'}',
|
||
];
|
||
return specialChars.reduce(
|
||
(escaped, char) => escaped.split(char).join('\\' + char),
|
||
value,
|
||
);
|
||
}
|
||
|
||
function normalizeDialogueSpeakerName(rawSpeakerName: string) {
|
||
return rawSpeakerName
|
||
.trim()
|
||
.replace(
|
||
/^[[\]{}()<>\u300a\u300b\u300c\u300d\u300e\u300f\u3010\u3011\uFF08\uFF09]+/u,
|
||
'',
|
||
)
|
||
.replace(
|
||
/[[\]{}()<>\u300a\u300b\u300c\u300d\u300e\u300f\u3010\u3011\uFF08\uFF09]+$/u,
|
||
'',
|
||
)
|
||
.replace(/^(?:\u540c\u4f34|\u961f\u53cb)\s*/u, '')
|
||
.trim();
|
||
}
|
||
|
||
function parseDialogueTurns(
|
||
text: string,
|
||
npcName: string,
|
||
): StoryDialogueTurn[] {
|
||
const turns: StoryDialogueTurn[] = [];
|
||
const dialogueColonPattern = '(?:\\uFF1A|:)';
|
||
const playerPrefixPattern = new RegExp(
|
||
'^(?:\\\\u4f60|\\\\u73a9\\\\u5bb6|\\\\u4e3b\\\\u89d2)\\\\s*' +
|
||
dialogueColonPattern +
|
||
'\\\\s*(.+)$',
|
||
'u',
|
||
);
|
||
const npcPrefixPattern = new RegExp(
|
||
'^' +
|
||
escapeRegExp(npcName) +
|
||
'\\\\s*' +
|
||
dialogueColonPattern +
|
||
'\\\\s*(.+)$',
|
||
'u',
|
||
);
|
||
const namedSpeakerPattern = new RegExp(
|
||
'^(.{1,24}?)\\\\s*' + dialogueColonPattern + '\\\\s*(.+)$',
|
||
'u',
|
||
);
|
||
const lines = text
|
||
.replace(/\r/g, '')
|
||
.split('\n')
|
||
.map((line) => line.trim())
|
||
.filter(Boolean);
|
||
|
||
for (const line of lines) {
|
||
const playerMatch = line.match(playerPrefixPattern);
|
||
const playerText = playerMatch?.[1]?.trim();
|
||
if (playerText) {
|
||
turns.push({ speaker: 'player', text: playerText });
|
||
continue;
|
||
}
|
||
|
||
const npcMatch = line.match(npcPrefixPattern);
|
||
const npcText = npcMatch?.[1]?.trim();
|
||
if (npcText) {
|
||
turns.push({ speaker: 'npc', speakerName: npcName, text: npcText });
|
||
continue;
|
||
}
|
||
|
||
const namedSpeakerMatch = line.match(namedSpeakerPattern);
|
||
if (namedSpeakerMatch) {
|
||
const rawSpeakerName = namedSpeakerMatch[1];
|
||
const rawSpeakerText = namedSpeakerMatch[2];
|
||
if (!rawSpeakerName || !rawSpeakerText) {
|
||
continue;
|
||
}
|
||
|
||
const speakerName = normalizeDialogueSpeakerName(rawSpeakerName);
|
||
const speakerText = rawSpeakerText.trim();
|
||
|
||
if (speakerName && speakerText) {
|
||
turns.push({
|
||
speaker: speakerName === npcName ? 'npc' : 'companion',
|
||
speakerName,
|
||
text: speakerText,
|
||
});
|
||
continue;
|
||
}
|
||
}
|
||
if (line.startsWith('你:') || line.startsWith('你:')) {
|
||
turns.push({ speaker: 'player', text: line.slice(2).trim() });
|
||
continue;
|
||
}
|
||
|
||
if (line.startsWith(npcName + ':') || line.startsWith(npcName + ':')) {
|
||
turns.push({
|
||
speaker: 'npc',
|
||
text: line.slice(npcName.length + 1).trim(),
|
||
});
|
||
continue;
|
||
}
|
||
|
||
if (line.startsWith('主角:') || line.startsWith('主角:')) {
|
||
turns.push({ speaker: 'player', text: line.slice(3).trim() });
|
||
continue;
|
||
}
|
||
|
||
if (turns.length > 0) {
|
||
const lastTurnIndex = turns.length - 1;
|
||
const lastTurn = turns[lastTurnIndex];
|
||
if (lastTurn) {
|
||
turns[lastTurnIndex] = {
|
||
...lastTurn,
|
||
text: lastTurn.text + line,
|
||
};
|
||
}
|
||
}
|
||
}
|
||
|
||
return turns.filter((turn) => turn.text.length > 0);
|
||
}
|
||
|
||
function buildDialogueStoryMoment(
|
||
npcName: string,
|
||
text: string,
|
||
options: StoryOption[],
|
||
streaming = false,
|
||
): StoryMoment {
|
||
return {
|
||
text,
|
||
options,
|
||
displayMode: 'dialogue',
|
||
dialogue: parseDialogueTurns(text, npcName),
|
||
streaming,
|
||
};
|
||
}
|
||
|
||
function hasRenderableDialogueTurns(text: string, npcName: string) {
|
||
return parseDialogueTurns(text, npcName).length >= 2;
|
||
}
|
||
|
||
function getTypewriterDelay(char: string) {
|
||
if (/[。!?!?]/u.test(char)) return 240;
|
||
if (/[,、;;:]/u.test(char)) return 150;
|
||
if (/\s/u.test(char)) return 45;
|
||
return 90;
|
||
}
|
||
|
||
function isCampCompanionEncounter(
|
||
encounter: GameState['currentEncounter'],
|
||
): encounter is CampCompanionEncounter {
|
||
return Boolean(
|
||
encounter?.kind === 'npc' && encounter.specialBehavior === 'camp_companion',
|
||
);
|
||
}
|
||
|
||
function isInitialCompanionEncounter(
|
||
encounter: GameState['currentEncounter'],
|
||
): encounter is Encounter {
|
||
return Boolean(
|
||
encounter?.kind === 'npc' &&
|
||
encounter.specialBehavior === 'initial_companion',
|
||
);
|
||
}
|
||
|
||
function _buildInitialCompanionResultText(
|
||
character: Character,
|
||
encounter: Encounter,
|
||
worldType: WorldType | null,
|
||
) {
|
||
const opening = getCharacterAdventureOpening(character, worldType);
|
||
if (!opening) {
|
||
return `${encounter.npcName}从不远处走近,先静静打量着你的反应。`;
|
||
}
|
||
|
||
return `${encounter.npcName}现身在你眼前,周围的局势也随之悄然变化。`;
|
||
}
|
||
|
||
function buildInitialCompanionDialogueText(
|
||
character: Character,
|
||
encounter: Encounter,
|
||
worldType: WorldType | null,
|
||
) {
|
||
const opening = getCharacterAdventureOpening(character, worldType);
|
||
const surfaceHook =
|
||
opening?.surfaceHook ?? '这个地方与我来此的目的息息相关。';
|
||
const immediateConcern =
|
||
opening?.immediateConcern ?? '前方似乎有些不对劲,我们不能贸然前进。';
|
||
const guardedMotive =
|
||
opening?.guardedMotive ?? '我并非偶然来到这里,但我还不准备全盘托出。';
|
||
|
||
return [
|
||
`你:${surfaceHook}`,
|
||
`${encounter.npcName}:那就不要说得太快太多。前方的道路并不稳定,贸然冲进去只会最先遇到最糟糕的情况。`,
|
||
`你:${immediateConcern}`,
|
||
`${encounter.npcName}:我能看出你不是误闯此地的。${guardedMotive} 剩下的我们可以在路上再梳理清楚。`,
|
||
].join('\n');
|
||
}
|
||
|
||
function buildCampCompanionOpeningResultText(
|
||
character: Character,
|
||
encounter: Encounter,
|
||
worldType: WorldType | null,
|
||
) {
|
||
const opening = getCharacterAdventureOpening(character, worldType);
|
||
if (!opening) {
|
||
return `${encounter.npcName} 已经来到你身边。在营地,你稍作停顿,决定下一步去向何方。`;
|
||
}
|
||
|
||
return `${encounter.npcName} 在营地来到你身边。你们首先就“${opening.immediateConcern ?? '前方不稳定的道路'}”交换了意见,而双方都暂时保留了部分真相。`;
|
||
}
|
||
|
||
function _buildCampCompanionChatResultText(
|
||
encounter: Encounter,
|
||
affinityGain: number,
|
||
nextAffinity: number,
|
||
) {
|
||
const teamworkText =
|
||
affinityGain > 0
|
||
? '你也对接下来的合作感到更加自信了一些。'
|
||
: '至少你们为接下来的行动重新调整了节奏。';
|
||
return `${encounter.npcName}和你交换了一番想法,${describeNpcAffinityInWords(encounter, nextAffinity)}${teamworkText}`;
|
||
}
|
||
|
||
function isNpcEncounter(
|
||
encounter: GameState['currentEncounter'],
|
||
): encounter is Encounter {
|
||
return Boolean(encounter?.kind === 'npc');
|
||
}
|
||
|
||
function isRegularNpcEncounter(
|
||
encounter: GameState['currentEncounter'],
|
||
): encounter is Encounter {
|
||
return Boolean(encounter?.kind === 'npc' && !encounter.specialBehavior);
|
||
}
|
||
|
||
function getNpcEncounterKey(encounter: Encounter) {
|
||
return encounter.id ?? encounter.npcName;
|
||
}
|
||
|
||
function cloneInventoryItemForOwner(
|
||
item: InventoryItem,
|
||
owner: 'player' | 'npc',
|
||
quantity = 1,
|
||
) {
|
||
const preserveIdentity = Boolean(
|
||
item.runtimeMetadata ||
|
||
item.buildProfile ||
|
||
item.equipmentSlotId ||
|
||
item.statProfile ||
|
||
item.attributeResonance,
|
||
);
|
||
|
||
return {
|
||
...item,
|
||
id: preserveIdentity
|
||
? `${owner}:${item.id}:${quantity}`
|
||
: `${owner}:${encodeURIComponent(`${item.category}-${item.name}`)}`,
|
||
quantity,
|
||
runtimeMetadata: item.runtimeMetadata
|
||
? {
|
||
...item.runtimeMetadata,
|
||
seedKey: `${item.runtimeMetadata.seedKey}:${owner}`,
|
||
}
|
||
: item.runtimeMetadata,
|
||
};
|
||
}
|
||
|
||
function tickCooldownMap(cooldowns: Record<string, number>) {
|
||
return Object.fromEntries(
|
||
Object.entries(cooldowns).map(([skillId, turns]) => [
|
||
skillId,
|
||
Math.max(0, turns - 1),
|
||
]),
|
||
);
|
||
}
|
||
|
||
export function useStoryGeneration({
|
||
gameState,
|
||
setGameState,
|
||
buildResolvedChoiceState,
|
||
playResolvedChoice,
|
||
}: {
|
||
gameState: GameState;
|
||
setGameState: Dispatch<SetStateAction<GameState>>;
|
||
buildResolvedChoiceState: (
|
||
state: GameState,
|
||
option: StoryOption,
|
||
character: Character,
|
||
) => ResolvedChoiceState;
|
||
playResolvedChoice: (
|
||
state: GameState,
|
||
option: StoryOption,
|
||
character: Character,
|
||
resolvedChoice: ResolvedChoiceState,
|
||
sync?: ResolvedChoicePlaybackSync,
|
||
) => Promise<GameState>;
|
||
}) {
|
||
const [currentStory, setCurrentStory] = useState<StoryMoment | null>(null);
|
||
const [aiError, setAiError] = useState<string | null>(null);
|
||
const [isLoading, setIsLoading] = useState(false);
|
||
const [battleReward, setBattleReward] = useState<BattleRewardSummary | null>(
|
||
null,
|
||
);
|
||
const [preparedOpeningAdventure, setPreparedOpeningAdventure] =
|
||
useState<PreparedOpeningAdventure | null>(null);
|
||
const { characterChatUi, clearCharacterChatModal } = useCharacterChatFlow({
|
||
gameState,
|
||
setGameState,
|
||
buildStoryContextFromState,
|
||
});
|
||
|
||
const getResolvedNpcState = (state: GameState, encounter: Encounter) =>
|
||
state.npcStates[getNpcEncounterKey(encounter)] ??
|
||
buildInitialNpcState(encounter, state.worldType, state);
|
||
|
||
const buildNpcStory = useCallback(
|
||
(
|
||
state: GameState,
|
||
character: Character,
|
||
encounter: Encounter,
|
||
overrideText?: string,
|
||
) =>
|
||
buildNpcEncounterStoryMoment({
|
||
state,
|
||
encounter,
|
||
npcState: getResolvedNpcState(state, encounter),
|
||
playerCharacter: character,
|
||
playerInventory: state.playerInventory,
|
||
activeQuests: state.quests,
|
||
scene: state.currentScenePreset,
|
||
partySize: state.companions.length,
|
||
overrideText,
|
||
worldType: state.worldType,
|
||
}),
|
||
[],
|
||
);
|
||
|
||
const getCampCompanionHomeScene = (
|
||
state: GameState,
|
||
character: Character,
|
||
) => {
|
||
if (!state.worldType) return null;
|
||
const sceneId = getCharacterHomeSceneId(state.worldType, character.id);
|
||
return getScenePresetById(state.worldType, sceneId);
|
||
};
|
||
|
||
const getCampCompanionTravelScene = useCallback(
|
||
(state: GameState, character: Character) => {
|
||
if (!state.worldType) return null;
|
||
|
||
const campScene = getWorldCampScenePreset(state.worldType);
|
||
const homeScene = getCampCompanionHomeScene(state, character);
|
||
if (
|
||
homeScene &&
|
||
homeScene.id !== campScene?.id &&
|
||
homeScene.id !== state.currentScenePreset?.id
|
||
) {
|
||
return homeScene;
|
||
}
|
||
|
||
const fallbackSceneId =
|
||
campScene?.id ?? state.currentScenePreset?.id ?? null;
|
||
return (
|
||
getForwardScenePreset(state.worldType, fallbackSceneId) ??
|
||
getTravelScenePreset(state.worldType, fallbackSceneId) ??
|
||
homeScene
|
||
);
|
||
},
|
||
[],
|
||
);
|
||
|
||
const buildCampCompanionOpeningOptions = useCallback(
|
||
(state: GameState, character: Character, encounter: Encounter) => {
|
||
const targetScene = getCampCompanionTravelScene(state, character);
|
||
const baseOptions = buildNpcStory(state, character, encounter).options;
|
||
const chatOptions = baseOptions
|
||
.filter((option) => option.functionId === NPC_CHAT_FUNCTION.id)
|
||
.slice(0, 1);
|
||
const recruitOption =
|
||
baseOptions.find(
|
||
(option) => option.functionId === NPC_RECRUIT_FUNCTION.id,
|
||
) ?? null;
|
||
const openingOptions = recruitOption
|
||
? [...chatOptions, recruitOption]
|
||
: chatOptions;
|
||
|
||
if (!targetScene) {
|
||
return openingOptions;
|
||
}
|
||
|
||
return [...openingOptions, buildCampTravelHomeOption(targetScene.name)];
|
||
},
|
||
[buildNpcStory, getCampCompanionTravelScene],
|
||
);
|
||
|
||
const inferOpeningCampFollowupOptions = useCallback(
|
||
async (
|
||
state: GameState,
|
||
character: Character,
|
||
baseOptions: StoryOption[],
|
||
openingBackground: string,
|
||
openingDialogue: string,
|
||
) => {
|
||
if (!state.worldType || baseOptions.length === 0) {
|
||
return baseOptions;
|
||
}
|
||
|
||
try {
|
||
const response = await generateNextStep(
|
||
state.worldType,
|
||
character,
|
||
getStoryGenerationHostileNpcs(state),
|
||
state.storyHistory,
|
||
'继续承接营地中的这段交谈,并整理出眼下最自然的后续行动。',
|
||
buildStoryContextFromState(state, {
|
||
openingCampBackground: openingBackground,
|
||
openingCampDialogue: openingDialogue,
|
||
}),
|
||
{
|
||
availableOptions: baseOptions,
|
||
},
|
||
);
|
||
|
||
return sortStoryOptionsByPriority(response.options);
|
||
} catch (error) {
|
||
console.error('Failed to infer opening camp follow-up options:', error);
|
||
return baseOptions;
|
||
}
|
||
},
|
||
[],
|
||
);
|
||
|
||
const buildOpeningCampChatContext = (
|
||
state: GameState,
|
||
character: Character,
|
||
encounter: Encounter,
|
||
) => {
|
||
if (encounter.specialBehavior !== 'camp_companion') {
|
||
return {};
|
||
}
|
||
|
||
const npcState =
|
||
state.npcStates[getNpcEncounterKey(encounter)] ??
|
||
buildInitialNpcState(encounter, state.worldType, state);
|
||
if (npcState.chattedCount > 2) {
|
||
return {};
|
||
}
|
||
|
||
const openingActionText = `在营地与 ${encounter.npcName} 交换开场判断`;
|
||
let openingDialogue: string | null = null;
|
||
|
||
for (let index = 0; index < state.storyHistory.length - 1; index += 1) {
|
||
const entry = state.storyHistory[index];
|
||
if (!entry) {
|
||
continue;
|
||
}
|
||
if (entry.historyRole !== 'action' || entry.text !== openingActionText) {
|
||
continue;
|
||
}
|
||
|
||
for (
|
||
let nextIndex = index + 1;
|
||
nextIndex < state.storyHistory.length;
|
||
nextIndex += 1
|
||
) {
|
||
const nextEntry = state.storyHistory[nextIndex];
|
||
if (!nextEntry) {
|
||
continue;
|
||
}
|
||
if (nextEntry.historyRole === 'action') {
|
||
break;
|
||
}
|
||
if (nextEntry.text.trim()) {
|
||
openingDialogue = nextEntry.text;
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (openingDialogue) {
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (!openingDialogue) {
|
||
return {};
|
||
}
|
||
|
||
return {
|
||
openingCampBackground: buildCampCompanionOpeningResultText(
|
||
character,
|
||
encounter,
|
||
state.worldType,
|
||
),
|
||
openingCampDialogue: openingDialogue,
|
||
};
|
||
};
|
||
|
||
const buildCampCompanionIdleOptions = useCallback(
|
||
(
|
||
state: GameState,
|
||
character: Character,
|
||
encounter: Encounter,
|
||
overrideText?: string,
|
||
): StoryMoment => {
|
||
const targetScene = getCampCompanionTravelScene(state, character);
|
||
const baseStory = buildNpcStory(
|
||
state,
|
||
character,
|
||
encounter,
|
||
overrideText,
|
||
);
|
||
const filteredOptions = baseStory.options.filter(
|
||
(option) =>
|
||
option.functionId !== NPC_LEAVE_FUNCTION.id &&
|
||
option.functionId !== NPC_FIGHT_FUNCTION.id,
|
||
);
|
||
|
||
if (!targetScene) {
|
||
return {
|
||
...baseStory,
|
||
options: filteredOptions,
|
||
};
|
||
}
|
||
|
||
return {
|
||
...baseStory,
|
||
options: [
|
||
...filteredOptions.slice(0, 2),
|
||
buildCampTravelHomeOption(targetScene.name),
|
||
...filteredOptions.slice(2),
|
||
],
|
||
};
|
||
},
|
||
[buildNpcStory, getCampCompanionTravelScene],
|
||
);
|
||
|
||
const getAvailableOptionsForState = useCallback(
|
||
(state: GameState, character: Character) => {
|
||
if (isCampCompanionEncounter(state.currentEncounter) && !state.inBattle) {
|
||
return buildCampCompanionIdleOptions(
|
||
state,
|
||
character,
|
||
state.currentEncounter,
|
||
).options;
|
||
}
|
||
|
||
if (
|
||
isInitialCompanionEncounter(state.currentEncounter) &&
|
||
!state.inBattle &&
|
||
!state.npcInteractionActive
|
||
) {
|
||
return buildNpcPreviewStory(state, character, state.currentEncounter)
|
||
.options;
|
||
}
|
||
|
||
if (isRegularNpcEncounter(state.currentEncounter) && !state.inBattle) {
|
||
if (!state.npcInteractionActive) {
|
||
return buildNpcPreviewStory(state, character, state.currentEncounter)
|
||
.options;
|
||
}
|
||
return buildNpcStory(state, character, state.currentEncounter).options;
|
||
}
|
||
|
||
if (isNpcEncounter(state.currentEncounter) && !state.inBattle) {
|
||
return buildNpcStory(state, character, state.currentEncounter).options;
|
||
}
|
||
|
||
if (isTreasureEncounter(state.currentEncounter) && !state.inBattle) {
|
||
return buildTreasureStory(state, character, state.currentEncounter)
|
||
.options;
|
||
}
|
||
|
||
return null;
|
||
},
|
||
[buildCampCompanionIdleOptions, buildNpcStory],
|
||
);
|
||
|
||
const buildFallbackStoryForState = useCallback(
|
||
(state: GameState, character: Character, fallbackText?: string) => {
|
||
if (isCampCompanionEncounter(state.currentEncounter) && !state.inBattle) {
|
||
return buildCampCompanionIdleOptions(
|
||
state,
|
||
character,
|
||
state.currentEncounter,
|
||
fallbackText,
|
||
);
|
||
}
|
||
|
||
if (
|
||
isInitialCompanionEncounter(state.currentEncounter) &&
|
||
!state.inBattle &&
|
||
!state.npcInteractionActive
|
||
) {
|
||
return buildNpcPreviewStory(
|
||
state,
|
||
character,
|
||
state.currentEncounter,
|
||
fallbackText,
|
||
);
|
||
}
|
||
|
||
if (isRegularNpcEncounter(state.currentEncounter) && !state.inBattle) {
|
||
if (!state.npcInteractionActive) {
|
||
return buildNpcPreviewStory(
|
||
state,
|
||
character,
|
||
state.currentEncounter,
|
||
fallbackText,
|
||
);
|
||
}
|
||
return buildNpcStory(
|
||
state,
|
||
character,
|
||
state.currentEncounter,
|
||
fallbackText,
|
||
);
|
||
}
|
||
|
||
if (isNpcEncounter(state.currentEncounter) && !state.inBattle) {
|
||
return buildNpcStory(
|
||
state,
|
||
character,
|
||
state.currentEncounter,
|
||
fallbackText,
|
||
);
|
||
}
|
||
|
||
if (isTreasureEncounter(state.currentEncounter) && !state.inBattle) {
|
||
return buildTreasureStory(
|
||
state,
|
||
character,
|
||
state.currentEncounter,
|
||
fallbackText,
|
||
);
|
||
}
|
||
|
||
const fallback = buildFallbackStoryMoment(state, character);
|
||
return fallbackText
|
||
? {
|
||
...fallback,
|
||
text: fallbackText,
|
||
}
|
||
: fallback;
|
||
},
|
||
[buildCampCompanionIdleOptions, buildNpcStory],
|
||
);
|
||
|
||
const buildStoryFromResponse = useCallback(
|
||
(
|
||
state: GameState,
|
||
character: Character,
|
||
response: StoryMoment,
|
||
availableOptions: StoryOption[] | null,
|
||
optionCatalog: StoryOption[] | null = null,
|
||
) => ({
|
||
text: response.text,
|
||
options: sortStoryOptionsByPriority(
|
||
availableOptions
|
||
? isCampCompanionEncounter(state.currentEncounter)
|
||
? availableOptions
|
||
: response.options
|
||
: optionCatalog
|
||
? response.options.length > 0
|
||
? response.options
|
||
: optionCatalog
|
||
: sanitizeOptions(response.options, character, state),
|
||
),
|
||
}),
|
||
[],
|
||
);
|
||
|
||
const generateStoryForState = useCallback(
|
||
async ({
|
||
state,
|
||
character,
|
||
history,
|
||
choice,
|
||
lastFunctionId,
|
||
optionCatalog,
|
||
}: {
|
||
state: GameState;
|
||
character: Character;
|
||
history: StoryMoment[];
|
||
choice?: string;
|
||
lastFunctionId?: string | null;
|
||
optionCatalog?: StoryOption[] | null;
|
||
}) => {
|
||
if (!state.worldType) {
|
||
throw new Error(
|
||
'The current world is not initialized, so story generation cannot continue.',
|
||
);
|
||
}
|
||
|
||
const resolvedOptionCatalog =
|
||
optionCatalog && optionCatalog.length > 0 ? optionCatalog : null;
|
||
const availableOptions = resolvedOptionCatalog
|
||
? null
|
||
: getAvailableOptionsForState(state, character);
|
||
const response = choice
|
||
? await generateNextStep(
|
||
state.worldType,
|
||
character,
|
||
getStoryGenerationHostileNpcs(state),
|
||
history,
|
||
choice,
|
||
buildStoryContextFromState(state, {
|
||
lastFunctionId,
|
||
}),
|
||
availableOptions
|
||
? { availableOptions }
|
||
: resolvedOptionCatalog
|
||
? { optionCatalog: resolvedOptionCatalog }
|
||
: undefined,
|
||
)
|
||
: await generateInitialStory(
|
||
state.worldType,
|
||
character,
|
||
getStoryGenerationHostileNpcs(state),
|
||
buildStoryContextFromState(state),
|
||
availableOptions
|
||
? { availableOptions }
|
||
: resolvedOptionCatalog
|
||
? { optionCatalog: resolvedOptionCatalog }
|
||
: undefined,
|
||
);
|
||
|
||
return buildStoryFromResponse(
|
||
state,
|
||
character,
|
||
{
|
||
text: response.storyText,
|
||
options: response.options,
|
||
},
|
||
availableOptions,
|
||
resolvedOptionCatalog,
|
||
);
|
||
},
|
||
[buildStoryFromResponse, getAvailableOptionsForState],
|
||
);
|
||
|
||
const updateNpcState = (
|
||
state: GameState,
|
||
encounter: Encounter,
|
||
updater: (
|
||
npcState: ReturnType<typeof getResolvedNpcState>,
|
||
) => ReturnType<typeof getResolvedNpcState>,
|
||
) => ({
|
||
...state,
|
||
npcStates: {
|
||
...state.npcStates,
|
||
[getNpcEncounterKey(encounter)]: normalizeNpcPersistentState(
|
||
updater(getResolvedNpcState(state, encounter)),
|
||
),
|
||
},
|
||
});
|
||
|
||
const updateQuestLog = (
|
||
state: GameState,
|
||
updater: (quests: GameState['quests']) => GameState['quests'],
|
||
) => ({
|
||
...state,
|
||
quests: updater(state.quests),
|
||
});
|
||
|
||
const incrementRuntimeStats = (
|
||
state: GameState,
|
||
increments: Parameters<typeof incrementGameRuntimeStats>[1],
|
||
) => ({
|
||
...state,
|
||
runtimeStats: incrementGameRuntimeStats(state.runtimeStats, increments),
|
||
});
|
||
|
||
const progressTreasureQuest = (state: GameState, sceneId: string | null) =>
|
||
updateQuestLog(state, (quests) =>
|
||
applyQuestProgressFromTreasure(quests, sceneId),
|
||
);
|
||
|
||
const appendHistory = useCallback(appendStoryHistory, []);
|
||
|
||
const prepareOpeningAdventure = useCallback(
|
||
(state: GameState, character: Character): PreparedOpeningAdventure | null =>
|
||
buildPreparedOpeningAdventureState({
|
||
state,
|
||
character,
|
||
getNpcEncounterKey,
|
||
appendHistory,
|
||
buildCampCompanionOpeningOptions,
|
||
buildCampCompanionOpeningResultText,
|
||
buildInitialCompanionDialogueText,
|
||
}),
|
||
[appendHistory, buildCampCompanionOpeningOptions],
|
||
);
|
||
|
||
useEffect(() => {
|
||
if (
|
||
gameState.currentScene !== 'Story' ||
|
||
!gameState.playerCharacter ||
|
||
gameState.storyHistory.length > 0 ||
|
||
currentStory ||
|
||
!isNpcEncounter(gameState.currentEncounter) ||
|
||
gameState.currentEncounter.specialBehavior !== 'initial_companion'
|
||
) {
|
||
setPreparedOpeningAdventure(null);
|
||
return;
|
||
}
|
||
|
||
setPreparedOpeningAdventure(
|
||
prepareOpeningAdventure(gameState, gameState.playerCharacter),
|
||
);
|
||
}, [
|
||
prepareOpeningAdventure,
|
||
currentStory,
|
||
gameState,
|
||
gameState.currentEncounter,
|
||
gameState.currentScene,
|
||
gameState.playerCharacter,
|
||
gameState.storyHistory,
|
||
]);
|
||
|
||
const { commitGeneratedState, commitGeneratedStateWithEncounterEntry } =
|
||
createStoryProgressionActions({
|
||
gameState,
|
||
setGameState,
|
||
setCurrentStory,
|
||
setAiError,
|
||
setIsLoading,
|
||
generateStoryForState,
|
||
buildFallbackStoryForState,
|
||
});
|
||
|
||
const startOpeningAdventure = useCallback(async () => {
|
||
if (
|
||
!gameState.playerCharacter ||
|
||
!isNpcEncounter(gameState.currentEncounter)
|
||
) {
|
||
return;
|
||
}
|
||
|
||
const encounter = gameState.currentEncounter;
|
||
if (encounter.specialBehavior !== 'initial_companion') {
|
||
return;
|
||
}
|
||
|
||
const preparedStory =
|
||
preparedOpeningAdventure?.encounterKey === getNpcEncounterKey(encounter)
|
||
? preparedOpeningAdventure
|
||
: prepareOpeningAdventure(gameState, gameState.playerCharacter);
|
||
|
||
if (!preparedStory) {
|
||
return;
|
||
}
|
||
|
||
await playOpeningAdventureSequence({
|
||
gameState,
|
||
character: gameState.playerCharacter,
|
||
encounter,
|
||
preparedStory,
|
||
setGameState,
|
||
setCurrentStory,
|
||
setAiError,
|
||
setIsLoading,
|
||
buildDialogueStoryMoment,
|
||
buildStoryContextFromState,
|
||
getStoryGenerationHostileNpcs,
|
||
hasRenderableDialogueTurns,
|
||
inferOpeningCampFollowupOptions,
|
||
getTypewriterDelay,
|
||
});
|
||
}, [
|
||
gameState,
|
||
inferOpeningCampFollowupOptions,
|
||
prepareOpeningAdventure,
|
||
preparedOpeningAdventure,
|
||
setGameState,
|
||
]);
|
||
|
||
const { handleTreasureInteraction } = useTreasureFlow({
|
||
gameState,
|
||
commitGeneratedState,
|
||
progressTreasureQuest,
|
||
});
|
||
const { inventoryUi } = useStoryInventoryActions({
|
||
gameState,
|
||
commitGeneratedState,
|
||
tickCooldowns: tickCooldownMap,
|
||
});
|
||
const npcInteractionFlow = useStoryNpcInteractionFlow({
|
||
gameState,
|
||
setGameState,
|
||
commitGeneratedState,
|
||
getNpcEncounterKey,
|
||
getResolvedNpcState,
|
||
updateNpcState,
|
||
cloneInventoryItemForOwner,
|
||
runtime: {
|
||
setCurrentStory,
|
||
setAiError,
|
||
setIsLoading,
|
||
buildStoryContextFromState,
|
||
buildFallbackStoryForState,
|
||
buildDialogueStoryMoment,
|
||
generateStoryForState,
|
||
getStoryGenerationHostileNpcs,
|
||
getTypewriterDelay,
|
||
},
|
||
});
|
||
|
||
const { enterNpcInteraction, handleNpcInteraction, finalizeNpcBattleResult } =
|
||
createStoryNpcEncounterActions({
|
||
gameState,
|
||
setGameState,
|
||
setCurrentStory,
|
||
setAiError,
|
||
setIsLoading,
|
||
commitGeneratedState,
|
||
commitGeneratedStateWithEncounterEntry,
|
||
appendHistory,
|
||
buildOpeningCampChatContext,
|
||
buildStoryContextFromState,
|
||
buildFallbackStoryForState,
|
||
buildDialogueStoryMoment,
|
||
getStoryGenerationHostileNpcs,
|
||
getTypewriterDelay,
|
||
getAvailableOptionsForState,
|
||
sanitizeOptions,
|
||
sortOptions: sortStoryOptionsByPriority,
|
||
buildContinueAdventureOption,
|
||
getNpcEncounterKey,
|
||
getResolvedNpcState,
|
||
updateNpcState,
|
||
cloneInventoryItemForOwner,
|
||
resolveNpcInteractionDecision,
|
||
npcInteractionFlow,
|
||
});
|
||
useEffect(() => {
|
||
const startStory = async () => {
|
||
if (
|
||
gameState.currentScene !== 'Story' ||
|
||
!gameState.worldType ||
|
||
!gameState.playerCharacter ||
|
||
currentStory ||
|
||
isLoading
|
||
) {
|
||
return;
|
||
}
|
||
|
||
if (
|
||
gameState.storyHistory.length === 0 &&
|
||
isInitialCompanionEncounter(gameState.currentEncounter) &&
|
||
!gameState.npcInteractionActive
|
||
) {
|
||
setAiError(null);
|
||
void startOpeningAdventure();
|
||
return;
|
||
}
|
||
|
||
setIsLoading(true);
|
||
try {
|
||
setAiError(null);
|
||
const nextStory = await generateStoryForState({
|
||
state: gameState,
|
||
character: gameState.playerCharacter,
|
||
history: [],
|
||
});
|
||
setCurrentStory(nextStory);
|
||
} catch (error) {
|
||
console.error('Failed to start story:', error);
|
||
setAiError(error instanceof Error ? error.message : '未知智能生成错误');
|
||
setCurrentStory(
|
||
buildFallbackStoryForState(gameState, gameState.playerCharacter),
|
||
);
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
};
|
||
|
||
startStory();
|
||
}, [
|
||
buildFallbackStoryForState,
|
||
generateStoryForState,
|
||
currentStory,
|
||
gameState,
|
||
gameState.currentEncounter,
|
||
gameState.currentScene,
|
||
gameState.inBattle,
|
||
gameState.playerCharacter,
|
||
gameState.playerX,
|
||
gameState.sceneHostileNpcs,
|
||
gameState.worldType,
|
||
isLoading,
|
||
startOpeningAdventure,
|
||
]);
|
||
|
||
const {
|
||
displayedOptions,
|
||
canRefreshOptions,
|
||
handleRefreshOptions,
|
||
resetStoryOptions,
|
||
} = useStoryOptions(currentStory);
|
||
const { handleChoice } = createStoryChoiceActions({
|
||
gameState,
|
||
currentStory,
|
||
isLoading,
|
||
setGameState,
|
||
setCurrentStory,
|
||
setAiError,
|
||
setIsLoading,
|
||
setBattleReward,
|
||
buildResolvedChoiceState,
|
||
playResolvedChoice,
|
||
buildStoryContextFromState,
|
||
buildStoryFromResponse,
|
||
buildFallbackStoryForState,
|
||
generateStoryForState,
|
||
getAvailableOptionsForState,
|
||
getStoryGenerationHostileNpcs,
|
||
getResolvedSceneHostileNpcs,
|
||
buildNpcStory,
|
||
updateQuestLog,
|
||
incrementRuntimeStats,
|
||
getCampCompanionTravelScene,
|
||
startOpeningAdventure,
|
||
enterNpcInteraction,
|
||
handleNpcInteraction,
|
||
handleTreasureInteraction,
|
||
commitGeneratedStateWithEncounterEntry,
|
||
finalizeNpcBattleResult,
|
||
isContinueAdventureOption,
|
||
isCampTravelHomeOption,
|
||
isInitialCompanionEncounter,
|
||
isRegularNpcEncounter,
|
||
isNpcEncounter,
|
||
npcPreviewTalkFunctionId: NPC_PREVIEW_TALK_FUNCTION_ID,
|
||
fallbackCompanionName: FALLBACK_COMPANION_NAME,
|
||
turnVisualMs: TURN_VISUAL_MS,
|
||
});
|
||
|
||
const clearStoryRuntimeUi = useCallback(() => {
|
||
resetStoryOptions();
|
||
setAiError(null);
|
||
setIsLoading(false);
|
||
setPreparedOpeningAdventure(null);
|
||
setBattleReward(null);
|
||
npcInteractionFlow.clearNpcInteractionUi();
|
||
clearCharacterChatModal();
|
||
}, [clearCharacterChatModal, npcInteractionFlow, resetStoryOptions]);
|
||
|
||
const {
|
||
acknowledgeQuestCompletion,
|
||
claimQuestReward,
|
||
resetStoryState,
|
||
hydrateStoryState,
|
||
travelToSceneFromMap,
|
||
} = createStorySessionActions({
|
||
gameState,
|
||
isLoading,
|
||
setGameState,
|
||
setCurrentStory,
|
||
clearStoryRuntimeUi,
|
||
commitGeneratedState,
|
||
buildFallbackStoryForState,
|
||
});
|
||
|
||
return {
|
||
currentStory,
|
||
isLoading,
|
||
aiError,
|
||
displayedOptions,
|
||
canRefreshOptions,
|
||
handleRefreshOptions,
|
||
handleChoice,
|
||
startOpeningAdventure,
|
||
isOpeningAdventureReady: Boolean(preparedOpeningAdventure),
|
||
resetStoryState,
|
||
hydrateStoryState,
|
||
travelToSceneFromMap,
|
||
battleRewardUi: {
|
||
reward: battleReward,
|
||
dismiss: () => setBattleReward(null),
|
||
} satisfies BattleRewardUi,
|
||
questUi: {
|
||
acknowledgeQuestCompletion,
|
||
claimQuestReward,
|
||
} satisfies QuestFlowUi,
|
||
npcUi: npcInteractionFlow.npcUi,
|
||
characterChatUi,
|
||
inventoryUi,
|
||
};
|
||
}
|