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 { normalizeCustomWorldLandmarks, } from '../data/customWorldSceneGraph'; import { getAllCustomWorldSceneImages, resolveCustomWorldCampSceneImage, resolveCustomWorldLandmarkImage, } from '../data/customWorldVisuals'; import { EDITOR_ITEM_CATALOG_API_PATH } from '../editor/shared/editorApiClient'; import { fetchJson } from '../editor/shared/jsonClient'; import { type CustomWorldSceneImageResult, generateCustomWorldSceneImage, generateCustomWorldSceneNpc, } from '../services/aiService'; import { resolveCustomWorldCampScene } from '../services/customWorldCamp'; import { AnimationState, type Character, type CharacterAnimationConfig, CustomWorldLandmark, CustomWorldNpc, CustomWorldPlayableNpc, CustomWorldProfile, type CustomWorldRoleInitialItem, type CustomWorldRoleRelation, type CustomWorldRoleSkill, CustomWorldSceneConnection, type ItemRarity, } from '../types'; import { CHROME_ICONS, getNineSliceStyle, UI_CHROME } from '../uiAssets'; import { buildAnimationClipFromVideoSource } from './asset-studio/characterAssetWorkflowModel'; import { type CharacterAnimationGenerationPayload, generateCharacterAnimationDraft, publishCharacterAnimationAssets, } from './asset-studio/characterAssetWorkflowPersistence'; 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: 'camp' } | { 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: '普通' }, { value: 'uncommon', label: '优秀' }, { value: 'rare', label: '稀有' }, { value: 'epic', label: '史诗' }, { value: 'legendary', label: '传说' }, ]; const ITEM_RARITY_LABELS: Record = { common: '普通', uncommon: '优秀', rare: '稀有', epic: '史诗', legendary: '传说', }; const CARDINAL_CONNECTION_DIRECTIONS = [ 'north', 'east', 'south', 'west', ] as const; type CardinalConnectionDirection = (typeof CARDINAL_CONNECTION_DIRECTIONS)[number]; const CARDINAL_CONNECTION_LABELS: Record = { north: '北', east: '东', south: '南', west: '西', }; 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 normalizeConnectionDirection( value: CustomWorldSceneConnection['relativePosition'], ): CardinalConnectionDirection | null { switch (value) { case 'north': case 'forward': return 'north'; case 'east': case 'right': return 'east'; case 'south': case 'back': return 'south'; case 'west': case 'left': return 'west'; default: return null; } } function buildConnectionSummary( direction: CardinalConnectionDirection, targetName?: string, ) { if (!targetName) { return ''; } return `${CARDINAL_CONNECTION_LABELS[direction]}侧可前往${targetName}`; } function buildDirectionalConnections( connections: CustomWorldSceneConnection[], landmarks: Array>, ) { const directionMap = new Map(); connections.forEach((connection) => { const direction = normalizeConnectionDirection(connection.relativePosition); if (!direction || directionMap.has(direction)) { return; } const targetName = landmarks.find((entry) => entry.id === connection.targetLandmarkId)?.name ?? ''; directionMap.set(direction, { targetLandmarkId: connection.targetLandmarkId, relativePosition: direction, summary: connection.summary || buildConnectionSummary(direction, targetName), }); }); return CARDINAL_CONNECTION_DIRECTIONS.flatMap((direction) => { const connection = directionMap.get(direction); return connection ? [connection] : []; }); } 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 hashText(value: string) { let hash = 0; for (let index = 0; index < value.length; index += 1) { hash = (hash * 31 + value.charCodeAt(index)) >>> 0; } return hash; } function getItemRarityLabel(rarity: ItemRarity) { return ITEM_RARITY_LABELS[rarity] ?? '普通'; } function getItemRarityCardClass(rarity: ItemRarity) { switch (rarity) { case 'legendary': return 'border-amber-400/45 bg-amber-500/10'; case 'epic': return 'border-fuchsia-400/40 bg-fuchsia-500/10'; case 'rare': return 'border-sky-400/40 bg-sky-500/10'; case 'uncommon': return 'border-emerald-400/35 bg-emerald-500/10'; default: return 'border-white/10 bg-black/20'; } } function inferSkillActionTemplateId(skill: Pick) { const source = `${skill.name} ${skill.summary}`; if (/[跑冲突进闪跃追袭位移]/u.test(source)) { return 'run'; } if (/[受伤护体格挡防御硬扛]/u.test(source)) { return 'hurt'; } if (/[终结死亡献祭同归于尽]/u.test(source)) { return 'die'; } if (/[待机吟唱蓄力观察潜伏准备]/u.test(source)) { return 'idle'; } return 'attack_slash'; } function buildSkillActionPrompt(params: { role: Pick; skill: Pick; }) { const { role, skill } = params; return [ `${role.name},${role.title || role.role}。`, `技能名称:${skill.name}。`, skill.summary ? `技能表现:${skill.summary}。` : '', role.description ? `角色气质:${role.description}。` : '', role.personality ? `性格补充:${role.personality}。` : '', role.motivation ? `动作目标:${role.motivation}。` : '', '横版 RPG 角色技能动作,角色轮廓稳定,动作起手明确,过程连贯,收招干净,镜头稳定。', ] .filter(Boolean) .join(' '); } function createRoleRelationDraft(seedLabel: string, index: number): CustomWorldRoleRelation { return { id: createEntryId('relation', seedLabel, Date.now() + index), targetRoleId: '', summary: '', }; } function deriveRelationshipHooksFromRelations(relations: CustomWorldRoleRelation[]) { return relations .map((item) => item.summary.trim()) .filter(Boolean) .slice(0, 8); } function createRoleSkillDraft(seedLabel: string, index: number) { return { id: createEntryId('skill', seedLabel, Date.now() + index), name: `新技能${index + 1}`, summary: '', style: '起手压制', actionPromptText: '', }; } 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: [], iconSrc: undefined, }; } 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 (
{ if (event.target === event.currentTarget) { onClose(); } } } >
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 (
{ if (event.target === event.currentTarget) { onClose(); } } } >
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: ReactNode; children: ReactNode }) { const hasVisibleChildren = Children.toArray(children).some( (child) => !(typeof child === 'string' && child.trim().length === 0), ); if (!hasVisibleChildren) return null; return ( ); } function LabelWithInfo({ label, info, }: { label: string; info: string; }) { const [open, setOpen] = useState(false); return ( {label} {open ? ( {info} ) : null} ); } 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 (