Simplify custom world result editing controls

This commit is contained in:
2026-04-08 19:07:46 +08:00
parent bd9fdcbe31
commit a02f7b6414
125 changed files with 8804 additions and 1462 deletions

View File

@@ -1,8 +1,8 @@
import { Children, type ReactNode, useEffect, useMemo, useState } from 'react';
import type { ChangeEvent } from 'react';
import { Children, type ReactNode, useEffect, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import {
AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS,
} from '../data/affinityLevels';
import { AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS } from '../data/affinityLevels';
import {
buildCustomWorldPlayableCharacters,
PRESET_CHARACTERS,
@@ -21,10 +21,6 @@ import {
type CustomWorldSceneImageResult,
generateCustomWorldSceneImage,
} from '../services/ai';
import {
buildCustomWorldSceneImagePrompt,
DEFAULT_CUSTOM_WORLD_SCENE_IMAGE_NEGATIVE_PROMPT,
} from '../services/customWorld';
import { resolveCustomWorldCampScene } from '../services/customWorldCamp';
import {
AnimationState,
@@ -42,6 +38,7 @@ import {
CustomWorldNpcPortrait,
CustomWorldNpcVisualEditor,
} from './CustomWorldNpcVisualEditor';
import { CustomWorldRoleAssetStudioModal } from './CustomWorldRoleAssetStudioModal';
import { PixelIcon } from './PixelIcon';
export type CustomWorldEditorTarget =
@@ -170,6 +167,15 @@ function useDraft<T>(value: T) {
return [draft, setDraft] as const;
}
function readImageFileAsDataUrl(file: File) {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result ?? ''));
reader.onerror = () => reject(reader.error ?? new Error('读取图片失败。'));
reader.readAsDataURL(file);
});
}
function ModalShell({
title,
subtitle,
@@ -179,6 +185,7 @@ function ModalShell({
overlayClassName = 'z-[98]',
bodyClassName = '',
disableClose = false,
usePixelFont = false,
}: {
title: string;
subtitle?: string;
@@ -188,6 +195,7 @@ function ModalShell({
overlayClassName?: string;
bodyClassName?: string;
disableClose?: boolean;
usePixelFont?: boolean;
}) {
return (
<div
@@ -195,7 +203,7 @@ function ModalShell({
onClick={disableClose ? undefined : onClose}
>
<div
className={`pixel-nine-slice pixel-modal-shell flex h-[92vh] w-full flex-col overflow-hidden rounded-t-[1.75rem] shadow-[0_24px_80px_rgba(0,0,0,0.6)] sm:h-auto sm:max-h-[min(92vh,56rem)] ${panelClassName} sm:rounded-[1.75rem]`}
className={`pixel-nine-slice pixel-modal-shell flex h-[92vh] w-full flex-col overflow-hidden rounded-t-[1.75rem] shadow-[0_24px_80px_rgba(0,0,0,0.6)] sm:h-auto sm:max-h-[min(92vh,56rem)] ${usePixelFont ? 'fusion-pixel-app' : ''} ${panelClassName} sm:rounded-[1.75rem]`}
style={getNineSliceStyle(UI_CHROME.modalPanel)}
onClick={(event) => event.stopPropagation()}
>
@@ -229,6 +237,83 @@ function ModalShell({
);
}
function _PortalModalShell(props: {
title: string;
subtitle?: string;
onClose: () => void;
children: ReactNode;
panelClassName?: string;
overlayClassName?: string;
bodyClassName?: string;
disableClose?: boolean;
usePixelFont?: boolean;
}) {
if (typeof document === 'undefined') {
return null;
}
return createPortal(<ModalShell {...props} />, document.body);
}
function CompactDialogShell({
title,
onClose,
children,
overlayClassName = 'z-[140]',
disableClose = false,
usePixelFont = false,
}: {
title: string;
onClose: () => void;
children: ReactNode;
overlayClassName?: string;
disableClose?: boolean;
usePixelFont?: boolean;
}) {
return (
<div
className={`fixed inset-0 ${overlayClassName} flex items-center justify-center bg-black/78 p-4 backdrop-blur-sm`}
onClick={disableClose ? undefined : onClose}
>
<div
className={`pixel-nine-slice pixel-modal-shell w-full max-w-md overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.6)] ${usePixelFont ? 'fusion-pixel-app' : ''}`}
style={getNineSliceStyle(UI_CHROME.modalPanel)}
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-center justify-between gap-3 border-b border-white/10 px-4 py-4">
<div className="min-w-0 text-sm font-semibold text-white">
{title}
</div>
<button
type="button"
onClick={onClose}
disabled={disableClose}
className={`rounded-full border border-white/10 bg-black/20 p-2 text-zinc-400 transition-colors hover:text-white ${disableClose ? 'cursor-not-allowed opacity-45' : ''}`}
>
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
</button>
</div>
<div className="p-4">{children}</div>
</div>
</div>
);
}
function PortalCompactDialogShell(props: {
title: string;
onClose: () => void;
children: ReactNode;
overlayClassName?: string;
disableClose?: boolean;
usePixelFont?: boolean;
}) {
if (typeof document === 'undefined') {
return null;
}
return createPortal(<CompactDialogShell {...props} />, document.body);
}
function Field({ label, children }: { label: string; children: ReactNode }) {
const hasVisibleChildren = Children.toArray(children).some(
(child) => !(typeof child === 'string' && child.trim().length === 0),
@@ -401,12 +486,14 @@ function ActionButton({
}: {
label: string;
onClick: () => void;
tone?: 'default' | 'sky';
tone?: 'default' | 'sky' | 'rose';
disabled?: boolean;
}) {
const toneClassName =
tone === 'sky'
? 'border-sky-300/22 bg-sky-500/12 text-sky-50 hover:border-sky-200/40 hover:text-white'
: tone === 'rose'
? 'border-rose-300/22 bg-rose-500/12 text-rose-50 hover:border-rose-200/40 hover:text-white'
: 'border-white/12 bg-black/20 text-zinc-200 hover:border-white/22 hover:text-white';
return (
@@ -556,36 +643,7 @@ function ScenePresetPickerModal({
);
}
function AiComingSoonModal({
title,
subtitle,
onClose,
}: {
title: string;
subtitle: string;
onClose: () => void;
}) {
return (
<ModalShell title={title} subtitle={subtitle} onClose={onClose}>
<div className="space-y-4">
<div className="rounded-3xl border border-white/10 bg-[radial-gradient(circle_at_top,rgba(56,189,248,0.18),transparent_55%),linear-gradient(180deg,rgba(19,24,39,0.96),rgba(8,10,17,0.92))] px-6 py-10 text-center">
<div className="whitespace-pre-line text-2xl font-black tracking-[0.2em] text-white">
{'\n'}
</div>
</div>
<div className="flex justify-end">
<ActionButton label="知道了" onClick={onClose} tone="sky" />
</div>
</div>
</ModalShell>
);
}
const SCENE_IMAGE_SIZE_OPTIONS = [
{ value: '1280*720', label: '横版 16:9推荐' },
{ value: '1280*1280', label: '方图 1:1' },
{ value: '960*1280', label: '竖版 3:4' },
] as const;
const FIXED_SCENE_IMAGE_SIZE = '1280*720';
function SceneImageGenerationModal({
profile,
@@ -598,27 +656,17 @@ function SceneImageGenerationModal({
onApply: (result: CustomWorldSceneImageResult) => void;
onClose: () => void;
}) {
const defaultPrompt = useMemo(
() => buildCustomWorldSceneImagePrompt(profile, landmark),
[profile, landmark],
);
const [prompt, setPrompt] = useDraft(defaultPrompt);
const [negativePrompt, setNegativePrompt] = useDraft(
DEFAULT_CUSTOM_WORLD_SCENE_IMAGE_NEGATIVE_PROMPT,
);
const [size, setSize] = useDraft<string>(
SCENE_IMAGE_SIZE_OPTIONS[0]?.value ?? '1280*720',
const [userPrompt, setUserPrompt] = useDraft(
landmark.name.trim() || landmark.description.trim(),
);
const [referenceImageSrc, setReferenceImageSrc] = useState('');
const [isGenerating, setIsGenerating] = useState(false);
const [error, setError] = useState<string | null>(null);
const [latestResult, setLatestResult] =
useState<CustomWorldSceneImageResult | null>(null);
const [isExitConfirmOpen, setIsExitConfirmOpen] = useState(false);
const previewImageSrc = useMemo(() => {
if (latestResult?.imageSrc) {
return latestResult.imageSrc;
}
const originalImageSrc = useMemo(() => {
const landmarkIndex = profile.landmarks.findIndex(
(entry) => entry.id === landmark.id,
);
@@ -632,11 +680,46 @@ function SceneImageGenerationModal({
.map((entry) => entry.imageSrc)
.filter((imageSrc): imageSrc is string => Boolean(imageSrc)),
);
}, [landmark, latestResult, profile]);
}, [landmark, profile]);
const previewImageSrc = latestResult?.imageSrc || originalImageSrc;
const handleReferenceImageChange = async (
event: ChangeEvent<HTMLInputElement>,
) => {
const file = event.target.files?.[0];
event.currentTarget.value = '';
if (!file) {
return;
}
try {
const dataUrl = await readImageFileAsDataUrl(file);
setReferenceImageSrc(dataUrl);
setError(null);
} catch (uploadError) {
setError(
uploadError instanceof Error
? uploadError.message
: '参考图读取失败,请重试。',
);
}
};
const handleRequestClose = () => {
if (isGenerating) {
return;
}
if (latestResult) {
setIsExitConfirmOpen(true);
return;
}
onClose();
};
const handleGenerate = async () => {
if (!prompt.trim()) {
setError('请先填写场景提示词。');
if (!userPrompt.trim()) {
setError('请先描述想要生成的画面内容。');
return;
}
@@ -647,12 +730,11 @@ function SceneImageGenerationModal({
const result = await generateCustomWorldSceneImage({
profile,
landmark,
prompt,
negativePrompt,
size,
userPrompt,
size: FIXED_SCENE_IMAGE_SIZE,
...(referenceImageSrc ? { referenceImageSrc } : {}),
});
setLatestResult(result);
onApply(result);
} catch (generationError) {
setError(
generationError instanceof Error
@@ -664,136 +746,195 @@ function SceneImageGenerationModal({
}
};
const handleSave = () => {
if (!latestResult || isGenerating) {
return;
}
onApply(latestResult);
onClose();
};
return (
<ModalShell
title={`智能生成:${landmark.name || '当前场景'}`}
subtitle="会调用阿里云文生图模型生成新的场景背景,并立即回写到当前编辑草稿。"
onClose={onClose}
panelClassName="sm:max-w-5xl"
overlayClassName="z-[99]"
disableClose={isGenerating}
>
<div className="grid gap-5 lg:grid-cols-[minmax(0,1.1fr)_minmax(20rem,0.9fr)]">
<div className="space-y-4">
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3 text-sm leading-6 text-zinc-300">
<>
<ModalShell
title={`智能生成:${landmark.name || '当前场景'}`}
onClose={handleRequestClose}
panelClassName="sm:max-w-5xl"
overlayClassName="z-[99]"
disableClose={isGenerating}
usePixelFont
>
<div className="grid gap-5 lg:grid-cols-[minmax(0,1.1fr)_minmax(20rem,0.9fr)]">
<div className="space-y-4">
<Field label="画面内容描述">
<TextArea
value={userPrompt}
onChange={(value) => setUserPrompt(value)}
rows={8}
placeholder="例如:雨夜的悬桥横跨黑色峡谷,桥下翻涌蓝绿色雾潮,远处有半坍塌塔楼与零星灯火。"
/>
</Field>
<Field label="自定义参考图(可选)">
<div className="space-y-3">
<label className="block rounded-2xl border border-dashed border-white/12 bg-black/20 px-4 py-4">
<input
type="file"
accept="image/png,image/jpeg,image/webp"
onChange={(event) => {
void handleReferenceImageChange(event);
}}
className="w-full text-xs text-zinc-300 file:mr-3 file:rounded-lg file:border-0 file:bg-sky-500 file:px-3 file:py-1.5 file:text-xs file:font-medium file:text-black"
/>
</label>
{referenceImageSrc ? (
<div className="flex items-center gap-3 rounded-2xl border border-white/8 bg-black/20 p-3">
<div className="h-16 w-24 overflow-hidden rounded-xl border border-white/10 bg-black/30">
<img
src={referenceImageSrc}
alt="自定义参考图"
className="h-full w-full object-cover"
/>
</div>
<div className="min-w-0 flex-1 text-xs leading-5 text-zinc-400">
</div>
<ActionButton
label="移除"
onClick={() => setReferenceImageSrc('')}
disabled={isGenerating}
/>
</div>
) : null}
</div>
</Field>
</div>
<Field label="场景提示词">
<TextArea
value={prompt}
onChange={(value) => setPrompt(value)}
rows={10}
placeholder="描述这个场景的地貌、建筑、天气、光线与氛围。"
/>
</Field>
<Field label="反向提示词">
<TextArea
value={negativePrompt}
onChange={(value) => setNegativePrompt(value)}
rows={4}
placeholder="例如文字、水印、logo、UI界面、人物近景。"
/>
</Field>
<Field label="图片比例">
<SelectField
value={size}
onChange={setSize}
options={SCENE_IMAGE_SIZE_OPTIONS.map((option) => ({
value: option.value,
label: option.label,
}))}
/>
</Field>
</div>
<div className="space-y-4">
<div className="rounded-2xl border border-white/8 bg-black/18 p-3">
<ImagePreview
src={previewImageSrc}
alt={landmark.name || '场景预览'}
fallbackLabel={landmark.name ? landmark.name.slice(0, 4) : '场景'}
tone="landscape"
/>
</div>
{latestResult ? (
<div className="rounded-2xl border border-emerald-300/18 bg-emerald-500/10 px-4 py-3 text-sm leading-6 text-emerald-50">
稿{latestResult.model}
{latestResult.size}
<div className="space-y-4">
<div className="rounded-2xl border border-white/8 bg-black/18 p-3">
<ImagePreview
src={previewImageSrc}
alt={landmark.name || '场景预览'}
fallbackLabel={
landmark.name ? landmark.name.slice(0, 4) : '场景'
}
tone="landscape"
/>
</div>
) : (
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3 text-sm leading-6 text-zinc-300">
</div>
)}
{error ? (
<div className="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}
{latestResult ? (
<div className="rounded-2xl border border-emerald-300/18 bg-emerald-500/10 px-4 py-3 text-sm leading-6 text-emerald-50">
退
</div>
) : null}
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<ActionButton
label="关闭"
onClick={onClose}
disabled={isGenerating}
/>
<ActionButton
label={
isGenerating
? '正在生成...'
: latestResult
? '重新生成'
: '开始生成'
}
onClick={() => {
void handleGenerate();
}}
tone="sky"
disabled={isGenerating}
/>
{error ? (
<div className="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="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<ActionButton
label="保存"
onClick={handleSave}
disabled={!latestResult || isGenerating}
/>
<ActionButton
label={
isGenerating
? '正在生成...'
: latestResult
? '重新生成'
: '开始生成'
}
onClick={() => {
void handleGenerate();
}}
tone="sky"
disabled={isGenerating}
/>
</div>
</div>
</div>
</div>
</ModalShell>
</ModalShell>
{isExitConfirmOpen ? (
<PortalCompactDialogShell
title="确认退出"
onClose={() => setIsExitConfirmOpen(false)}
overlayClassName="z-[140]"
usePixelFont
>
<div className="space-y-4">
<div className="rounded-2xl border border-amber-300/16 bg-amber-500/10 px-4 py-4 text-sm leading-6 text-amber-50">
退退
</div>
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<ActionButton
label="继续编辑"
onClick={() => setIsExitConfirmOpen(false)}
/>
<ActionButton
label="仍然退出"
onClick={() => {
setIsExitConfirmOpen(false);
onClose();
}}
tone="sky"
/>
</div>
</div>
</PortalCompactDialogShell>
) : null}
</>
);
}
function SaveBar({
onClose,
onSave,
extraAction,
}: {
onClose: () => void;
onSave: () => void;
extraAction?: ReactNode;
}) {
return (
<div className="sticky bottom-0 z-10 -mx-4 border-t border-white/10 bg-[linear-gradient(180deg,rgba(8,10,17,0.2)_0%,rgba(8,10,17,0.96)_28%)] px-4 pb-[calc(env(safe-area-inset-bottom,0px)+0.35rem)] pt-3 backdrop-blur sm:static sm:mx-0 sm:border-0 sm:bg-transparent sm:px-0 sm:pb-0 sm:pt-2 sm:backdrop-blur-0">
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<button
type="button"
onClick={onClose}
className="rounded-full border border-white/10 bg-black/20 px-4 py-2 text-sm text-zinc-300 transition-colors hover:text-white"
<div
className={`flex flex-col gap-3 ${
extraAction
? 'sm:flex-row sm:items-center sm:justify-between'
: 'sm:flex-row sm:justify-end'
}`}
>
</button>
<button
type="button"
onClick={onSave}
className="pixel-nine-slice pixel-pressable text-left"
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>
{extraAction ? (
<div className="flex flex-col gap-3 sm:flex-row">{extraAction}</div>
) : null}
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<button
type="button"
onClick={onClose}
className="rounded-full border border-white/10 bg-black/20 px-4 py-2 text-sm text-zinc-300 transition-colors hover:text-white"
>
</button>
<button
type="button"
onClick={onSave}
className="pixel-nine-slice pixel-pressable text-left"
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>
</div>
);
@@ -1025,7 +1166,9 @@ function SkillListEditor({
<ActionButton
label="删除技能"
onClick={() =>
onChange(value.filter((_skill, skillIndex) => skillIndex !== index))
onChange(
value.filter((_skill, skillIndex) => skillIndex !== index),
)
}
/>
</div>
@@ -1120,7 +1263,9 @@ function InitialItemsEditor({
<ActionButton
label="删除物品"
onClick={() =>
onChange(value.filter((_item, itemIndex) => itemIndex !== index))
onChange(
value.filter((_item, itemIndex) => itemIndex !== index),
)
}
/>
</div>
@@ -1209,15 +1354,15 @@ function StoryNpcVisualEditorModal({
npc,
visual,
onChange,
onOpenAiStudio,
onClose,
}: {
npc: CustomWorldNpc;
visual: NonNullable<CustomWorldNpc['visual']>;
onChange: (visual: NonNullable<CustomWorldNpc['visual']>) => void;
onOpenAiStudio?: () => void;
onClose: () => void;
}) {
const [isAiGenerateOpen, setIsAiGenerateOpen] = useState(false);
return (
<ModalShell
title={`修改形象:${npc.name}`}
@@ -1235,15 +1380,11 @@ function StoryNpcVisualEditorModal({
}}
value={visual}
onChange={onChange}
onAiGenerate={() => setIsAiGenerateOpen(true)}
onAiGenerate={() => {
onClose();
onOpenAiStudio?.();
}}
/>
{isAiGenerateOpen ? (
<AiComingSoonModal
title="智能生成场景角色形象"
subtitle="场景角色形象智能生成功能仍在开发中。"
onClose={() => setIsAiGenerateOpen(false)}
/>
) : null}
</ModalShell>
);
}
@@ -1391,7 +1532,7 @@ function WorldEditor({
tone="landscape"
showInput={false}
previewOverlay={<SceneSparringPreview profile={draft} />}
footer={(
footer={
<div className="space-y-3">
<div className="flex flex-wrap gap-3">
<ActionButton
@@ -1408,7 +1549,7 @@ function WorldEditor({
</div>
</div>
)}
}
/>
<Field label="玩家原始设定">
<TextArea
@@ -1475,6 +1616,7 @@ function PlayableNpcEditor({
onClose: () => void;
}) {
const [draft, setDraft] = useDraft(npc);
const [isAiAssetStudioOpen, setIsAiAssetStudioOpen] = useState(false);
const selectedTemplate =
PRESET_CHARACTERS.find(
(character) => character.id === draft.templateCharacterId,
@@ -1493,7 +1635,7 @@ function PlayableNpcEditor({
<div className="grid gap-4 rounded-2xl border border-white/8 bg-black/20 p-4 sm:grid-cols-[7rem_minmax(0,1fr)]">
<div className="overflow-hidden rounded-2xl border border-white/10 bg-black/30">
<img
src={selectedTemplate.portrait}
src={draft.imageSrc || selectedTemplate.portrait}
alt={selectedTemplate.name}
className="h-28 w-full object-cover object-top"
/>
@@ -1511,6 +1653,25 @@ function PlayableNpcEditor({
<div className="mt-3 text-sm leading-6 text-zinc-300">
{selectedTemplate.description}
</div>
<div className="mt-3 flex flex-wrap gap-2">
{draft.generatedVisualAssetId ? (
<span className="rounded-full border border-emerald-400/20 bg-emerald-500/10 px-2.5 py-1 text-[10px] text-emerald-100">
</span>
) : null}
{draft.generatedAnimationSetId ? (
<span className="rounded-full border border-amber-300/20 bg-amber-500/10 px-2.5 py-1 text-[10px] text-amber-100">
</span>
) : null}
</div>
<div className="mt-3">
<ActionButton
label="AI生成形象与动作"
onClick={() => setIsAiAssetStudioOpen(true)}
tone="sky"
/>
</div>
</div>
</div>
) : null}
@@ -1678,6 +1839,19 @@ function PlayableNpcEditor({
onClose();
}}
/>
{isAiAssetStudioOpen ? (
<CustomWorldRoleAssetStudioModal
role={draft}
roleKind="playable"
onApply={(nextRole) =>
setDraft((current) => ({
...current,
...nextRole,
}))
}
onClose={() => setIsAiAssetStudioOpen(false)}
/>
) : null}
</div>
</ModalShell>
);
@@ -1696,6 +1870,7 @@ function StoryNpcEditor({
}) {
const [draft, setDraft] = useDraft(npc);
const [isVisualEditorOpen, setIsVisualEditorOpen] = useState(false);
const [isAiAssetStudioOpen, setIsAiAssetStudioOpen] = useState(false);
return (
<ModalShell
@@ -1712,6 +1887,7 @@ function StoryNpcEditor({
visual={draft.visual}
className="aspect-square w-full max-w-[9.5rem]"
scale={2.05}
preferImageSrc
/>
</div>
<div className="min-w-0 space-y-3">
@@ -1729,6 +1905,23 @@ function StoryNpcEditor({
onClick={() => setIsVisualEditorOpen(true)}
tone="sky"
/>
<ActionButton
label="AI生成形象与动作"
onClick={() => setIsAiAssetStudioOpen(true)}
tone="sky"
/>
</div>
<div className="flex flex-wrap gap-2">
{draft.generatedVisualAssetId ? (
<span className="rounded-full border border-emerald-400/20 bg-emerald-500/10 px-2.5 py-1 text-[10px] text-emerald-100">
</span>
) : null}
{draft.generatedAnimationSetId ? (
<span className="rounded-full border border-amber-300/20 bg-amber-500/10 px-2.5 py-1 text-[10px] text-amber-100">
</span>
) : null}
</div>
</div>
</div>
@@ -1892,9 +2085,23 @@ function StoryNpcEditor({
onChange={(visual) =>
setDraft((current) => ({ ...current, visual }))
}
onOpenAiStudio={() => setIsAiAssetStudioOpen(true)}
onClose={() => setIsVisualEditorOpen(false)}
/>
) : null}
{isAiAssetStudioOpen ? (
<CustomWorldRoleAssetStudioModal
role={draft}
roleKind="story"
onApply={(nextRole) =>
setDraft((current) => ({
...current,
...nextRole,
}))
}
onClose={() => setIsAiAssetStudioOpen(false)}
/>
) : null}
</div>
</ModalShell>
);
@@ -1957,7 +2164,9 @@ function LandmarkEditor({
const updateConnection = (
index: number,
updater: (connection: CustomWorldSceneConnection) => CustomWorldSceneConnection,
updater: (
connection: CustomWorldSceneConnection,
) => CustomWorldSceneConnection,
) => {
setDraft((current) => ({
...current,
@@ -1996,7 +2205,9 @@ function LandmarkEditor({
const nextLandmarks =
mode === 'create'
? [...profile.landmarks, draft]
: profile.landmarks.map((entry) => (entry.id === draft.id ? draft : entry));
: profile.landmarks.map((entry) =>
entry.id === draft.id ? draft : entry,
);
onSaveProfile({
...profile,
@@ -2077,7 +2288,8 @@ function LandmarkEditor({
NPC
</div>
<div className="mt-2 text-sm leading-6 text-zinc-400">
3 NPC NPC
3 NPC
NPC
</div>
</div>
<ActionButton
@@ -2179,11 +2391,7 @@ function LandmarkEditor({
线
</div>
</div>
<ActionButton
label="新增连接"
onClick={addConnection}
tone="sky"
/>
<ActionButton label="新增连接" onClick={addConnection} tone="sky" />
</div>
<div className="mt-3 space-y-3">
{draft.connections.length > 0 ? (
@@ -2214,7 +2422,8 @@ function LandmarkEditor({
onChange={(value) =>
updateConnection(index, (current) => ({
...current,
relativePosition: value as CustomWorldSceneConnection['relativePosition'],
relativePosition:
value as CustomWorldSceneConnection['relativePosition'],
}))
}
options={CUSTOM_WORLD_SCENE_RELATIVE_POSITION_OPTIONS.map(
@@ -2246,7 +2455,8 @@ function LandmarkEditor({
setDraft((current) => ({
...current,
connections: current.connections.filter(
(_item, connectionIndex) => connectionIndex !== index,
(_item, connectionIndex) =>
connectionIndex !== index,
),
}))
}
@@ -2309,7 +2519,9 @@ function LandmarkEditor({
setDraftStoryNpcs((current) =>
npcEditorState.mode === 'create'
? [...current, nextNpc]
: current.map((item) => (item.id === nextNpc.id ? nextNpc : item)),
: current.map((item) =>
item.id === nextNpc.id ? nextNpc : item,
),
);
setDraft((current) => ({
...current,
@@ -2428,7 +2640,9 @@ function createPlayableNpc(
};
}
function createStoryNpc(profile: Pick<CustomWorldProfile, 'storyNpcs'>): CustomWorldNpc {
function createStoryNpc(
profile: Pick<CustomWorldProfile, 'storyNpcs'>,
): CustomWorldNpc {
const seed = Date.now() + profile.storyNpcs.length;
const npc = {
id: createEntryId(
@@ -2638,15 +2852,15 @@ export function CustomWorldEntityEditorModal({
}
if (target.mode === 'create') {
return (
<LandmarkEditor
profile={profile}
landmark={createLandmark(profile)}
mode="create"
onSaveProfile={onProfileChange}
onClose={onClose}
/>
);
return (
<LandmarkEditor
profile={profile}
landmark={createLandmark(profile)}
mode="create"
onSaveProfile={onProfileChange}
onClose={onClose}
/>
);
}
const landmark = profile.landmarks.find((entry) => entry.id === target.id);