import {
type ReactNode,
useDeferredValue,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import type {
EightAnchorContent,
KeyRelationshipValue,
} from '../../packages/shared/src/contracts/customWorldAgent';
import { getCustomWorldSceneRelativePositionLabel } from '../data/customWorldSceneGraph';
import {
resolveCustomWorldCampSceneImage,
resolveCustomWorldLandmarkImageMap,
} from '../data/customWorldVisuals';
import { resolveCustomWorldCampScene } from '../services/customWorldCamp';
import { normalizeCustomWorldCreatorIntent } from '../services/customWorldCreatorIntent';
import {
AnimationState,
Character,
CustomWorldProfile,
type SceneActBlueprint,
type SceneChapterBlueprint,
} from '../types';
import { CharacterAnimator } from './CharacterAnimator';
import type { RpgCreationEditorTarget } from './rpg-creation-editor/RpgCreationEntityEditorModal';
import { CustomWorldNpcPortrait } from './CustomWorldNpcVisualEditor';
import { ResolvedAssetImage } from './ResolvedAssetImage';
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: '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: 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 (
);
}
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 (
);
}
function resolveSceneEntrySceneChapters(params: {
sceneChapters: CustomWorldProfile['sceneChapterBlueprints'];
sceneId: string;
sceneName: string;
}) {
const sceneChapters = params.sceneChapters ?? [];
const normalizedSceneId = params.sceneId.trim();
const normalizedSceneName = params.sceneName.trim();
const directMatches = sceneChapters.filter(
(chapter) => chapter.sceneId.trim() === normalizedSceneId,
);
if (directMatches.length > 0) {
return directMatches;
}
const linkedMatches = sceneChapters.filter((chapter) =>
chapter.linkedLandmarkIds.some(
(landmarkId) => landmarkId.trim() === normalizedSceneId,
),
);
if (linkedMatches.length > 0) {
return linkedMatches;
}
return sceneChapters.filter((chapter) => {
const chapterTitle = chapter.title.trim();
return (
chapterTitle === normalizedSceneName ||
chapter.summary.includes(normalizedSceneName) ||
chapter.acts.some(
(act) =>
act.title.includes(normalizedSceneName) ||
act.summary.includes(normalizedSceneName),
)
);
});
}
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.acts.flatMap((act) => [
act.title,
act.summary,
act.actGoal,
act.transitionHook,
buildSceneActParticipantText(act, roleById),
]),
])
.filter(Boolean)
.join(' ');
}
function resolveSceneCardImage(params: {
sceneImageSrc?: string | null;
sceneChapters: SceneChapterBlueprint[];
}) {
const firstActImageSrc =
params.sceneChapters
.flatMap((chapter) => chapter.acts)
.map((act) => act.backgroundImageSrc?.trim() || '')
.find(Boolean) || '';
return firstActImageSrc || params.sceneImageSrc?.trim() || '';
}
function collectSceneActImagePreviews(sceneChapters: SceneChapterBlueprint[]) {
return sceneChapters.flatMap((chapter) =>
chapter.acts
.map((act, index) => ({
id: act.id.trim() || `${chapter.id}-act-${index}`,
title: act.title.trim() || `第${index + 1}幕`,
imageSrc: act.backgroundImageSrc?.trim() || '',
}))
.filter((act) => act.imageSrc),
);
}
function buildFallbackSceneActImagePreviews(params: {
sceneChapters: SceneChapterBlueprint[];
sceneImageSrc?: string | null;
}) {
const actPreviews = collectSceneActImagePreviews(params.sceneChapters);
const sceneImageSrc = params.sceneImageSrc?.trim() || '';
if (actPreviews.length > 0 || !sceneImageSrc) {
return actPreviews;
}
// 中文注释:旧草稿可能只把开局场景图写在 camp.imageSrc,尚未回填到每一幕;目录侧先用场景图兜底,避免开局场景看起来没有幕图片。
return [1, 2, 3].map((actNumber) => ({
id: `fallback-scene-act-${actNumber}`,
title: `第${actNumber}幕`,
imageSrc: sceneImageSrc,
}));
}
function SceneActPreviewStrip({
acts,
sceneName,
}: {
acts: Array<{ id: string; title: string; imageSrc: string }>;
sceneName: string;
}) {
if (acts.length <= 0) return null;
return (
);
}
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 toText(value: unknown) {
return typeof value === 'string' ? value.trim() : '';
}
function toTextArray(value: unknown) {
return Array.isArray(value)
? value.map((item) => toText(item)).filter(Boolean)
: [];
}
function toRecord(value: unknown) {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record)
: null;
}
function buildRelationshipSeedText(value: unknown) {
const record = toRecord(value);
if (!record) {
return '';
}
return compactTextList([
toText(record.name),
toText(record.role),
toText(record.relationToPlayer)
? `与玩家:${toText(record.relationToPlayer)}`
: '',
toText(record.hiddenHook) ? `代价/暗线:${toText(record.hiddenHook)}` : '',
]).join(';');
}
function buildKeyRelationshipText(value: KeyRelationshipValue) {
return compactTextList([
value.pairs,
value.relationshipType,
value.secretOrCost ? `代价/秘密:${value.secretOrCost}` : '',
]).join(';');
}
function buildAnchorContentFromProfileFallback(
profile: CustomWorldProfile,
): EightAnchorContent {
const creatorIntent = normalizeCustomWorldCreatorIntent(profile.creatorIntent);
const relationshipSeed = creatorIntent?.keyCharacters[0] ?? null;
return {
worldPromise: {
hook:
creatorIntent?.worldHook ||
profile.anchorPack?.worldSummary ||
profile.summary,
differentiator: profile.subtitle || profile.settingText,
desiredExperience:
compactTextList([
creatorIntent?.toneDirectives.join('、') || '',
profile.tone,
]).join(';') || profile.tone,
},
playerFantasy: {
playerRole: creatorIntent?.playerPremise || profile.playerGoal,
corePursuit: profile.playerGoal,
fearOfLoss:
relationshipSeed?.hiddenHook ||
creatorIntent?.coreConflicts[0] ||
profile.coreConflicts[0] ||
'',
},
themeBoundary: {
toneKeywords: compactTextList([
creatorIntent?.themeKeywords.join('、') || '',
creatorIntent?.toneDirectives.join('、') || '',
]),
aestheticDirectives: compactTextList([profile.tone, profile.subtitle]),
forbiddenDirectives: creatorIntent?.forbiddenDirectives ?? [],
},
playerEntryPoint: {
openingIdentity: creatorIntent?.playerPremise || '',
openingProblem:
creatorIntent?.openingSituation || profile.coreConflicts[0] || '',
entryMotivation: profile.playerGoal,
},
coreConflict: {
surfaceConflicts:
creatorIntent?.coreConflicts.length
? creatorIntent.coreConflicts
: profile.coreConflicts,
hiddenCrisis:
relationshipSeed?.hiddenHook ||
profile.summary ||
profile.settingText,
firstTouchedConflict:
creatorIntent?.openingSituation ||
profile.coreConflicts[0] ||
profile.playerGoal,
},
keyRelationships: relationshipSeed
? [
{
pairs: compactTextList([
relationshipSeed.name,
relationshipSeed.role,
]).join(' · '),
relationshipType: relationshipSeed.relationToPlayer || '',
secretOrCost: relationshipSeed.hiddenHook || '',
},
]
: [],
hiddenLines: {
hiddenTruths: compactTextList([
relationshipSeed?.hiddenHook || '',
profile.summary,
]),
misdirectionHints: compactTextList([
profile.subtitle,
profile.majorFactions[0] || '',
]),
revealPacing:
creatorIntent?.openingSituation ||
profile.coreConflicts[0] ||
profile.playerGoal,
},
iconicElements: {
iconicMotifs:
creatorIntent?.iconicElements.length
? creatorIntent.iconicElements
: compactTextList([
profile.anchorPack?.motifDirectives.join('、') || '',
profile.landmarks[0]?.name || '',
]),
institutionsOrArtifacts: compactTextList([
profile.camp?.name || '',
profile.majorFactions[0] || '',
]),
hardRules: compactTextList([profile.playerGoal, profile.coreConflicts[0] || '']),
},
};
}
function getProfileAnchorContent(profile: CustomWorldProfile) {
const anchorContentRecord = profile.anchorContent;
if (!anchorContentRecord) {
return buildAnchorContentFromProfileFallback(profile);
}
const worldPromiseRecord = toRecord(anchorContentRecord.worldPromise);
const playerFantasyRecord = toRecord(anchorContentRecord.playerFantasy);
const themeBoundaryRecord = toRecord(anchorContentRecord.themeBoundary);
const playerEntryPointRecord = toRecord(anchorContentRecord.playerEntryPoint);
const coreConflictRecord = toRecord(anchorContentRecord.coreConflict);
const hiddenLinesRecord = toRecord(anchorContentRecord.hiddenLines);
const iconicElementsRecord = toRecord(anchorContentRecord.iconicElements);
return {
worldPromise: worldPromiseRecord
? {
hook: toText(worldPromiseRecord.hook),
differentiator: toText(worldPromiseRecord.differentiator),
desiredExperience: toText(worldPromiseRecord.desiredExperience),
}
: null,
playerFantasy: playerFantasyRecord
? {
playerRole: toText(playerFantasyRecord.playerRole),
corePursuit: toText(playerFantasyRecord.corePursuit),
fearOfLoss: toText(playerFantasyRecord.fearOfLoss),
}
: null,
themeBoundary: themeBoundaryRecord
? {
toneKeywords: toTextArray(themeBoundaryRecord.toneKeywords),
aestheticDirectives: toTextArray(
themeBoundaryRecord.aestheticDirectives,
),
forbiddenDirectives: toTextArray(themeBoundaryRecord.forbiddenDirectives),
}
: null,
playerEntryPoint: playerEntryPointRecord
? {
openingIdentity: toText(playerEntryPointRecord.openingIdentity),
openingProblem: toText(playerEntryPointRecord.openingProblem),
entryMotivation: toText(playerEntryPointRecord.entryMotivation),
}
: null,
coreConflict: coreConflictRecord
? {
surfaceConflicts: toTextArray(coreConflictRecord.surfaceConflicts),
hiddenCrisis: toText(coreConflictRecord.hiddenCrisis),
firstTouchedConflict: toText(coreConflictRecord.firstTouchedConflict),
}
: null,
keyRelationships: Array.isArray(anchorContentRecord.keyRelationships)
? anchorContentRecord.keyRelationships
.map((entry) => toRecord(entry))
.filter(Boolean)
.map((entry) => ({
pairs: toText(entry?.pairs),
relationshipType: toText(entry?.relationshipType),
secretOrCost: toText(entry?.secretOrCost),
}))
: [],
hiddenLines: hiddenLinesRecord
? {
hiddenTruths: toTextArray(hiddenLinesRecord.hiddenTruths),
misdirectionHints: toTextArray(hiddenLinesRecord.misdirectionHints),
revealPacing: toText(hiddenLinesRecord.revealPacing),
}
: null,
iconicElements: iconicElementsRecord
? {
iconicMotifs: toTextArray(iconicElementsRecord.iconicMotifs),
institutionsOrArtifacts: toTextArray(
iconicElementsRecord.institutionsOrArtifacts,
),
hardRules: toTextArray(iconicElementsRecord.hardRules),
}
: null,
} satisfies EightAnchorContent;
}
function buildOpeningSceneSearchText(
profile: CustomWorldProfile,
campScene: ReturnType,
) {
return [
campScene.name,
campScene.description,
profile.playerGoal,
profile.summary,
'开局场景',
'开局归处',
].join(' ');
}
function buildStructuredFoundationEntries(profile: CustomWorldProfile) {
const creatorIntent = normalizeCustomWorldCreatorIntent(profile.creatorIntent);
const anchorContent = getProfileAnchorContent(profile);
const fallbackRelationshipText =
buildRelationshipSeedText(creatorIntent?.keyCharacters[0]) ||
profile.playableNpcs[0]?.relationshipHooks.join(';') ||
profile.storyNpcs[0]?.relationshipHooks.join(';') ||
'';
return [
{
id: 'world-promise',
label: '世界承诺',
value: compactTextList([
anchorContent.worldPromise?.hook || '',
anchorContent.worldPromise?.differentiator || '',
anchorContent.worldPromise?.desiredExperience || '',
]).join(';'),
},
{
id: 'player-fantasy',
label: '玩家幻想',
value: compactTextList([
anchorContent.playerFantasy?.playerRole || '',
anchorContent.playerFantasy?.corePursuit || '',
anchorContent.playerFantasy?.fearOfLoss || '',
]).join(';'),
},
{
id: 'theme-boundary',
label: '主题边界',
value: compactTextList([
anchorContent.themeBoundary?.toneKeywords.join('、') || '',
anchorContent.themeBoundary?.aestheticDirectives.join('、') || '',
anchorContent.themeBoundary?.forbiddenDirectives.length
? `避免:${anchorContent.themeBoundary.forbiddenDirectives.join('、')}`
: '',
]).join(';'),
},
{
id: 'player-entry-point',
label: '玩家切入口',
value: compactTextList([
anchorContent.playerEntryPoint?.openingIdentity || '',
anchorContent.playerEntryPoint?.openingProblem || '',
anchorContent.playerEntryPoint?.entryMotivation || '',
]).join(';'),
},
{
id: 'core-conflict',
label: '核心冲突',
value: compactTextList([
anchorContent.coreConflict?.surfaceConflicts.join('、') || '',
anchorContent.coreConflict?.hiddenCrisis || '',
anchorContent.coreConflict?.firstTouchedConflict || '',
]).join(';'),
},
{
id: 'key-relationships',
label: '关键关系',
value:
anchorContent.keyRelationships.map(buildKeyRelationshipText).join('\n') ||
fallbackRelationshipText,
},
{
id: 'hidden-lines',
label: '暗线与揭示',
value: compactTextList([
anchorContent.hiddenLines?.hiddenTruths.join('、') || '',
anchorContent.hiddenLines?.misdirectionHints.join('、') || '',
anchorContent.hiddenLines?.revealPacing || '',
]).join(';'),
},
{
id: 'iconic-elements',
label: '标志元素',
value: compactTextList([
anchorContent.iconicElements?.iconicMotifs.join('、') || '',
anchorContent.iconicElements?.institutionsOrArtifacts.join('、') || '',
anchorContent.iconicElements?.hardRules.join('、') || '',
]).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(' ');
}
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 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 normalizedCreatorIntent = useMemo(
() => normalizeCustomWorldCreatorIntent(profile.creatorIntent),
[profile.creatorIntent],
);
const filteredSceneEntries = useMemo(() => {
const openingSceneChapters = resolveSceneEntrySceneChapters({
sceneChapters: profile.sceneChapterBlueprints,
sceneId: resolvedCampScene.id,
sceneName: resolvedCampScene.name,
});
const openingSceneImageSrc = resolveSceneCardImage({
sceneImageSrc: resolvedCampImageSrc,
sceneChapters: openingSceneChapters,
});
const openingSceneEntry = {
id: resolvedCampScene.id,
kind: 'camp' as const,
name: resolvedCampScene.name,
description: resolvedCampScene.description,
imageSrc: openingSceneImageSrc,
sceneChapters: openingSceneChapters,
actPreviews: buildFallbackSceneActImagePreviews({
sceneChapters: openingSceneChapters,
sceneImageSrc: openingSceneImageSrc,
}),
searchText: [
buildOpeningSceneSearchText(profile, resolvedCampScene),
buildSceneChapterSearchText(openingSceneChapters, roleById),
]
.filter(Boolean)
.join(' '),
};
const landmarkEntries = profile.landmarks.map((landmark) => {
const sceneChapters = resolveSceneEntrySceneChapters({
sceneChapters: profile.sceneChapterBlueprints,
sceneId: landmark.id,
sceneName: landmark.name,
});
const sceneImageSrc = resolveSceneCardImage({
sceneImageSrc: landmarkImageById.get(landmark.id) ?? landmark.imageSrc,
sceneChapters,
});
return {
id: landmark.id,
kind: 'landmark' as const,
name: landmark.name,
description: landmark.description,
imageSrc: sceneImageSrc,
sceneChapters,
actPreviews: buildFallbackSceneActImagePreviews({
sceneChapters,
sceneImageSrc,
}),
searchText: [
buildLandmarkSearchText(landmark, storyNpcById, landmarkById),
buildSceneChapterSearchText(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,
landmarkImageById,
profile,
recentLandmarkIdSet,
resolvedCampImageSrc,
resolvedCampScene,
roleById,
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 || '待补充'}
))}
) : 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}
);
}