Simplify custom world result editing controls

This commit is contained in:
2026-04-08 19:07:46 +08:00
parent bd9fdcbe31
commit a02f7b6414
125 changed files with 8804 additions and 1462 deletions

View File

@@ -11,6 +11,8 @@ import {
} from '../data/customWorldSceneGraph';
import {
ActorNarrativeProfile,
AnimationState,
CharacterAnimationConfig,
CharacterBackstoryChapter,
CharacterBackstoryRevealConfig,
CustomWorldAnchorPack,
@@ -35,6 +37,7 @@ import {
normalizeCustomWorldCreatorIntent,
normalizeCustomWorldLockState,
} from './customWorldCreatorIntent';
import { normalizeCustomWorldOwnedSettingLayers } from './customWorldOwnedSettingLayers';
import {
buildFallbackActorNarrativeProfile,
normalizeActorNarrativeProfile,
@@ -73,6 +76,9 @@ const DEFAULT_CUSTOM_WORLD_ROLE_SKILL_COUNT = 3;
const DEFAULT_CUSTOM_WORLD_ROLE_INITIAL_ITEM_COUNT = 3;
const CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES =
AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS;
const CUSTOM_WORLD_ANIMATION_STATES = new Set<AnimationState>(
Object.values(AnimationState),
);
export const MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT = 5;
export const MIN_CUSTOM_WORLD_TOTAL_NPC_COUNT = 30;
@@ -135,6 +141,12 @@ function toText(value: unknown) {
return typeof value === 'string' ? value.trim() : '';
}
function toFiniteInteger(value: unknown) {
return typeof value === 'number' && Number.isFinite(value)
? Math.round(value)
: undefined;
}
function toRecordArray(value: unknown) {
return Array.isArray(value)
? (value.filter((item) => item && typeof item === 'object') as Array<
@@ -165,6 +177,59 @@ function normalizeInitialAffinity(value: unknown, fallback: number) {
: fallback;
}
function normalizeGeneratedAnimationConfig(
value: unknown,
): CharacterAnimationConfig | null {
if (!value || typeof value !== 'object') {
return null;
}
const item = value as Record<string, unknown>;
const folder = toText(item.folder);
const prefix = toText(item.prefix);
const frames = Math.max(1, toFiniteInteger(item.frames) ?? 0);
if (!folder || !prefix || frames <= 0) {
return null;
}
const startFrame = toFiniteInteger(item.startFrame);
const extension = toText(item.extension);
const file = toText(item.file);
const basePath = toText(item.basePath);
return {
folder,
prefix,
frames,
...(startFrame ? { startFrame: Math.max(1, startFrame) } : {}),
...(extension ? { extension } : {}),
...(file ? { file } : {}),
...(basePath ? { basePath } : {}),
};
}
function normalizeGeneratedAnimationMap(value: unknown) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return undefined;
}
const entries = Object.entries(value).flatMap(([key, rawConfig]) => {
if (!CUSTOM_WORLD_ANIMATION_STATES.has(key as AnimationState)) {
return [];
}
const config = normalizeGeneratedAnimationConfig(rawConfig);
return config ? [[key as AnimationState, config] as const] : [];
});
return entries.length > 0
? (Object.fromEntries(entries) as Partial<
Record<AnimationState, CharacterAnimationConfig>
>)
: undefined;
}
function normalizeWorldType(value: unknown, sourceText: string) {
const worldType = toText(value).toUpperCase();
if (worldType === WorldType.WUXIA || worldType === WorldType.XIANXIA) {
@@ -184,9 +249,7 @@ function normalizeRarity(
function normalizeRoleItemCategory(value: unknown, fallback = '材料') {
const category = toText(value);
if (
(
CUSTOM_WORLD_ROLE_ITEM_CATEGORIES as readonly string[]
).includes(category)
(CUSTOM_WORLD_ROLE_ITEM_CATEGORIES as readonly string[]).includes(category)
) {
return category === '专属物' ? '专属物品' : category;
}
@@ -289,7 +352,10 @@ function buildFallbackBackstoryReveal(
CUSTOM_WORLD_BACKSTORY_CHAPTER_TITLES[index] ??
`背景片段${index + 1}`,
affinityRequired,
teaser: truncateText(fallbackContents[index] ?? normalizedBackstory, 22),
teaser: truncateText(
fallbackContents[index] ?? normalizedBackstory,
22,
),
content: truncateText(
fallbackContents[index] ?? normalizedBackstory,
72,
@@ -335,7 +401,8 @@ function normalizeBackstoryReveal(
(rawChapter && toText(rawChapter.title)) ||
fallbackChapter?.title ||
`背景片段${index + 1}`,
affinityRequired: fallbackChapter?.affinityRequired ?? defaultAffinity,
affinityRequired:
fallbackChapter?.affinityRequired ?? defaultAffinity,
teaser:
(rawChapter && toText(rawChapter.teaser)) ||
fallbackChapter?.teaser ||
@@ -358,7 +425,8 @@ function buildFallbackRoleSkills(source: CustomWorldRoleFallbackSource) {
const skillNameSeed = source.title || source.role || source.name || '角色';
const skillSummarySeed =
source.combatStyle || source.description || `${source.name}善于把握局势。`;
const motivationSeed = source.motivation || source.personality || source.backstory;
const motivationSeed =
source.motivation || source.personality || source.backstory;
return [
{
@@ -447,7 +515,9 @@ function buildFallbackRoleInitialItems(source: CustomWorldRoleFallbackSource) {
quantity: 1,
rarity: 'rare',
description: truncateText(
source.backstory || source.motivation || `${source.name}不愿随意交出的信物。`,
source.backstory ||
source.motivation ||
`${source.name}不愿随意交出的信物。`,
36,
),
tags: normalizeTags(
@@ -540,7 +610,7 @@ function buildBaseCustomWorldProfile(settingText: string): CustomWorldProfile {
templateWorldType,
});
return {
const baseProfile = {
id: `custom-world-${Date.now().toString(36)}-${slugify(name)}`,
settingText: settingText.trim(),
name,
@@ -573,6 +643,14 @@ function buildBaseCustomWorldProfile(settingText: string): CustomWorldProfile {
lockState: normalizeCustomWorldLockState(null),
generationMode: 'full',
generationStatus: 'complete',
} satisfies CustomWorldProfile;
return {
...baseProfile,
ownedSettingLayers: normalizeCustomWorldOwnedSettingLayers(
null,
baseProfile,
),
};
}
@@ -715,7 +793,8 @@ function normalizeRoleProfile(
},
) {
const name = toText(item.name);
const title = toText(item.title) || toText(item.role) || options.titleFallback;
const title =
toText(item.title) || toText(item.role) || options.titleFallback;
const role = toText(item.role) || title;
const relationshipHooks = normalizeTags(
item.relationshipHooks,
@@ -741,9 +820,19 @@ function normalizeRoleProfile(
return {
...normalizedRole,
backstoryReveal: normalizeBackstoryReveal(item.backstoryReveal, normalizedRole),
backstoryReveal: normalizeBackstoryReveal(
item.backstoryReveal,
normalizedRole,
),
skills: normalizeRoleSkillList(item.skills, normalizedRole),
initialItems: normalizeRoleInitialItemList(item.initialItems, normalizedRole),
initialItems: normalizeRoleInitialItemList(
item.initialItems,
normalizedRole,
),
imageSrc: toText(item.imageSrc) || undefined,
generatedVisualAssetId: toText(item.generatedVisualAssetId) || undefined,
generatedAnimationSetId: toText(item.generatedAnimationSetId) || undefined,
animationMap: normalizeGeneratedAnimationMap(item.animationMap),
narrativeProfile:
item.narrativeProfile && typeof item.narrativeProfile === 'object'
? (item.narrativeProfile as ActorNarrativeProfile)
@@ -767,19 +856,20 @@ function normalizePlayableNpcList(value: unknown) {
function normalizeStoryNpcList(value: unknown) {
return toRecordArray(value)
.map((item, index) =>
({
...normalizeRoleProfile(item, index, {
idPrefix: 'story-npc',
titleFallback: '未定称号',
defaultAffinity: DEFAULT_STORY_NPC_INITIAL_AFFINITY,
}),
imageSrc: toText(item.imageSrc) || undefined,
visual:
item.visual && typeof item.visual === 'object'
? (item.visual as CustomWorldNpc['visual'])
: undefined,
}) satisfies CustomWorldNpc,
.map(
(item, index) =>
({
...normalizeRoleProfile(item, index, {
idPrefix: 'story-npc',
titleFallback: '未定称号',
defaultAffinity: DEFAULT_STORY_NPC_INITIAL_AFFINITY,
}),
imageSrc: toText(item.imageSrc) || undefined,
visual:
item.visual && typeof item.visual === 'object'
? (item.visual as CustomWorldNpc['visual'])
: undefined,
}) satisfies CustomWorldNpc,
)
.filter((entry) => entry.name);
}
@@ -812,7 +902,8 @@ function normalizeRoleOutlineList(
const normalized = toRecordArray(value)
.map((item) => {
const name = toText(item.name);
const title = toText(item.title) || toText(item.role) || options.titleFallback;
const title =
toText(item.title) || toText(item.role) || options.titleFallback;
const role = toText(item.role) || title;
const relationshipHooks = normalizeTags(
item.relationshipHooks,
@@ -846,12 +937,19 @@ function normalizeCampOutline(
value: unknown,
fallbackProfile: Pick<
CustomWorldProfile,
'name' | 'summary' | 'tone' | 'playerGoal' | 'settingText' | 'templateWorldType'
| 'name'
| 'summary'
| 'tone'
| 'playerGoal'
| 'settingText'
| 'templateWorldType'
>,
): CustomWorldGenerationCampOutline {
const fallback = buildFallbackCustomWorldCampScene(fallbackProfile);
const item =
value && typeof value === 'object' ? (value as Record<string, unknown>) : {};
value && typeof value === 'object'
? (value as Record<string, unknown>)
: {};
return {
name: toText(item.name) || fallback.name,
@@ -867,7 +965,8 @@ function normalizeLandmarkOutlineList(value: unknown) {
return {
name,
description:
toText(item.description) || truncateText(`${name}暗藏新的局势变化。`, 40),
toText(item.description) ||
truncateText(`${name}暗藏新的局势变化。`, 40),
dangerLevel: toText(item.dangerLevel) || 'medium',
sceneNpcNames: [
...toStringArray(item.sceneNpcNames),
@@ -956,12 +1055,19 @@ function normalizeCampScene(
value: unknown,
fallbackProfile: Pick<
CustomWorldProfile,
'name' | 'summary' | 'tone' | 'playerGoal' | 'settingText' | 'templateWorldType'
| 'name'
| 'summary'
| 'tone'
| 'playerGoal'
| 'settingText'
| 'templateWorldType'
>,
): CustomWorldCampScene {
const fallback = buildFallbackCustomWorldCampScene(fallbackProfile);
const item =
value && typeof value === 'object' ? (value as Record<string, unknown>) : {};
value && typeof value === 'object'
? (value as Record<string, unknown>)
: {};
return {
name: toText(item.name) || fallback.name,
@@ -1019,7 +1125,7 @@ export function normalizeCustomWorldProfile(
templateWorldType,
});
return {
const normalizedProfile = {
id:
toText(item.id) ||
`custom-world-${Date.now().toString(36)}-${slugify(name)}`,
@@ -1070,9 +1176,18 @@ export function normalizeCustomWorldProfile(
? item.generationMode
: fallback.generationMode,
generationStatus:
item.generationStatus === 'key_only' || item.generationStatus === 'complete'
item.generationStatus === 'key_only' ||
item.generationStatus === 'complete'
? item.generationStatus
: fallback.generationStatus,
} satisfies CustomWorldProfile;
return {
...normalizedProfile,
ownedSettingLayers: normalizeCustomWorldOwnedSettingLayers(
item.ownedSettingLayers,
normalizedProfile,
),
};
}
@@ -1148,7 +1263,7 @@ function buildRoleOutlinePromptLines(
.map((role) => {
const appearanceText =
options.roleType === 'story'
? appearanceLookup.get(role.name)?.join('、') ?? '未指定'
? (appearanceLookup.get(role.name)?.join('、') ?? '未指定')
: '';
return [
`- ${role.name} / ${role.title}`,
@@ -1636,9 +1751,10 @@ export function buildCustomWorldLandmarkNetworkBatchPrompt(params: {
storyNpcs: CustomWorldGenerationRoleOutline[];
}) {
const { framework, landmarkBatch, storyNpcs } = params;
const relativePositionValues = CUSTOM_WORLD_SCENE_RELATIVE_POSITION_OPTIONS.map(
(option) => option.value,
).join('|');
const relativePositionValues =
CUSTOM_WORLD_SCENE_RELATIVE_POSITION_OPTIONS.map(
(option) => option.value,
).join('|');
const allLandmarkNames = framework.landmarks.map((landmark) => landmark.name);
const storyNpcNames = storyNpcs.map((npc) => npc.name);
@@ -1779,8 +1895,8 @@ export function buildCustomWorldRoleBatchPrompt(params: {
'{',
` "${key}": [`,
' {',
' "name": "角色名称",',
' "backstoryReveal": {',
' "name": "角色名称",',
' "backstoryReveal": {',
' "publicSummary": "公开可见的背景摘要",',
' "chapters": [',
` { "id": "surface", "title": "表层来意", "affinityRequired": ${CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES[0]}, "teaser": "${CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES[0]}好感可见的提示", "content": "${CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES[0]}好感时解锁的背景内容", "contextSnippet": "可供后续剧情引用的摘要" },`,
@@ -1862,9 +1978,10 @@ export function buildCustomWorldRoleBatchJsonRepairPrompt(params: {
}
export function buildCustomWorldGenerationPrompt(settingText: string) {
const relativePositionValues = CUSTOM_WORLD_SCENE_RELATIVE_POSITION_OPTIONS.map(
(option) => option.value,
).join('|');
const relativePositionValues =
CUSTOM_WORLD_SCENE_RELATIVE_POSITION_OPTIONS.map(
(option) => option.value,
).join('|');
return [
'请根据下面的玩家设定创建一份自定义世界档案。',
'你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。',
@@ -2005,21 +2122,28 @@ export function buildCustomWorldReferenceText(
const landmarkById = new Map(
profile.landmarks.map((landmark) => [landmark.id, landmark]),
);
const themePack = profile.themePack ?? buildThemePackFromWorldProfile(profile);
const themePack =
profile.themePack ?? buildThemePackFromWorldProfile(profile);
const storyGraph =
profile.storyGraph ?? buildFallbackWorldStoryGraph(profile, themePack);
const activeThreadIds =
options.activeThreadIds?.filter(Boolean)?.length
? options.activeThreadIds.filter(Boolean)
: storyGraph.visibleThreads.slice(0, 3).map((thread) => thread.id);
const activeThreads = [...storyGraph.visibleThreads, ...storyGraph.hiddenThreads]
const activeThreadIds = options.activeThreadIds?.filter(Boolean)?.length
? options.activeThreadIds.filter(Boolean)
: storyGraph.visibleThreads.slice(0, 3).map((thread) => thread.id);
const activeThreads = [
...storyGraph.visibleThreads,
...storyGraph.hiddenThreads,
]
.filter((thread) => activeThreadIds.includes(thread.id))
.slice(0, 3);
const highlightNpcNames = new Set(
(options.highlightNpcNames ?? []).map((name) => name.trim()).filter(Boolean),
(options.highlightNpcNames ?? [])
.map((name) => name.trim())
.filter(Boolean),
);
const describeNpcReference = (
npc: CustomWorldProfile['storyNpcs'][number] | CustomWorldProfile['playableNpcs'][number],
npc:
| CustomWorldProfile['storyNpcs'][number]
| CustomWorldProfile['playableNpcs'][number],
) => {
const narrativeProfile = normalizeActorNarrativeProfile(
npc.narrativeProfile,
@@ -2028,9 +2152,11 @@ export function buildCustomWorldReferenceText(
return `- ${npc.name} / ${npc.title}:身份 ${npc.role};公开面:${narrativeProfile.publicMask};表层线:${narrativeProfile.visibleLine};当前压力:${narrativeProfile.immediatePressure};相关线程:${
narrativeProfile.relatedThreadIds
.map((threadId) =>
[...storyGraph.visibleThreads, ...storyGraph.hiddenThreads]
.find((thread) => thread.id === threadId)?.title ?? threadId,
.map(
(threadId) =>
[...storyGraph.visibleThreads, ...storyGraph.hiddenThreads].find(
(thread) => thread.id === threadId,
)?.title ?? threadId,
)
.join('、') || '暂无'
};反应钩子:${narrativeProfile.reactionHooks.join('、') || '暂无'}`;
@@ -2058,7 +2184,9 @@ export function buildCustomWorldReferenceText(
};连接:${
landmark.connections
.map((connection) => {
const targetLandmark = landmarkById.get(connection.targetLandmarkId);
const targetLandmark = landmarkById.get(
connection.targetLandmarkId,
);
if (!targetLandmark) {
return '';
}
@@ -2110,7 +2238,9 @@ export function validateGeneratedCustomWorldProfile(
}
const validStoryNpcIds = new Set(profile.storyNpcs.map((npc) => npc.id));
const validLandmarkIds = new Set(profile.landmarks.map((landmark) => landmark.id));
const validLandmarkIds = new Set(
profile.landmarks.map((landmark) => landmark.id),
);
profile.landmarks.forEach((landmark) => {
const uniqueSceneNpcIds = [...new Set(landmark.sceneNpcIds)];
@@ -2185,6 +2315,10 @@ export function buildCustomWorldSceneImagePrompt(
'name' | 'subtitle' | 'summary' | 'tone' | 'playerGoal' | 'settingText'
>,
landmark: Pick<CustomWorldLandmark, 'name' | 'description' | 'dangerLevel'>,
userPrompt = '',
options: {
hasReferenceImage?: boolean;
} = {},
) {
const worldName = clampSceneImageText(profile.name, 18) || '未命名世界';
const worldSubtitle = clampSceneImageText(profile.subtitle, 18);
@@ -2194,10 +2328,18 @@ export function buildCustomWorldSceneImagePrompt(
const worldSetting = clampSceneImageText(profile.settingText, 72);
const landmarkName = clampSceneImageText(landmark.name, 18) || '未命名场景';
const landmarkDescription = clampSceneImageText(landmark.description, 96);
const requestedVisual = clampSceneImageText(userPrompt, 120);
const dangerMood = describeDangerLevel(landmark.dangerLevel);
return [
'横版幻想 RPG 场景背景概念图,适合作为 2D 游戏战斗与探索背景,环境主体清晰,空间层次明确,电影感光影,细节丰富。',
'横版 16:9 2D RPG 生成高完成度像素风场景背景,适合作为剧情探索与战斗底图。',
'画面构图必须严格按上下 1:1 分区:上半部分严格控制在整张图的 1/2 高度内,只描绘场景远景与中远景轮廓,不要让背景内容向下侵占超过半屏。',
'下半部分严格占据整张图的 1/2 高度,用于玩家角色站位与展示,必须是模拟 3D 游戏视角的地面近景,有明确的透视延伸和近大远小关系,不是平铺的 2D 侧视地面。',
'下半部分的内容必须是明确可站立的地面本体,例如道路、石板、平台、广场、甲板、沙地或草地,要有连续、稳定、可落脚的站位逻辑,不能只是装饰性前景、坑洞、障碍堆、栏杆带或不可通行的景物。',
'下半部分地面近景要保持相对简洁、低细节、轮廓清楚、便于角色站立,不要堆满道具、植被、碎石、栏杆或复杂装饰。',
options.hasReferenceImage
? '已提供一张自定义参考图,可适度参考其构图、镜头或氛围,但仍以本次场景需求为准,不要生硬照搬。'
: '',
`世界:${worldName}${worldSubtitle ? `${worldSubtitle}` : ''}`,
worldSetting ? `玩家设定:${worldSetting}` : '',
worldSummary ? `世界概述:${worldSummary}` : '',
@@ -2205,8 +2347,9 @@ export function buildCustomWorldSceneImagePrompt(
worldGoal ? `玩家目标关联:${worldGoal}` : '',
`场景名称:${landmarkName}`,
landmarkDescription ? `场景描述:${landmarkDescription}` : '',
requestedVisual ? `本次想要生成的画面内容:${requestedVisual}` : '',
`${dangerMood}`,
'不要出现 UI、字幕、文字、水印logo人物仅可作为很小的远景剪影画面重点放在建筑、地貌、光线与氛围。',
'不要出现 UI、字幕、文字、水印logo 或装饰边框,人物仅可作为很小的远景剪影,画面重点放在场景本身,不要遮挡下半部分的角色展示区域。',
]
.filter(Boolean)
.join('');