Persist custom world asset configs in runtime snapshots

This commit is contained in:
2026-04-18 17:00:46 +08:00
parent 7ce61e9879
commit ac801fe05f
29 changed files with 3397 additions and 400 deletions

View File

@@ -11,6 +11,7 @@ import {
useState,
} from 'react';
import { ROLE_TEMPLATE_CHARACTERS } from '../data/characterPresets';
import { getCustomWorldSceneRelativePositionLabel } from '../data/customWorldSceneGraph';
import {
resolveCustomWorldCampSceneImage,
@@ -184,6 +185,14 @@ function EmptyState({ title }: { title: string }) {
);
}
function buildFallbackRenderKey(
value: string | null | undefined,
fallback: string,
) {
const normalizedValue = value?.trim();
return normalizedValue ? normalizedValue : fallback;
}
function NewBadge() {
return (
<span className="rounded-full border border-amber-300/24 bg-amber-500/12 px-2.5 py-1 text-[10px] font-semibold text-amber-100">
@@ -349,6 +358,43 @@ function compactTextList(values: Array<string | null | undefined>) {
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 (previewCharacter?.portrait?.trim()) {
return previewCharacter.portrait;
}
if (previewCharacter?.avatar?.trim()) {
return previewCharacter.avatar;
}
if (role.imageSrc?.trim()) {
return role.imageSrc;
}
const template = role.templateCharacterId
? ROLE_TEMPLATE_CHARACTERS.find(
(character) => character.id === role.templateCharacterId,
) ?? null
: null;
return template?.portrait ?? '';
}
function toText(value: unknown) {
return typeof value === 'string' ? value.trim() : '';
}
@@ -1165,166 +1211,95 @@ export function CustomWorldEntityCatalog({
progress={pendingGeneratedEntity.progress}
/>
) : null}
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3 text-sm text-zinc-300">
{readOnly
? '当前是草稿结果预览,可先浏览角色结构,再回到工作区继续精修。'
: '可扮演角色支持新增、删除与更换外观模板。'}
</div>
{filteredPlayable.length === 0 ? (
<EmptyState title="当前没有符合搜索条件的可扮演角色。" />
) : (
filteredPlayable.map((role) => {
filteredPlayable.map((role, index) => {
const previewCharacter =
previewCharacterById.get(role.id) ?? null;
const previewImageSrc = resolvePlayableRolePreviewImage(
role,
previewCharacter,
);
const description = buildPlayableRoleCardDescription(role);
return (
<div key={role.id}>
<Section
<div
key={buildFallbackRenderKey(
role.id,
`playable-role-${index}-${role.name.trim() || 'unnamed'}`,
)}
className="space-y-2"
>
<CatalogCard
title={role.name}
subtitle={role.title}
description={description || '暂无描述'}
badge={recentPlayableIdSet.has(role.id) ? <NewBadge /> : null}
actions={
readOnly ? (
<SmallButton
onClick={() =>
onEditTarget({
kind: 'playable',
mode: 'edit',
id: role.id,
})
}
tone="sky"
>
</SmallButton>
isSelectionMode={false}
isSelected={false}
layout="compact"
mediaClassName="h-[4.75rem] w-[4.75rem] sm:h-[5.25rem] sm:w-[5.25rem]"
onClick={() =>
onEditTarget({
kind: 'playable',
mode: 'edit',
id: role.id,
})
}
media={
previewCharacter ? (
<CharacterAnimator
state={AnimationState.RUN}
character={previewCharacter}
className="h-full w-full"
imageClassName="object-bottom"
/>
) : previewImageSrc ? (
<img
src={previewImageSrc}
alt={role.name}
className="h-full w-full object-cover object-top"
/>
) : (
<div className="flex items-center gap-2">
<SmallButton
onClick={() =>
onEditTarget({
kind: 'playable',
mode: 'edit',
id: role.id,
})
}
tone="sky"
>
</SmallButton>
<SmallButton
onClick={() => removePlayable(role.id, role.name)}
tone="rose"
>
</SmallButton>
<div className="flex h-full w-full items-center justify-center bg-black/30 px-3 text-center text-xs font-semibold tracking-[0.16em] text-zinc-400">
{role.name.slice(0, 4) || '角色'}
</div>
)
}
>
<div className="flex flex-col gap-3 sm:flex-row">
<div className="flex h-28 w-28 shrink-0 items-end justify-center overflow-hidden rounded-2xl border border-white/10 bg-black/35">
{previewCharacter ? (
<CharacterAnimator
state={AnimationState.RUN}
character={previewCharacter}
className="h-full w-full"
imageClassName="object-bottom"
/>
) : null}
/>
<div className="flex flex-wrap items-center gap-2 px-1">
{lockedCharacterNames.has(role.name.trim()) ? (
<span className="rounded-full border border-amber-300/18 bg-amber-500/10 px-2.5 py-1 text-[10px] text-amber-100">
</span>
) : null}
<span className="rounded-full border border-white/10 bg-black/20 px-2.5 py-1 text-[10px] text-zinc-300">
{role.initialAffinity}
</span>
{role.generatedVisualAssetId ? (
<span className="rounded-full border border-emerald-400/20 bg-emerald-500/10 px-2.5 py-1 text-[10px] text-emerald-100">
</span>
) : null}
{role.tags.slice(0, 2).map((tag) => (
<span
key={`${role.id}-${tag}`}
className="rounded-full border border-white/10 bg-black/20 px-2.5 py-1 text-[10px] text-zinc-300"
>
{tag}
</span>
))}
{!readOnly ? (
<div className="ml-auto">
<SmallButton
onClick={() => removePlayable(role.id, role.name)}
tone="rose"
>
</SmallButton>
</div>
<div className="min-w-0 flex-1">
{lockedCharacterNames.has(role.name.trim()) ? (
<div className="mb-2 inline-flex rounded-full border border-amber-300/18 bg-amber-500/10 px-2.5 py-1 text-[10px] text-amber-100">
</div>
) : null}
<div className="text-sm leading-6 text-zinc-300">
{role.description}
</div>
<div className="mt-2 text-xs leading-6 text-zinc-400">
{role.backstory}
</div>
<div className="mt-3 rounded-xl border border-sky-300/12 bg-sky-500/8 px-3 py-2 text-xs leading-6 text-sky-50/95">
{role.backstoryReveal.publicSummary || '未填写'}
</div>
<div className="mt-3 grid gap-2 sm:grid-cols-2">
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">
{role.role}
</div>
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">
{role.initialAffinity}
</div>
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">
{role.personality}
</div>
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">
{role.combatStyle}
</div>
</div>
<div className="mt-3 rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">
{role.motivation}
</div>
<div className="mt-3 rounded-xl border border-white/8 bg-black/20 px-3 py-3">
<div className="text-[11px] font-bold tracking-[0.16em] text-zinc-400">
</div>
<div className="mt-2 space-y-2">
{role.backstoryReveal.chapters.map((chapter) => (
<div
key={`${role.id}-${chapter.id}`}
className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300"
>
{chapter.affinityRequired} ·{' '}
{chapter.title}{chapter.teaser}
</div>
))}
</div>
</div>
<div className="mt-3 rounded-xl border border-white/8 bg-black/20 px-3 py-3">
<div className="text-[11px] font-bold tracking-[0.16em] text-zinc-400">
</div>
<div className="mt-2 space-y-2">
{role.skills.map((skill) => (
<div
key={`${role.id}-${skill.id}`}
className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300"
>
{skill.name} · {skill.style}{skill.summary}
</div>
))}
</div>
</div>
<div className="mt-3 rounded-xl border border-white/8 bg-black/20 px-3 py-3">
<div className="text-[11px] font-bold tracking-[0.16em] text-zinc-400">
</div>
<div className="mt-2 space-y-2">
{role.initialItems.map((item) => (
<div
key={`${role.id}-${item.id}`}
className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300"
>
{item.name} x{item.quantity} · {item.category} ·{' '}
{item.rarity}{item.description}
</div>
))}
</div>
</div>
<div className="mt-3 flex flex-wrap gap-2">
{role.tags.map((tag) => (
<span
key={`${role.id}-${tag}`}
className="rounded-full border border-white/10 bg-black/20 px-2.5 py-1 text-[10px] text-zinc-300"
>
{tag}
</span>
))}
</div>
</div>
</div>
</Section>
) : null}
</div>
</div>
);
})
@@ -1344,8 +1319,13 @@ export function CustomWorldEntityCatalog({
{filteredStory.length === 0 ? (
<EmptyState title="当前没有符合搜索条件的场景角色。" />
) : (
filteredStory.map((npc) => (
<div key={npc.id}>
filteredStory.map((npc, index) => (
<div
key={buildFallbackRenderKey(
npc.id,
`story-npc-${index}-${npc.name.trim() || 'unnamed'}`,
)}
>
<CatalogCard
title={npc.name}
description={npc.description}
@@ -1399,8 +1379,13 @@ export function CustomWorldEntityCatalog({
{filteredSceneEntries.length === 0 ? (
<EmptyState title="当前没有符合搜索条件的场景。" />
) : (
filteredSceneEntries.map((scene) => (
<div key={scene.id}>
filteredSceneEntries.map((scene, index) => (
<div
key={buildFallbackRenderKey(
scene.id,
`scene-entry-${index}-${scene.name.trim() || scene.kind}`,
)}
>
<CatalogCard
title={scene.name}
description={