import { type ReactNode, useDeferredValue, useEffect, useMemo, useRef, useState, } from 'react'; import { getCustomWorldSceneRelativePositionLabel } from '../data/customWorldSceneGraph'; import { normalizeCustomWorldCreatorIntent } from '../services/customWorldCreatorIntent'; import { buildCustomWorldFoundationEntries, parseFoundationTagText, } from '../services/customWorldFoundationEntries'; import { buildCustomWorldScenePresentations } from '../services/customWorldScenePresentation'; import { AnimationState, type Character, type CustomWorldProfile, type SceneActBlueprint, type SceneChapterBlueprint, } from '../types'; import { CharacterAnimator } from './CharacterAnimator'; import { CustomWorldNpcPortrait } from './CustomWorldNpcVisualEditor'; import { ResolvedAssetImage } from './ResolvedAssetImage'; import type { RpgCreationEditorTarget } from './rpg-creation-editor/RpgCreationEntityEditorModal'; 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: RpgCreationEditorTarget) => 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: 'landmarks', label: '场景' }, { id: 'playable', label: '可扮演角色' }, { id: 'story', 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: React.MouseEventHandler; children: ReactNode; tone?: 'default' | 'sky' | 'rose'; disabled?: boolean; actions?: ReactNode; }) { const toneClassName = tone === 'sky' ? 'platform-button platform-button--primary' : tone === 'rose' ? 'platform-button platform-button--danger' : 'platform-button platform-button--ghost'; 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-[var(--platform-text-strong)] outline-none placeholder:text-[var(--platform-text-soft)]" />
); } function ImageFrame({ src, alt, fallbackLabel, tone = 'square', }: { src?: string; alt: string; fallbackLabel: string; tone?: 'square' | 'landscape'; }) { return (
{src ? ( ) : (
{fallbackLabel}
)}
); } function EmptyState({ title }: { title: string }) { return (
{title}
); } function buildFallbackRenderKey( value: string | null | undefined, fallback: string, ) { const normalizedValue = value?.trim(); return normalizedValue ? normalizedValue : fallback; } function NewBadge() { return ( ); } function PendingEntityCard({ title, phaseLabel, progress, }: { title: string; phaseLabel: string; progress: number; }) { return (
{title}
{phaseLabel}
{Math.round(progress)}%
); } function buildSceneActParticipantText( act: SceneActBlueprint, roleById: Map< string, | CustomWorldProfile['playableNpcs'][number] | CustomWorldProfile['storyNpcs'][number] >, ) { const primaryRoleName = roleById.get(act.primaryNpcId)?.name?.trim() || ''; const supportRoleNames = act.encounterNpcIds .filter((roleId) => roleId !== act.primaryNpcId) .map((roleId) => roleById.get(roleId)?.name?.trim() || '') .filter(Boolean); return compactTextList([ primaryRoleName ? `主角色:${primaryRoleName}` : '', supportRoleNames.length > 0 ? `相遇角色:${supportRoleNames.join('、')}` : '', ]).join(';'); } function buildSceneChapterSearchText( sceneChapters: SceneChapterBlueprint[], roleById: Map< string, | CustomWorldProfile['playableNpcs'][number] | CustomWorldProfile['storyNpcs'][number] >, ) { return sceneChapters .flatMap((chapter) => [ chapter.title, chapter.summary, chapter.sceneTaskDescription, ...chapter.acts.flatMap((act) => [ act.title, act.summary, act.actGoal, act.transitionHook, buildSceneActParticipantText(act, roleById), ]), ]) .filter(Boolean) .join(' '); } function buildSceneTaskDescriptionText(sceneChapters: SceneChapterBlueprint[]) { return compactTextList( sceneChapters.map((chapter) => chapter.sceneTaskDescription), )[0] ?? ''; } function SceneActPreviewStrip({ acts, sceneName, }: { acts: Array<{ id: string; title: string; imageSrc: string }>; sceneName: string; }) { if (acts.length <= 0) return null; return (
{acts.map((act) => (
))}
); } function CatalogCard({ title, description, media, badge, isSelectionMode, isSelected, onClick, layout = 'stacked', mediaClassName, disabled = false, actions, }: { title: string; description: string; media: ReactNode; badge?: ReactNode; isSelectionMode: boolean; isSelected: boolean; onClick: () => void; layout?: 'stacked' | 'compact'; mediaClassName?: string; disabled?: boolean; actions?: ReactNode; }) { const selectionBadge = isSelectionMode ? (
{isSelected ? '已选' : '选择'}
) : null; if (layout === 'compact') { return (
{media}
{title}
{badge} {selectionBadge}
{description || '暂无描述'}
{actions ?
{actions}
: null}
); } return (
{media}
{title}
{badge} {selectionBadge}
{description || '暂无描述'}
{actions ?
{actions}
: null}
); } 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 buildPlayableRoleCardDescription( role: CustomWorldProfile['playableNpcs'][number], ) { const summary = role.description.trim() || role.backstoryReveal.publicSummary.trim() || role.backstory.trim() || role.motivation.trim(); return compactTextList([role.title || role.role, summary]).join(' / '); } function resolvePlayableRolePreviewImage( role: CustomWorldProfile['playableNpcs'][number], previewCharacter: Character | null, ) { if (role.imageSrc?.trim()) { return role.imageSrc; } if (previewCharacter?.portrait?.trim()) { return previewCharacter.portrait; } if (previewCharacter?.avatar?.trim()) { return previewCharacter.avatar; } return ''; } function buildOpeningSceneSearchText( profile: CustomWorldProfile, campScene: { name: string; description: string }, ) { return [ campScene.name, campScene.description, profile.playerGoal, profile.summary, '开局场景', '开局归处', ].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.sceneNpcIds.map((npcId) => storyNpcById.get(npcId)?.name ?? ''), ...landmark.connections.flatMap((connection) => [ landmarkById.get(connection.targetLandmarkId)?.name ?? '', getCustomWorldSceneRelativePositionLabel(connection.relativePosition), connection.summary, ]), ].join(' '); } function buildAttributeSlotSummary( slot: CustomWorldProfile['attributeSchema']['slots'][number], ) { return compactTextList([ slot.combatUseText, slot.socialUseText, slot.explorationUseText, ]).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 roleById = useMemo( () => new Map( [...profile.playableNpcs, ...profile.storyNpcs].map((role) => [ role.id, role, ]), ), [profile.playableNpcs, profile.storyNpcs], ); const landmarkById = useMemo( () => new Map(profile.landmarks.map((landmark) => [landmark.id, landmark])), [profile.landmarks], ); const scenePresentations = useMemo( () => buildCustomWorldScenePresentations(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 structuredFoundationEntries = useMemo( () => buildCustomWorldFoundationEntries(profile), [profile], ); const normalizedCreatorIntent = useMemo( () => normalizeCustomWorldCreatorIntent(profile.creatorIntent), [profile.creatorIntent], ); const attributeSlots = Array.isArray(profile.attributeSchema?.slots) ? profile.attributeSchema.slots : []; const filteredSceneEntries = useMemo(() => { const openingSceneEntry = { ...scenePresentations.camp, sceneTaskDescription: buildSceneTaskDescriptionText( scenePresentations.camp.sceneChapters, ), searchText: [ buildOpeningSceneSearchText(profile, scenePresentations.camp), buildSceneChapterSearchText( scenePresentations.camp.sceneChapters, roleById, ), ] .filter(Boolean) .join(' '), }; const landmarkEntries = scenePresentations.landmarks.map((scene) => { const landmark = profile.landmarks.find((entry) => entry.id === scene.id); return { ...scene, sceneTaskDescription: buildSceneTaskDescriptionText( scene.sceneChapters, ), searchText: [ landmark ? buildLandmarkSearchText(landmark, storyNpcById, landmarkById) : '', buildSceneChapterSearchText(scene.sceneChapters, roleById), ] .filter(Boolean) .join(' '), }; }); 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, landmarkById, profile, recentLandmarkIdSet, roleById, scenePresentations, 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}
场景
{attributeSlots.map((slot) => (
{slot.name}
{buildAttributeSlotSummary(slot) || slot.definition}
))}
onEditTarget({ kind: 'world' })} tone="sky" > 查看详情 ) : ( onEditTarget({ kind: 'world' })} tone="sky" > 编辑 ) } >

