import { type ReactNode, useDeferredValue, useEffect, useMemo, useRef, useState, } from 'react'; import { getCustomWorldSceneRelativePositionLabel } from '../data/customWorldSceneGraph'; import { resolveCustomWorldCampSceneImage, resolveCustomWorldLandmarkImageMap, } from '../data/customWorldVisuals'; import { resolveCustomWorldCampScene } from '../services/customWorldCamp'; import { buildCustomWorldCreatorIntentFoundationText, normalizeCustomWorldCreatorIntent, } from '../services/customWorldCreatorIntent'; import { AnimationState, Character, CustomWorldProfile } from '../types'; import { getNineSliceStyle, UI_CHROME } from '../uiAssets'; import { CharacterAnimator } from './CharacterAnimator'; import type { CustomWorldEditorTarget } from './CustomWorldEntityEditorModal'; import { CustomWorldNpcPortrait } from './CustomWorldNpcVisualEditor'; export type ResultTab = 'world' | 'playable' | 'story' | 'landmarks'; type PendingGeneratedEntity = { id: string; kind: 'playable' | 'story' | 'landmark'; title: string; progress: number; phaseLabel: string; }; type RecentGeneratedIds = Record<'playable' | 'story' | 'landmark', string[]>; interface CustomWorldEntityCatalogProps { profile: CustomWorldProfile; previewCharacters: Character[]; activeTab: ResultTab; onActiveTabChange: (tab: ResultTab) => void; onEditTarget: (target: CustomWorldEditorTarget) => void; onProfileChange: (profile: CustomWorldProfile) => void; onDeleteStoryNpcs?: (ids: string[]) => void; onDeleteLandmarks?: (ids: string[]) => void; createActionLabel?: string; onCreateAction?: () => void; createActionDisabled?: boolean; pendingGeneratedEntity?: PendingGeneratedEntity | null; recentGeneratedIds?: RecentGeneratedIds; readOnly?: boolean; } const RESULT_TABS: Array<{ id: ResultTab; label: string }> = [ { id: 'world', label: '世界' }, { id: 'playable', label: '可扮演角色' }, { id: 'story', label: '场景角色' }, { id: 'landmarks', label: '场景' }, ]; function Section({ title, subtitle, badge, actions, children, }: { title: string; subtitle?: string; badge?: ReactNode; actions?: ReactNode; children: ReactNode; }) { return (
{title}
{subtitle ? (
{subtitle}
) : null}
{badge} {actions}
{children}
); } function SmallButton({ onClick, children, tone = 'default', disabled = false, }: { onClick: () => void; children: ReactNode; tone?: 'default' | 'sky' | 'rose'; disabled?: boolean; }) { const toneClassName = tone === 'sky' ? 'border-sky-300/20 bg-sky-500/10 text-sky-100 hover:text-white' : tone === 'rose' ? 'border-rose-300/20 bg-rose-500/10 text-rose-100 hover:text-white' : 'border-white/10 bg-black/20 text-zinc-300 hover:text-white'; return ( ); } function SearchBox({ value, onChange, placeholder, }: { value: string; onChange: (value: string) => void; placeholder: string; }) { return (
onChange(event.target.value)} placeholder={placeholder} className="w-full bg-transparent text-sm text-zinc-100 outline-none placeholder:text-zinc-500" />
); } function ImageFrame({ src, alt, fallbackLabel, tone = 'square', }: { src?: string; alt: string; fallbackLabel: string; tone?: 'square' | 'landscape'; }) { return (
{src ? ( {alt} ) : (
{fallbackLabel}
)}
); } function EmptyState({ title }: { title: string }) { return (
{title}
); } function NewBadge() { return ( ); } function PendingEntityCard({ title, phaseLabel, progress, }: { title: string; phaseLabel: string; progress: number; }) { return (
{title}
{phaseLabel}
{Math.round(progress)}%
); } function CatalogCard({ title, description, media, badge, isSelectionMode, isSelected, onClick, layout = 'stacked', mediaClassName, disabled = false, }: { title: string; description: string; media: ReactNode; badge?: ReactNode; isSelectionMode: boolean; isSelected: boolean; onClick: () => void; layout?: 'stacked' | 'compact'; mediaClassName?: string; disabled?: boolean; }) { const selectionBadge = isSelectionMode ? (
{isSelected ? '已选' : '选择'}
) : null; if (layout === 'compact') { return ( ); } return ( ); } function matchText(text: string, query: string) { return text.toLowerCase().includes(query.toLowerCase()); } function getSearchPlaceholder(tab: ResultTab) { if (tab === 'playable') return '搜索角色名称、称号、标签'; if (tab === 'story') return '搜索场景角色名称、身份、动机'; if (tab === 'landmarks') return '搜索场景名称、描述、NPC、连接'; return '搜索'; } function compactTextList(values: Array) { return values.map((value) => value?.trim() ?? '').filter(Boolean); } function buildOpeningSceneSearchText( profile: CustomWorldProfile, campScene: ReturnType, ) { return [ campScene.name, campScene.description, campScene.dangerLevel, profile.playerGoal, profile.summary, '开局场景', '开局归处', ].join(' '); } function buildStructuredFoundationEntries(profile: CustomWorldProfile) { const creatorIntent = normalizeCustomWorldCreatorIntent(profile.creatorIntent); const relationshipSeed = creatorIntent?.keyCharacters[0]; const relationshipText = relationshipSeed ? compactTextList([ relationshipSeed.name, relationshipSeed.role, relationshipSeed.relationToPlayer ? `与玩家:${relationshipSeed.relationToPlayer}` : '', relationshipSeed.hiddenHook ? `暗线:${relationshipSeed.hiddenHook}` : '', ]).join(' · ') : ''; const themeToneText = compactTextList([ creatorIntent?.themeKeywords.join('、') || '', creatorIntent?.toneDirectives.join('、') || '', ]).join(' / '); const playerOpeningText = compactTextList([ creatorIntent?.playerPremise || '', creatorIntent?.openingSituation || '', ]).join(';'); return [ { id: 'world-hook', label: '世界一句话', value: creatorIntent?.worldHook || profile.anchorPack?.worldSummary || profile.summary, }, { id: 'player-opening', label: '玩家开局', value: playerOpeningText || profile.playerGoal, }, { id: 'theme-tone', label: '主题气质', value: themeToneText || profile.tone, }, { id: 'core-conflict', label: '核心冲突', value: creatorIntent?.coreConflicts.join(';') || profile.coreConflicts.join(';') || profile.summary, }, { id: 'relationship-seed', label: '关键关系', value: relationshipText || profile.playableNpcs[0]?.relationshipHooks.join(';') || profile.storyNpcs[0]?.relationshipHooks.join(';') || '待补充', }, { id: 'iconic-elements', label: '标志元素', value: creatorIntent?.iconicElements.join('、') || profile.anchorPack?.motifDirectives.join('、') || '待补充', }, ]; } type CatalogRole = | CustomWorldProfile['playableNpcs'][number] | CustomWorldProfile['storyNpcs'][number]; type BulkDeleteTab = 'story' | 'landmarks'; function buildRoleSearchText(role: CatalogRole) { return [ role.name, role.title, role.role, role.description, role.backstory, role.backstoryReveal.publicSummary, role.personality, role.motivation, role.combatStyle, ...role.backstoryReveal.chapters.flatMap((chapter) => [ chapter.title, chapter.teaser, chapter.content, chapter.contextSnippet, ]), ...role.skills.flatMap((skill) => [skill.name, skill.summary, skill.style]), ...role.initialItems.flatMap((item) => [ item.name, item.category, item.description, ...item.tags, ]), ...role.relationshipHooks, ...role.tags, ].join(' '); } function buildLandmarkSearchText( landmark: CustomWorldProfile['landmarks'][number], storyNpcById: Map, landmarkById: Map, ) { return [ landmark.name, landmark.description, landmark.dangerLevel, ...landmark.sceneNpcIds.map((npcId) => storyNpcById.get(npcId)?.name ?? ''), ...landmark.connections.flatMap((connection) => [ landmarkById.get(connection.targetLandmarkId)?.name ?? '', getCustomWorldSceneRelativePositionLabel(connection.relativePosition), connection.summary, ]), ].join(' '); } export function CustomWorldEntityCatalog({ profile, previewCharacters, activeTab, onActiveTabChange, onEditTarget, onProfileChange, onDeleteStoryNpcs, onDeleteLandmarks, createActionLabel, onCreateAction, createActionDisabled = false, pendingGeneratedEntity = null, recentGeneratedIds = { playable: [], story: [], landmark: [], }, readOnly = false, }: CustomWorldEntityCatalogProps) { const scrollContainerRef = useRef(null); const [searchDraft, setSearchDraft] = useState(''); const [bulkDeleteMode, setBulkDeleteMode] = useState( null, ); const [selectedBulkIds, setSelectedBulkIds] = useState([]); const deferredSearch = useDeferredValue(searchDraft.trim()); const storyNpcById = useMemo( () => new Map(profile.storyNpcs.map((npc) => [npc.id, npc])), [profile.storyNpcs], ); const landmarkById = useMemo( () => new Map(profile.landmarks.map((landmark) => [landmark.id, landmark])), [profile.landmarks], ); const landmarkImageById = useMemo( () => resolveCustomWorldLandmarkImageMap(profile), [profile], ); const resolvedCampScene = useMemo( () => resolveCustomWorldCampScene(profile), [profile], ); const resolvedCampImageSrc = useMemo( () => resolveCustomWorldCampSceneImage(profile), [profile], ); const previewCharacterById = useMemo( () => new Map( profile.playableNpcs.map((role, index) => [ role.id, previewCharacters[index] ?? null, ]), ), [previewCharacters, profile.playableNpcs], ); const recentPlayableIdSet = useMemo( () => new Set(recentGeneratedIds.playable), [recentGeneratedIds.playable], ); const recentStoryIdSet = useMemo( () => new Set(recentGeneratedIds.story), [recentGeneratedIds.story], ); const recentLandmarkIdSet = useMemo( () => new Set(recentGeneratedIds.landmark), [recentGeneratedIds.landmark], ); const filteredPlayable = useMemo( () => profile.playableNpcs.filter( (role) => !deferredSearch || matchText(buildRoleSearchText(role), deferredSearch), ), [deferredSearch, profile.playableNpcs], ); const filteredStory = useMemo( () => profile.storyNpcs.filter( (npc) => !deferredSearch || matchText(buildRoleSearchText(npc), deferredSearch), ), [deferredSearch, profile.storyNpcs], ); const filteredLandmarks = useMemo( () => profile.landmarks.filter( (landmark) => !deferredSearch || matchText( buildLandmarkSearchText(landmark, storyNpcById, landmarkById), deferredSearch, ), ), [deferredSearch, landmarkById, profile.landmarks, storyNpcById], ); const structuredFoundationEntries = useMemo( () => buildStructuredFoundationEntries(profile), [profile], ); const structuredFoundationSourceText = useMemo( () => buildCustomWorldCreatorIntentFoundationText(profile.creatorIntent).trim() || profile.settingText.trim(), [profile.creatorIntent, profile.settingText], ); const normalizedCreatorIntent = useMemo( () => normalizeCustomWorldCreatorIntent(profile.creatorIntent), [profile.creatorIntent], ); const filteredSceneEntries = useMemo(() => { const openingSceneEntry = { id: 'custom-world-opening-scene', kind: 'camp' as const, name: resolvedCampScene.name, description: resolvedCampScene.description, imageSrc: resolvedCampImageSrc, searchText: buildOpeningSceneSearchText(profile, resolvedCampScene), }; const landmarkEntries = filteredLandmarks.map((landmark) => ({ id: landmark.id, kind: 'landmark' as const, name: landmark.name, description: landmark.description, imageSrc: landmarkImageById.get(landmark.id) ?? landmark.imageSrc, searchText: buildLandmarkSearchText(landmark, storyNpcById, landmarkById), })); const recentEntries = landmarkEntries.filter((entry) => recentLandmarkIdSet.has(entry.id), ); const restEntries = landmarkEntries.filter( (entry) => !recentLandmarkIdSet.has(entry.id), ); const allEntries = [...recentEntries, openingSceneEntry, ...restEntries]; if (!deferredSearch) { return allEntries; } return allEntries.filter((entry) => matchText(entry.searchText, deferredSearch), ); }, [ deferredSearch, filteredLandmarks, landmarkById, landmarkImageById, profile, recentLandmarkIdSet, resolvedCampImageSrc, resolvedCampScene, storyNpcById, ]); const lockedCharacterNames = useMemo( () => new Set( normalizedCreatorIntent?.keyCharacters .filter((entry) => entry.locked) .map((entry) => entry.name.trim()) .filter(Boolean) ?? [], ), [normalizedCreatorIntent], ); const counts = { world: 1, playable: profile.playableNpcs.length + (pendingGeneratedEntity?.kind === 'playable' ? 1 : 0), story: profile.storyNpcs.length + (pendingGeneratedEntity?.kind === 'story' ? 1 : 0), landmarks: profile.landmarks.length + 1 + (pendingGeneratedEntity?.kind === 'landmark' ? 1 : 0), } satisfies Record; const bulkDeleteTab: BulkDeleteTab | null = activeTab === 'story' || activeTab === 'landmarks' ? activeTab : null; const isBulkDeleteMode = bulkDeleteTab !== null && bulkDeleteMode === bulkDeleteTab; useEffect(() => { if (bulkDeleteMode && bulkDeleteMode !== activeTab) { setBulkDeleteMode(null); setSelectedBulkIds([]); } }, [activeTab, bulkDeleteMode]); useEffect(() => { const container = scrollContainerRef.current; if (!container) return; if (typeof container.scrollTo === 'function') { container.scrollTo({ top: 0, behavior: 'auto' }); return; } container.scrollTop = 0; }, [activeTab]); const removePlayable = (id: string, name: string) => { if (profile.playableNpcs.length <= 1) { window.alert('至少保留一个可扮演角色,才能正常进入自定义世界。'); return; } if (!window.confirm(`确认删除可扮演角色「${name}」吗?`)) return; onProfileChange({ ...profile, playableNpcs: profile.playableNpcs.filter((role) => role.id !== id), }); }; const startBulkDelete = (tab: BulkDeleteTab) => { setBulkDeleteMode(tab); setSelectedBulkIds([]); }; const cancelBulkDelete = () => { setBulkDeleteMode(null); setSelectedBulkIds([]); }; const toggleBulkSelected = (id: string) => { setSelectedBulkIds((current) => current.includes(id) ? current.filter((entry) => entry !== id) : [...current, id], ); }; const confirmBulkDelete = () => { if (!bulkDeleteTab || selectedBulkIds.length === 0) { return; } const label = bulkDeleteTab === 'story' ? '场景角色' : '场景'; const confirmed = window.confirm( `确认批量删除 ${selectedBulkIds.length} 个${label}吗?`, ); if (!confirmed) { return; } if (bulkDeleteTab === 'story') { onDeleteStoryNpcs?.(selectedBulkIds); } else { onDeleteLandmarks?.(selectedBulkIds); } cancelBulkDelete(); }; return (
世界档案
{profile.name}
{profile.subtitle}
{RESULT_TABS.map((tab) => (
))}
{activeTab !== 'world' ? (
{isBulkDeleteMode ? ( <>
已选 {selectedBulkIds.length}
取消 删除选中 ) : ( <> {!readOnly && createActionLabel && onCreateAction ? ( {createActionLabel} ) : null} {!readOnly && bulkDeleteTab && ((bulkDeleteTab === 'story' && onDeleteStoryNpcs) || (bulkDeleteTab === 'landmarks' && onDeleteLandmarks)) ? ( startBulkDelete(bulkDeleteTab)} tone="rose" > 批量删除 ) : null} )}
) : null}
{activeTab === 'world' ? ( <>
{profile.playableNpcs.length}
可扮演角色
{profile.storyNpcs.length}
场景角色
{profile.landmarks.length + 1}
场景
onEditTarget({ kind: 'world' })} tone="sky" > 查看详情 ) : ( onEditTarget({ kind: 'world' })} tone="sky" > 编辑 ) } >

