2877 lines
87 KiB
TypeScript
2877 lines
87 KiB
TypeScript
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;
|
||
}
|