Simplify custom world result editing controls
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user