1073 lines
38 KiB
TypeScript
1073 lines
38 KiB
TypeScript
import { AnimatePresence, motion } from 'motion/react';
|
||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||
|
||
import {
|
||
buildCustomWorldPlayableCharacters,
|
||
} from '../../data/characterPresets';
|
||
import {
|
||
readSavedCustomWorldProfiles,
|
||
upsertSavedCustomWorldProfile,
|
||
} from '../../data/customWorldLibrary';
|
||
import { getScenePreset } from '../../data/scenePresets';
|
||
import {
|
||
type CustomWorldGenerationProgress,
|
||
generateCustomWorldProfile,
|
||
} from '../../services/ai';
|
||
import {
|
||
buildCustomWorldCreatorIntentDisplayText,
|
||
buildCustomWorldCreatorIntentGenerationText,
|
||
createEmptyCustomWorldCreatorIntent,
|
||
} from '../../services/customWorldCreatorIntent';
|
||
import {
|
||
type CustomWorldCreatorIntent,
|
||
type CustomWorldGenerationMode,
|
||
type CustomWorldProfile,
|
||
type GameState,
|
||
WorldType,
|
||
} from '../../types';
|
||
import {
|
||
CHROME_ICONS,
|
||
getNineSliceStyle,
|
||
UI_CHROME,
|
||
WORLD_SELECT_ICONS,
|
||
} from '../../uiAssets';
|
||
import { CustomWorldGenerationView } from '../CustomWorldGenerationView';
|
||
import { CustomWorldResultView } from '../CustomWorldResultView';
|
||
import { DeveloperTeamModal } from '../DeveloperTeamModal';
|
||
import { PixelIcon } from '../PixelIcon';
|
||
import { CustomWorldCreatorModal } from '../SelectionCustomizationModals';
|
||
|
||
export type SelectionStage =
|
||
| 'start'
|
||
| 'world'
|
||
| 'custom-world-generating'
|
||
| 'custom-world-result';
|
||
|
||
type WorldOnlineCounts = Partial<Record<WorldType, number>>;
|
||
|
||
type PreGameSelectionFlowProps = {
|
||
selectionStage: SelectionStage;
|
||
setSelectionStage: (stage: SelectionStage) => void;
|
||
gameState: GameState;
|
||
hasSavedGame: boolean;
|
||
handleContinueGame: () => void;
|
||
handleStartNewGame: () => void;
|
||
handleWorldSelect: (
|
||
type: WorldType,
|
||
customWorldProfile?: GameState['customWorldProfile'],
|
||
) => void;
|
||
};
|
||
|
||
const DEVELOPER_TEAM_MESSAGE =
|
||
'\u7a0b\u7b56\u7f8e\uff1a\u53d9\u4e16AI \u5305\u4ef2\u822a\n\u5408\u4f5c\u8bf7\u8054\u7cfb\u5fae\u4fe1\uff1abzh253518756';
|
||
|
||
const START_SCREEN_CONTACTS = [
|
||
{ label: 'QQ群', value: '1094580241' },
|
||
{ label: '微信', value: 'bzh253518756' },
|
||
] as const;
|
||
|
||
const WORLD_OPTIONS = [
|
||
{
|
||
id: WorldType.WUXIA,
|
||
name: '武侠',
|
||
subtitle: '刀剑江湖',
|
||
icon: WORLD_SELECT_ICONS.wuxia,
|
||
texture: UI_CHROME.worldButtonWuxia,
|
||
},
|
||
{
|
||
id: WorldType.XIANXIA,
|
||
name: '仙侠',
|
||
subtitle: '云灵仙境',
|
||
icon: WORLD_SELECT_ICONS.xianxia,
|
||
texture: UI_CHROME.worldButtonXianxia,
|
||
},
|
||
] as const;
|
||
|
||
function generateWorldOnlineCounts(): WorldOnlineCounts {
|
||
const roll = (base: number) =>
|
||
Math.max(100, Math.min(200, base + Math.floor(Math.random() * 19) - 9));
|
||
return {
|
||
[WorldType.WUXIA]: roll(146),
|
||
[WorldType.XIANXIA]: roll(173),
|
||
};
|
||
}
|
||
|
||
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,
|
||
gameState,
|
||
hasSavedGame,
|
||
handleContinueGame,
|
||
handleStartNewGame,
|
||
handleWorldSelect,
|
||
}: PreGameSelectionFlowProps) {
|
||
const [generatedCustomWorldProfile, setGeneratedCustomWorldProfile] =
|
||
useState<GameState['customWorldProfile']>(null);
|
||
const [savedCustomWorldProfiles, setSavedCustomWorldProfiles] = useState<
|
||
CustomWorldProfile[]
|
||
>(() => readSavedCustomWorldProfiles());
|
||
const [showDeveloperTeamModal, setShowDeveloperTeamModal] = useState(false);
|
||
const [worldOnlineCounts, setWorldOnlineCounts] = useState<WorldOnlineCounts>(
|
||
() => generateWorldOnlineCounts(),
|
||
);
|
||
const [showCustomWorldModal, setShowCustomWorldModal] = useState(false);
|
||
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] =
|
||
useState<CustomWorldGenerationProgress | null>(null);
|
||
const customWorldAbortControllerRef = useRef<AbortController | null>(null);
|
||
|
||
const previewCustomWorldCharacters = useMemo(
|
||
() =>
|
||
generatedCustomWorldProfile
|
||
? buildCustomWorldPlayableCharacters(generatedCustomWorldProfile)
|
||
: [],
|
||
[generatedCustomWorldProfile],
|
||
);
|
||
|
||
const worldCards = useMemo(
|
||
() =>
|
||
WORLD_OPTIONS.map((world, index) => ({
|
||
...world,
|
||
sceneImage:
|
||
getScenePreset(world.id, index + 1)?.imageSrc ??
|
||
getScenePreset(world.id, 0)?.imageSrc ??
|
||
'',
|
||
featureIcon:
|
||
world.id === WorldType.WUXIA
|
||
? '/Icons/03_Torch.png'
|
||
: '/Icons/19_Mana_potion.png',
|
||
onlineCount: worldOnlineCounts[world.id] ?? 0,
|
||
})),
|
||
[worldOnlineCounts],
|
||
);
|
||
|
||
const savedCustomWorldCards = useMemo(
|
||
() =>
|
||
savedCustomWorldProfiles.map((profile, index) => {
|
||
const anchorWorldType = profile.templateWorldType;
|
||
const leadCharacter =
|
||
buildCustomWorldPlayableCharacters(profile)[0] ?? null;
|
||
|
||
return {
|
||
id: profile.id,
|
||
profile,
|
||
texture:
|
||
anchorWorldType === WorldType.WUXIA
|
||
? UI_CHROME.worldButtonWuxia
|
||
: UI_CHROME.worldButtonXianxia,
|
||
sceneImage:
|
||
profile.landmarks[0]?.imageSrc ??
|
||
getScenePreset(anchorWorldType, (index % 3) + 1)?.imageSrc ??
|
||
getScenePreset(anchorWorldType, 0)?.imageSrc ??
|
||
'',
|
||
featurePortrait: leadCharacter?.portrait ?? '',
|
||
featureIcon:
|
||
anchorWorldType === WorldType.WUXIA
|
||
? WORLD_SELECT_ICONS.wuxia
|
||
: WORLD_SELECT_ICONS.xianxia,
|
||
accentLabel:
|
||
anchorWorldType === WorldType.WUXIA ? '武侠基础' : '仙侠基础',
|
||
};
|
||
}),
|
||
[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());
|
||
}
|
||
}, [gameState.worldType, selectionStage]);
|
||
|
||
useEffect(() => {
|
||
if (
|
||
selectionStage === 'custom-world-result' &&
|
||
!generatedCustomWorldProfile
|
||
) {
|
||
setSelectionStage('world');
|
||
}
|
||
}, [generatedCustomWorldProfile, selectionStage, setSelectionStage]);
|
||
|
||
useEffect(
|
||
() => () => {
|
||
customWorldAbortControllerRef.current?.abort();
|
||
},
|
||
[],
|
||
);
|
||
|
||
const leaveCustomWorldResult = () => {
|
||
setGeneratedCustomWorldProfile(null);
|
||
setCustomWorldError(null);
|
||
setCustomWorldProgress(null);
|
||
setSelectionStage('world');
|
||
};
|
||
|
||
const leaveCustomWorldGeneration = () => {
|
||
if (isGeneratingCustomWorld) {
|
||
return;
|
||
}
|
||
|
||
setCustomWorldError(null);
|
||
setCustomWorldProgress(null);
|
||
setSelectionStage('world');
|
||
};
|
||
|
||
const openCustomWorldCreator = () => {
|
||
if (isGeneratingCustomWorld) {
|
||
return;
|
||
}
|
||
|
||
setCustomWorldError(null);
|
||
setCustomWorldProgress(null);
|
||
setShowCustomWorldModal(true);
|
||
};
|
||
|
||
const editCustomWorldSetting = () => {
|
||
if (isGeneratingCustomWorld) {
|
||
return;
|
||
}
|
||
|
||
if (generatedCustomWorldProfile) {
|
||
setCustomWorldCreatorIntent(
|
||
generatedCustomWorldProfile.creatorIntent ??
|
||
({
|
||
...createEmptyCustomWorldCreatorIntent('freeform'),
|
||
rawSettingText: generatedCustomWorldProfile.settingText,
|
||
} satisfies CustomWorldCreatorIntent),
|
||
);
|
||
setCustomWorldGenerationMode(
|
||
generatedCustomWorldProfile.generationMode ?? 'full',
|
||
);
|
||
}
|
||
setCustomWorldError(null);
|
||
setCustomWorldProgress(null);
|
||
setSelectionStage('world');
|
||
setShowCustomWorldModal(true);
|
||
};
|
||
|
||
const saveGeneratedCustomWorld = () => {
|
||
if (!generatedCustomWorldProfile) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
setSavedCustomWorldProfiles(
|
||
upsertSavedCustomWorldProfile(generatedCustomWorldProfile),
|
||
);
|
||
} catch (error) {
|
||
setCustomWorldError(
|
||
error instanceof Error ? error.message : '本地保存自定义世界失败。',
|
||
);
|
||
return;
|
||
}
|
||
|
||
handleWorldSelect(WorldType.CUSTOM, generatedCustomWorldProfile);
|
||
setGeneratedCustomWorldProfile(null);
|
||
setCustomWorldError(null);
|
||
setCustomWorldProgress(null);
|
||
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 generationText =
|
||
buildCustomWorldCreatorIntentGenerationText(
|
||
customWorldCreatorIntent,
|
||
).trim() || customWorldCreatorIntent.rawSettingText.trim();
|
||
const settingText = customWorldSettingPreview.trim() || generationText;
|
||
|
||
if (!generationText) {
|
||
setCustomWorldError(
|
||
customWorldCreatorIntent.sourceMode === 'card'
|
||
? '请至少填写一个世界锚点。'
|
||
: '请先输入世界设置。',
|
||
);
|
||
return;
|
||
}
|
||
|
||
const abortController = new AbortController();
|
||
customWorldAbortControllerRef.current?.abort();
|
||
customWorldAbortControllerRef.current = abortController;
|
||
setCustomWorldError(null);
|
||
setGeneratedCustomWorldProfile(null);
|
||
setCustomWorldProgress(null);
|
||
setShowCustomWorldModal(false);
|
||
setSelectionStage('custom-world-generating');
|
||
setIsGeneratingCustomWorld(true);
|
||
|
||
try {
|
||
const profile = await generateCustomWorldProfile(
|
||
{
|
||
settingText,
|
||
creatorIntent: customWorldCreatorIntent,
|
||
generationMode: customWorldGenerationMode,
|
||
},
|
||
{
|
||
signal: abortController.signal,
|
||
onProgress: setCustomWorldProgress,
|
||
},
|
||
);
|
||
if (abortController.signal.aborted) {
|
||
return;
|
||
}
|
||
const persistedProfile = generatedCustomWorldProfile
|
||
? {
|
||
...profile,
|
||
id: generatedCustomWorldProfile.id,
|
||
}
|
||
: profile;
|
||
const savedProfiles = upsertSavedCustomWorldProfile(persistedProfile);
|
||
setSavedCustomWorldProfiles(savedProfiles);
|
||
setGeneratedCustomWorldProfile(null);
|
||
setCustomWorldError(null);
|
||
setCustomWorldProgress(null);
|
||
setSelectionStage('world');
|
||
} 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 interruptCustomWorldGeneration = () => {
|
||
if (!isGeneratingCustomWorld || !customWorldAbortControllerRef.current) {
|
||
return;
|
||
}
|
||
|
||
const confirmed = window.confirm(
|
||
'确认中断当前世界生成吗?本轮未完成的内容不会保留。',
|
||
);
|
||
if (!confirmed) {
|
||
return;
|
||
}
|
||
|
||
customWorldAbortControllerRef.current.abort(new Error('世界生成已中断。'));
|
||
};
|
||
|
||
return (
|
||
<>
|
||
<AnimatePresence mode="wait">
|
||
{!gameState.worldType && selectionStage === 'start' && (
|
||
<motion.div
|
||
key="start-screen"
|
||
initial={{ opacity: 0, y: 12 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
exit={{ opacity: 0, y: -12 }}
|
||
className="flex h-full w-full items-center justify-center"
|
||
>
|
||
<div className="flex h-full w-full max-w-sm flex-col gap-5 py-4 sm:py-6">
|
||
<div className="flex min-h-0 flex-1 items-center">
|
||
<div className="flex w-full flex-col gap-3">
|
||
{hasSavedGame && (
|
||
<button
|
||
type="button"
|
||
onClick={handleContinueGame}
|
||
className="pixel-nine-slice pixel-pressable w-full text-left"
|
||
style={getNineSliceStyle(UI_CHROME.choiceButton, {
|
||
paddingX: 18,
|
||
paddingY: 13,
|
||
})}
|
||
>
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-base font-semibold text-white">
|
||
继续游戏
|
||
</span>
|
||
<span className="text-white/60">开始</span>
|
||
</div>
|
||
</button>
|
||
)}
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
handleStartNewGame();
|
||
setGeneratedCustomWorldProfile(null);
|
||
setCustomWorldCreatorIntent(
|
||
createEmptyCustomWorldCreatorIntent('freeform'),
|
||
);
|
||
setCustomWorldGenerationMode('fast');
|
||
setCustomWorldError(null);
|
||
setCustomWorldProgress(null);
|
||
setShowCustomWorldModal(false);
|
||
setSelectionStage('world');
|
||
}}
|
||
className="pixel-nine-slice pixel-pressable w-full text-left"
|
||
style={getNineSliceStyle(UI_CHROME.choiceButton, {
|
||
paddingX: 18,
|
||
paddingY: 13,
|
||
})}
|
||
>
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-base font-semibold text-white">
|
||
{hasSavedGame ? '新游戏' : '开始游戏'}
|
||
</span>
|
||
<span className="text-white/60">开始</span>
|
||
</div>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowDeveloperTeamModal(true)}
|
||
className="pixel-nine-slice pixel-pressable w-full text-left"
|
||
style={getNineSliceStyle(UI_CHROME.choiceButton, {
|
||
paddingX: 18,
|
||
paddingY: 13,
|
||
})}
|
||
>
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-base font-semibold text-white">
|
||
开发团队
|
||
</span>
|
||
<span className="text-white/60">查看</span>
|
||
</div>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div
|
||
className="pixel-nine-slice pixel-panel w-full"
|
||
style={getNineSliceStyle(UI_CHROME.panel, {
|
||
paddingX: 12,
|
||
paddingY: 10,
|
||
})}
|
||
>
|
||
<div className="text-[10px] font-bold tracking-[0.2em] text-emerald-200/75">
|
||
联系方式
|
||
</div>
|
||
<div className="mt-3 space-y-2">
|
||
{START_SCREEN_CONTACTS.map((contact) => (
|
||
<div
|
||
key={contact.label}
|
||
className="flex items-center justify-between gap-3 rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-sm text-zinc-200"
|
||
>
|
||
<span className="text-zinc-400">
|
||
{contact.label}
|
||
</span>
|
||
<span className="font-semibold text-white">
|
||
{contact.value}
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</motion.div>
|
||
)}
|
||
|
||
{!gameState.worldType && selectionStage === 'world' && (
|
||
<motion.div
|
||
key="world-select"
|
||
initial={{ opacity: 0, y: 12 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
exit={{ opacity: 0, y: -12 }}
|
||
className="flex h-full min-h-0 flex-col"
|
||
>
|
||
<div className="mb-4 flex items-center justify-between gap-3">
|
||
<div className="text-sm font-bold tracking-[0.2em] text-zinc-400">
|
||
选择世界
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setGeneratedCustomWorldProfile(null);
|
||
setSelectionStage('start');
|
||
}}
|
||
className="rounded-full border border-white/10 bg-black/18 px-3 py-1.5 text-[11px] text-zinc-300 transition-colors hover:text-white"
|
||
>
|
||
返回
|
||
</button>
|
||
</div>
|
||
|
||
<div className="min-h-0 flex-1 overflow-y-auto pr-1">
|
||
<div className="grid gap-3 pb-1 md:grid-cols-2 xl:grid-cols-3">
|
||
{worldCards.map((world) => (
|
||
<button
|
||
key={world.id}
|
||
type="button"
|
||
onClick={() => handleWorldSelect(world.id)}
|
||
className="pixel-nine-slice pixel-pressable order-2 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.subtitle}
|
||
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.12),rgba(8,10,14,0.82))]" />
|
||
<div className="absolute right-3 top-3 flex h-9 w-9 items-center justify-center rounded-full border border-white/10 bg-black/25">
|
||
<PixelIcon
|
||
src={world.featureIcon}
|
||
className="h-5 w-5 opacity-95"
|
||
/>
|
||
</div>
|
||
<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-white/10 bg-black/24 px-3 py-1 text-[10px] tracking-[0.2em] text-zinc-100">
|
||
{world.name}
|
||
</div>
|
||
<PixelIcon
|
||
src={world.icon}
|
||
className="h-10 w-10 opacity-95"
|
||
/>
|
||
</div>
|
||
<div className="mt-auto">
|
||
<div className="text-3xl font-black text-white">
|
||
{world.subtitle}
|
||
</div>
|
||
<div className="mt-2 flex items-center 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.onlineCount}
|
||
</span>
|
||
<span className="rounded-full border border-white/10 bg-black/24 px-2.5 py-1 text-[10px] text-zinc-100">
|
||
推荐
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</button>
|
||
))}
|
||
|
||
{savedCustomWorldCards.map((world) => (
|
||
<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="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
|
||
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
|
||
type="button"
|
||
onClick={openCustomWorldCreator}
|
||
className="pixel-nine-slice pixel-pressable order-first relative flex min-h-[12.5rem] flex-col items-start justify-between overflow-hidden text-left"
|
||
style={getNineSliceStyle(UI_CHROME.panel, {
|
||
paddingX: 18,
|
||
paddingY: 16,
|
||
})}
|
||
>
|
||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(125,211,252,0.18),transparent_42%),linear-gradient(180deg,rgba(8,10,14,0.18),rgba(8,10,14,0.82))]" />
|
||
<div className="absolute right-3 top-3 flex h-9 w-9 items-center justify-center rounded-full border border-sky-300/20 bg-sky-500/10">
|
||
<PixelIcon
|
||
src={CHROME_ICONS.refreshOptions}
|
||
className="h-5 w-5 opacity-95"
|
||
/>
|
||
</div>
|
||
<div className="relative z-10 flex h-full w-full flex-col">
|
||
<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="mt-auto">
|
||
<div className="text-3xl font-black text-white">
|
||
创建自定义世界
|
||
</div>
|
||
<div className="mt-2 max-w-[16rem] text-sm leading-6 text-zinc-300">
|
||
输入世界设置,让系统生成可玩角色、场景角色、场景内 NPC 分布与场景连接关系。
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</motion.div>
|
||
)}
|
||
|
||
{!gameState.worldType &&
|
||
selectionStage === 'custom-world-generating' && (
|
||
<motion.div
|
||
key="custom-world-generating"
|
||
initial={{ opacity: 0, y: 12 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
exit={{ opacity: 0, y: -12 }}
|
||
className="flex h-full min-h-0 flex-col"
|
||
>
|
||
<CustomWorldGenerationView
|
||
settingText={customWorldSettingPreview}
|
||
progress={customWorldProgress}
|
||
isGenerating={isGeneratingCustomWorld}
|
||
error={customWorldError}
|
||
onBack={leaveCustomWorldGeneration}
|
||
onEditSetting={editCustomWorldSetting}
|
||
onRetry={() => {
|
||
void createCustomWorld();
|
||
}}
|
||
onInterrupt={interruptCustomWorldGeneration}
|
||
/>
|
||
</motion.div>
|
||
)}
|
||
|
||
{!gameState.worldType &&
|
||
selectionStage === 'custom-world-result' &&
|
||
generatedCustomWorldProfile && (
|
||
<motion.div
|
||
key="custom-world-result"
|
||
initial={{ opacity: 0, y: 12 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
exit={{ opacity: 0, y: -12 }}
|
||
className="flex h-full min-h-0 flex-col"
|
||
>
|
||
<CustomWorldResultView
|
||
profile={generatedCustomWorldProfile}
|
||
previewCharacters={previewCustomWorldCharacters}
|
||
isGenerating={isGeneratingCustomWorld}
|
||
progress={customWorldProgress?.overallProgress ?? 0}
|
||
progressLabel={customWorldProgress?.phaseLabel ?? ''}
|
||
error={customWorldError}
|
||
onProfileChange={setGeneratedCustomWorldProfile}
|
||
onBack={leaveCustomWorldResult}
|
||
onEditSetting={editCustomWorldSetting}
|
||
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>
|
||
)}
|
||
</AnimatePresence>
|
||
|
||
<CustomWorldCreatorModal
|
||
isOpen={showCustomWorldModal}
|
||
creatorIntent={customWorldCreatorIntent}
|
||
onCreatorIntentChange={(value) => {
|
||
setCustomWorldCreatorIntent(value);
|
||
if (customWorldError) setCustomWorldError(null);
|
||
}}
|
||
generationMode={customWorldGenerationMode}
|
||
onGenerationModeChange={setCustomWorldGenerationMode}
|
||
onClose={() => {
|
||
if (isGeneratingCustomWorld) return;
|
||
setShowCustomWorldModal(false);
|
||
}}
|
||
onSubmit={() => {
|
||
void createCustomWorld();
|
||
}}
|
||
isGenerating={isGeneratingCustomWorld}
|
||
progress={customWorldProgress?.overallProgress ?? 0}
|
||
progressLabel={customWorldProgress?.phaseLabel ?? '正在准备生成'}
|
||
error={customWorldError}
|
||
/>
|
||
|
||
<DeveloperTeamModal
|
||
isOpen={showDeveloperTeamModal}
|
||
message={DEVELOPER_TEAM_MESSAGE}
|
||
onClose={() => setShowDeveloperTeamModal(false)}
|
||
/>
|
||
</>
|
||
);
|
||
}
|