创作数据流程收束

This commit is contained in:
2026-04-21 09:44:17 +08:00
parent effe0355bd
commit 3614e1f5a2
93 changed files with 1794 additions and 8651 deletions

View File

@@ -1,249 +0,0 @@
import type { Dispatch, SetStateAction } from 'react';
import { useCallback, useEffect, useState } from 'react';
import type { StoryGenerationContext } from '../../services/aiTypes';
import { applyStoryReasoningRecovery } from '../../data/storyRecovery';
import type { Character, Encounter, GameState, StoryMoment, StoryOption } from '../../types';
import {
playOpeningAdventureSequence,
type PreparedOpeningAdventure,
} from './openingAdventure';
type BuildFallbackStoryForState = (
state: GameState,
character: Character,
fallbackText?: string,
) => StoryMoment;
type GenerateStoryForState = (params: {
state: GameState;
character: Character;
history: StoryMoment[];
choice?: string;
lastFunctionId?: string | null;
optionCatalog?: StoryOption[] | null;
}) => Promise<StoryMoment>;
type BuildDialogueStoryMoment = (
npcName: string,
text: string,
options: StoryOption[],
streaming?: boolean,
) => StoryMoment;
export function useStoryBootstrap(params: {
gameState: GameState;
currentStory: StoryMoment | null;
isLoading: boolean;
setGameState: Dispatch<SetStateAction<GameState>>;
setCurrentStory: Dispatch<SetStateAction<StoryMoment | null>>;
setAiError: Dispatch<SetStateAction<string | null>>;
setIsLoading: Dispatch<SetStateAction<boolean>>;
prepareOpeningAdventure: (
state: GameState,
character: Character,
) => PreparedOpeningAdventure | null;
getNpcEncounterKey: (encounter: Encounter) => string;
buildFallbackStoryForState: BuildFallbackStoryForState;
generateStoryForState: GenerateStoryForState;
buildDialogueStoryMoment: BuildDialogueStoryMoment;
buildStoryContextFromState: (
state: GameState,
extras?: {
lastFunctionId?: string | null;
openingCampBackground?: string | null;
openingCampDialogue?: string | null;
encounterNpcStateOverride?: GameState['npcStates'][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;
isNpcEncounter: (
encounter: GameState['currentEncounter'],
) => encounter is Encounter;
isInitialCompanionEncounter: (
encounter: GameState['currentEncounter'],
) => encounter is Encounter;
}) {
const {
gameState,
currentStory,
isLoading,
setGameState,
setCurrentStory,
setAiError,
setIsLoading,
prepareOpeningAdventure,
getNpcEncounterKey,
buildFallbackStoryForState,
generateStoryForState,
buildDialogueStoryMoment,
buildStoryContextFromState,
getStoryGenerationHostileNpcs,
hasRenderableDialogueTurns,
inferOpeningCampFollowupOptions,
getTypewriterDelay,
isNpcEncounter,
isInitialCompanionEncounter,
} = params;
const [preparedOpeningAdventure, setPreparedOpeningAdventure] =
useState<PreparedOpeningAdventure | null>(null);
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),
);
}, [
currentStory,
gameState,
isNpcEncounter,
prepareOpeningAdventure,
]);
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,
});
}, [
buildDialogueStoryMoment,
buildStoryContextFromState,
gameState,
getNpcEncounterKey,
getStoryGenerationHostileNpcs,
getTypewriterDelay,
hasRenderableDialogueTurns,
inferOpeningCampFollowupOptions,
isNpcEncounter,
prepareOpeningAdventure,
preparedOpeningAdventure,
setAiError,
setCurrentStory,
setGameState,
setIsLoading,
]);
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: [],
});
setGameState(applyStoryReasoningRecovery(gameState));
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);
}
};
void startStory();
}, [
buildFallbackStoryForState,
currentStory,
gameState,
generateStoryForState,
isInitialCompanionEncounter,
isLoading,
setAiError,
setCurrentStory,
setGameState,
setIsLoading,
startOpeningAdventure,
]);
const resetPreparedOpeningAdventure = useCallback(() => {
setPreparedOpeningAdventure(null);
}, []);
return {
preparedOpeningAdventure,
startOpeningAdventure,
resetPreparedOpeningAdventure,
};
}

View File

