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(value: T) { const [draft, setDraft] = useState(value); useEffect(() => setDraft(value), [value]); return [draft, setDraft] as const; } function readImageFileAsDataUrl(file: File) { return new Promise((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 (
event.stopPropagation()} >
{title}
{subtitle ? (
{subtitle}
) : null}
{children}
); } 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(, 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 (
event.stopPropagation()} >
{title}
{children}
); } function PortalCompactDialogShell(props: { title: string; onClose: () => void; children: ReactNode; overlayClassName?: string; disableClose?: boolean; usePixelFont?: boolean; }) { if (typeof document === 'undefined') { return null; } return createPortal(, 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 ( ); } function TextInput({ value, onChange, type = 'text', placeholder, }: { value: string | number; onChange: (value: string) => void; type?: 'text' | 'number'; placeholder?: string; }) { return ( 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 (