Rework story engine flow and reorganize project docs
Some checks failed
CI / verify (push) Has been cancelled
Some checks failed
CI / verify (push) Has been cancelled
This commit is contained in:
@@ -4,13 +4,14 @@ import {
|
||||
getCustomWorldSceneRelativePositionLabel,
|
||||
normalizeCustomWorldLandmarks,
|
||||
} from '../data/customWorldSceneGraph';
|
||||
import { buildCustomWorldCreatorIntentDisplayText } from '../services/customWorldCreatorIntent';
|
||||
import { AnimationState, Character, CustomWorldProfile } from '../types';
|
||||
import { getNineSliceStyle, UI_CHROME } from '../uiAssets';
|
||||
import { CharacterAnimator } from './CharacterAnimator';
|
||||
import type { CustomWorldEditorTarget } from './CustomWorldEntityEditorModal';
|
||||
import { CustomWorldNpcPortrait } from './CustomWorldNpcVisualEditor';
|
||||
|
||||
export type ResultTab = 'world' | 'playable' | 'story' | 'landmarks';
|
||||
export type ResultTab = 'world' | 'anchors' | 'playable' | 'story' | 'landmarks';
|
||||
|
||||
interface CustomWorldEntityCatalogProps {
|
||||
profile: CustomWorldProfile;
|
||||
@@ -19,12 +20,18 @@ interface CustomWorldEntityCatalogProps {
|
||||
onActiveTabChange: (tab: ResultTab) => void;
|
||||
onEditTarget: (target: CustomWorldEditorTarget) => void;
|
||||
onProfileChange: (profile: CustomWorldProfile) => void;
|
||||
onRegeneratePlayableNpc?: (id: string) => void;
|
||||
onRegenerateStoryNpc?: (id: string) => void;
|
||||
onRegenerateLandmark?: (id: string) => void;
|
||||
onRegenerateStoryExpansion?: () => void;
|
||||
onRegenerateLandmarkNetwork?: () => void;
|
||||
createActionLabel?: string;
|
||||
onCreateAction?: () => void;
|
||||
}
|
||||
|
||||
const RESULT_TABS: Array<{ id: ResultTab; label: string }> = [
|
||||
{ id: 'world', label: '世界' },
|
||||
{ id: 'anchors', label: '锚点' },
|
||||
{ id: 'playable', label: '可扮演角色' },
|
||||
{ id: 'story', label: '场景角色' },
|
||||
{ id: 'landmarks', label: '场景' },
|
||||
@@ -203,6 +210,11 @@ export function CustomWorldEntityCatalog({
|
||||
onActiveTabChange,
|
||||
onEditTarget,
|
||||
onProfileChange,
|
||||
onRegeneratePlayableNpc,
|
||||
onRegenerateStoryNpc,
|
||||
onRegenerateLandmark,
|
||||
onRegenerateStoryExpansion,
|
||||
onRegenerateLandmarkNetwork,
|
||||
createActionLabel,
|
||||
onCreateAction,
|
||||
}: CustomWorldEntityCatalogProps) {
|
||||
@@ -249,8 +261,34 @@ export function CustomWorldEntityCatalog({
|
||||
[deferredSearch, landmarkById, profile.landmarks, storyNpcById],
|
||||
);
|
||||
|
||||
const creatorIntentSummary = useMemo(
|
||||
() => buildCustomWorldCreatorIntentDisplayText(profile.creatorIntent).trim(),
|
||||
[profile.creatorIntent],
|
||||
);
|
||||
const lockedCharacterNames = useMemo(
|
||||
() =>
|
||||
new Set(
|
||||
profile.creatorIntent?.keyCharacters
|
||||
.filter((entry) => entry.locked)
|
||||
.map((entry) => entry.name.trim())
|
||||
.filter(Boolean) ?? [],
|
||||
),
|
||||
[profile.creatorIntent],
|
||||
);
|
||||
const lockedLandmarkNames = useMemo(
|
||||
() =>
|
||||
new Set(
|
||||
profile.creatorIntent?.keyLandmarks
|
||||
.filter((entry) => entry.locked)
|
||||
.map((entry) => entry.name.trim())
|
||||
.filter(Boolean) ?? [],
|
||||
),
|
||||
[profile.creatorIntent],
|
||||
);
|
||||
|
||||
const counts = {
|
||||
world: 1,
|
||||
anchors: 1,
|
||||
playable: profile.playableNpcs.length,
|
||||
story: profile.storyNpcs.length,
|
||||
landmarks: profile.landmarks.length,
|
||||
@@ -325,7 +363,7 @@ export function CustomWorldEntityCatalog({
|
||||
))}
|
||||
</div>
|
||||
|
||||
{activeTab !== 'world' ? (
|
||||
{activeTab !== 'world' && activeTab !== 'anchors' ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<SearchBox value={searchDraft} onChange={setSearchDraft} placeholder={getSearchPlaceholder(activeTab)} />
|
||||
@@ -348,6 +386,14 @@ export function CustomWorldEntityCatalog({
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{creatorIntentSummary ? (
|
||||
<Section title="创作锚点" subtitle="这部分来自创作者输入,AI 会围绕它继续展开世界。">
|
||||
<div className="whitespace-pre-line rounded-2xl border border-sky-300/14 bg-sky-500/8 px-4 py-4 text-sm leading-7 text-sky-50/95">
|
||||
{creatorIntentSummary}
|
||||
</div>
|
||||
</Section>
|
||||
) : null}
|
||||
|
||||
<Section title="档案规模" subtitle="结果页现在会同时维护场景角色归属和场景之间的相对位置连接关系。">
|
||||
<div className="grid grid-cols-1 gap-2 text-center text-[11px] text-zinc-300 sm:grid-cols-3">
|
||||
<div className="rounded-xl border border-white/8 bg-black/20 px-2 py-3">
|
||||
@@ -370,6 +416,101 @@ export function CustomWorldEntityCatalog({
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{activeTab === 'anchors' ? (
|
||||
<div className="space-y-3">
|
||||
<Section
|
||||
title="创作者输入"
|
||||
subtitle="这些内容来自创作者工作台,会作为 AI 继续展开世界的锚点。"
|
||||
>
|
||||
<div className="whitespace-pre-line rounded-2xl border border-sky-300/14 bg-sky-500/8 px-4 py-4 text-sm leading-7 text-sky-50/95">
|
||||
{creatorIntentSummary || '当前还没有记录创作锚点。'}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="关键势力">
|
||||
<div className="space-y-2">
|
||||
{profile.creatorIntent?.keyFactions.length ? (
|
||||
profile.creatorIntent.keyFactions.map((entry) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3 text-sm leading-6 text-zinc-300"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="font-semibold text-white">{entry.name || '未命名势力'}</div>
|
||||
{entry.locked ? (
|
||||
<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}
|
||||
</div>
|
||||
<div className="mt-1">{entry.publicGoal || '暂无目标说明'}</div>
|
||||
{entry.tension ? <div className="mt-1 text-zinc-400">冲突:{entry.tension}</div> : null}
|
||||
{entry.notes ? <div className="mt-1 text-zinc-500">补充:{entry.notes}</div> : null}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<EmptyState title="当前没有关键势力锚点。" />
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="关键角色">
|
||||
<div className="space-y-2">
|
||||
{profile.creatorIntent?.keyCharacters.length ? (
|
||||
profile.creatorIntent.keyCharacters.map((entry) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3 text-sm leading-6 text-zinc-300"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="font-semibold text-white">{entry.name || '未命名角色'}</div>
|
||||
{entry.locked ? (
|
||||
<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}
|
||||
</div>
|
||||
<div className="mt-1">{entry.role || '未填写身份'}</div>
|
||||
{entry.publicMask ? <div className="mt-1 text-zinc-400">表面:{entry.publicMask}</div> : null}
|
||||
{entry.hiddenHook ? <div className="mt-1 text-zinc-400">暗线:{entry.hiddenHook}</div> : null}
|
||||
{entry.relationToPlayer ? <div className="mt-1 text-zinc-500">与玩家:{entry.relationToPlayer}</div> : null}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<EmptyState title="当前没有关键角色锚点。" />
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="关键地点">
|
||||
<div className="space-y-2">
|
||||
{profile.creatorIntent?.keyLandmarks.length ? (
|
||||
profile.creatorIntent.keyLandmarks.map((entry) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3 text-sm leading-6 text-zinc-300"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="font-semibold text-white">{entry.name || '未命名地点'}</div>
|
||||
{entry.locked ? (
|
||||
<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}
|
||||
</div>
|
||||
<div className="mt-1">{entry.purpose || '未填写作用'}</div>
|
||||
{entry.mood ? <div className="mt-1 text-zinc-400">氛围:{entry.mood}</div> : null}
|
||||
{entry.secret ? <div className="mt-1 text-zinc-500">秘密:{entry.secret}</div> : null}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<EmptyState title="当前没有关键地点锚点。" />
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{activeTab === 'playable' ? (
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3 text-sm text-zinc-300">
|
||||
@@ -388,6 +529,14 @@ export function CustomWorldEntityCatalog({
|
||||
subtitle={role.title}
|
||||
actions={(
|
||||
<div className="flex items-center gap-2">
|
||||
{onRegeneratePlayableNpc && !lockedCharacterNames.has(role.name.trim()) ? (
|
||||
<SmallButton
|
||||
onClick={() => onRegeneratePlayableNpc(role.id)}
|
||||
tone="sky"
|
||||
>
|
||||
AI重生成
|
||||
</SmallButton>
|
||||
) : null}
|
||||
<SmallButton onClick={() => onEditTarget({ kind: 'playable', mode: 'edit', id: role.id })} tone="sky">编辑</SmallButton>
|
||||
<SmallButton onClick={() => removePlayable(role.id, role.name)} tone="rose">删除</SmallButton>
|
||||
</div>
|
||||
@@ -400,6 +549,11 @@ export function CustomWorldEntityCatalog({
|
||||
) : null}
|
||||
</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">
|
||||
@@ -463,6 +617,13 @@ export function CustomWorldEntityCatalog({
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3 text-sm text-zinc-300">
|
||||
场景角色默认可组合中世纪奇幻角色形象;当角色文本明显指向怪物型 NPC 且初始好感偏敌对时,预览也会自动尝试引用怪物素材。
|
||||
{onRegenerateStoryExpansion ? (
|
||||
<div className="mt-3">
|
||||
<SmallButton onClick={onRegenerateStoryExpansion} tone="sky">
|
||||
重生成长尾场景角色
|
||||
</SmallButton>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{filteredStory.length === 0 ? (
|
||||
<EmptyState title="当前没有符合搜索条件的场景角色。" />
|
||||
@@ -474,6 +635,14 @@ export function CustomWorldEntityCatalog({
|
||||
subtitle={npc.role}
|
||||
actions={(
|
||||
<div className="flex items-center gap-2">
|
||||
{onRegenerateStoryNpc && !lockedCharacterNames.has(npc.name.trim()) ? (
|
||||
<SmallButton
|
||||
onClick={() => onRegenerateStoryNpc(npc.id)}
|
||||
tone="sky"
|
||||
>
|
||||
AI重生成
|
||||
</SmallButton>
|
||||
) : null}
|
||||
<SmallButton onClick={() => onEditTarget({ kind: 'story', mode: 'edit', id: npc.id })} tone="sky">编辑</SmallButton>
|
||||
<SmallButton onClick={() => removeStoryNpc(npc.id, npc.name)} tone="rose">删除</SmallButton>
|
||||
</div>
|
||||
@@ -487,6 +656,11 @@ export function CustomWorldEntityCatalog({
|
||||
scale={2.18}
|
||||
/>
|
||||
<div className="min-w-0 space-y-3">
|
||||
{lockedCharacterNames.has(npc.name.trim()) ? (
|
||||
<div className="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">{npc.description}</div>
|
||||
<div className="rounded-2xl border border-sky-300/12 bg-sky-500/8 px-3 py-3 text-sm leading-6 text-sky-50/95">
|
||||
公开背景:{npc.backstoryReveal.publicSummary || '未填写'}
|
||||
@@ -556,6 +730,13 @@ export function CustomWorldEntityCatalog({
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3 text-sm text-zinc-300">
|
||||
场景图会同步用于结果页和正式世界中的背景展示;这里还能看到每个场景承载的 NPC 和连接关系。
|
||||
{onRegenerateLandmarkNetwork ? (
|
||||
<div className="mt-3">
|
||||
<SmallButton onClick={onRegenerateLandmarkNetwork} tone="sky">
|
||||
重生成场景网络
|
||||
</SmallButton>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{filteredLandmarks.length === 0 ? (
|
||||
<EmptyState title="当前没有符合搜索条件的场景。" />
|
||||
@@ -566,12 +747,25 @@ export function CustomWorldEntityCatalog({
|
||||
title={landmark.name}
|
||||
actions={(
|
||||
<div className="flex items-center gap-2">
|
||||
{onRegenerateLandmark && !lockedLandmarkNames.has(landmark.name.trim()) ? (
|
||||
<SmallButton
|
||||
onClick={() => onRegenerateLandmark(landmark.id)}
|
||||
tone="sky"
|
||||
>
|
||||
AI重生成
|
||||
</SmallButton>
|
||||
) : null}
|
||||
<SmallButton onClick={() => onEditTarget({ kind: 'landmark', mode: 'edit', id: landmark.id })} tone="sky">编辑</SmallButton>
|
||||
<SmallButton onClick={() => removeLandmark(landmark.id, landmark.name)} tone="rose">删除</SmallButton>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{lockedLandmarkNames.has(landmark.name.trim()) ? (
|
||||
<div className="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}
|
||||
<ImageFrame src={landmark.imageSrc} alt={landmark.name} fallbackLabel={landmark.name.slice(0, 4) || '场景'} tone="landscape" />
|
||||
<div className="text-sm leading-7 text-zinc-300">{landmark.description}</div>
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3 text-sm leading-6 text-zinc-300">
|
||||
|
||||
Reference in New Issue
Block a user