Split custom world generation into staged lightweight batches
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-05 22:20:30 +08:00
parent 89cecda7da
commit fcd8d727b0
57 changed files with 7646 additions and 1425 deletions

View File

@@ -1,9 +1,17 @@
import { Children, type ReactNode, useEffect, useMemo, useState } from 'react';
import {
AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS,
} from '../data/affinityLevels';
import {
buildCustomWorldPlayableCharacters,
PRESET_CHARACTERS,
} from '../data/characterPresets';
import {
CUSTOM_WORLD_SCENE_RELATIVE_POSITION_OPTIONS,
getCustomWorldSceneRelativePositionLabel,
normalizeCustomWorldLandmarks,
} from '../data/customWorldSceneGraph';
import {
getAllCustomWorldSceneImages,
getDefaultCustomWorldSceneImage,
@@ -22,6 +30,7 @@ import {
CustomWorldNpc,
CustomWorldPlayableNpc,
CustomWorldProfile,
CustomWorldSceneConnection,
} from '../types';
import { CHROME_ICONS, getNineSliceStyle, UI_CHROME } from '../uiAssets';
import { CharacterAnimator } from './CharacterAnimator';
@@ -48,6 +57,13 @@ interface CustomWorldEntityEditorModalProps {
onProfileChange: (profile: CustomWorldProfile) => void;
}
const [
BACKSTORY_UNLOCK_AFFINITY_EASED,
BACKSTORY_UNLOCK_AFFINITY_FRIENDLY,
BACKSTORY_UNLOCK_AFFINITY_TRUSTED,
BACKSTORY_UNLOCK_AFFINITY_CLOSE,
] = AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS;
function slugify(value: string) {
const normalized = value
.trim()
@@ -85,6 +101,16 @@ function clampInitialAffinity(value: string, fallback: number) {
return Math.max(-40, Math.min(90, Math.round(parsed)));
}
function syncLandmarksWithStoryNpcs(
landmarks: CustomWorldLandmark[],
storyNpcs: CustomWorldProfile['storyNpcs'],
) {
return normalizeCustomWorldLandmarks({
landmarks,
storyNpcs,
});
}
function useDraft<T>(value: T) {
const [draft, setDraft] = useState(value);
useEffect(() => setDraft(value), [value]);
@@ -1208,24 +1234,97 @@ function LandmarkEditor({
profile,
landmark,
mode,
onSave,
onSaveProfile,
onClose,
}: {
profile: CustomWorldProfile;
landmark: CustomWorldLandmark;
mode: 'create' | 'edit';
onSave: (landmark: CustomWorldLandmark) => void;
onSaveProfile: (profile: CustomWorldProfile) => void;
onClose: () => void;
}) {
const [draft, setDraft] = useDraft(landmark);
const [draftStoryNpcs, setDraftStoryNpcs] = useDraft(profile.storyNpcs);
const [isPresetPickerOpen, setIsPresetPickerOpen] = useState(false);
const [isAiGenerateOpen, setIsAiGenerateOpen] = useState(false);
const [npcEditorState, setNpcEditorState] = useState<{
mode: 'create' | 'edit';
npc: CustomWorldNpc;
} | null>(null);
const presetImages = useMemo(() => getAllCustomWorldSceneImages(), []);
const storyNpcById = useMemo(
() => new Map(draftStoryNpcs.map((npc) => [npc.id, npc])),
[draftStoryNpcs],
);
const availableTargetLandmarks = useMemo(
() => profile.landmarks.filter((entry) => entry.id !== draft.id),
[draft.id, profile.landmarks],
);
const toggleSceneNpc = (npcId: string) => {
setDraft((current) => ({
...current,
sceneNpcIds: current.sceneNpcIds.includes(npcId)
? current.sceneNpcIds.filter((entry) => entry !== npcId)
: [...current.sceneNpcIds, npcId],
}));
};
const updateConnection = (
index: number,
updater: (connection: CustomWorldSceneConnection) => CustomWorldSceneConnection,
) => {
setDraft((current) => ({
...current,
connections: current.connections.map((connection, connectionIndex) =>
connectionIndex === index ? updater(connection) : connection,
),
}));
};
const addConnection = () => {
const fallbackTarget = availableTargetLandmarks[0];
if (!fallbackTarget) {
window.alert('请先保留至少一个其他场景,才能配置连接关系。');
return;
}
setDraft((current) => ({
...current,
connections: [
...current.connections,
{
targetLandmarkId: fallbackTarget.id,
relativePosition: 'forward',
summary: `可通往${fallbackTarget.name}`,
},
],
}));
};
const saveLandmarkProfile = () => {
if (draft.sceneNpcIds.length < 3) {
window.alert('每个场景至少需要分配 3 个 NPC。');
return;
}
const nextLandmarks =
mode === 'create'
? [...profile.landmarks, draft]
: profile.landmarks.map((entry) => (entry.id === draft.id ? draft : entry));
onSaveProfile({
...profile,
storyNpcs: draftStoryNpcs,
landmarks: syncLandmarksWithStoryNpcs(nextLandmarks, draftStoryNpcs),
});
onClose();
};
return (
<ModalShell
title={mode === 'create' ? '新增场景' : `编辑场景:${landmark.name}`}
subtitle="这里的场景图片会同步用于结果页展示和正式进入世界后的场景背景。"
subtitle="这里可以同时配置场景图片、场景内 NPC以及场景之间的相对位置连接关系。"
onClose={onClose}
>
<div className="space-y-4">
@@ -1286,12 +1385,213 @@ function LandmarkEditor({
}
/>
</Field>
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="text-[11px] font-bold tracking-[0.16em] text-zinc-300">
NPC
</div>
<div className="mt-2 text-sm leading-6 text-zinc-400">
3 NPC NPC
</div>
</div>
<ActionButton
label="新增 NPC 并加入此场景"
onClick={() =>
setNpcEditorState({
mode: 'create',
npc: createStoryNpc({ storyNpcs: draftStoryNpcs }),
})
}
tone="sky"
/>
</div>
<div className="mt-3 space-y-2">
{draft.sceneNpcIds.length > 0 ? (
draft.sceneNpcIds.map((npcId) => {
const npc = storyNpcById.get(npcId);
return (
<div
key={`${draft.id}-selected-npc-${npcId}`}
className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3"
>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-semibold text-white">
{npc?.name ?? '未匹配场景角色'}
</div>
<div className="mt-1 text-xs leading-6 text-zinc-400">
{npc?.role || npc?.title || '未填写身份'}
</div>
</div>
<div className="flex flex-wrap gap-2">
{npc ? (
<ActionButton
label="编辑"
onClick={() =>
setNpcEditorState({
mode: 'edit',
npc,
})
}
tone="sky"
/>
) : null}
<ActionButton
label="移出场景"
onClick={() => toggleSceneNpc(npcId)}
/>
</div>
</div>
</div>
);
})
) : (
<div className="rounded-2xl border border-dashed border-white/12 bg-black/20 px-4 py-4 text-sm text-zinc-500">
NPC
</div>
)}
</div>
<div className="mt-3 max-h-64 space-y-2 overflow-y-auto pr-1">
{draftStoryNpcs.map((npc) => {
const selected = draft.sceneNpcIds.includes(npc.id);
return (
<button
key={`${draft.id}-npc-picker-${npc.id}`}
type="button"
onClick={() => toggleSceneNpc(npc.id)}
className={`w-full rounded-2xl border px-3 py-3 text-left transition-colors ${
selected
? 'border-sky-300/28 bg-sky-500/10'
: 'border-white/8 bg-black/20 hover:border-white/18'
}`}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-semibold text-white">
{npc.name}
</div>
<div className="mt-1 text-xs leading-6 text-zinc-400">
{npc.role}
</div>
</div>
<div className="rounded-full border border-white/10 bg-black/20 px-2.5 py-1 text-[10px] text-zinc-300">
{selected ? '已加入' : '点击加入'}
</div>
</div>
</button>
);
})}
</div>
</div>
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="text-[11px] font-bold tracking-[0.16em] text-zinc-300">
</div>
<div className="mt-2 text-sm leading-6 text-zinc-400">
线
</div>
</div>
<ActionButton
label="新增连接"
onClick={addConnection}
tone="sky"
/>
</div>
<div className="mt-3 space-y-3">
{draft.connections.length > 0 ? (
draft.connections.map((connection, index) => (
<div
key={`${draft.id}-connection-${index}`}
className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3"
>
<div className="grid gap-3 md:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)]">
<Field label="目标场景">
<SelectField
value={connection.targetLandmarkId}
onChange={(value) =>
updateConnection(index, (current) => ({
...current,
targetLandmarkId: value,
}))
}
options={availableTargetLandmarks.map((entry) => ({
value: entry.id,
label: entry.name,
}))}
/>
</Field>
<Field label="相对位置">
<SelectField
value={connection.relativePosition}
onChange={(value) =>
updateConnection(index, (current) => ({
...current,
relativePosition: value as CustomWorldSceneConnection['relativePosition'],
}))
}
options={CUSTOM_WORLD_SCENE_RELATIVE_POSITION_OPTIONS.map(
(option) => ({
value: option.value,
label: option.label,
}),
)}
/>
</Field>
</div>
<Field label="连接说明">
<TextArea
value={connection.summary}
onChange={(value) =>
updateConnection(index, (current) => ({
...current,
summary: value,
}))
}
rows={2}
placeholder="例如:沿山脊向北翻过去,可到达断桥。"
/>
</Field>
<div className="flex justify-end">
<ActionButton
label="删除连接"
onClick={() =>
setDraft((current) => ({
...current,
connections: current.connections.filter(
(_item, connectionIndex) => connectionIndex !== index,
),
}))
}
/>
</div>
</div>
))
) : (
<div className="rounded-2xl border border-dashed border-white/12 bg-black/20 px-4 py-4 text-sm text-zinc-500">
</div>
)}
</div>
{draft.connections.length > 0 ? (
<div className="mt-3 rounded-2xl border border-white/8 bg-black/20 px-4 py-3 text-xs leading-6 text-zinc-400">
{draft.connections
.map((connection) => {
const targetLandmark = availableTargetLandmarks.find(
(entry) => entry.id === connection.targetLandmarkId,
);
return `${getCustomWorldSceneRelativePositionLabel(connection.relativePosition)} -> ${targetLandmark?.name ?? '未匹配场景'}`;
})
.join('')}
</div>
) : null}
</div>
<SaveBar
onClose={onClose}
onSave={() => {
onSave(draft);
onClose();
}}
onSave={saveLandmarkProfile}
/>
{isPresetPickerOpen ? (
<ScenePresetPickerModal
@@ -1316,6 +1616,27 @@ function LandmarkEditor({
onClose={() => setIsAiGenerateOpen(false)}
/>
) : null}
{npcEditorState ? (
<StoryNpcEditor
npc={npcEditorState.npc}
mode={npcEditorState.mode}
onSave={(nextNpc) => {
setDraftStoryNpcs((current) =>
npcEditorState.mode === 'create'
? [...current, nextNpc]
: current.map((item) => (item.id === nextNpc.id ? nextNpc : item)),
);
setDraft((current) => ({
...current,
sceneNpcIds: current.sceneNpcIds.includes(nextNpc.id)
? current.sceneNpcIds
: [...current.sceneNpcIds, nextNpc.id],
}));
setNpcEditorState(null);
}}
onClose={() => setNpcEditorState(null)}
/>
) : null}
</div>
</ModalShell>
);
@@ -1347,11 +1668,82 @@ function createPlayableNpc(
initialAffinity: 18,
relationshipHooks: ['首次接触', '合作空间'],
tags: ['自定义'],
backstoryReveal: {
publicSummary: '',
chapters: [
{
id: 'surface',
title: '表层来意',
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_EASED,
teaser: '',
content: '',
contextSnippet: '',
},
{
id: 'scar',
title: '旧事裂痕',
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_FRIENDLY,
teaser: '',
content: '',
contextSnippet: '',
},
{
id: 'hidden',
title: '隐藏执念',
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_TRUSTED,
teaser: '',
content: '',
contextSnippet: '',
},
{
id: 'final',
title: '最终底牌',
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_CLOSE,
teaser: '',
content: '',
contextSnippet: '',
},
],
},
skills: [
{ id: 'skill-1', name: '基础起手', summary: '', style: '起手压制' },
{ id: 'skill-2', name: '常用变招', summary: '', style: '机动周旋' },
{ id: 'skill-3', name: '压箱底牌', summary: '', style: '爆发终结' },
],
initialItems: [
{
id: 'item-1',
name: '随身武具',
category: '武器',
quantity: 1,
rarity: 'rare',
description: '',
tags: ['自定义'],
},
{
id: 'item-2',
name: '补给包',
category: '消耗品',
quantity: 2,
rarity: 'uncommon',
description: '',
tags: ['自定义'],
},
{
id: 'item-3',
name: '私人物件',
category: '专属物品',
quantity: 1,
rarity: 'rare',
description: '',
tags: ['自定义'],
},
],
templateCharacterId: template?.id,
};
}
function createStoryNpc(profile: CustomWorldProfile): CustomWorldNpc {
function createStoryNpc(profile: Pick<CustomWorldProfile, 'storyNpcs'>): CustomWorldNpc {
const seed = Date.now() + profile.storyNpcs.length;
const npc = {
id: createEntryId(
@@ -1370,6 +1762,77 @@ function createStoryNpc(profile: CustomWorldProfile): CustomWorldNpc {
initialAffinity: 6,
relationshipHooks: ['合作', '互动'],
tags: ['自定义'],
backstoryReveal: {
publicSummary: '',
chapters: [
{
id: 'surface',
title: '表层来意',
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_EASED,
teaser: '',
content: '',
contextSnippet: '',
},
{
id: 'scar',
title: '旧事裂痕',
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_FRIENDLY,
teaser: '',
content: '',
contextSnippet: '',
},
{
id: 'hidden',
title: '隐藏执念',
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_TRUSTED,
teaser: '',
content: '',
contextSnippet: '',
},
{
id: 'final',
title: '最终底牌',
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_CLOSE,
teaser: '',
content: '',
contextSnippet: '',
},
],
},
skills: [
{ id: 'skill-1', name: '基础起手', summary: '', style: '起手压制' },
{ id: 'skill-2', name: '常用变招', summary: '', style: '机动周旋' },
{ id: 'skill-3', name: '压箱底牌', summary: '', style: '爆发终结' },
],
initialItems: [
{
id: 'item-1',
name: '随身武具',
category: '武器',
quantity: 1,
rarity: 'rare',
description: '',
tags: ['自定义'],
},
{
id: 'item-2',
name: '补给包',
category: '消耗品',
quantity: 2,
rarity: 'uncommon',
description: '',
tags: ['自定义'],
},
{
id: 'item-3',
name: '私人物件',
category: '专属物品',
quantity: 1,
rarity: 'rare',
description: '',
tags: ['自定义'],
},
],
} satisfies CustomWorldNpc;
return npc;
@@ -1377,6 +1840,7 @@ function createStoryNpc(profile: CustomWorldProfile): CustomWorldNpc {
function createLandmark(profile: CustomWorldProfile): CustomWorldLandmark {
const seed = Date.now() + profile.landmarks.length;
const previousLandmark = profile.landmarks[profile.landmarks.length - 1];
return {
id: createEntryId(
'landmark',
@@ -1391,6 +1855,16 @@ function createLandmark(profile: CustomWorldProfile): CustomWorldLandmark {
profile.landmarks.length,
profile.templateWorldType,
),
sceneNpcIds: profile.storyNpcs.slice(0, 3).map((npc) => npc.id),
connections: previousLandmark
? [
{
targetLandmarkId: previousLandmark.id,
relativePosition: 'back',
summary: `暂时接回${previousLandmark.name}这条旧路`,
},
]
: [],
};
}
@@ -1483,20 +1957,15 @@ export function CustomWorldEntityEditorModal({
}
if (target.mode === 'create') {
return (
<LandmarkEditor
profile={profile}
landmark={createLandmark(profile)}
mode="create"
onSave={(nextLandmark) =>
onProfileChange({
...profile,
landmarks: [...profile.landmarks, nextLandmark],
})
}
onClose={onClose}
/>
);
return (
<LandmarkEditor
profile={profile}
landmark={createLandmark(profile)}
mode="create"
onSaveProfile={onProfileChange}
onClose={onClose}
/>
);
}
const landmark = profile.landmarks.find((entry) => entry.id === target.id);
@@ -1505,14 +1974,7 @@ export function CustomWorldEntityEditorModal({
profile={profile}
landmark={landmark}
mode="edit"
onSave={(nextLandmark) =>
onProfileChange({
...profile,
landmarks: profile.landmarks.map((entry) =>
entry.id === nextLandmark.id ? nextLandmark : entry,
),
})
}
onSaveProfile={onProfileChange}
onClose={onClose}
/>
) : null;