@@ -1,133 +0,0 @@
import { useCallback } from 'react';
import {
applyEquipmentLoadoutToState,
getEquipmentSlotFromItem,
getEquipmentSlotLabel,
} from '../data/equipmentEffects';
import {
EQUIPMENT_EQUIP_FUNCTION,
EQUIPMENT_UNEQUIP_FUNCTION,
} from '../data/functionCatalog';
import {
addInventoryItems,
removeInventoryItem,
} from '../data/npcInteractions';
import { EquipmentSlotId, GameState, InventoryItem } from '../types';
import type { CommitGeneratedState } from './generatedState';
function normalizeEquippedItem(item: InventoryItem) {
return {
...item,
quantity: 1,
};
}
function buildEquipResultText(
item: InventoryItem,
slot: EquipmentSlotId,
replacedItem?: InventoryItem | null,
) {
return replacedItem
? `你将${replacedItem.name}${getEquipmentSlotLabel(slot)}位上换下,改为装备${item.name}`
: `你将${item.name}装备在${getEquipmentSlotLabel(slot)}位上。`;
}
function buildUnequipResultText(item: InventoryItem) {
return `你卸下了${item.name},暂时收回背包。`;
}
export function useEquipmentFlow({
gameState,
commitGeneratedState,
}: {
gameState: GameState;
commitGeneratedState: CommitGeneratedState;
}) {
const handleEquipInventoryItem = useCallback(
async (itemId: string) => {
if (!gameState.playerCharacter || gameState.inBattle) return false;
const item = gameState.playerInventory.find(
(candidate) => candidate.id === itemId,
);
if (!item || item.quantity <= 0) return false;
const slot = getEquipmentSlotFromItem(item);
if (!slot) return false;
const replacedItem = gameState.playerEquipment[slot];
const nextEquipment = {
...gameState.playerEquipment,
[slot]: normalizeEquippedItem(item),
};
let nextInventory = removeInventoryItem(
gameState.playerInventory,
item.id,
1,
);
if (replacedItem) {
nextInventory = addInventoryItems(nextInventory, [replacedItem]);
}
const nextState = applyEquipmentLoadoutToState(
{
...gameState,
playerInventory: nextInventory,
},
nextEquipment,
);
await commitGeneratedState(
nextState,
gameState.playerCharacter,
`装备${item.name}`,
buildEquipResultText(item, slot, replacedItem),
EQUIPMENT_EQUIP_FUNCTION.id,
);
return true;
},
[commitGeneratedState, gameState],
);
const handleUnequipItem = useCallback(
async (slot: EquipmentSlotId) => {
if (!gameState.playerCharacter || gameState.inBattle) return false;
const equippedItem = gameState.playerEquipment[slot];
if (!equippedItem) return false;
const nextEquipment = {
...gameState.playerEquipment,
[slot]: null,
};
const nextState = applyEquipmentLoadoutToState(
{
...gameState,
playerInventory: addInventoryItems(gameState.playerInventory, [
equippedItem,
]),
},
nextEquipment,
);
await commitGeneratedState(
nextState,
gameState.playerCharacter,
`卸下${equippedItem.name}`,
buildUnequipResultText(equippedItem),
EQUIPMENT_UNEQUIP_FUNCTION.id,
);
return true;
},
[commitGeneratedState, gameState],
);
return {
handleEquipInventoryItem,
handleUnequipItem,
};
}

View File