{profile.summary}

主线目标:{profile.playerGoal}
世界基调:{profile.tone}
onEditTarget({ kind: 'foundation' })} tone="sky" > 查看详情 ) : ( onEditTarget({ kind: 'foundation' })} tone="sky" > 编辑 ) } >
{structuredFoundationEntries.map((entry) => (
{entry.label}
{entry.value ? (
{parseFoundationTagText(entry.value).map((tag, index) => ( {tag} ))}
) : (
待补充
)}
))}
) : null} {activeTab === 'playable' ? (
{pendingGeneratedEntity?.kind === 'playable' ? ( ) : null} {filteredPlayable.length === 0 ? ( ) : ( filteredPlayable.map((role, index) => { const previewCharacter = previewCharacterById.get(role.id) ?? null; const previewImageSrc = resolvePlayableRolePreviewImage( role, previewCharacter, ); const description = buildPlayableRoleCardDescription(role); return (
: null} isSelectionMode={false} isSelected={false} layout="compact" mediaClassName="h-[4.75rem] w-[4.75rem] sm:h-[5.25rem] sm:w-[5.25rem] xl:h-[5.75rem] xl:w-[5.75rem]" onClick={() => onEditTarget({ kind: 'playable', mode: 'edit', id: role.id, }) } media={ role.imageSrc?.trim() ? ( ) : previewCharacter ? ( ) : previewImageSrc ? ( ) : (
{role.name.slice(0, 4) || '角色'}
) } />
{lockedCharacterNames.has(role.name.trim()) ? ( 创作者锁定 ) : null} 初始好感 {role.initialAffinity} {role.generatedVisualAssetId ? ( 已生成主图 ) : null} {role.tags.slice(0, 2).map((tag) => ( {tag} ))} {!readOnly ? (
removePlayable(role.id, role.name)} tone="rose" > 删除
) : null}
); }) )}
) : null} {activeTab === 'story' ? (
{pendingGeneratedEntity?.kind === 'story' ? ( ) : null} {filteredStory.length === 0 ? ( ) : ( filteredStory.map((npc, index) => (
: 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] xl:h-[5.75rem] xl:w-[5.75rem]" 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, index) => ( ) : 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={ } actions={ } disabled={scene.kind === 'camp' && isBulkDeleteMode} /> )) )}
) : null}
); }