Rework story engine flow and reorganize project docs
Some checks failed
CI / verify (push) Has been cancelled
Some checks failed
CI / verify (push) Has been cancelled
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user