232 lines
7.9 KiB
TypeScript
232 lines
7.9 KiB
TypeScript
import { type ReactNode,useMemo, useState } from 'react';
|
||
|
||
import { normalizeCustomWorldLandmarks } from '../data/customWorldSceneGraph';
|
||
import { Character, CustomWorldProfile } from '../types';
|
||
import { getNineSliceStyle, UI_CHROME } from '../uiAssets';
|
||
import { CustomWorldEntityCatalog, type ResultTab } from './CustomWorldEntityCatalog';
|
||
import { type CustomWorldEditorTarget,CustomWorldEntityEditorModal } from './CustomWorldEntityEditorModal';
|
||
|
||
interface CustomWorldResultViewProps {
|
||
profile: CustomWorldProfile;
|
||
previewCharacters: Character[];
|
||
isGenerating: boolean;
|
||
progress: number;
|
||
progressLabel: string;
|
||
error: string | null;
|
||
onBack: () => void;
|
||
onEditSetting: () => void;
|
||
onRegenerate: () => void;
|
||
onContinueExpand?: () => void;
|
||
onSave: () => void;
|
||
onProfileChange: (profile: CustomWorldProfile) => void;
|
||
}
|
||
|
||
function SmallButton({
|
||
onClick,
|
||
children,
|
||
tone = 'default',
|
||
disabled = false,
|
||
}: {
|
||
onClick: () => void;
|
||
children: ReactNode;
|
||
tone?: 'default' | 'sky';
|
||
disabled?: boolean;
|
||
}) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={onClick}
|
||
disabled={disabled}
|
||
className={`rounded-full border px-3 py-2 text-sm transition-colors ${
|
||
tone === 'sky'
|
||
? 'border-sky-300/20 bg-sky-500/10 text-sky-100 hover:text-white'
|
||
: 'border-white/10 bg-black/20 text-zinc-300 hover:text-white'
|
||
} ${disabled ? 'cursor-not-allowed opacity-45' : ''}`}
|
||
>
|
||
{children}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
function getCreateTargetByTab(activeTab: ResultTab): CustomWorldEditorTarget | null {
|
||
if (activeTab === 'playable') return { kind: 'playable', mode: 'create' };
|
||
if (activeTab === 'story') return { kind: 'story', mode: 'create' };
|
||
if (activeTab === 'landmarks') return { kind: 'landmark', mode: 'create' };
|
||
return null;
|
||
}
|
||
|
||
function getCreateLabelByTab(activeTab: ResultTab) {
|
||
if (activeTab === 'playable') return '新增可扮演角色';
|
||
if (activeTab === 'story') return '新增场景角色';
|
||
if (activeTab === 'landmarks') return '新增场景';
|
||
return '';
|
||
}
|
||
|
||
function removeStoryNpcsFromProfile(
|
||
profile: CustomWorldProfile,
|
||
ids: string[],
|
||
) {
|
||
const idSet = new Set(ids);
|
||
const nextStoryNpcs = profile.storyNpcs.filter((npc) => !idSet.has(npc.id));
|
||
|
||
return {
|
||
...profile,
|
||
storyNpcs: nextStoryNpcs,
|
||
landmarks: normalizeCustomWorldLandmarks({
|
||
landmarks: profile.landmarks.map((landmark) => ({
|
||
...landmark,
|
||
sceneNpcIds: landmark.sceneNpcIds.filter((npcId) => !idSet.has(npcId)),
|
||
})),
|
||
storyNpcs: nextStoryNpcs,
|
||
}),
|
||
} satisfies CustomWorldProfile;
|
||
}
|
||
|
||
function removeLandmarksFromProfile(profile: CustomWorldProfile, ids: string[]) {
|
||
const idSet = new Set(ids);
|
||
const nextLandmarks = profile.landmarks.filter(
|
||
(landmark) => !idSet.has(landmark.id),
|
||
);
|
||
|
||
return {
|
||
...profile,
|
||
landmarks: normalizeCustomWorldLandmarks({
|
||
landmarks: nextLandmarks.map((landmark) => ({
|
||
...landmark,
|
||
connections: landmark.connections.filter(
|
||
(connection) => !idSet.has(connection.targetLandmarkId),
|
||
),
|
||
})),
|
||
storyNpcs: profile.storyNpcs,
|
||
}),
|
||
} satisfies CustomWorldProfile;
|
||
}
|
||
|
||
export function CustomWorldResultView({
|
||
profile,
|
||
previewCharacters,
|
||
isGenerating,
|
||
progress,
|
||
progressLabel,
|
||
error,
|
||
onBack,
|
||
onEditSetting,
|
||
onRegenerate: triggerRegenerate,
|
||
onContinueExpand,
|
||
onSave,
|
||
onProfileChange,
|
||
}: CustomWorldResultViewProps) {
|
||
const [editorTarget, setEditorTarget] = useState<CustomWorldEditorTarget | null>(null);
|
||
const [activeTab, setActiveTab] = useState<ResultTab>('world');
|
||
|
||
const createTarget = useMemo(() => getCreateTargetByTab(activeTab), [activeTab]);
|
||
const createLabel = useMemo(() => getCreateLabelByTab(activeTab), [activeTab]);
|
||
const onRegenerate = () => {
|
||
if (isGenerating) return;
|
||
|
||
const confirmed = window.confirm(
|
||
`确认重新生成“${profile.name}”吗?\n\n重新生成会重新生成当前世界中的所有信息,包括你修改和新增的所有内容。`,
|
||
);
|
||
if (!confirmed) return;
|
||
|
||
triggerRegenerate();
|
||
};
|
||
|
||
const handleDeleteStoryNpcs = (ids: string[]) => {
|
||
if (ids.length === 0) return;
|
||
onProfileChange(removeStoryNpcsFromProfile(profile, ids));
|
||
};
|
||
|
||
const handleDeleteLandmarks = (ids: string[]) => {
|
||
if (ids.length === 0) return;
|
||
onProfileChange(removeLandmarksFromProfile(profile, ids));
|
||
};
|
||
|
||
return (
|
||
<div className="flex h-full min-h-0 flex-col">
|
||
<div className="mb-4 flex justify-start">
|
||
<button
|
||
type="button"
|
||
onClick={onBack}
|
||
disabled={isGenerating}
|
||
className={`self-start rounded-full border border-white/10 bg-black/18 px-3 py-1.5 text-[11px] text-zinc-300 transition-colors hover:text-white ${isGenerating ? 'opacity-45' : ''}`}
|
||
>
|
||
返回
|
||
</button>
|
||
</div>
|
||
|
||
<div className="min-h-0 flex-1 overflow-hidden">
|
||
<CustomWorldEntityCatalog
|
||
profile={profile}
|
||
previewCharacters={previewCharacters}
|
||
activeTab={activeTab}
|
||
onActiveTabChange={setActiveTab}
|
||
onEditTarget={setEditorTarget}
|
||
onProfileChange={onProfileChange}
|
||
onDeleteStoryNpcs={handleDeleteStoryNpcs}
|
||
onDeleteLandmarks={handleDeleteLandmarks}
|
||
createActionLabel={createLabel}
|
||
onCreateAction={createTarget ? () => setEditorTarget(createTarget) : undefined}
|
||
/>
|
||
</div>
|
||
|
||
{isGenerating && (
|
||
<div className="mt-3 rounded-2xl border border-sky-400/18 bg-sky-500/10 px-4 py-4">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<div className="text-sm font-semibold text-white">{progressLabel}</div>
|
||
<div className="text-xs text-sky-100">{Math.round(progress)}%</div>
|
||
</div>
|
||
<div className="mt-3 h-3 overflow-hidden rounded-full border border-white/10 bg-black/35">
|
||
<div
|
||
className="h-full bg-[linear-gradient(90deg,#38bdf8_0%,#67e8f9_48%,#fef08a_100%)] transition-[width] duration-300"
|
||
style={{ width: `${Math.max(0, Math.min(100, progress))}%` }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{error ? (
|
||
<div className="mt-3 rounded-2xl border border-rose-400/18 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100">
|
||
{error}
|
||
</div>
|
||
) : null}
|
||
|
||
<div className="mt-4 flex flex-col gap-3">
|
||
{profile.generationStatus === 'key_only' ? (
|
||
<div className="rounded-2xl border border-amber-300/16 bg-amber-500/10 px-4 py-3 text-sm leading-6 text-amber-100">
|
||
当前世界处于快速预览模式,只生成了关键对象。继续补全后,系统会生成长尾场景角色与完整场景网络。
|
||
</div>
|
||
) : null}
|
||
<div className="flex items-center justify-end gap-3">
|
||
<SmallButton onClick={onEditSetting}>修改设定</SmallButton>
|
||
<SmallButton onClick={onRegenerate} tone="sky">重新生成</SmallButton>
|
||
{profile.generationStatus === 'key_only' && onContinueExpand ? (
|
||
<SmallButton onClick={onContinueExpand} tone="sky" disabled={isGenerating}>
|
||
继续补全世界
|
||
</SmallButton>
|
||
) : null}
|
||
<button
|
||
type="button"
|
||
onClick={onSave}
|
||
disabled={isGenerating}
|
||
className={`pixel-nine-slice pixel-pressable text-left ${isGenerating ? 'opacity-55' : ''}`}
|
||
style={getNineSliceStyle(UI_CHROME.choiceButton, { paddingX: 16, paddingY: 10 })}
|
||
>
|
||
<div className="flex items-center justify-between gap-4">
|
||
<span className="text-sm font-semibold text-white">保存并进入世界</span>
|
||
<span className="text-white/60">→</span>
|
||
</div>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<CustomWorldEntityEditorModal
|
||
profile={profile}
|
||
target={editorTarget}
|
||
onClose={() => setEditorTarget(null)}
|
||
onProfileChange={onProfileChange}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|