@@ -1,158 +0,0 @@
import { useCallback, useMemo } from 'react';
import {
buildForgeSuccessText,
executeDismantleItem,
executeForgeRecipe,
executeReforgeItem,
getForgeRecipeViews,
getReforgeCostView,
} from '../data/forgeSystem';
import {
FORGE_CRAFT_FUNCTION,
FORGE_DISMANTLE_FUNCTION,
FORGE_REFORGE_FUNCTION,
} from '../data/functionCatalog';
import type { GameState } from '../types';
import type { CommitGeneratedState } from './generatedState';
export function useForgeFlow({
gameState,
commitGeneratedState,
}: {
gameState: GameState;
commitGeneratedState: CommitGeneratedState;
}) {
const forgeRecipes = useMemo(
() =>
getForgeRecipeViews(
gameState.playerInventory,
gameState.playerCurrency,
gameState.worldType,
),
[gameState.playerCurrency, gameState.playerInventory, gameState.worldType],
);
const handleCraftRecipe = useCallback(
async (recipeId: string) => {
if (!gameState.playerCharacter || gameState.inBattle) return false;
const result = executeForgeRecipe(
gameState.playerInventory,
recipeId,
gameState.worldType,
gameState.playerCurrency,
);
if (!result) return false;
const recipe = forgeRecipes.find(
(candidate) => candidate.id === recipeId,
);
const nextState: GameState = {
...gameState,
playerCurrency: result.currency,
playerInventory: result.inventory,
};
await commitGeneratedState(
nextState,
gameState.playerCharacter,
`制作${result.createdItem.name}`,
buildForgeSuccessText('craft', {
recipeName: recipe?.name ?? recipeId,
createdItemName: result.createdItem.name,
currencyText: recipe?.currencyText,
}),
FORGE_CRAFT_FUNCTION.id,
);
return true;
},
[commitGeneratedState, forgeRecipes, gameState],
);
const handleDismantleItem = useCallback(
async (itemId: string) => {
if (!gameState.playerCharacter || gameState.inBattle) return false;
const sourceItem = gameState.playerInventory.find(
(item) => item.id === itemId,
);
if (!sourceItem) return false;
const result = executeDismantleItem(gameState.playerInventory, itemId);
if (!result) return false;
const nextState: GameState = {
...gameState,
playerInventory: result.inventory,
};
await commitGeneratedState(
nextState,
gameState.playerCharacter,
`拆解${sourceItem.name}`,
buildForgeSuccessText('dismantle', {
sourceItemName: sourceItem.name,
outputNames: result.outputs.map((item) => item.name),
}),
FORGE_DISMANTLE_FUNCTION.id,
);
return true;
},
[commitGeneratedState, gameState],
);
const handleReforgeItem = useCallback(
async (itemId: string) => {
if (!gameState.playerCharacter || gameState.inBattle) return false;
const sourceItem = gameState.playerInventory.find(
(item) => item.id === itemId,
);
if (!sourceItem) return false;
const result = executeReforgeItem(
gameState.playerInventory,
itemId,
gameState.playerCurrency,
);
if (!result) return false;
const nextState: GameState = {
...gameState,
playerCurrency: Math.max(
0,
gameState.playerCurrency - result.currencyCost,
),
playerInventory: result.inventory,
};
const reforgeCost = getReforgeCostView(sourceItem, gameState.worldType);
await commitGeneratedState(
nextState,
gameState.playerCharacter,
`重铸${sourceItem.name}`,
buildForgeSuccessText('reforge', {
sourceItemName: sourceItem.name,
createdItemName: result.reforgedItem.name,
currencyText: reforgeCost.currencyText,
}),
FORGE_REFORGE_FUNCTION.id,
);
return true;
},
[commitGeneratedState, gameState],
);
return {
forgeRecipes,
handleCraftRecipe,
handleDismantleItem,
handleReforgeItem,
getReforgeCostView,
};
}

View File

@@ -1,99 +0,0 @@
import { useCallback } from 'react';
import { appendBuildBuffs } from '../data/buildDamage';
import { INVENTORY_USE_FUNCTION } from '../data/functionCatalog';
import {
buildInventoryUseResultText,
isInventoryItemUsable,
resolveInventoryItemUseEffect,
} from '../data/inventoryEffects';
import { removeInventoryItem } from '../data/npcInteractions';
import { incrementGameRuntimeStats } from '../data/runtimeStats';
import { GameState } from '../types';
import type { CommitGeneratedState } from './generatedState';
type TickCooldowns = (
cooldowns: Record<string, number>,
) => Record<string, number>;
export function useInventoryFlow({
gameState,
commitGeneratedState,
tickCooldowns,
}: {
gameState: GameState;
commitGeneratedState: CommitGeneratedState;
tickCooldowns: TickCooldowns;
}) {
const handleUseInventoryItem = useCallback(
async (itemId: string) => {
if (!gameState.playerCharacter) return false;
const item = gameState.playerInventory.find(
(candidate) => candidate.id === itemId,
);
if (!item || !isInventoryItemUsable(item) || item.quantity <= 0)
return false;
const effect = resolveInventoryItemUseEffect(
item,
gameState.playerCharacter,
);
if (!effect) return false;
if (
effect.hpRestore <= 0 &&
effect.manaRestore <= 0 &&
effect.cooldownReduction <= 0 &&
effect.buildBuffs.length <= 0
) {
return false;
}
let cooldowns = gameState.playerSkillCooldowns;
for (let index = 0; index < effect.cooldownReduction; index += 1) {
cooldowns = tickCooldowns(cooldowns);
}
const nextState: GameState = {
...gameState,
playerHp: Math.min(
gameState.playerMaxHp,
gameState.playerHp + effect.hpRestore,
),
playerMana: Math.min(
gameState.playerMaxMana,
gameState.playerMana + effect.manaRestore,
),
playerSkillCooldowns: cooldowns,
activeBuildBuffs: appendBuildBuffs(
gameState.activeBuildBuffs,
effect.buildBuffs,
),
playerInventory: removeInventoryItem(
gameState.playerInventory,
item.id,
1,
),
runtimeStats: incrementGameRuntimeStats(gameState.runtimeStats, {
itemsUsed: 1,
}),
};
await commitGeneratedState(
nextState,
gameState.playerCharacter,
`使用${item.name}`,
buildInventoryUseResultText(item, effect),
INVENTORY_USE_FUNCTION.id,
);
return true;
},
[commitGeneratedState, gameState, tickCooldowns],
);
return {
handleUseInventoryItem,
};
}