1
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-20 21:06:48 +08:00
parent 1c72066bab
commit 75944b1f1f
102 changed files with 9648 additions and 1540 deletions

View File

@@ -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>