Files
Genarrative/src/components/CustomWorldEntityEditorModal.tsx
victo 3d6f31433a
Some checks failed
CI / verify (push) Has been cancelled
update: 表改动 主页改动
2026-04-14 18:58:33 +08:00

2877 lines
87 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 {
buildCustomWorldPlayableCharacters,
ROLE_TEMPLATE_CHARACTERS,
} from '../data/characterPresets';
import {
CUSTOM_WORLD_SCENE_RELATIVE_POSITION_OPTIONS,
getCustomWorldSceneRelativePositionLabel,
normalizeCustomWorldLandmarks,
} from '../data/customWorldSceneGraph';
import {
getAllCustomWorldSceneImages,
resolveCustomWorldCampSceneImage,
resolveCustomWorldLandmarkImage,
} from '../data/customWorldVisuals';
import {
type CustomWorldSceneImageResult,
generateCustomWorldSceneImage,
} from '../services/aiService';
import { resolveCustomWorldCampScene } from '../services/customWorldCamp';
import {
AnimationState,
CustomWorldLandmark,
CustomWorldNpc,
CustomWorldPlayableNpc,
CustomWorldProfile,
CustomWorldSceneConnection,
type ItemRarity,
} from '../types';
import { CHROME_ICONS, getNineSliceStyle, UI_CHROME } from '../uiAssets';
import { CharacterAnimator } from './CharacterAnimator';
import { buildDefaultCustomWorldNpcVisual } from './customWorldNpcVisualDefaults';
import {
CustomWorldNpcPortrait,
CustomWorldNpcVisualEditor,
} from './CustomWorldNpcVisualEditor';
import { CustomWorldRoleAssetStudioModal } from './CustomWorldRoleAssetStudioModal';
import { PixelIcon } from './PixelIcon';
export type CustomWorldEditorTarget =
| { kind: 'world' }
| { kind: 'playable'; mode: 'create' }
| { kind: 'playable'; mode: 'edit'; id: string }
| { kind: 'story'; mode: 'create' }
| { kind: 'story'; mode: 'edit'; id: string }
| { kind: 'landmark'; mode: 'create' }
| { kind: 'landmark'; mode: 'edit'; id: string };
interface CustomWorldEntityEditorModalProps {
profile: CustomWorldProfile;
target: CustomWorldEditorTarget | null;
onClose: () => void;
onProfileChange: (profile: CustomWorldProfile) => void;
}
const [
BACKSTORY_UNLOCK_AFFINITY_EASED,
BACKSTORY_UNLOCK_AFFINITY_FRIENDLY,
BACKSTORY_UNLOCK_AFFINITY_TRUSTED,
BACKSTORY_UNLOCK_AFFINITY_CLOSE,
] = AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS;
const ITEM_RARITY_OPTIONS: Array<{ value: ItemRarity; label: string }> = [
{ value: 'common', label: 'common' },
{ value: 'uncommon', label: 'uncommon' },
{ value: 'rare', label: 'rare' },
{ value: 'epic', label: 'epic' },
{ value: 'legendary', label: 'legendary' },
];
function slugify(value: string) {
const normalized = value
.trim()
.toLowerCase()
.replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '-')
.replace(/^-+|-+$/g, '');
return normalized || 'entry';
}
function createEntryId(prefix: string, label: string, seed: number) {
return `${prefix}-${slugify(label || `${prefix}-${seed}`)}-${seed.toString(36)}`;
}
function parseCommaText(value: string) {
return [
...new Set(
value
.split(/[\n,]/u)
.map((item) => item.trim())
.filter(Boolean),
),
];
}
function commaText(value: string[]) {
return value.join(', ');
}
function clampInitialAffinity(value: string, fallback: number) {
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed)) {
return fallback;
}
return Math.max(-40, Math.min(90, Math.round(parsed)));
}
function parseOptionalNumber(value: string) {
const trimmed = value.trim();
if (!trimmed) return undefined;
const parsed = Number.parseInt(trimmed, 10);
return Number.isFinite(parsed) ? parsed : undefined;
}
function createRoleSkillDraft(seedLabel: string, index: number) {
return {
id: createEntryId('skill', seedLabel, Date.now() + index),
name: `新技能${index + 1}`,
summary: '',
style: '起手压制',
};
}
function createRoleInitialItemDraft(seedLabel: string, index: number) {
return {
id: createEntryId('item', seedLabel, Date.now() + index),
name: `新物品${index + 1}`,
category: '材料',
quantity: 1,
rarity: 'rare' as ItemRarity,
description: '',
tags: [],
};
}
function createBackstoryChapterDraft(seedLabel: string, index: number) {
return {
id: createEntryId('backstory-chapter', seedLabel, Date.now() + index),
title: `背景片段${index + 1}`,
affinityRequired:
AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS[
Math.min(index, AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS.length - 1)
] ?? BACKSTORY_UNLOCK_AFFINITY_CLOSE,
teaser: '',
content: '',
contextSnippet: '',
};
}
function syncLandmarksWithStoryNpcs(
landmarks: CustomWorldLandmark[],
storyNpcs: CustomWorldProfile['storyNpcs'],
) {
return normalizeCustomWorldLandmarks({
landmarks,
storyNpcs,
});
}
function useDraft<T>(value: T) {
const [draft, setDraft] = useState(value);
useEffect(() => setDraft(value), [value]);
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,
onClose,
children,
panelClassName = 'sm:max-w-2xl',
overlayClassName = 'z-[98]',
bodyClassName = '',
disableClose = false,
usePixelFont = false,
}: {
title: string;
subtitle?: string;
onClose: () => void;
children: ReactNode;
panelClassName?: string;
overlayClassName?: string;
bodyClassName?: string;
disableClose?: boolean;
usePixelFont?: boolean;
}) {
return (
<div
className={`fixed inset-0 ${overlayClassName} flex items-end justify-center bg-black/78 p-0 backdrop-blur-sm sm:items-center sm:p-4`}
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)] ${usePixelFont ? 'fusion-pixel-app' : ''} ${panelClassName} sm:rounded-[1.75rem]`}
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 sm:px-5">
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-white">
{title}
</div>
{subtitle ? (
<div className="mt-1 text-xs leading-6 text-zinc-400">
{subtitle}
</div>
) : null}
</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={`min-h-0 flex-1 overflow-y-auto p-4 sm:p-5 ${bodyClassName}`}
>
{children}
</div>
</div>
</div>
);
}
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),
);
if (!hasVisibleChildren) return null;
return (
<label className="block">
<div className="mb-2 text-[11px] font-bold tracking-[0.14em] text-zinc-300">
{label}
</div>
{children}
</label>
);
}
function TextInput({
value,
onChange,
type = 'text',
placeholder,
}: {
value: string | number;
onChange: (value: string) => void;
type?: 'text' | 'number';
placeholder?: string;
}) {
return (
<input
type={type}
value={value}
placeholder={placeholder}
onChange={(event) => onChange(event.target.value)}
className="w-full rounded-2xl border border-white/10 bg-black/25 px-4 py-3 text-sm text-zinc-100 outline-none transition-colors placeholder:text-zinc-500 focus:border-sky-300/35"
/>
);
}
function TextArea({
value,
onChange,
rows = 4,
placeholder,
}: {
value: string;
onChange: (value: string) => void;
rows?: number;
placeholder?: string;
}) {
return (
<textarea
rows={rows}
value={value}
placeholder={placeholder}
onChange={(event) => onChange(event.target.value)}
className="w-full rounded-2xl border border-white/10 bg-black/25 px-4 py-3 text-sm leading-7 text-zinc-100 outline-none transition-colors placeholder:text-zinc-500 focus:border-sky-300/35"
/>
);
}
function SelectField({
value,
onChange,
options,
}: {
value: string;
onChange: (value: string) => void;
options: Array<{ value: string; label: string }>;
}) {
return (
<select
value={value}
onChange={(event) => onChange(event.target.value)}
className="w-full rounded-2xl border border-white/10 bg-black/25 px-4 py-3 text-sm text-zinc-100 outline-none transition-colors focus:border-sky-300/35"
>
{options.map((option) => (
<option key={`${option.value}-${option.label}`} value={option.value}>
{option.label}
</option>
))}
</select>
);
}
function ImagePreview({
src,
alt,
fallbackLabel,
tone = 'square',
children,
}: {
src?: string;
alt: string;
fallbackLabel: string;
tone?: 'square' | 'landscape';
children?: ReactNode;
}) {
return (
<div
className={`relative overflow-hidden rounded-2xl border border-white/10 bg-[radial-gradient(circle_at_top,rgba(56,189,248,0.16),transparent_48%),linear-gradient(180deg,rgba(19,24,39,0.95),rgba(8,10,17,0.92))] ${tone === 'landscape' ? 'aspect-[16/9]' : 'aspect-square'}`}
>
{src ? (
<img
src={src}
alt={alt}
loading="lazy"
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full w-full items-center justify-center px-4 text-center text-sm font-semibold tracking-[0.18em] text-zinc-400">
{fallbackLabel}
</div>
)}
{children ? (
<div className="pointer-events-none absolute inset-0">{children}</div>
) : null}
</div>
);
}
function ImageField({
label,
value,
onChange,
fallbackLabel,
tone = 'square',
showInput = true,
previewOverlay,
footer,
}: {
label: string;
value?: string;
onChange: (value: string) => void;
fallbackLabel: string;
tone?: 'square' | 'landscape';
showInput?: boolean;
previewOverlay?: ReactNode;
footer?: ReactNode;
}) {
return (
<div className="space-y-3">
<div className="text-[11px] font-bold tracking-[0.14em] text-zinc-300">
{label}
</div>
<ImagePreview
src={value}
alt={label}
fallbackLabel={fallbackLabel}
tone={tone}
>
{previewOverlay}
</ImagePreview>
{showInput ? (
<TextInput
value={value ?? ''}
onChange={onChange}
placeholder="支持填写项目内图片路径或外链地址"
/>
) : null}
{footer}
</div>
);
}
function ActionButton({
label,
onClick,
tone = 'default',
disabled = false,
}: {
label: string;
onClick: () => void;
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 (
<button
type="button"
onClick={onClick}
disabled={disabled}
className={`rounded-full border px-4 py-2 text-sm font-semibold transition-colors ${toneClassName} ${disabled ? 'cursor-not-allowed opacity-45' : ''}`}
>
{label}
</button>
);
}
function SceneSparringPreview({ profile }: { profile: CustomWorldProfile }) {
const sparringCharacters = useMemo(() => {
const candidates = buildCustomWorldPlayableCharacters(profile);
if (candidates.length >= 2) {
return candidates.slice(0, 2);
}
if (candidates.length === 1) {
const firstCandidate = candidates[0];
if (!firstCandidate) {
return ROLE_TEMPLATE_CHARACTERS.slice(0, 2);
}
const fallback =
ROLE_TEMPLATE_CHARACTERS.find(
(character) => character.id !== firstCandidate.id,
) ??
ROLE_TEMPLATE_CHARACTERS[0] ??
firstCandidate;
return [firstCandidate, fallback];
}
return ROLE_TEMPLATE_CHARACTERS.slice(0, 2);
}, [profile]);
const [leftCharacter, rightCharacter] = sparringCharacters;
if (!leftCharacter || !rightCharacter) {
return null;
}
return (
<>
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(8,10,17,0.02)_0%,rgba(8,10,17,0.08)_42%,rgba(8,10,17,0.68)_100%)]" />
<div className="absolute left-3 top-3 rounded-full border border-white/12 bg-black/46 px-3 py-1 text-[10px] font-bold tracking-[0.18em] text-zinc-100">
</div>
<div className="absolute left-1/2 top-[58%] h-16 w-16 -translate-x-1/2 -translate-y-1/2 rounded-full bg-amber-300/30 blur-2xl animate-pulse" />
<div className="absolute inset-x-0 bottom-0 h-20 bg-[linear-gradient(180deg,rgba(8,10,17,0)_0%,rgba(8,10,17,0.72)_100%)]" />
<div className="absolute inset-x-0 bottom-0 flex items-end justify-between px-2 pb-1 sm:px-4 sm:pb-2">
<div className="flex flex-col items-center gap-1">
<div className="rounded-full border border-sky-300/20 bg-black/48 px-2.5 py-0.5 text-[10px] text-sky-50">
{leftCharacter.name}
</div>
<div className="h-24 w-24 sm:h-28 sm:w-28">
<CharacterAnimator
state={AnimationState.ATTACK}
character={leftCharacter}
className="h-full w-full"
imageClassName="object-bottom drop-shadow-[0_10px_18px_rgba(0,0,0,0.45)]"
/>
</div>
</div>
<div className="mb-10 rounded-full border border-amber-300/24 bg-amber-400/12 px-3 py-1 text-[10px] font-semibold tracking-[0.18em] text-amber-50">
</div>
<div className="flex flex-col items-center gap-1">
<div className="rounded-full border border-rose-300/20 bg-black/48 px-2.5 py-0.5 text-[10px] text-rose-50">
{rightCharacter.name}
</div>
<div className="h-24 w-24 scale-x-[-1] sm:h-28 sm:w-28">
<CharacterAnimator
state={AnimationState.ATTACK}
character={rightCharacter}
className="h-full w-full"
imageClassName="object-bottom drop-shadow-[0_10px_18px_rgba(0,0,0,0.45)]"
/>
</div>
</div>
</div>
</>
);
}
function ScenePresetPickerModal({
selectedSrc,
presetImages,
onSelect,
onClose,
}: {
selectedSrc?: string;
presetImages: string[];
onSelect: (value: string) => void;
onClose: () => void;
}) {
return (
<ModalShell
title="预设选择"
subtitle={`${presetImages.length} 张场景图片,点击任意图片即可应用到当前场景。`}
onClose={onClose}
>
<div className="mb-4 rounded-2xl border border-white/8 bg-black/20 px-4 py-3 text-sm leading-6 text-zinc-300">
</div>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
{presetImages.map((src, index) => {
const isSelected = src === selectedSrc;
return (
<button
key={src}
type="button"
onClick={() => {
onSelect(src);
onClose();
}}
className={`overflow-hidden rounded-2xl border text-left transition-colors ${
isSelected
? 'border-sky-300/55 bg-sky-500/10'
: 'border-white/10 bg-black/20 hover:border-white/25'
}`}
>
<div className="relative aspect-[16/9] overflow-hidden">
<img
src={src}
alt={`Scene preset ${index + 1}`}
loading="lazy"
className="h-full w-full object-cover"
/>
<div className="absolute inset-x-0 bottom-0 bg-[linear-gradient(180deg,rgba(8,10,17,0)_0%,rgba(8,10,17,0.82)_100%)] px-3 py-2 text-[11px] text-zinc-100">
#{(index + 1).toString().padStart(3, '0')}
</div>
{isSelected ? (
<div className="absolute right-2 top-2 rounded-full border border-sky-200/40 bg-black/55 px-2 py-0.5 text-[10px] font-semibold text-sky-50">
</div>
) : null}
</div>
</button>
);
})}
</div>
</ModalShell>
);
}
const FIXED_SCENE_IMAGE_SIZE = '1280*720';
function SceneImageGenerationModal({
profile,
landmark,
onApply,
onClose,
}: {
profile: CustomWorldProfile;
landmark: CustomWorldLandmark;
onApply: (result: CustomWorldSceneImageResult) => void;
onClose: () => void;
}) {
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 originalImageSrc = useMemo(() => {
const landmarkIndex = profile.landmarks.findIndex(
(entry) => entry.id === landmark.id,
);
return resolveCustomWorldLandmarkImage(
profile,
landmark,
landmarkIndex >= 0 ? landmarkIndex : profile.landmarks.length,
profile.landmarks
.filter((entry) => entry.id !== landmark.id)
.map((entry) => entry.imageSrc)
.filter((imageSrc): imageSrc is string => Boolean(imageSrc)),
);
}, [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 (!userPrompt.trim()) {
setError('请先描述想要生成的画面内容。');
return;
}
setIsGenerating(true);
setError(null);
try {
const result = await generateCustomWorldSceneImage({
profile,
landmark,
userPrompt,
size: FIXED_SCENE_IMAGE_SIZE,
...(referenceImageSrc ? { referenceImageSrc } : {}),
});
setLatestResult(result);
} catch (generationError) {
setError(
generationError instanceof Error
? generationError.message
: '场景图片生成失败,请稍后重试。',
);
} finally {
setIsGenerating(false);
}
};
const handleSave = () => {
if (!latestResult || isGenerating) {
return;
}
onApply(latestResult);
onClose();
};
return (
<>
<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>
<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">
退
</div>
) : null}
{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>
</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 gap-3 ${
extraAction
? 'sm:flex-row sm:items-center sm:justify-between'
: 'sm:flex-row sm:justify-end'
}`}
>
{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>
);
}
function SectionPanel({
title,
subtitle,
actions,
children,
}: {
title: string;
subtitle?: string;
actions?: ReactNode;
children: ReactNode;
}) {
return (
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3 sm:px-4 sm:py-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="text-[11px] font-bold tracking-[0.16em] text-zinc-300">
{title}
</div>
{subtitle ? (
<div className="mt-2 text-sm leading-6 text-zinc-400">
{subtitle}
</div>
) : null}
</div>
{actions}
</div>
<div className="mt-4 space-y-3">{children}</div>
</div>
);
}
function BackstoryRevealEditor({
value,
onChange,
}: {
value: CustomWorldPlayableNpc['backstoryReveal'];
onChange: (value: CustomWorldPlayableNpc['backstoryReveal']) => void;
}) {
const updateChapter = (
index: number,
updater: (
chapter: CustomWorldPlayableNpc['backstoryReveal']['chapters'][number],
) => CustomWorldPlayableNpc['backstoryReveal']['chapters'][number],
) => {
onChange({
...value,
chapters: value.chapters.map((chapter, chapterIndex) =>
chapterIndex === index ? updater(chapter) : chapter,
),
});
};
const addChapter = () => {
onChange({
...value,
chapters: [
...value.chapters,
createBackstoryChapterDraft('custom-role', value.chapters.length),
],
});
};
const removeChapter = (index: number) => {
if (value.chapters.length <= 1) {
window.alert('至少保留一个背景章节。');
return;
}
onChange({
...value,
chapters: value.chapters.filter(
(_chapter, chapterIndex) => chapterIndex !== index,
),
});
};
return (
<SectionPanel
title="背景公开与章节"
subtitle="这里直接决定结果页、关系推进和后续剧情提示词看到的背景摘要与章节线索。"
actions={
<ActionButton label="新增章节" onClick={addChapter} tone="sky" />
}
>
<Field label="公开背景摘要">
<TextArea
value={value.publicSummary}
onChange={(nextValue) =>
onChange({
...value,
publicSummary: nextValue,
})
}
rows={3}
/>
</Field>
{value.chapters.map((chapter, index) => (
<div
key={`${chapter.id}-${index}`}
className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3"
>
<div className="mb-3 flex items-center justify-between gap-3">
<div className="text-sm font-semibold text-white">
#{index + 1}
</div>
<ActionButton
label="删除章节"
onClick={() => removeChapter(index)}
/>
</div>
<Field label="章节标题">
<TextInput
value={chapter.title}
onChange={(nextValue) =>
updateChapter(index, (current) => ({
...current,
title: nextValue,
}))
}
/>
</Field>
<Field label="解锁好感">
<TextInput
type="number"
value={chapter.affinityRequired}
onChange={(nextValue) =>
updateChapter(index, (current) => ({
...current,
affinityRequired: clampInitialAffinity(
nextValue,
current.affinityRequired,
),
}))
}
/>
</Field>
<Field label="章节提示">
<TextArea
value={chapter.teaser}
onChange={(nextValue) =>
updateChapter(index, (current) => ({
...current,
teaser: nextValue,
}))
}
rows={2}
/>
</Field>
<Field label="章节内容">
<TextArea
value={chapter.content}
onChange={(nextValue) =>
updateChapter(index, (current) => ({
...current,
content: nextValue,
}))
}
rows={3}
/>
</Field>
<Field label="剧情引用摘要">
<TextArea
value={chapter.contextSnippet}
onChange={(nextValue) =>
updateChapter(index, (current) => ({
...current,
contextSnippet: nextValue,
}))
}
rows={2}
/>
</Field>
</div>
))}
</SectionPanel>
);
}
function SkillListEditor({
value,
onChange,
labelSeed,
}: {
value: CustomWorldPlayableNpc['skills'];
onChange: (value: CustomWorldPlayableNpc['skills']) => void;
labelSeed: string;
}) {
const updateSkill = (
index: number,
updater: (
skill: CustomWorldPlayableNpc['skills'][number],
) => CustomWorldPlayableNpc['skills'][number],
) => {
onChange(
value.map((skill, skillIndex) =>
skillIndex === index ? updater(skill) : skill,
),
);
};
return (
<SectionPanel
title="技能"
subtitle="技能名、摘要和风格都会进入结果页与运行时 NPC 档案。"
actions={
<ActionButton
label="新增技能"
onClick={() =>
onChange([...value, createRoleSkillDraft(labelSeed, value.length)])
}
tone="sky"
/>
}
>
{value.map((skill, index) => (
<div
key={`${skill.id}-${index}`}
className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3"
>
<div className="mb-3 flex items-center justify-between gap-3">
<div className="text-sm font-semibold text-white">
#{index + 1}
</div>
<ActionButton
label="删除技能"
onClick={() =>
onChange(
value.filter((_skill, skillIndex) => skillIndex !== index),
)
}
/>
</div>
<Field label="技能名称">
<TextInput
value={skill.name}
onChange={(nextValue) =>
updateSkill(index, (current) => ({
...current,
name: nextValue,
}))
}
/>
</Field>
<Field label="技能风格">
<TextInput
value={skill.style}
onChange={(nextValue) =>
updateSkill(index, (current) => ({
...current,
style: nextValue,
}))
}
/>
</Field>
<Field label="技能摘要">
<TextArea
value={skill.summary}
onChange={(nextValue) =>
updateSkill(index, (current) => ({
...current,
summary: nextValue,
}))
}
rows={3}
/>
</Field>
</div>
))}
</SectionPanel>
);
}
function InitialItemsEditor({
value,
onChange,
labelSeed,
}: {
value: CustomWorldPlayableNpc['initialItems'];
onChange: (value: CustomWorldPlayableNpc['initialItems']) => void;
labelSeed: string;
}) {
const updateItem = (
index: number,
updater: (
item: CustomWorldPlayableNpc['initialItems'][number],
) => CustomWorldPlayableNpc['initialItems'][number],
) => {
onChange(
value.map((item, itemIndex) =>
itemIndex === index ? updater(item) : item,
),
);
};
return (
<SectionPanel
title="初始物品"
subtitle="这里的内容会影响结果页展示,也会作为后续运行时参考档案。"
actions={
<ActionButton
label="新增物品"
onClick={() =>
onChange([
...value,
createRoleInitialItemDraft(labelSeed, value.length),
])
}
tone="sky"
/>
}
>
{value.map((item, index) => (
<div
key={`${item.id}-${index}`}
className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3"
>
<div className="mb-3 flex items-center justify-between gap-3">
<div className="text-sm font-semibold text-white">
#{index + 1}
</div>
<ActionButton
label="删除物品"
onClick={() =>
onChange(
value.filter((_item, itemIndex) => itemIndex !== index),
)
}
/>
</div>
<Field label="名称">
<TextInput
value={item.name}
onChange={(nextValue) =>
updateItem(index, (current) => ({
...current,
name: nextValue,
}))
}
/>
</Field>
<div className="grid gap-3 sm:grid-cols-2">
<Field label="分类">
<TextInput
value={item.category}
onChange={(nextValue) =>
updateItem(index, (current) => ({
...current,
category: nextValue,
}))
}
/>
</Field>
<Field label="稀有度">
<SelectField
value={item.rarity}
onChange={(nextValue) =>
updateItem(index, (current) => ({
...current,
rarity: nextValue as ItemRarity,
}))
}
options={ITEM_RARITY_OPTIONS}
/>
</Field>
</div>
<Field label="数量">
<TextInput
type="number"
value={item.quantity}
onChange={(nextValue) =>
updateItem(index, (current) => ({
...current,
quantity: Math.max(
1,
parseOptionalNumber(nextValue) ?? current.quantity,
),
}))
}
/>
</Field>
<Field label="描述">
<TextArea
value={item.description}
onChange={(nextValue) =>
updateItem(index, (current) => ({
...current,
description: nextValue,
}))
}
rows={3}
/>
</Field>
<Field label="标签">
<TextArea
value={commaText(item.tags)}
onChange={(nextValue) =>
updateItem(index, (current) => ({
...current,
tags: parseCommaText(nextValue),
}))
}
rows={2}
/>
</Field>
</div>
))}
</SectionPanel>
);
}
function StoryNpcVisualEditorModal({
npc,
visual,
onChange,
onOpenAiStudio,
onClose,
}: {
npc: CustomWorldNpc;
visual: NonNullable<CustomWorldNpc['visual']>;
onChange: (visual: NonNullable<CustomWorldNpc['visual']>) => void;
onOpenAiStudio?: () => void;
onClose: () => void;
}) {
return (
<ModalShell
title={`修改形象:${npc.name}`}
subtitle="在独立面板中组合中世纪奇幻角色形象,左侧预览会保持吸顶。"
onClose={onClose}
panelClassName="sm:max-w-6xl"
overlayClassName="z-[99]"
>
<CustomWorldNpcVisualEditor
npc={{
id: npc.id,
name: npc.name,
role: npc.role,
description: npc.description,
}}
value={visual}
onChange={onChange}
onAiGenerate={() => {
onClose();
onOpenAiStudio?.();
}}
/>
</ModalShell>
);
}
function WorldEditor({
profile,
onSave,
onClose,
}: {
profile: CustomWorldProfile;
onSave: (profile: CustomWorldProfile) => void;
onClose: () => void;
}) {
const [draft, setDraft] = useDraft(profile);
const [isCampPresetPickerOpen, setIsCampPresetPickerOpen] = useState(false);
const [isCampAiGenerateOpen, setIsCampAiGenerateOpen] = useState(false);
const presetImages = useMemo(() => getAllCustomWorldSceneImages(), []);
const resolvedCampScene = useMemo(
() => resolveCustomWorldCampScene(draft),
[draft],
);
const resolvedCampImageSrc = useMemo(
() => resolveCustomWorldCampSceneImage(draft),
[draft],
);
const campSceneDraft = useMemo<CustomWorldLandmark>(
() => ({
id: 'custom-scene-camp',
name: resolvedCampScene.name,
description: resolvedCampScene.description,
dangerLevel: resolvedCampScene.dangerLevel,
imageSrc: resolvedCampScene.imageSrc,
sceneNpcIds: [],
connections: [],
}),
[resolvedCampScene],
);
return (
<ModalShell
title="编辑世界信息"
subtitle="修改后的内容会直接反映在结果页,并会作为进入世界前的最终档案。"
onClose={onClose}
>
<div className="space-y-4">
<Field label="世界名称">
<TextInput
value={draft.name}
onChange={(value) =>
setDraft((current) => ({ ...current, name: value }))
}
/>
</Field>
<Field label="副标题">
<TextInput
value={draft.subtitle}
onChange={(value) =>
setDraft((current) => ({ ...current, subtitle: value }))
}
/>
</Field>
<Field label="世界概述">
<TextArea
value={draft.summary}
onChange={(value) =>
setDraft((current) => ({ ...current, summary: value }))
}
rows={4}
/>
</Field>
<Field label="世界基调">
<TextArea
value={draft.tone}
onChange={(value) =>
setDraft((current) => ({ ...current, tone: value }))
}
rows={3}
/>
</Field>
<Field label="主线目标">
<TextArea
value={draft.playerGoal}
onChange={(value) =>
setDraft((current) => ({ ...current, playerGoal: value }))
}
rows={3}
/>
</Field>
<Field label="开局归处名称">
<TextInput
value={resolvedCampScene.name}
onChange={(value) =>
setDraft((current) => ({
...current,
camp: {
...resolveCustomWorldCampScene(current),
name: value,
},
}))
}
/>
</Field>
<Field label="开局归处描述">
<TextArea
value={resolvedCampScene.description}
onChange={(value) =>
setDraft((current) => ({
...current,
camp: {
...resolveCustomWorldCampScene(current),
description: value,
},
}))
}
rows={4}
/>
</Field>
<Field label="开局归处危险度">
<TextInput
value={resolvedCampScene.dangerLevel}
onChange={(value) =>
setDraft((current) => ({
...current,
camp: {
...resolveCustomWorldCampScene(current),
dangerLevel: value,
},
}))
}
/>
</Field>
<ImageField
label="开局归处背景"
value={resolvedCampImageSrc}
onChange={(value) =>
setDraft((current) => ({
...current,
camp: {
...resolveCustomWorldCampScene(current),
imageSrc: value || undefined,
},
}))
}
fallbackLabel={resolvedCampScene.name.slice(0, 4) || '归处'}
tone="landscape"
showInput={false}
previewOverlay={<SceneSparringPreview profile={draft} />}
footer={
<div className="space-y-3">
<div className="flex flex-wrap gap-3">
<ActionButton
label="预设选择"
onClick={() => setIsCampPresetPickerOpen(true)}
tone="sky"
/>
<ActionButton
label="智能生成"
onClick={() => setIsCampAiGenerateOpen(true)}
/>
</div>
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3 text-sm leading-6 text-zinc-300">
</div>
</div>
}
/>
<Field label="玩家原始设定">
<TextArea
value={draft.settingText}
onChange={(value) =>
setDraft((current) => ({ ...current, settingText: value }))
}
rows={4}
/>
</Field>
{isCampPresetPickerOpen ? (
<ScenePresetPickerModal
selectedSrc={resolvedCampScene.imageSrc}
presetImages={presetImages}
onSelect={(value) =>
setDraft((current) => ({
...current,
camp: {
...resolveCustomWorldCampScene(current),
imageSrc: value,
},
}))
}
onClose={() => setIsCampPresetPickerOpen(false)}
/>
) : null}
{isCampAiGenerateOpen ? (
<SceneImageGenerationModal
profile={draft}
landmark={campSceneDraft}
onApply={(result) => {
setDraft((current) => ({
...current,
camp: {
...resolveCustomWorldCampScene(current),
imageSrc: result.imageSrc,
},
}));
}}
onClose={() => setIsCampAiGenerateOpen(false)}
/>
) : null}
<SaveBar
onClose={onClose}
onSave={() => {
onSave(draft);
onClose();
}}
/>
</div>
</ModalShell>
);
}
function PlayableNpcEditor({
npc,
mode,
onSave,
onClose,
}: {
npc: CustomWorldPlayableNpc;
mode: 'create' | 'edit';
onSave: (npc: CustomWorldPlayableNpc) => void;
onClose: () => void;
}) {
const [draft, setDraft] = useDraft(npc);
const [isAiAssetStudioOpen, setIsAiAssetStudioOpen] = useState(false);
const selectedTemplate =
ROLE_TEMPLATE_CHARACTERS.find(
(character) => character.id === draft.templateCharacterId,
) ??
ROLE_TEMPLATE_CHARACTERS[0] ??
null;
return (
<ModalShell
title={mode === 'create' ? '新增可扮演角色' : `编辑角色:${npc.name}`}
subtitle="这里可以直接修改可扮演角色的完整档案字段,结果页和正式选角都会同步使用。"
onClose={onClose}
>
<div className="space-y-4">
{selectedTemplate ? (
<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={draft.imageSrc || selectedTemplate.portrait}
alt={selectedTemplate.name}
className="h-28 w-full object-cover object-top"
/>
</div>
<div className="min-w-0">
<div className="text-[11px] tracking-[0.18em] text-zinc-500">
</div>
<div className="mt-2 text-base font-semibold text-white">
{selectedTemplate.name}
</div>
<div className="mt-1 text-sm text-zinc-400">
{selectedTemplate.title}
</div>
<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}
<Field label="外观模板">
<SelectField
value={draft.templateCharacterId ?? ROLE_TEMPLATE_CHARACTERS[0]?.id ?? ''}
onChange={(value) =>
setDraft((current) => ({
...current,
templateCharacterId: value,
}))
}
options={ROLE_TEMPLATE_CHARACTERS.map((character) => ({
value: character.id,
label: `${character.name} / ${character.title}`,
}))}
/>
</Field>
<Field label="名称">
<TextInput
value={draft.name}
onChange={(value) =>
setDraft((current) => ({ ...current, name: value }))
}
/>
</Field>
<Field label="称号 / 头衔">
<TextInput
value={draft.title}
onChange={(value) =>
setDraft((current) => ({ ...current, title: value }))
}
/>
</Field>
<Field label="世界身份 / 职责">
<TextInput
value={draft.role}
onChange={(value) =>
setDraft((current) => ({ ...current, role: value }))
}
/>
</Field>
<Field label="简介">
<TextArea
value={draft.description}
onChange={(value) =>
setDraft((current) => ({ ...current, description: value }))
}
rows={3}
/>
</Field>
<Field label="背景">
<TextArea
value={draft.backstory}
onChange={(value) =>
setDraft((current) => ({ ...current, backstory: value }))
}
rows={5}
/>
</Field>
<Field label="性格">
<TextArea
value={draft.personality}
onChange={(value) =>
setDraft((current) => ({ ...current, personality: value }))
}
rows={3}
/>
</Field>
<Field label="当前动机">
<TextArea
value={draft.motivation}
onChange={(value) =>
setDraft((current) => ({ ...current, motivation: value }))
}
rows={3}
/>
</Field>
<Field label="战斗风格">
<TextArea
value={draft.combatStyle}
onChange={(value) =>
setDraft((current) => ({ ...current, combatStyle: value }))
}
rows={3}
/>
</Field>
<Field label="初始好感">
<TextInput
type="number"
value={draft.initialAffinity}
onChange={(value) =>
setDraft((current) => ({
...current,
initialAffinity: clampInitialAffinity(
value,
current.initialAffinity,
),
}))
}
/>
</Field>
<Field label="关系切入口">
<TextArea
value={commaText(draft.relationshipHooks)}
onChange={(value) =>
setDraft((current) => ({
...current,
relationshipHooks: parseCommaText(value),
}))
}
rows={2}
/>
</Field>
<Field label="标签">
<TextArea
value={commaText(draft.tags)}
onChange={(value) =>
setDraft((current) => ({
...current,
tags: parseCommaText(value),
}))
}
rows={2}
/>
</Field>
<BackstoryRevealEditor
value={draft.backstoryReveal}
onChange={(backstoryReveal) =>
setDraft((current) => ({
...current,
backstoryReveal,
}))
}
/>
<SkillListEditor
value={draft.skills}
onChange={(skills) =>
setDraft((current) => ({
...current,
skills,
}))
}
labelSeed={draft.name || draft.id}
/>
<InitialItemsEditor
value={draft.initialItems}
onChange={(initialItems) =>
setDraft((current) => ({
...current,
initialItems,
}))
}
labelSeed={draft.name || draft.id}
/>
<SaveBar
onClose={onClose}
onSave={() => {
onSave({
...draft,
templateCharacterId:
draft.templateCharacterId ?? ROLE_TEMPLATE_CHARACTERS[0]?.id,
});
onClose();
}}
/>
{isAiAssetStudioOpen ? (
<CustomWorldRoleAssetStudioModal
role={draft}
roleKind="playable"
onApply={(nextRole) =>
setDraft((current) => ({
...current,
...nextRole,
}))
}
onClose={() => setIsAiAssetStudioOpen(false)}
/>
) : null}
</div>
</ModalShell>
);
}
function StoryNpcEditor({
npc,
mode,
onSave,
onClose,
}: {
npc: CustomWorldNpc;
mode: 'create' | 'edit';
onSave: (npc: CustomWorldNpc) => void;
onClose: () => void;
}) {
const [draft, setDraft] = useDraft(npc);
const [isVisualEditorOpen, setIsVisualEditorOpen] = useState(false);
const [isAiAssetStudioOpen, setIsAiAssetStudioOpen] = useState(false);
return (
<ModalShell
title={mode === 'create' ? '新增场景角色' : `编辑场景角色:${npc.name}`}
subtitle="这里可以直接修改场景角色的完整档案字段,形象编辑仍保留在独立面板。"
onClose={onClose}
>
<div className="space-y-4">
<div className="rounded-3xl border border-white/10 bg-black/20 p-4">
<div className="grid gap-4 sm:grid-cols-[10rem_minmax(0,1fr)] sm:items-center">
<div className="flex justify-center">
<CustomWorldNpcPortrait
npc={draft}
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">
<div>
<div className="text-[11px] font-bold tracking-[0.18em] text-zinc-300">
</div>
<div className="mt-2 text-sm leading-6 text-zinc-400">
便
</div>
</div>
<div className="flex flex-wrap gap-3">
<ActionButton
label="修改形象"
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>
</div>
<Field label="名称">
<TextInput
value={draft.name}
onChange={(value) =>
setDraft((current) => ({ ...current, name: value }))
}
/>
</Field>
<Field label="头衔 / 职能">
<TextInput
value={draft.title}
onChange={(value) =>
setDraft((current) => ({ ...current, title: value }))
}
/>
</Field>
<Field label="世界身份 / 职能">
<TextInput
value={draft.role}
onChange={(value) =>
setDraft((current) => ({ ...current, role: value }))
}
/>
</Field>
<Field label="描述">
<TextArea
value={draft.description}
onChange={(value) =>
setDraft((current) => ({ ...current, description: value }))
}
rows={4}
/>
</Field>
<Field label="背景">
<TextArea
value={draft.backstory}
onChange={(value) =>
setDraft((current) => ({ ...current, backstory: value }))
}
rows={4}
/>
</Field>
<Field label="性格">
<TextArea
value={draft.personality}
onChange={(value) =>
setDraft((current) => ({ ...current, personality: value }))
}
rows={3}
/>
</Field>
<Field label="动机">
<TextArea
value={draft.motivation}
onChange={(value) =>
setDraft((current) => ({ ...current, motivation: value }))
}
rows={4}
/>
</Field>
<Field label="战斗风格">
<TextArea
value={draft.combatStyle}
onChange={(value) =>
setDraft((current) => ({ ...current, combatStyle: value }))
}
rows={3}
/>
</Field>
<Field label="初始好感">
<TextInput
type="number"
value={draft.initialAffinity}
onChange={(value) =>
setDraft((current) => ({
...current,
initialAffinity: clampInitialAffinity(
value,
current.initialAffinity,
),
}))
}
/>
</Field>
<Field label="关系切入口">
<TextArea
value={commaText(draft.relationshipHooks)}
onChange={(value) =>
setDraft((current) => ({
...current,
relationshipHooks: parseCommaText(value),
}))
}
rows={3}
/>
</Field>
<Field label="标签">
<TextArea
value={commaText(draft.tags)}
onChange={(value) =>
setDraft((current) => ({
...current,
tags: parseCommaText(value),
}))
}
rows={2}
/>
</Field>
<BackstoryRevealEditor
value={draft.backstoryReveal}
onChange={(backstoryReveal) =>
setDraft((current) => ({
...current,
backstoryReveal,
}))
}
/>
<SkillListEditor
value={draft.skills}
onChange={(skills) =>
setDraft((current) => ({
...current,
skills,
}))
}
labelSeed={draft.name || draft.id}
/>
<InitialItemsEditor
value={draft.initialItems}
onChange={(initialItems) =>
setDraft((current) => ({
...current,
initialItems,
}))
}
labelSeed={draft.name || draft.id}
/>
<SaveBar
onClose={onClose}
onSave={() => {
onSave(draft);
onClose();
}}
/>
{isVisualEditorOpen ? (
<StoryNpcVisualEditorModal
npc={draft}
visual={
draft.visual ??
buildDefaultCustomWorldNpcVisual({
id: draft.id,
name: draft.name,
role: draft.role,
description: draft.description,
})
}
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>
);
}
function LandmarkEditor({
profile,
landmark,
mode,
onSaveProfile,
onClose,
}: {
profile: CustomWorldProfile;
landmark: CustomWorldLandmark;
mode: 'create' | 'edit';
onSaveProfile: (profile: CustomWorldProfile) => void;
onClose: () => void;
}) {
const [draft, setDraft] = useDraft(landmark);
const [draftStoryNpcs, setDraftStoryNpcs] = useDraft(profile.storyNpcs);
const [isPresetPickerOpen, setIsPresetPickerOpen] = useState(false);
const [isAiGenerateOpen, setIsAiGenerateOpen] = useState(false);
const [npcEditorState, setNpcEditorState] = useState<{
mode: 'create' | 'edit';
npc: CustomWorldNpc;
} | null>(null);
const presetImages = useMemo(() => getAllCustomWorldSceneImages(), []);
const resolvedDraftImageSrc = useMemo(() => {
const landmarkIndex = profile.landmarks.findIndex(
(entry) => entry.id === draft.id,
);
return resolveCustomWorldLandmarkImage(
profile,
draft,
landmarkIndex >= 0 ? landmarkIndex : profile.landmarks.length,
profile.landmarks
.filter((entry) => entry.id !== draft.id)
.map((entry) => entry.imageSrc)
.filter((imageSrc): imageSrc is string => Boolean(imageSrc)),
);
}, [draft, profile]);
const storyNpcById = useMemo(
() => new Map(draftStoryNpcs.map((npc) => [npc.id, npc])),
[draftStoryNpcs],
);
const availableTargetLandmarks = useMemo(
() => profile.landmarks.filter((entry) => entry.id !== draft.id),
[draft.id, profile.landmarks],
);
const toggleSceneNpc = (npcId: string) => {
setDraft((current) => ({
...current,
sceneNpcIds: current.sceneNpcIds.includes(npcId)
? current.sceneNpcIds.filter((entry) => entry !== npcId)
: [...current.sceneNpcIds, npcId],
}));
};
const updateConnection = (
index: number,
updater: (
connection: CustomWorldSceneConnection,
) => CustomWorldSceneConnection,
) => {
setDraft((current) => ({
...current,
connections: current.connections.map((connection, connectionIndex) =>
connectionIndex === index ? updater(connection) : connection,
),
}));
};
const addConnection = () => {
const fallbackTarget = availableTargetLandmarks[0];
if (!fallbackTarget) {
window.alert('请先保留至少一个其他场景,才能配置连接关系。');
return;
}
setDraft((current) => ({
...current,
connections: [
...current.connections,
{
targetLandmarkId: fallbackTarget.id,
relativePosition: 'forward',
summary: `可通往${fallbackTarget.name}`,
},
],
}));
};
const saveLandmarkProfile = () => {
if (draft.sceneNpcIds.length < 3) {
window.alert('每个场景至少需要分配 3 个 NPC。');
return;
}
const nextLandmarks =
mode === 'create'
? [...profile.landmarks, draft]
: profile.landmarks.map((entry) =>
entry.id === draft.id ? draft : entry,
);
onSaveProfile({
...profile,
storyNpcs: draftStoryNpcs,
landmarks: syncLandmarksWithStoryNpcs(nextLandmarks, draftStoryNpcs),
});
onClose();
};
return (
<ModalShell
title={mode === 'create' ? '新增场景' : `编辑场景:${landmark.name}`}
subtitle="这里可以同时配置场景图片、场景内 NPC以及场景之间的相对位置连接关系。"
onClose={onClose}
>
<div className="space-y-4">
<ImageField
label="场景图片"
value={resolvedDraftImageSrc}
onChange={(value) =>
setDraft((current) => ({
...current,
imageSrc: value || undefined,
}))
}
fallbackLabel={draft.name ? draft.name.slice(0, 4) : '背景'}
tone="landscape"
showInput={false}
previewOverlay={<SceneSparringPreview profile={profile} />}
footer={
<div className="space-y-3">
<div className="flex flex-wrap gap-3">
<ActionButton
label="预设选择"
onClick={() => setIsPresetPickerOpen(true)}
tone="sky"
/>
<ActionButton
label="智能生成"
onClick={() => setIsAiGenerateOpen(true)}
/>
</div>
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3 text-sm leading-6 text-zinc-300">
</div>
</div>
}
/>
<Field label="名称">
<TextInput
value={draft.name}
onChange={(value) =>
setDraft((current) => ({ ...current, name: value }))
}
/>
</Field>
<Field label="描述">
<TextArea
value={draft.description}
onChange={(value) =>
setDraft((current) => ({ ...current, description: value }))
}
rows={5}
/>
</Field>
<Field label="危险度">
<TextInput
value={draft.dangerLevel}
onChange={(value) =>
setDraft((current) => ({ ...current, dangerLevel: value }))
}
/>
</Field>
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="text-[11px] font-bold tracking-[0.16em] text-zinc-300">
NPC
</div>
<div className="mt-2 text-sm leading-6 text-zinc-400">
3 NPC
NPC
</div>
</div>
<ActionButton
label="新增 NPC 并加入此场景"
onClick={() =>
setNpcEditorState({
mode: 'create',
npc: createStoryNpc({ storyNpcs: draftStoryNpcs }),
})
}
tone="sky"
/>
</div>
<div className="mt-3 space-y-2">
{draft.sceneNpcIds.length > 0 ? (
draft.sceneNpcIds.map((npcId) => {
const npc = storyNpcById.get(npcId);
return (
<div
key={`${draft.id}-selected-npc-${npcId}`}
className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3"
>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-semibold text-white">
{npc?.name ?? '未匹配场景角色'}
</div>
<div className="mt-1 text-xs leading-6 text-zinc-400">
{npc?.role || npc?.title || '未填写身份'}
</div>
</div>
<div className="flex flex-wrap gap-2">
{npc ? (
<ActionButton
label="编辑"
onClick={() =>
setNpcEditorState({
mode: 'edit',
npc,
})
}
tone="sky"
/>
) : null}
<ActionButton
label="移出场景"
onClick={() => toggleSceneNpc(npcId)}
/>
</div>
</div>
</div>
);
})
) : (
<div className="rounded-2xl border border-dashed border-white/12 bg-black/20 px-4 py-4 text-sm text-zinc-500">
NPC
</div>
)}
</div>
<div className="mt-3 max-h-64 space-y-2 overflow-y-auto pr-1">
{draftStoryNpcs.map((npc) => {
const selected = draft.sceneNpcIds.includes(npc.id);
return (
<button
key={`${draft.id}-npc-picker-${npc.id}`}
type="button"
onClick={() => toggleSceneNpc(npc.id)}
className={`w-full rounded-2xl border px-3 py-3 text-left transition-colors ${
selected
? 'border-sky-300/28 bg-sky-500/10'
: 'border-white/8 bg-black/20 hover:border-white/18'
}`}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-semibold text-white">
{npc.name}
</div>
<div className="mt-1 text-xs leading-6 text-zinc-400">
{npc.role}
</div>
</div>
<div className="rounded-full border border-white/10 bg-black/20 px-2.5 py-1 text-[10px] text-zinc-300">
{selected ? '已加入' : '点击加入'}
</div>
</div>
</button>
);
})}
</div>
</div>
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="text-[11px] font-bold tracking-[0.16em] text-zinc-300">
</div>
<div className="mt-2 text-sm leading-6 text-zinc-400">
线
</div>
</div>
<ActionButton label="新增连接" onClick={addConnection} tone="sky" />
</div>
<div className="mt-3 space-y-3">
{draft.connections.length > 0 ? (
draft.connections.map((connection, index) => (
<div
key={`${draft.id}-connection-${index}`}
className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3"
>
<div className="grid gap-3 md:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)]">
<Field label="目标场景">
<SelectField
value={connection.targetLandmarkId}
onChange={(value) =>
updateConnection(index, (current) => ({
...current,
targetLandmarkId: value,
}))
}
options={availableTargetLandmarks.map((entry) => ({
value: entry.id,
label: entry.name,
}))}
/>
</Field>
<Field label="相对位置">
<SelectField
value={connection.relativePosition}
onChange={(value) =>
updateConnection(index, (current) => ({
...current,
relativePosition:
value as CustomWorldSceneConnection['relativePosition'],
}))
}
options={CUSTOM_WORLD_SCENE_RELATIVE_POSITION_OPTIONS.map(
(option) => ({
value: option.value,
label: option.label,
}),
)}
/>
</Field>
</div>
<Field label="连接说明">
<TextArea
value={connection.summary}
onChange={(value) =>
updateConnection(index, (current) => ({
...current,
summary: value,
}))
}
rows={2}
placeholder="例如:沿山脊向北翻过去,可到达断桥。"
/>
</Field>
<div className="flex justify-end">
<ActionButton
label="删除连接"
onClick={() =>
setDraft((current) => ({
...current,
connections: current.connections.filter(
(_item, connectionIndex) =>
connectionIndex !== index,
),
}))
}
/>
</div>
</div>
))
) : (
<div className="rounded-2xl border border-dashed border-white/12 bg-black/20 px-4 py-4 text-sm text-zinc-500">
</div>
)}
</div>
{draft.connections.length > 0 ? (
<div className="mt-3 rounded-2xl border border-white/8 bg-black/20 px-4 py-3 text-xs leading-6 text-zinc-400">
{draft.connections
.map((connection) => {
const targetLandmark = availableTargetLandmarks.find(
(entry) => entry.id === connection.targetLandmarkId,
);
return `${getCustomWorldSceneRelativePositionLabel(connection.relativePosition)} -> ${targetLandmark?.name ?? '未匹配场景'}`;
})
.join('')}
</div>
) : null}
</div>
<SaveBar
onClose={onClose}
onSave={saveLandmarkProfile}
/>
{isPresetPickerOpen ? (
<ScenePresetPickerModal
selectedSrc={draft.imageSrc}
presetImages={presetImages}
onSelect={(value) =>
setDraft((current) => ({ ...current, imageSrc: value }))
}
onClose={() => setIsPresetPickerOpen(false)}
/>
) : null}
{isAiGenerateOpen ? (
<SceneImageGenerationModal
profile={profile}
landmark={draft}
onApply={(result) => {
setDraft((current) => ({
...current,
imageSrc: result.imageSrc,
}));
}}
onClose={() => setIsAiGenerateOpen(false)}
/>
) : null}
{npcEditorState ? (
<StoryNpcEditor
npc={npcEditorState.npc}
mode={npcEditorState.mode}
onSave={(nextNpc) => {
setDraftStoryNpcs((current) =>
npcEditorState.mode === 'create'
? [...current, nextNpc]
: current.map((item) =>
item.id === nextNpc.id ? nextNpc : item,
),
);
setDraft((current) => ({
...current,
sceneNpcIds: current.sceneNpcIds.includes(nextNpc.id)
? current.sceneNpcIds
: [...current.sceneNpcIds, nextNpc.id],
}));
setNpcEditorState(null);
}}
onClose={() => setNpcEditorState(null)}
/>
) : null}
</div>
</ModalShell>
);
}
function createPlayableNpc(
profile: CustomWorldProfile,
): CustomWorldPlayableNpc {
const seed = Date.now() + profile.playableNpcs.length;
const template =
ROLE_TEMPLATE_CHARACTERS[
profile.playableNpcs.length % Math.max(1, ROLE_TEMPLATE_CHARACTERS.length)
] ?? ROLE_TEMPLATE_CHARACTERS[0];
return {
id: createEntryId(
'playable-npc',
`角色-${profile.playableNpcs.length + 1}`,
seed,
),
name: `自定义角色${profile.playableNpcs.length + 1}`,
title: '自定义身份',
role: '世界中的行动者',
description: '',
backstory: '',
personality: '',
motivation: '',
combatStyle: '',
initialAffinity: 18,
relationshipHooks: ['首次接触', '合作空间'],
tags: ['自定义'],
backstoryReveal: {
publicSummary: '',
chapters: [
{
id: 'surface',
title: '表层来意',
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_EASED,
teaser: '',
content: '',
contextSnippet: '',
},
{
id: 'scar',
title: '旧事裂痕',
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_FRIENDLY,
teaser: '',
content: '',
contextSnippet: '',
},
{
id: 'hidden',
title: '隐藏执念',
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_TRUSTED,
teaser: '',
content: '',
contextSnippet: '',
},
{
id: 'final',
title: '最终底牌',
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_CLOSE,
teaser: '',
content: '',
contextSnippet: '',
},
],
},
skills: [
{ id: 'skill-1', name: '基础起手', summary: '', style: '起手压制' },
{ id: 'skill-2', name: '常用变招', summary: '', style: '机动周旋' },
{ id: 'skill-3', name: '压箱底牌', summary: '', style: '爆发终结' },
],
initialItems: [
{
id: 'item-1',
name: '随身武具',
category: '武器',
quantity: 1,
rarity: 'rare',
description: '',
tags: ['自定义'],
},
{
id: 'item-2',
name: '补给包',
category: '消耗品',
quantity: 2,
rarity: 'uncommon',
description: '',
tags: ['自定义'],
},
{
id: 'item-3',
name: '私人物件',
category: '专属物品',
quantity: 1,
rarity: 'rare',
description: '',
tags: ['自定义'],
},
],
templateCharacterId: template?.id,
};
}
function createStoryNpc(
profile: Pick<CustomWorldProfile, 'storyNpcs'>,
): CustomWorldNpc {
const seed = Date.now() + profile.storyNpcs.length;
const npc = {
id: createEntryId(
'story-npc',
`场景角色-${profile.storyNpcs.length + 1}`,
seed,
),
name: `自定义场景角色${profile.storyNpcs.length + 1}`,
title: '自定义头衔',
role: '自定义身份',
description: '',
backstory: '',
personality: '',
motivation: '',
combatStyle: '',
initialAffinity: 6,
relationshipHooks: ['合作', '互动'],
tags: ['自定义'],
backstoryReveal: {
publicSummary: '',
chapters: [
{
id: 'surface',
title: '表层来意',
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_EASED,
teaser: '',
content: '',
contextSnippet: '',
},
{
id: 'scar',
title: '旧事裂痕',
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_FRIENDLY,
teaser: '',
content: '',
contextSnippet: '',
},
{
id: 'hidden',
title: '隐藏执念',
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_TRUSTED,
teaser: '',
content: '',
contextSnippet: '',
},
{
id: 'final',
title: '最终底牌',
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_CLOSE,
teaser: '',
content: '',
contextSnippet: '',
},
],
},
skills: [
{ id: 'skill-1', name: '基础起手', summary: '', style: '起手压制' },
{ id: 'skill-2', name: '常用变招', summary: '', style: '机动周旋' },
{ id: 'skill-3', name: '压箱底牌', summary: '', style: '爆发终结' },
],
initialItems: [
{
id: 'item-1',
name: '随身武具',
category: '武器',
quantity: 1,
rarity: 'rare',
description: '',
tags: ['自定义'],
},
{
id: 'item-2',
name: '补给包',
category: '消耗品',
quantity: 2,
rarity: 'uncommon',
description: '',
tags: ['自定义'],
},
{
id: 'item-3',
name: '私人物件',
category: '专属物品',
quantity: 1,
rarity: 'rare',
description: '',
tags: ['自定义'],
},
],
} satisfies CustomWorldNpc;
return npc;
}
function createLandmark(profile: CustomWorldProfile): CustomWorldLandmark {
const seed = Date.now() + profile.landmarks.length;
const previousLandmark = profile.landmarks[profile.landmarks.length - 1];
return {
id: createEntryId(
'landmark',
`scene-${profile.landmarks.length + 1}`,
seed,
),
name: `自定义场景${profile.landmarks.length + 1}`,
description: '',
dangerLevel: '中',
imageSrc: undefined,
sceneNpcIds: profile.storyNpcs.slice(0, 3).map((npc) => npc.id),
connections: previousLandmark
? [
{
targetLandmarkId: previousLandmark.id,
relativePosition: 'back',
summary: `暂时接回${previousLandmark.name}这条旧路`,
},
]
: [],
};
}
export function CustomWorldEntityEditorModal({
profile,
target,
onClose,
onProfileChange,
}: CustomWorldEntityEditorModalProps) {
if (!target) return null;
if (target.kind === 'world') {
return (
<WorldEditor
profile={profile}
onSave={onProfileChange}
onClose={onClose}
/>
);
}
if (target.kind === 'playable') {
if (target.mode === 'create') {
return (
<PlayableNpcEditor
npc={createPlayableNpc(profile)}
mode="create"
onSave={(nextNpc) =>
onProfileChange({
...profile,
playableNpcs: [...profile.playableNpcs, nextNpc],
})
}
onClose={onClose}
/>
);
}
const npc = profile.playableNpcs.find((item) => item.id === target.id);
return npc ? (
<PlayableNpcEditor
npc={npc}
mode="edit"
onSave={(nextNpc) =>
onProfileChange({
...profile,
playableNpcs: profile.playableNpcs.map((item) =>
item.id === nextNpc.id ? nextNpc : item,
),
})
}
onClose={onClose}
/>
) : null;
}
if (target.kind === 'story') {
if (target.mode === 'create') {
return (
<StoryNpcEditor
npc={createStoryNpc(profile)}
mode="create"
onSave={(nextNpc) =>
onProfileChange({
...profile,
storyNpcs: [...profile.storyNpcs, nextNpc],
})
}
onClose={onClose}
/>
);
}
const npc = profile.storyNpcs.find((item) => item.id === target.id);
return npc ? (
<StoryNpcEditor
npc={npc}
mode="edit"
onSave={(nextNpc) =>
onProfileChange({
...profile,
storyNpcs: profile.storyNpcs.map((item) =>
item.id === nextNpc.id ? nextNpc : item,
),
})
}
onClose={onClose}
/>
) : null;
}
if (target.mode === 'create') {
return (
<LandmarkEditor
profile={profile}
landmark={createLandmark(profile)}
mode="create"
onSaveProfile={onProfileChange}
onClose={onClose}
/>
);
}
const landmark = profile.landmarks.find((entry) => entry.id === target.id);
return landmark ? (
<LandmarkEditor
profile={profile}
landmark={landmark}
mode="edit"
onSaveProfile={onProfileChange}
onClose={onClose}
/>
) : null;
}