Rework story engine flow and reorganize project docs
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-06 23:19:00 +08:00
parent d678929064
commit ddcb5d5c8c
241 changed files with 19805 additions and 2478 deletions

View File

@@ -3,7 +3,6 @@ import { useEffect, useMemo, useRef, useState } from 'react';
import {
buildCustomWorldPlayableCharacters,
PRESET_CHARACTERS,
} from '../../data/characterPresets';
import {
readSavedCustomWorldProfiles,
@@ -15,6 +14,13 @@ import {
generateCustomWorldProfile,
} from '../../services/ai';
import {
buildCustomWorldCreatorIntentDisplayText,
buildCustomWorldCreatorIntentGenerationText,
createEmptyCustomWorldCreatorIntent,
} from '../../services/customWorldCreatorIntent';
import {
type CustomWorldCreatorIntent,
type CustomWorldGenerationMode,
type CustomWorldProfile,
type GameState,
WorldType,
@@ -77,8 +83,6 @@ const WORLD_OPTIONS = [
},
] as const;
const GENERATION_PREVIEW_CHARACTERS = PRESET_CHARACTERS.slice(0, 3);
function generateWorldOnlineCounts(): WorldOnlineCounts {
const roll = (base: number) =>
Math.max(100, Math.min(200, base + Math.floor(Math.random() * 19) - 9));
@@ -88,6 +92,73 @@ function generateWorldOnlineCounts(): WorldOnlineCounts {
};
}
function buildLockedSeedNameSets(profile: CustomWorldProfile) {
const lockedCharacterNames = new Set(
profile.creatorIntent?.keyCharacters
.filter((entry) => entry.locked)
.map((entry) => entry.name.trim())
.filter(Boolean) ?? [],
);
const lockedLandmarkNames = new Set(
profile.creatorIntent?.keyLandmarks
.filter((entry) => entry.locked)
.map((entry) => entry.name.trim())
.filter(Boolean) ?? [],
);
return {
lockedCharacterNames,
lockedLandmarkNames,
};
}
function mergeLockedProfileContent(
currentProfile: CustomWorldProfile,
nextProfile: CustomWorldProfile,
) {
const { lockedCharacterNames, lockedLandmarkNames } =
buildLockedSeedNameSets(currentProfile);
const nextPlayableNpcs = nextProfile.playableNpcs.map((npc) => {
if (!lockedCharacterNames.has(npc.name.trim())) {
return npc;
}
return (
currentProfile.playableNpcs.find(
(currentNpc) => currentNpc.name.trim() === npc.name.trim(),
) ?? npc
);
});
const nextStoryNpcs = nextProfile.storyNpcs.map((npc) => {
if (!lockedCharacterNames.has(npc.name.trim())) {
return npc;
}
return (
currentProfile.storyNpcs.find(
(currentNpc) => currentNpc.name.trim() === npc.name.trim(),
) ?? npc
);
});
const nextLandmarks = nextProfile.landmarks.map((landmark) => {
if (!lockedLandmarkNames.has(landmark.name.trim())) {
return landmark;
}
return (
currentProfile.landmarks.find(
(currentLandmark) =>
currentLandmark.name.trim() === landmark.name.trim(),
) ?? landmark
);
});
return {
...nextProfile,
playableNpcs: nextPlayableNpcs,
storyNpcs: nextStoryNpcs,
landmarks: nextLandmarks,
} satisfies CustomWorldProfile;
}
export function PreGameSelectionFlow({
selectionStage,
setSelectionStage,
@@ -107,7 +178,12 @@ export function PreGameSelectionFlow({
() => generateWorldOnlineCounts(),
);
const [showCustomWorldModal, setShowCustomWorldModal] = useState(false);
const [customWorldDraft, setCustomWorldDraft] = useState('');
const [customWorldCreatorIntent, setCustomWorldCreatorIntent] =
useState<CustomWorldCreatorIntent>(() =>
createEmptyCustomWorldCreatorIntent('freeform'),
);
const [customWorldGenerationMode, setCustomWorldGenerationMode] =
useState<CustomWorldGenerationMode>('fast');
const [customWorldError, setCustomWorldError] = useState<string | null>(null);
const [isGeneratingCustomWorld, setIsGeneratingCustomWorld] = useState(false);
const [customWorldProgress, setCustomWorldProgress] =
@@ -170,6 +246,19 @@ export function PreGameSelectionFlow({
[savedCustomWorldProfiles],
);
const customWorldSettingPreview = useMemo(() => {
if (customWorldCreatorIntent.sourceMode === 'freeform') {
return customWorldCreatorIntent.rawSettingText.trim();
}
const intentSummary = buildCustomWorldCreatorIntentDisplayText(
customWorldCreatorIntent,
).trim();
if (intentSummary) {
return intentSummary;
}
return customWorldCreatorIntent.rawSettingText.trim();
}, [customWorldCreatorIntent]);
useEffect(() => {
if (!gameState.worldType && selectionStage === 'world') {
setWorldOnlineCounts(generateWorldOnlineCounts());
@@ -224,6 +313,18 @@ export function PreGameSelectionFlow({
return;
}
if (generatedCustomWorldProfile) {
setCustomWorldCreatorIntent(
generatedCustomWorldProfile.creatorIntent ??
({
...createEmptyCustomWorldCreatorIntent('freeform'),
rawSettingText: generatedCustomWorldProfile.settingText,
} satisfies CustomWorldCreatorIntent),
);
setCustomWorldGenerationMode(
generatedCustomWorldProfile.generationMode ?? 'full',
);
}
setCustomWorldError(null);
setCustomWorldProgress(null);
setSelectionStage('world');
@@ -253,14 +354,268 @@ export function PreGameSelectionFlow({
setSelectionStage('world');
};
const openSavedCustomWorldEditor = (profile: CustomWorldProfile) => {
if (isGeneratingCustomWorld) {
return;
}
setGeneratedCustomWorldProfile(profile);
setCustomWorldCreatorIntent(
profile.creatorIntent ??
({
...createEmptyCustomWorldCreatorIntent('freeform'),
rawSettingText: profile.settingText,
} satisfies CustomWorldCreatorIntent),
);
setCustomWorldGenerationMode(profile.generationMode ?? 'full');
setCustomWorldError(null);
setCustomWorldProgress(null);
setSelectionStage('custom-world-result');
};
const regenerateFromCurrentProfile = async (
applyProfile: (
currentProfile: CustomWorldProfile,
regeneratedProfile: CustomWorldProfile,
) => CustomWorldProfile,
options: {
confirmMessage: string;
generationMode?: CustomWorldGenerationMode;
},
) => {
if (!generatedCustomWorldProfile || isGeneratingCustomWorld) {
return;
}
const confirmed = window.confirm(options.confirmMessage);
if (!confirmed) {
return;
}
const abortController = new AbortController();
customWorldAbortControllerRef.current?.abort();
customWorldAbortControllerRef.current = abortController;
setIsGeneratingCustomWorld(true);
setCustomWorldError(null);
try {
const regeneratedProfile = await generateCustomWorldProfile(
{
settingText:
generatedCustomWorldProfile.settingText.trim() ||
customWorldSettingPreview,
creatorIntent: generatedCustomWorldProfile.creatorIntent,
generationMode:
options.generationMode ??
generatedCustomWorldProfile.generationMode ??
'full',
},
{
signal: abortController.signal,
onProgress: setCustomWorldProgress,
},
);
if (abortController.signal.aborted) {
return;
}
const mergedProfile = applyProfile(
generatedCustomWorldProfile,
mergeLockedProfileContent(generatedCustomWorldProfile, regeneratedProfile),
);
setGeneratedCustomWorldProfile(mergedProfile);
setCustomWorldProgress(null);
setCustomWorldError(null);
} catch (error) {
if (abortController.signal.aborted) {
setCustomWorldError('世界生成已中断。你可以重新尝试本次操作。');
return;
}
setCustomWorldError(
error instanceof Error ? error.message : '局部重生成失败。',
);
} finally {
if (customWorldAbortControllerRef.current === abortController) {
customWorldAbortControllerRef.current = null;
}
setIsGeneratingCustomWorld(false);
}
};
const continueExpandCustomWorld = async () => {
await regenerateFromCurrentProfile(
(_currentProfile, regeneratedProfile) => ({
...regeneratedProfile,
generationMode: 'full',
generationStatus: 'complete',
}),
{
confirmMessage:
'确认继续补全当前世界吗?系统会在保留已锁定锚点的前提下,继续生成长尾角色和场景网络。',
generationMode: 'full',
},
);
};
const regeneratePlayableNpc = async (id: string) => {
await regenerateFromCurrentProfile(
(currentProfile, regeneratedProfile) => {
const targetIndex = currentProfile.playableNpcs.findIndex(
(entry) => entry.id === id,
);
if (targetIndex < 0) {
return currentProfile;
}
const nextNpc =
regeneratedProfile.playableNpcs[targetIndex] ??
regeneratedProfile.playableNpcs.find(
(entry) =>
entry.name === currentProfile.playableNpcs[targetIndex]?.name,
);
if (!nextNpc) {
return currentProfile;
}
return {
...currentProfile,
playableNpcs: currentProfile.playableNpcs.map((entry, index) =>
index === targetIndex ? nextNpc : entry,
),
};
},
{
confirmMessage: '确认重新生成这个可扮演角色吗?当前角色的 AI 生成内容会被替换。',
generationMode: 'full',
},
);
};
const regenerateStoryNpc = async (id: string) => {
await regenerateFromCurrentProfile(
(currentProfile, regeneratedProfile) => {
const targetIndex = currentProfile.storyNpcs.findIndex(
(entry) => entry.id === id,
);
if (targetIndex < 0) {
return currentProfile;
}
const nextNpc =
regeneratedProfile.storyNpcs[targetIndex] ??
regeneratedProfile.storyNpcs.find(
(entry) => entry.name === currentProfile.storyNpcs[targetIndex]?.name,
);
if (!nextNpc) {
return currentProfile;
}
const nextStoryNpcs = currentProfile.storyNpcs.map((entry, index) =>
index === targetIndex ? nextNpc : entry,
);
return {
...currentProfile,
storyNpcs: nextStoryNpcs,
landmarks: currentProfile.landmarks.map((landmark) => ({
...landmark,
sceneNpcIds: landmark.sceneNpcIds.map((npcId) =>
npcId === id ? nextNpc.id : npcId,
),
})),
};
},
{
confirmMessage: '确认重新生成这个场景角色吗?当前角色的 AI 生成内容会被替换。',
generationMode: 'full',
},
);
};
const regenerateLandmark = async (id: string) => {
await regenerateFromCurrentProfile(
(currentProfile, regeneratedProfile) => {
const targetIndex = currentProfile.landmarks.findIndex(
(entry) => entry.id === id,
);
if (targetIndex < 0) {
return currentProfile;
}
const nextLandmark =
regeneratedProfile.landmarks[targetIndex] ??
regeneratedProfile.landmarks.find(
(entry) => entry.name === currentProfile.landmarks[targetIndex]?.name,
);
if (!nextLandmark) {
return currentProfile;
}
return {
...currentProfile,
landmarks: currentProfile.landmarks.map((entry, index) =>
index === targetIndex ? nextLandmark : entry,
),
};
},
{
confirmMessage: '确认重新生成这个关键地点吗?当前场景的 AI 生成内容会被替换。',
generationMode: 'full',
},
);
};
const regenerateStoryExpansion = async () => {
await regenerateFromCurrentProfile(
(currentProfile, regeneratedProfile) => ({
...currentProfile,
storyNpcs: regeneratedProfile.storyNpcs,
}),
{
confirmMessage:
'确认重新生成长尾场景角色吗?已锁定锚点会保留,其余场景角色会被新的生成结果替换。',
generationMode: 'full',
},
);
};
const regenerateLandmarkNetwork = async () => {
await regenerateFromCurrentProfile(
(currentProfile, regeneratedProfile) => ({
...currentProfile,
landmarks: currentProfile.landmarks.map((landmark, index) => ({
...landmark,
sceneNpcIds:
regeneratedProfile.landmarks[index]?.sceneNpcIds ??
landmark.sceneNpcIds,
connections:
regeneratedProfile.landmarks[index]?.connections ??
landmark.connections,
})),
}),
{
confirmMessage:
'确认重新生成场景网络吗?已锁定场景名称与描述会保留,但 NPC 分布和连接关系会按最新结果刷新。',
generationMode: 'full',
},
);
};
const createCustomWorld = async () => {
if (isGeneratingCustomWorld) {
return;
}
const settingText = customWorldDraft.trim();
if (!settingText) {
setCustomWorldError('请先输入世界设置。');
const generationText =
buildCustomWorldCreatorIntentGenerationText(
customWorldCreatorIntent,
).trim() || customWorldCreatorIntent.rawSettingText.trim();
const settingText = customWorldSettingPreview.trim() || generationText;
if (!generationText) {
setCustomWorldError(
customWorldCreatorIntent.sourceMode === 'card'
? '请至少填写一个世界锚点。'
: '请先输入世界设置。',
);
return;
}
@@ -275,16 +630,32 @@ export function PreGameSelectionFlow({
setIsGeneratingCustomWorld(true);
try {
const profile = await generateCustomWorldProfile(settingText, {
signal: abortController.signal,
onProgress: setCustomWorldProgress,
});
const profile = await generateCustomWorldProfile(
{
settingText,
creatorIntent: customWorldCreatorIntent,
generationMode: customWorldGenerationMode,
},
{
signal: abortController.signal,
onProgress: setCustomWorldProgress,
},
);
if (abortController.signal.aborted) {
return;
}
setGeneratedCustomWorldProfile(profile);
const persistedProfile = generatedCustomWorldProfile
? {
...profile,
id: generatedCustomWorldProfile.id,
}
: profile;
const savedProfiles = upsertSavedCustomWorldProfile(persistedProfile);
setSavedCustomWorldProfiles(savedProfiles);
setGeneratedCustomWorldProfile(null);
setCustomWorldError(null);
setSelectionStage('custom-world-result');
setCustomWorldProgress(null);
setSelectionStage('world');
} catch (error) {
if (abortController.signal.aborted) {
setCustomWorldError('世界生成已中断。你可以返回修改设定,或重新开始。');
@@ -353,7 +724,10 @@ export function PreGameSelectionFlow({
onClick={() => {
handleStartNewGame();
setGeneratedCustomWorldProfile(null);
setCustomWorldDraft('');
setCustomWorldCreatorIntent(
createEmptyCustomWorldCreatorIntent('freeform'),
);
setCustomWorldGenerationMode('fast');
setCustomWorldError(null);
setCustomWorldProgress(null);
setShowCustomWorldModal(false);
@@ -500,56 +874,64 @@ export function PreGameSelectionFlow({
))}
{savedCustomWorldCards.map((world) => (
<button
key={world.id}
type="button"
onClick={() =>
handleWorldSelect(WorldType.CUSTOM, world.profile)
}
className="pixel-nine-slice pixel-pressable order-1 relative flex min-h-[12.5rem] flex-col items-start justify-between overflow-hidden text-left"
style={getNineSliceStyle(world.texture, {
paddingX: 18,
paddingY: 16,
})}
>
{world.sceneImage && (
<img
src={world.sceneImage}
alt={world.profile.name}
className="absolute inset-0 h-full w-full object-cover opacity-25"
style={{ imageRendering: 'pixelated' }}
/>
)}
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(8,10,14,0.14),rgba(8,10,14,0.84))]" />
<div className="relative z-10 flex h-full w-full flex-col">
<div className="flex items-start justify-between gap-3">
<div className="rounded-full border border-sky-300/20 bg-sky-500/10 px-3 py-1 text-[10px] tracking-[0.2em] text-sky-100">
<div key={world.id} className="order-1 relative">
<button
type="button"
onClick={() =>
handleWorldSelect(WorldType.CUSTOM, world.profile)
}
className="pixel-nine-slice pixel-pressable relative flex min-h-[12.5rem] w-full flex-col items-start justify-between overflow-hidden text-left"
style={getNineSliceStyle(world.texture, {
paddingX: 18,
paddingY: 16,
})}
>
{world.sceneImage && (
<img
src={world.sceneImage}
alt={world.profile.name}
className="absolute inset-0 h-full w-full object-cover opacity-25"
style={{ imageRendering: 'pixelated' }}
/>
)}
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(8,10,14,0.14),rgba(8,10,14,0.84))]" />
<div className="relative z-10 flex h-full w-full flex-col">
<div className="flex items-start justify-between gap-3 pr-16">
<div className="rounded-full border border-sky-300/20 bg-sky-500/10 px-3 py-1 text-[10px] tracking-[0.2em] text-sky-100">
</div>
<div className="rounded-full border border-white/10 bg-black/24 px-2.5 py-1 text-[10px] text-zinc-100">
{world.accentLabel === '武侠基础'
? '武侠'
: '仙侠'}
</div>
</div>
<div className="rounded-full border border-white/10 bg-black/24 px-2.5 py-1 text-[10px] text-zinc-100">
{world.accentLabel === '武侠基础'
? '武侠'
: '仙侠'}
<div className="mt-auto">
<div className="text-2xl font-black text-white sm:text-[1.7rem]">
{world.profile.name}
</div>
<div className="mt-2 line-clamp-2 max-w-[18rem] text-xs leading-5 text-zinc-200/90">
{world.profile.summary}
</div>
<div className="mt-3 flex flex-wrap gap-2">
<span className="rounded-full border border-emerald-400/20 bg-emerald-500/10 px-3 py-1 text-[10px] text-emerald-100">
{world.profile.playableNpcs.length}
</span>
<span className="rounded-full border border-white/10 bg-black/24 px-2.5 py-1 text-[10px] text-zinc-100">
{world.profile.landmarks.length}
</span>
</div>
</div>
</div>
<div className="mt-auto">
<div className="text-2xl font-black text-white sm:text-[1.7rem]">
{world.profile.name}
</div>
<div className="mt-2 line-clamp-2 max-w-[18rem] text-xs leading-5 text-zinc-200/90">
{world.profile.summary}
</div>
<div className="mt-3 flex flex-wrap gap-2">
<span className="rounded-full border border-emerald-400/20 bg-emerald-500/10 px-3 py-1 text-[10px] text-emerald-100">
{world.profile.playableNpcs.length}
</span>
<span className="rounded-full border border-white/10 bg-black/24 px-2.5 py-1 text-[10px] text-zinc-100">
{world.profile.landmarks.length}
</span>
</div>
</div>
</div>
</button>
</button>
<button
type="button"
onClick={() => openSavedCustomWorldEditor(world.profile)}
className="absolute right-3 top-3 z-20 rounded-full border border-white/10 bg-black/35 px-3 py-1.5 text-[11px] text-zinc-100 transition-colors hover:text-white"
>
</button>
</div>
))}
<button
@@ -597,8 +979,7 @@ export function PreGameSelectionFlow({
className="flex h-full min-h-0 flex-col"
>
<CustomWorldGenerationView
settingText={customWorldDraft.trim()}
actionPreviewCharacters={GENERATION_PREVIEW_CHARACTERS}
settingText={customWorldSettingPreview}
progress={customWorldProgress}
isGenerating={isGeneratingCustomWorld}
error={customWorldError}
@@ -635,6 +1016,24 @@ export function PreGameSelectionFlow({
onRegenerate={() => {
void createCustomWorld();
}}
onContinueExpand={() => {
void continueExpandCustomWorld();
}}
onRegeneratePlayableNpc={(id) => {
void regeneratePlayableNpc(id);
}}
onRegenerateStoryNpc={(id) => {
void regenerateStoryNpc(id);
}}
onRegenerateLandmark={(id) => {
void regenerateLandmark(id);
}}
onRegenerateStoryExpansion={() => {
void regenerateStoryExpansion();
}}
onRegenerateLandmarkNetwork={() => {
void regenerateLandmarkNetwork();
}}
onSave={saveGeneratedCustomWorld}
/>
</motion.div>
@@ -643,11 +1042,13 @@ export function PreGameSelectionFlow({
<CustomWorldCreatorModal
isOpen={showCustomWorldModal}
draft={customWorldDraft}
onDraftChange={(value) => {
setCustomWorldDraft(value);
creatorIntent={customWorldCreatorIntent}
onCreatorIntentChange={(value) => {
setCustomWorldCreatorIntent(value);
if (customWorldError) setCustomWorldError(null);
}}
generationMode={customWorldGenerationMode}
onGenerationModeChange={setCustomWorldGenerationMode}
onClose={() => {
if (isGeneratingCustomWorld) return;
setShowCustomWorldModal(false);