{profile.summary}

主线目标:{profile.playerGoal}
世界基调:{profile.tone}
onEditTarget({ kind: 'world' })} tone="sky" > 查看详情 ) : ( onEditTarget({ kind: 'world' })} tone="sky" > 编辑 ) } >
解析字段
{structuredFoundationEntries.map((entry) => (
{entry.label}
{entry.value || '待补充'}
))}
{structuredFoundationSourceText ? (
锚点原文
{structuredFoundationSourceText}
) : null}
) : null} {activeTab === 'playable' ? (
{pendingGeneratedEntity?.kind === 'playable' ? ( ) : null}
{readOnly ? '当前是草稿结果预览,可先浏览角色结构,再回到工作区继续精修。' : '可扮演角色支持新增、删除与更换外观模板。'}
{filteredPlayable.length === 0 ? ( ) : ( filteredPlayable.map((role) => { const previewCharacter = previewCharacterById.get(role.id) ?? null; return (
: null} actions={ readOnly ? ( onEditTarget({ kind: 'playable', mode: 'edit', id: role.id, }) } tone="sky" > 查看详情 ) : (
onEditTarget({ kind: 'playable', mode: 'edit', id: role.id, }) } tone="sky" > 编辑 removePlayable(role.id, role.name)} tone="rose" > 删除
) } >
{previewCharacter ? ( ) : null}
{lockedCharacterNames.has(role.name.trim()) ? (
创作者锁定角色
) : null}
{role.description}
{role.backstory}
公开背景: {role.backstoryReveal.publicSummary || '未填写'}
身份:{role.role}
初始好感:{role.initialAffinity}
性格:{role.personality}
战斗:{role.combatStyle}
动机:{role.motivation}
好感背景章节
{role.backstoryReveal.chapters.map((chapter) => (
{chapter.affinityRequired} 好感 ·{' '} {chapter.title}:{chapter.teaser}
))}
技能
{role.skills.map((skill) => (
{skill.name} · {skill.style}:{skill.summary}
))}
初始物品
{role.initialItems.map((item) => (
{item.name} x{item.quantity} · {item.category} ·{' '} {item.rarity}:{item.description}
))}
{role.tags.map((tag) => ( {tag} ))}
); }) )}
) : null} {activeTab === 'story' ? (
{pendingGeneratedEntity?.kind === 'story' ? ( ) : null} {filteredStory.length === 0 ? ( ) : ( filteredStory.map((npc) => (
: null} isSelectionMode={isBulkDeleteMode} isSelected={selectedBulkIds.includes(npc.id)} layout="compact" mediaClassName="h-[4.75rem] w-[4.75rem] sm:h-[5.25rem] sm:w-[5.25rem]" onClick={() => isBulkDeleteMode ? toggleBulkSelected(npc.id) : readOnly ? onEditTarget({ kind: 'story', mode: 'edit', id: npc.id, }) : onEditTarget({ kind: 'story', mode: 'edit', id: npc.id, }) } media={ } />
)) )}
) : null} {activeTab === 'landmarks' ? (
{pendingGeneratedEntity?.kind === 'landmark' ? ( ) : null} {filteredSceneEntries.length === 0 ? ( ) : ( filteredSceneEntries.map((scene) => (
) : null } isSelectionMode={scene.kind === 'landmark' && isBulkDeleteMode} isSelected={ scene.kind === 'landmark' && selectedBulkIds.includes(scene.id) } onClick={() => scene.kind === 'camp' ? onEditTarget({ kind: 'camp' }) : isBulkDeleteMode ? toggleBulkSelected(scene.id) : onEditTarget({ kind: 'landmark', mode: 'edit', id: scene.id, }) } media={ } disabled={scene.kind === 'camp' && isBulkDeleteMode} />
)) )}
) : null}
); }