@@ -20,7 +20,13 @@ import {
|
||||
import { resolveCustomWorldCampScene } from '../services/customWorldCamp';
|
||||
import { resolveCustomWorldCoverPresentation } from '../services/customWorldCover';
|
||||
import { normalizeCustomWorldCreatorIntent } from '../services/customWorldCreatorIntent';
|
||||
import { AnimationState, Character, CustomWorldProfile } from '../types';
|
||||
import {
|
||||
AnimationState,
|
||||
Character,
|
||||
CustomWorldProfile,
|
||||
type SceneActBlueprint,
|
||||
type SceneChapterBlueprint,
|
||||
} from '../types';
|
||||
import { CharacterAnimator } from './CharacterAnimator';
|
||||
import { CustomWorldCoverArtwork } from './CustomWorldCoverArtwork';
|
||||
import type { CustomWorldEditorTarget } from './CustomWorldEntityEditorModal';
|
||||
@@ -233,6 +239,104 @@ function PendingEntityCard({
|
||||
);
|
||||
}
|
||||
|
||||
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 CatalogCard({
|
||||
title,
|
||||
description,
|
||||
@@ -370,6 +474,10 @@ function resolvePlayableRolePreviewImage(
|
||||
role: CustomWorldProfile['playableNpcs'][number],
|
||||
previewCharacter: Character | null,
|
||||
) {
|
||||
if (role.imageSrc?.trim()) {
|
||||
return role.imageSrc;
|
||||
}
|
||||
|
||||
if (previewCharacter?.portrait?.trim()) {
|
||||
return previewCharacter.portrait;
|
||||
}
|
||||
@@ -378,10 +486,6 @@ function resolvePlayableRolePreviewImage(
|
||||
return previewCharacter.avatar;
|
||||
}
|
||||
|
||||
if (role.imageSrc?.trim()) {
|
||||
return role.imageSrc;
|
||||
}
|
||||
|
||||
const template = role.templateCharacterId
|
||||
? ROLE_TEMPLATE_CHARACTERS.find(
|
||||
(character) => character.id === role.templateCharacterId,
|
||||
@@ -796,6 +900,16 @@ export function CustomWorldEntityCatalog({
|
||||
() => 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],
|
||||
@@ -876,22 +990,53 @@ export function CustomWorldEntityCatalog({
|
||||
[profile.creatorIntent],
|
||||
);
|
||||
const filteredSceneEntries = useMemo(() => {
|
||||
const openingSceneChapters = resolveSceneEntrySceneChapters({
|
||||
sceneChapters: profile.sceneChapterBlueprints,
|
||||
sceneId: resolvedCampScene.id,
|
||||
sceneName: resolvedCampScene.name,
|
||||
});
|
||||
const openingSceneEntry = {
|
||||
id: 'custom-world-opening-scene',
|
||||
id: resolvedCampScene.id,
|
||||
kind: 'camp' as const,
|
||||
name: resolvedCampScene.name,
|
||||
description: resolvedCampScene.description,
|
||||
imageSrc: resolvedCampImageSrc,
|
||||
searchText: buildOpeningSceneSearchText(profile, resolvedCampScene),
|
||||
imageSrc: resolveSceneCardImage({
|
||||
sceneImageSrc: resolvedCampImageSrc,
|
||||
sceneChapters: openingSceneChapters,
|
||||
}),
|
||||
sceneChapters: openingSceneChapters,
|
||||
searchText: [
|
||||
buildOpeningSceneSearchText(profile, resolvedCampScene),
|
||||
buildSceneChapterSearchText(openingSceneChapters, roleById),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' '),
|
||||
};
|
||||
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 landmarkEntries = profile.landmarks.map((landmark) => {
|
||||
const sceneChapters = resolveSceneEntrySceneChapters({
|
||||
sceneChapters: profile.sceneChapterBlueprints,
|
||||
sceneId: landmark.id,
|
||||
sceneName: landmark.name,
|
||||
});
|
||||
|
||||
return {
|
||||
id: landmark.id,
|
||||
kind: 'landmark' as const,
|
||||
name: landmark.name,
|
||||
description: landmark.description,
|
||||
imageSrc: resolveSceneCardImage({
|
||||
sceneImageSrc: landmarkImageById.get(landmark.id) ?? landmark.imageSrc,
|
||||
sceneChapters,
|
||||
}),
|
||||
sceneChapters,
|
||||
searchText: [
|
||||
buildLandmarkSearchText(landmark, storyNpcById, landmarkById),
|
||||
buildSceneChapterSearchText(sceneChapters, roleById),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' '),
|
||||
};
|
||||
});
|
||||
const recentEntries = landmarkEntries.filter((entry) =>
|
||||
recentLandmarkIdSet.has(entry.id),
|
||||
);
|
||||
@@ -909,13 +1054,13 @@ export function CustomWorldEntityCatalog({
|
||||
);
|
||||
}, [
|
||||
deferredSearch,
|
||||
filteredLandmarks,
|
||||
landmarkById,
|
||||
landmarkImageById,
|
||||
profile,
|
||||
recentLandmarkIdSet,
|
||||
resolvedCampImageSrc,
|
||||
resolvedCampScene,
|
||||
roleById,
|
||||
storyNpcById,
|
||||
]);
|
||||
|
||||
@@ -1281,7 +1426,13 @@ export function CustomWorldEntityCatalog({
|
||||
})
|
||||
}
|
||||
media={
|
||||
previewCharacter ? (
|
||||
role.imageSrc?.trim() ? (
|
||||
<img
|
||||
src={role.imageSrc}
|
||||
alt={role.name}
|
||||
className="h-full w-full object-cover object-top"
|
||||
/>
|
||||
) : previewCharacter ? (
|
||||
<CharacterAnimator
|
||||
state={AnimationState.RUN}
|
||||
character={previewCharacter}
|
||||
@@ -1414,51 +1565,48 @@ export function CustomWorldEntityCatalog({
|
||||
<EmptyState title="当前没有符合搜索条件的场景。" />
|
||||
) : (
|
||||
filteredSceneEntries.map((scene, index) => (
|
||||
<div
|
||||
<CatalogCard
|
||||
key={buildFallbackRenderKey(
|
||||
scene.id,
|
||||
`scene-entry-${index}-${scene.name.trim() || scene.kind}`,
|
||||
)}
|
||||
>
|
||||
<CatalogCard
|
||||
title={scene.name}
|
||||
description={
|
||||
scene.kind === 'camp'
|
||||
? `开局场景 · ${scene.description}`
|
||||
: scene.description
|
||||
}
|
||||
badge={
|
||||
scene.kind === 'landmark' && recentLandmarkIdSet.has(scene.id) ? (
|
||||
<NewBadge />
|
||||
) : 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={
|
||||
<ImageFrame
|
||||
src={scene.imageSrc}
|
||||
alt={scene.name}
|
||||
fallbackLabel={scene.name.slice(0, 4) || '场景'}
|
||||
tone="landscape"
|
||||
/>
|
||||
}
|
||||
disabled={scene.kind === 'camp' && isBulkDeleteMode}
|
||||
/>
|
||||
</div>
|
||||
title={scene.name}
|
||||
description={
|
||||
scene.kind === 'camp'
|
||||
? `开局场景 · ${scene.description}`
|
||||
: scene.description
|
||||
}
|
||||
badge={
|
||||
scene.kind === 'landmark' && recentLandmarkIdSet.has(scene.id) ? (
|
||||
<NewBadge />
|
||||
) : 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={
|
||||
<ImageFrame
|
||||
src={scene.imageSrc}
|
||||
alt={scene.name}
|
||||
fallbackLabel={scene.name.slice(0, 4) || '场景'}
|
||||
tone="landscape"
|
||||
/>
|
||||
}
|
||||
disabled={scene.kind === 'camp' && isBulkDeleteMode}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user