Simplify custom world result editing controls
This commit is contained in:
@@ -1,8 +1,7 @@
|
||||
import { type ReactNode, useDeferredValue, useMemo, useState } from 'react';
|
||||
import { type ReactNode, useDeferredValue, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
getCustomWorldSceneRelativePositionLabel,
|
||||
normalizeCustomWorldLandmarks,
|
||||
} from '../data/customWorldSceneGraph';
|
||||
import {
|
||||
resolveCustomWorldCampSceneImage,
|
||||
@@ -25,11 +24,8 @@ 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;
|
||||
onDeleteStoryNpcs?: (ids: string[]) => void;
|
||||
onDeleteLandmarks?: (ids: string[]) => void;
|
||||
createActionLabel?: string;
|
||||
onCreateAction?: () => void;
|
||||
}
|
||||
@@ -146,6 +142,57 @@ function EmptyState({ title }: { title: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function CatalogCard({
|
||||
title,
|
||||
description,
|
||||
media,
|
||||
isSelectionMode,
|
||||
isSelected,
|
||||
onClick,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
media: ReactNode;
|
||||
isSelectionMode: boolean;
|
||||
isSelected: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`w-full rounded-[1.4rem] border p-3 text-left transition-colors ${
|
||||
isSelected
|
||||
? 'border-rose-300/35 bg-rose-500/10'
|
||||
: 'border-white/10 bg-black/20 hover:border-white/20 hover:bg-black/28'
|
||||
}`}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="overflow-hidden rounded-[1.1rem] border border-white/8 bg-black/25">
|
||||
{media}
|
||||
</div>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 text-base font-semibold text-white">{title}</div>
|
||||
{isSelectionMode ? (
|
||||
<div
|
||||
className={`shrink-0 rounded-full border px-2.5 py-1 text-[10px] ${
|
||||
isSelected
|
||||
? 'border-rose-300/25 bg-rose-500/14 text-rose-50'
|
||||
: 'border-white/10 bg-black/20 text-zinc-400'
|
||||
}`}
|
||||
>
|
||||
{isSelected ? '已选' : '选择'}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="text-sm leading-6 text-zinc-300">
|
||||
{description || '暂无描述'}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function matchText(text: string, query: string) {
|
||||
return text.toLowerCase().includes(query.toLowerCase());
|
||||
}
|
||||
@@ -161,6 +208,8 @@ type CatalogRole =
|
||||
| CustomWorldProfile['playableNpcs'][number]
|
||||
| CustomWorldProfile['storyNpcs'][number];
|
||||
|
||||
type BulkDeleteTab = 'story' | 'landmarks';
|
||||
|
||||
function buildRoleSearchText(role: CatalogRole) {
|
||||
return [
|
||||
role.name,
|
||||
@@ -215,15 +264,14 @@ export function CustomWorldEntityCatalog({
|
||||
onActiveTabChange,
|
||||
onEditTarget,
|
||||
onProfileChange,
|
||||
onRegeneratePlayableNpc,
|
||||
onRegenerateStoryNpc,
|
||||
onRegenerateLandmark,
|
||||
onRegenerateStoryExpansion,
|
||||
onRegenerateLandmarkNetwork,
|
||||
onDeleteStoryNpcs,
|
||||
onDeleteLandmarks,
|
||||
createActionLabel,
|
||||
onCreateAction,
|
||||
}: CustomWorldEntityCatalogProps) {
|
||||
const [searchDraft, setSearchDraft] = useState('');
|
||||
const [bulkDeleteMode, setBulkDeleteMode] = useState<BulkDeleteTab | null>(null);
|
||||
const [selectedBulkIds, setSelectedBulkIds] = useState<string[]>([]);
|
||||
const deferredSearch = useDeferredValue(searchDraft.trim());
|
||||
|
||||
const storyNpcById = useMemo(
|
||||
@@ -289,16 +337,6 @@ export function CustomWorldEntityCatalog({
|
||||
),
|
||||
[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,
|
||||
@@ -308,6 +346,17 @@ export function CustomWorldEntityCatalog({
|
||||
landmarks: profile.landmarks.length,
|
||||
} satisfies Record<ResultTab, number>;
|
||||
|
||||
const bulkDeleteTab: BulkDeleteTab | null =
|
||||
activeTab === 'story' || activeTab === 'landmarks' ? activeTab : null;
|
||||
const isBulkDeleteMode = bulkDeleteMode === bulkDeleteTab;
|
||||
|
||||
useEffect(() => {
|
||||
if (bulkDeleteMode && bulkDeleteMode !== activeTab) {
|
||||
setBulkDeleteMode(null);
|
||||
setSelectedBulkIds([]);
|
||||
}
|
||||
}, [activeTab, bulkDeleteMode]);
|
||||
|
||||
const removePlayable = (id: string, name: string) => {
|
||||
if (profile.playableNpcs.length <= 1) {
|
||||
window.alert('至少保留一个可扮演角色,才能正常进入自定义世界。');
|
||||
@@ -320,37 +369,43 @@ export function CustomWorldEntityCatalog({
|
||||
});
|
||||
};
|
||||
|
||||
const removeStoryNpc = (id: string, name: string) => {
|
||||
if (!window.confirm(`确认删除场景角色「${name}」吗?`)) return;
|
||||
const nextStoryNpcs = profile.storyNpcs.filter(npc => npc.id !== id);
|
||||
onProfileChange({
|
||||
...profile,
|
||||
storyNpcs: nextStoryNpcs,
|
||||
landmarks: normalizeCustomWorldLandmarks({
|
||||
landmarks: profile.landmarks.map((landmark) => ({
|
||||
...landmark,
|
||||
sceneNpcIds: landmark.sceneNpcIds.filter((npcId) => npcId !== id),
|
||||
})),
|
||||
storyNpcs: nextStoryNpcs,
|
||||
}),
|
||||
});
|
||||
const startBulkDelete = (tab: BulkDeleteTab) => {
|
||||
setBulkDeleteMode(tab);
|
||||
setSelectedBulkIds([]);
|
||||
};
|
||||
|
||||
const removeLandmark = (id: string, name: string) => {
|
||||
if (!window.confirm(`确认删除场景「${name}」吗?`)) return;
|
||||
const nextLandmarks = profile.landmarks.filter(landmark => landmark.id !== id);
|
||||
onProfileChange({
|
||||
...profile,
|
||||
landmarks: normalizeCustomWorldLandmarks({
|
||||
landmarks: nextLandmarks.map((landmark) => ({
|
||||
...landmark,
|
||||
connections: landmark.connections.filter(
|
||||
(connection) => connection.targetLandmarkId !== id,
|
||||
),
|
||||
})),
|
||||
storyNpcs: profile.storyNpcs,
|
||||
}),
|
||||
});
|
||||
const cancelBulkDelete = () => {
|
||||
setBulkDeleteMode(null);
|
||||
setSelectedBulkIds([]);
|
||||
};
|
||||
|
||||
const toggleBulkSelected = (id: string) => {
|
||||
setSelectedBulkIds((current) =>
|
||||
current.includes(id)
|
||||
? current.filter((entry) => entry !== id)
|
||||
: [...current, id],
|
||||
);
|
||||
};
|
||||
|
||||
const confirmBulkDelete = () => {
|
||||
if (!bulkDeleteTab || selectedBulkIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const label = bulkDeleteTab === 'story' ? '场景角色' : '场景';
|
||||
const confirmed = window.confirm(
|
||||
`确认批量删除 ${selectedBulkIds.length} 个${label}吗?`,
|
||||
);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (bulkDeleteTab === 'story') {
|
||||
onDeleteStoryNpcs?.(selectedBulkIds);
|
||||
} else {
|
||||
onDeleteLandmarks?.(selectedBulkIds);
|
||||
}
|
||||
cancelBulkDelete();
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -378,13 +433,37 @@ export function CustomWorldEntityCatalog({
|
||||
</div>
|
||||
|
||||
{activeTab !== 'world' && activeTab !== 'anchors' ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<div className="min-w-0 flex-1">
|
||||
<SearchBox value={searchDraft} onChange={setSearchDraft} placeholder={getSearchPlaceholder(activeTab)} />
|
||||
</div>
|
||||
{createActionLabel && onCreateAction ? (
|
||||
<SmallButton onClick={onCreateAction} tone="sky">{createActionLabel}</SmallButton>
|
||||
) : null}
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
{isBulkDeleteMode ? (
|
||||
<>
|
||||
<div className="rounded-full border border-white/10 bg-black/20 px-3 py-1 text-[11px] text-zinc-300">
|
||||
已选 {selectedBulkIds.length}
|
||||
</div>
|
||||
<SmallButton onClick={cancelBulkDelete}>取消</SmallButton>
|
||||
<SmallButton
|
||||
onClick={confirmBulkDelete}
|
||||
tone="rose"
|
||||
>
|
||||
删除选中
|
||||
</SmallButton>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{createActionLabel && onCreateAction ? (
|
||||
<SmallButton onClick={onCreateAction} tone="sky">{createActionLabel}</SmallButton>
|
||||
) : null}
|
||||
{bulkDeleteTab && ((bulkDeleteTab === 'story' && onDeleteStoryNpcs) || (bulkDeleteTab === 'landmarks' && onDeleteLandmarks)) ? (
|
||||
<SmallButton onClick={() => startBulkDelete(bulkDeleteTab)} tone="rose">
|
||||
批量删除
|
||||
</SmallButton>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -560,14 +639,6 @@ 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>
|
||||
@@ -646,111 +717,32 @@ export function CustomWorldEntityCatalog({
|
||||
|
||||
{activeTab === 'story' ? (
|
||||
<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="当前没有符合搜索条件的场景角色。" />
|
||||
) : (
|
||||
filteredStory.map(npc => (
|
||||
<div key={npc.id}>
|
||||
<Section
|
||||
<CatalogCard
|
||||
title={npc.name}
|
||||
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>
|
||||
)}
|
||||
>
|
||||
<div className="grid gap-4 sm:grid-cols-[7rem_minmax(0,1fr)]">
|
||||
description={npc.description}
|
||||
isSelectionMode={isBulkDeleteMode}
|
||||
isSelected={selectedBulkIds.includes(npc.id)}
|
||||
onClick={() =>
|
||||
isBulkDeleteMode
|
||||
? toggleBulkSelected(npc.id)
|
||||
: onEditTarget({ kind: 'story', mode: 'edit', id: npc.id })
|
||||
}
|
||||
media={(
|
||||
<CustomWorldNpcPortrait
|
||||
npc={npc}
|
||||
profile={profile}
|
||||
visual={npc.visual}
|
||||
className="aspect-square"
|
||||
scale={2.18}
|
||||
preferImageSrc
|
||||
/>
|
||||
<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 || '未填写'}
|
||||
</div>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3 text-sm leading-6 text-zinc-300">头衔:{npc.title}</div>
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3 text-sm leading-6 text-zinc-300">初始好感:{npc.initialAffinity}</div>
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3 text-sm leading-6 text-zinc-300">性格:{npc.personality || '未填写'}</div>
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3 text-sm leading-6 text-zinc-300">战斗:{npc.combatStyle || '未填写'}</div>
|
||||
</div>
|
||||
{npc.backstory ? (
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3 text-sm leading-6 text-zinc-300">背景:{npc.backstory}</div>
|
||||
) : null}
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3 text-sm leading-6 text-zinc-300">动机:{npc.motivation}</div>
|
||||
<div className="rounded-2xl 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">
|
||||
{npc.backstoryReveal.chapters.map(chapter => (
|
||||
<div key={`${npc.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="rounded-2xl 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">
|
||||
{npc.skills.map(skill => (
|
||||
<div key={`${npc.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="rounded-2xl 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">
|
||||
{npc.initialItems.map(item => (
|
||||
<div key={`${npc.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="flex flex-wrap gap-2">
|
||||
{npc.relationshipHooks.map(hook => (
|
||||
<span key={`${npc.id}-${hook}`} className="rounded-full border border-white/10 bg-black/20 px-2.5 py-1 text-[10px] text-zinc-300">
|
||||
{hook}
|
||||
</span>
|
||||
))}
|
||||
{npc.tags.map(tag => (
|
||||
<span key={`${npc.id}-tag-${tag}`} className="rounded-full border border-sky-300/12 bg-sky-500/8 px-2.5 py-1 text-[10px] text-sky-100">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
@@ -759,85 +751,30 @@ export function CustomWorldEntityCatalog({
|
||||
|
||||
{activeTab === 'landmarks' ? (
|
||||
<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="当前没有符合搜索条件的场景。" />
|
||||
) : (
|
||||
filteredLandmarks.map(landmark => (
|
||||
<div key={landmark.id}>
|
||||
<Section
|
||||
<CatalogCard
|
||||
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}
|
||||
description={landmark.description}
|
||||
isSelectionMode={isBulkDeleteMode}
|
||||
isSelected={selectedBulkIds.includes(landmark.id)}
|
||||
onClick={() =>
|
||||
isBulkDeleteMode
|
||||
? toggleBulkSelected(landmark.id)
|
||||
: onEditTarget({ kind: 'landmark', mode: 'edit', id: landmark.id })
|
||||
}
|
||||
media={(
|
||||
<ImageFrame
|
||||
src={landmarkImageById.get(landmark.id) ?? 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">
|
||||
危险度:{landmark.dangerLevel || '未填写'}
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3">
|
||||
<div className="text-[11px] font-bold tracking-[0.16em] text-zinc-400">场景内 NPC</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{landmark.sceneNpcIds.length > 0 ? (
|
||||
landmark.sceneNpcIds.map((npcId) => (
|
||||
<span key={`${landmark.id}-npc-${npcId}`} className="rounded-full border border-sky-300/12 bg-sky-500/8 px-2.5 py-1 text-[10px] text-sky-100">
|
||||
{storyNpcById.get(npcId)?.name ?? '未匹配角色'}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span className="text-xs text-zinc-500">尚未分配场景角色</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl 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">
|
||||
{landmark.connections.length > 0 ? (
|
||||
landmark.connections.map((connection) => (
|
||||
<div key={`${landmark.id}-connection-${connection.targetLandmarkId}-${connection.relativePosition}`} className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">
|
||||
{getCustomWorldSceneRelativePositionLabel(connection.relativePosition)} · {landmarkById.get(connection.targetLandmarkId)?.name ?? '未匹配场景'}
|
||||
{connection.summary ? `:${connection.summary}` : ''}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-xs text-zinc-500">尚未配置连接关系</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Children, type ReactNode, useEffect, useMemo, useState } from 'react';
|
||||
import type { ChangeEvent } from 'react';
|
||||
import { Children, type ReactNode, useEffect, useMemo, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import {
|
||||
AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS,
|
||||
} from '../data/affinityLevels';
|
||||
import { AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS } from '../data/affinityLevels';
|
||||
import {
|
||||
buildCustomWorldPlayableCharacters,
|
||||
PRESET_CHARACTERS,
|
||||
@@ -21,10 +21,6 @@ import {
|
||||
type CustomWorldSceneImageResult,
|
||||
generateCustomWorldSceneImage,
|
||||
} from '../services/ai';
|
||||
import {
|
||||
buildCustomWorldSceneImagePrompt,
|
||||
DEFAULT_CUSTOM_WORLD_SCENE_IMAGE_NEGATIVE_PROMPT,
|
||||
} from '../services/customWorld';
|
||||
import { resolveCustomWorldCampScene } from '../services/customWorldCamp';
|
||||
import {
|
||||
AnimationState,
|
||||
@@ -42,6 +38,7 @@ import {
|
||||
CustomWorldNpcPortrait,
|
||||
CustomWorldNpcVisualEditor,
|
||||
} from './CustomWorldNpcVisualEditor';
|
||||
import { CustomWorldRoleAssetStudioModal } from './CustomWorldRoleAssetStudioModal';
|
||||
import { PixelIcon } from './PixelIcon';
|
||||
|
||||
export type CustomWorldEditorTarget =
|
||||
@@ -170,6 +167,15 @@ function useDraft<T>(value: T) {
|
||||
return [draft, setDraft] as const;
|
||||
}
|
||||
|
||||
function readImageFileAsDataUrl(file: File) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(String(reader.result ?? ''));
|
||||
reader.onerror = () => reject(reader.error ?? new Error('读取图片失败。'));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
function ModalShell({
|
||||
title,
|
||||
subtitle,
|
||||
@@ -179,6 +185,7 @@ function ModalShell({
|
||||
overlayClassName = 'z-[98]',
|
||||
bodyClassName = '',
|
||||
disableClose = false,
|
||||
usePixelFont = false,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
@@ -188,6 +195,7 @@ function ModalShell({
|
||||
overlayClassName?: string;
|
||||
bodyClassName?: string;
|
||||
disableClose?: boolean;
|
||||
usePixelFont?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
@@ -195,7 +203,7 @@ function ModalShell({
|
||||
onClick={disableClose ? undefined : onClose}
|
||||
>
|
||||
<div
|
||||
className={`pixel-nine-slice pixel-modal-shell flex h-[92vh] w-full flex-col overflow-hidden rounded-t-[1.75rem] shadow-[0_24px_80px_rgba(0,0,0,0.6)] sm:h-auto sm:max-h-[min(92vh,56rem)] ${panelClassName} sm:rounded-[1.75rem]`}
|
||||
className={`pixel-nine-slice pixel-modal-shell flex h-[92vh] w-full flex-col overflow-hidden rounded-t-[1.75rem] shadow-[0_24px_80px_rgba(0,0,0,0.6)] sm:h-auto sm:max-h-[min(92vh,56rem)] ${usePixelFont ? 'fusion-pixel-app' : ''} ${panelClassName} sm:rounded-[1.75rem]`}
|
||||
style={getNineSliceStyle(UI_CHROME.modalPanel)}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
@@ -229,6 +237,83 @@ function ModalShell({
|
||||
);
|
||||
}
|
||||
|
||||
function _PortalModalShell(props: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
panelClassName?: string;
|
||||
overlayClassName?: string;
|
||||
bodyClassName?: string;
|
||||
disableClose?: boolean;
|
||||
usePixelFont?: boolean;
|
||||
}) {
|
||||
if (typeof document === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return createPortal(<ModalShell {...props} />, document.body);
|
||||
}
|
||||
|
||||
function CompactDialogShell({
|
||||
title,
|
||||
onClose,
|
||||
children,
|
||||
overlayClassName = 'z-[140]',
|
||||
disableClose = false,
|
||||
usePixelFont = false,
|
||||
}: {
|
||||
title: string;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
overlayClassName?: string;
|
||||
disableClose?: boolean;
|
||||
usePixelFont?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`fixed inset-0 ${overlayClassName} flex items-center justify-center bg-black/78 p-4 backdrop-blur-sm`}
|
||||
onClick={disableClose ? undefined : onClose}
|
||||
>
|
||||
<div
|
||||
className={`pixel-nine-slice pixel-modal-shell w-full max-w-md overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.6)] ${usePixelFont ? 'fusion-pixel-app' : ''}`}
|
||||
style={getNineSliceStyle(UI_CHROME.modalPanel)}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3 border-b border-white/10 px-4 py-4">
|
||||
<div className="min-w-0 text-sm font-semibold text-white">
|
||||
{title}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={disableClose}
|
||||
className={`rounded-full border border-white/10 bg-black/20 p-2 text-zinc-400 transition-colors hover:text-white ${disableClose ? 'cursor-not-allowed opacity-45' : ''}`}
|
||||
>
|
||||
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-4">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PortalCompactDialogShell(props: {
|
||||
title: string;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
overlayClassName?: string;
|
||||
disableClose?: boolean;
|
||||
usePixelFont?: boolean;
|
||||
}) {
|
||||
if (typeof document === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return createPortal(<CompactDialogShell {...props} />, document.body);
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: ReactNode }) {
|
||||
const hasVisibleChildren = Children.toArray(children).some(
|
||||
(child) => !(typeof child === 'string' && child.trim().length === 0),
|
||||
@@ -401,12 +486,14 @@ function ActionButton({
|
||||
}: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
tone?: 'default' | 'sky';
|
||||
tone?: 'default' | 'sky' | 'rose';
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const toneClassName =
|
||||
tone === 'sky'
|
||||
? 'border-sky-300/22 bg-sky-500/12 text-sky-50 hover:border-sky-200/40 hover:text-white'
|
||||
: tone === 'rose'
|
||||
? 'border-rose-300/22 bg-rose-500/12 text-rose-50 hover:border-rose-200/40 hover:text-white'
|
||||
: 'border-white/12 bg-black/20 text-zinc-200 hover:border-white/22 hover:text-white';
|
||||
|
||||
return (
|
||||
@@ -556,36 +643,7 @@ function ScenePresetPickerModal({
|
||||
);
|
||||
}
|
||||
|
||||
function AiComingSoonModal({
|
||||
title,
|
||||
subtitle,
|
||||
onClose,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<ModalShell title={title} subtitle={subtitle} onClose={onClose}>
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-3xl border border-white/10 bg-[radial-gradient(circle_at_top,rgba(56,189,248,0.18),transparent_55%),linear-gradient(180deg,rgba(19,24,39,0.96),rgba(8,10,17,0.92))] px-6 py-10 text-center">
|
||||
<div className="whitespace-pre-line text-2xl font-black tracking-[0.2em] text-white">
|
||||
功能开发中{'\n'}敬请期待
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<ActionButton label="知道了" onClick={onClose} tone="sky" />
|
||||
</div>
|
||||
</div>
|
||||
</ModalShell>
|
||||
);
|
||||
}
|
||||
|
||||
const SCENE_IMAGE_SIZE_OPTIONS = [
|
||||
{ value: '1280*720', label: '横版 16:9(推荐)' },
|
||||
{ value: '1280*1280', label: '方图 1:1' },
|
||||
{ value: '960*1280', label: '竖版 3:4' },
|
||||
] as const;
|
||||
const FIXED_SCENE_IMAGE_SIZE = '1280*720';
|
||||
|
||||
function SceneImageGenerationModal({
|
||||
profile,
|
||||
@@ -598,27 +656,17 @@ function SceneImageGenerationModal({
|
||||
onApply: (result: CustomWorldSceneImageResult) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const defaultPrompt = useMemo(
|
||||
() => buildCustomWorldSceneImagePrompt(profile, landmark),
|
||||
[profile, landmark],
|
||||
);
|
||||
const [prompt, setPrompt] = useDraft(defaultPrompt);
|
||||
const [negativePrompt, setNegativePrompt] = useDraft(
|
||||
DEFAULT_CUSTOM_WORLD_SCENE_IMAGE_NEGATIVE_PROMPT,
|
||||
);
|
||||
const [size, setSize] = useDraft<string>(
|
||||
SCENE_IMAGE_SIZE_OPTIONS[0]?.value ?? '1280*720',
|
||||
const [userPrompt, setUserPrompt] = useDraft(
|
||||
landmark.name.trim() || landmark.description.trim(),
|
||||
);
|
||||
const [referenceImageSrc, setReferenceImageSrc] = useState('');
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [latestResult, setLatestResult] =
|
||||
useState<CustomWorldSceneImageResult | null>(null);
|
||||
const [isExitConfirmOpen, setIsExitConfirmOpen] = useState(false);
|
||||
|
||||
const previewImageSrc = useMemo(() => {
|
||||
if (latestResult?.imageSrc) {
|
||||
return latestResult.imageSrc;
|
||||
}
|
||||
|
||||
const originalImageSrc = useMemo(() => {
|
||||
const landmarkIndex = profile.landmarks.findIndex(
|
||||
(entry) => entry.id === landmark.id,
|
||||
);
|
||||
@@ -632,11 +680,46 @@ function SceneImageGenerationModal({
|
||||
.map((entry) => entry.imageSrc)
|
||||
.filter((imageSrc): imageSrc is string => Boolean(imageSrc)),
|
||||
);
|
||||
}, [landmark, latestResult, profile]);
|
||||
}, [landmark, profile]);
|
||||
|
||||
const previewImageSrc = latestResult?.imageSrc || originalImageSrc;
|
||||
|
||||
const handleReferenceImageChange = async (
|
||||
event: ChangeEvent<HTMLInputElement>,
|
||||
) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.currentTarget.value = '';
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const dataUrl = await readImageFileAsDataUrl(file);
|
||||
setReferenceImageSrc(dataUrl);
|
||||
setError(null);
|
||||
} catch (uploadError) {
|
||||
setError(
|
||||
uploadError instanceof Error
|
||||
? uploadError.message
|
||||
: '参考图读取失败,请重试。',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRequestClose = () => {
|
||||
if (isGenerating) {
|
||||
return;
|
||||
}
|
||||
if (latestResult) {
|
||||
setIsExitConfirmOpen(true);
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!prompt.trim()) {
|
||||
setError('请先填写场景提示词。');
|
||||
if (!userPrompt.trim()) {
|
||||
setError('请先描述想要生成的画面内容。');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -647,12 +730,11 @@ function SceneImageGenerationModal({
|
||||
const result = await generateCustomWorldSceneImage({
|
||||
profile,
|
||||
landmark,
|
||||
prompt,
|
||||
negativePrompt,
|
||||
size,
|
||||
userPrompt,
|
||||
size: FIXED_SCENE_IMAGE_SIZE,
|
||||
...(referenceImageSrc ? { referenceImageSrc } : {}),
|
||||
});
|
||||
setLatestResult(result);
|
||||
onApply(result);
|
||||
} catch (generationError) {
|
||||
setError(
|
||||
generationError instanceof Error
|
||||
@@ -664,136 +746,195 @@ function SceneImageGenerationModal({
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (!latestResult || isGenerating) {
|
||||
return;
|
||||
}
|
||||
onApply(latestResult);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalShell
|
||||
title={`智能生成:${landmark.name || '当前场景'}`}
|
||||
subtitle="会调用阿里云文生图模型生成新的场景背景,并立即回写到当前编辑草稿。"
|
||||
onClose={onClose}
|
||||
panelClassName="sm:max-w-5xl"
|
||||
overlayClassName="z-[99]"
|
||||
disableClose={isGenerating}
|
||||
>
|
||||
<div className="grid gap-5 lg:grid-cols-[minmax(0,1.1fr)_minmax(20rem,0.9fr)]">
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3 text-sm leading-6 text-zinc-300">
|
||||
这里会把世界设定、场景描述和危险度组织成默认提示词。生成成功后,上层场景预览会立即替换,但仍需要点击“保存修改”才会正式写入当前世界档案。
|
||||
<>
|
||||
<ModalShell
|
||||
title={`智能生成:${landmark.name || '当前场景'}`}
|
||||
onClose={handleRequestClose}
|
||||
panelClassName="sm:max-w-5xl"
|
||||
overlayClassName="z-[99]"
|
||||
disableClose={isGenerating}
|
||||
usePixelFont
|
||||
>
|
||||
<div className="grid gap-5 lg:grid-cols-[minmax(0,1.1fr)_minmax(20rem,0.9fr)]">
|
||||
<div className="space-y-4">
|
||||
<Field label="画面内容描述">
|
||||
<TextArea
|
||||
value={userPrompt}
|
||||
onChange={(value) => setUserPrompt(value)}
|
||||
rows={8}
|
||||
placeholder="例如:雨夜的悬桥横跨黑色峡谷,桥下翻涌蓝绿色雾潮,远处有半坍塌塔楼与零星灯火。"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="自定义参考图(可选)">
|
||||
<div className="space-y-3">
|
||||
<label className="block rounded-2xl border border-dashed border-white/12 bg-black/20 px-4 py-4">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
onChange={(event) => {
|
||||
void handleReferenceImageChange(event);
|
||||
}}
|
||||
className="w-full text-xs text-zinc-300 file:mr-3 file:rounded-lg file:border-0 file:bg-sky-500 file:px-3 file:py-1.5 file:text-xs file:font-medium file:text-black"
|
||||
/>
|
||||
</label>
|
||||
{referenceImageSrc ? (
|
||||
<div className="flex items-center gap-3 rounded-2xl border border-white/8 bg-black/20 p-3">
|
||||
<div className="h-16 w-24 overflow-hidden rounded-xl border border-white/10 bg-black/30">
|
||||
<img
|
||||
src={referenceImageSrc}
|
||||
alt="自定义参考图"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 text-xs leading-5 text-zinc-400">
|
||||
已载入自定义参考图
|
||||
</div>
|
||||
<ActionButton
|
||||
label="移除"
|
||||
onClick={() => setReferenceImageSrc('')}
|
||||
disabled={isGenerating}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field label="场景提示词">
|
||||
<TextArea
|
||||
value={prompt}
|
||||
onChange={(value) => setPrompt(value)}
|
||||
rows={10}
|
||||
placeholder="描述这个场景的地貌、建筑、天气、光线与氛围。"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="反向提示词">
|
||||
<TextArea
|
||||
value={negativePrompt}
|
||||
onChange={(value) => setNegativePrompt(value)}
|
||||
rows={4}
|
||||
placeholder="例如:文字、水印、logo、UI界面、人物近景。"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="图片比例">
|
||||
<SelectField
|
||||
value={size}
|
||||
onChange={setSize}
|
||||
options={SCENE_IMAGE_SIZE_OPTIONS.map((option) => ({
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
}))}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-2xl border border-white/8 bg-black/18 p-3">
|
||||
<ImagePreview
|
||||
src={previewImageSrc}
|
||||
alt={landmark.name || '场景预览'}
|
||||
fallbackLabel={landmark.name ? landmark.name.slice(0, 4) : '场景'}
|
||||
tone="landscape"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{latestResult ? (
|
||||
<div className="rounded-2xl border border-emerald-300/18 bg-emerald-500/10 px-4 py-3 text-sm leading-6 text-emerald-50">
|
||||
已生成并回写当前场景草稿。模型:{latestResult.model},尺寸:
|
||||
{latestResult.size}。
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-2xl border border-white/8 bg-black/18 p-3">
|
||||
<ImagePreview
|
||||
src={previewImageSrc}
|
||||
alt={landmark.name || '场景预览'}
|
||||
fallbackLabel={
|
||||
landmark.name ? landmark.name.slice(0, 4) : '场景'
|
||||
}
|
||||
tone="landscape"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3 text-sm leading-6 text-zinc-300">
|
||||
当前预览会优先显示最近一次生成结果;如果还未生成,则显示你当前选中的场景图片。
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error ? (
|
||||
<div className="rounded-2xl border border-rose-400/18 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
{latestResult ? (
|
||||
<div className="rounded-2xl border border-emerald-300/18 bg-emerald-500/10 px-4 py-3 text-sm leading-6 text-emerald-50">
|
||||
已生成完毕,请保存后再退出页面
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
|
||||
<ActionButton
|
||||
label="关闭"
|
||||
onClick={onClose}
|
||||
disabled={isGenerating}
|
||||
/>
|
||||
<ActionButton
|
||||
label={
|
||||
isGenerating
|
||||
? '正在生成...'
|
||||
: latestResult
|
||||
? '重新生成'
|
||||
: '开始生成'
|
||||
}
|
||||
onClick={() => {
|
||||
void handleGenerate();
|
||||
}}
|
||||
tone="sky"
|
||||
disabled={isGenerating}
|
||||
/>
|
||||
{error ? (
|
||||
<div className="rounded-2xl border border-rose-400/18 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
|
||||
<ActionButton
|
||||
label="保存"
|
||||
onClick={handleSave}
|
||||
disabled={!latestResult || isGenerating}
|
||||
/>
|
||||
<ActionButton
|
||||
label={
|
||||
isGenerating
|
||||
? '正在生成...'
|
||||
: latestResult
|
||||
? '重新生成'
|
||||
: '开始生成'
|
||||
}
|
||||
onClick={() => {
|
||||
void handleGenerate();
|
||||
}}
|
||||
tone="sky"
|
||||
disabled={isGenerating}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModalShell>
|
||||
</ModalShell>
|
||||
|
||||
{isExitConfirmOpen ? (
|
||||
<PortalCompactDialogShell
|
||||
title="确认退出"
|
||||
onClose={() => setIsExitConfirmOpen(false)}
|
||||
overlayClassName="z-[140]"
|
||||
usePixelFont
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-2xl border border-amber-300/16 bg-amber-500/10 px-4 py-4 text-sm leading-6 text-amber-50">
|
||||
当前生成画面还未保存,退出后将丢失这次生成结果,仍然退出吗?
|
||||
</div>
|
||||
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
|
||||
<ActionButton
|
||||
label="继续编辑"
|
||||
onClick={() => setIsExitConfirmOpen(false)}
|
||||
/>
|
||||
<ActionButton
|
||||
label="仍然退出"
|
||||
onClick={() => {
|
||||
setIsExitConfirmOpen(false);
|
||||
onClose();
|
||||
}}
|
||||
tone="sky"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</PortalCompactDialogShell>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SaveBar({
|
||||
onClose,
|
||||
onSave,
|
||||
extraAction,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
onSave: () => void;
|
||||
extraAction?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="sticky bottom-0 z-10 -mx-4 border-t border-white/10 bg-[linear-gradient(180deg,rgba(8,10,17,0.2)_0%,rgba(8,10,17,0.96)_28%)] px-4 pb-[calc(env(safe-area-inset-bottom,0px)+0.35rem)] pt-3 backdrop-blur sm:static sm:mx-0 sm:border-0 sm:bg-transparent sm:px-0 sm:pb-0 sm:pt-2 sm:backdrop-blur-0">
|
||||
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-full border border-white/10 bg-black/20 px-4 py-2 text-sm text-zinc-300 transition-colors hover:text-white"
|
||||
<div
|
||||
className={`flex flex-col gap-3 ${
|
||||
extraAction
|
||||
? 'sm:flex-row sm:items-center sm:justify-between'
|
||||
: 'sm:flex-row sm:justify-end'
|
||||
}`}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSave}
|
||||
className="pixel-nine-slice pixel-pressable text-left"
|
||||
style={getNineSliceStyle(UI_CHROME.choiceButton, {
|
||||
paddingX: 16,
|
||||
paddingY: 10,
|
||||
})}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-sm font-semibold text-white">保存修改</span>
|
||||
<span className="text-white/60">→</span>
|
||||
</div>
|
||||
</button>
|
||||
{extraAction ? (
|
||||
<div className="flex flex-col gap-3 sm:flex-row">{extraAction}</div>
|
||||
) : null}
|
||||
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-full border border-white/10 bg-black/20 px-4 py-2 text-sm text-zinc-300 transition-colors hover:text-white"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSave}
|
||||
className="pixel-nine-slice pixel-pressable text-left"
|
||||
style={getNineSliceStyle(UI_CHROME.choiceButton, {
|
||||
paddingX: 16,
|
||||
paddingY: 10,
|
||||
})}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-sm font-semibold text-white">保存修改</span>
|
||||
<span className="text-white/60">→</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -1025,7 +1166,9 @@ function SkillListEditor({
|
||||
<ActionButton
|
||||
label="删除技能"
|
||||
onClick={() =>
|
||||
onChange(value.filter((_skill, skillIndex) => skillIndex !== index))
|
||||
onChange(
|
||||
value.filter((_skill, skillIndex) => skillIndex !== index),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
@@ -1120,7 +1263,9 @@ function InitialItemsEditor({
|
||||
<ActionButton
|
||||
label="删除物品"
|
||||
onClick={() =>
|
||||
onChange(value.filter((_item, itemIndex) => itemIndex !== index))
|
||||
onChange(
|
||||
value.filter((_item, itemIndex) => itemIndex !== index),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
@@ -1209,15 +1354,15 @@ function StoryNpcVisualEditorModal({
|
||||
npc,
|
||||
visual,
|
||||
onChange,
|
||||
onOpenAiStudio,
|
||||
onClose,
|
||||
}: {
|
||||
npc: CustomWorldNpc;
|
||||
visual: NonNullable<CustomWorldNpc['visual']>;
|
||||
onChange: (visual: NonNullable<CustomWorldNpc['visual']>) => void;
|
||||
onOpenAiStudio?: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [isAiGenerateOpen, setIsAiGenerateOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<ModalShell
|
||||
title={`修改形象:${npc.name}`}
|
||||
@@ -1235,15 +1380,11 @@ function StoryNpcVisualEditorModal({
|
||||
}}
|
||||
value={visual}
|
||||
onChange={onChange}
|
||||
onAiGenerate={() => setIsAiGenerateOpen(true)}
|
||||
onAiGenerate={() => {
|
||||
onClose();
|
||||
onOpenAiStudio?.();
|
||||
}}
|
||||
/>
|
||||
{isAiGenerateOpen ? (
|
||||
<AiComingSoonModal
|
||||
title="智能生成场景角色形象"
|
||||
subtitle="场景角色形象智能生成功能仍在开发中。"
|
||||
onClose={() => setIsAiGenerateOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
</ModalShell>
|
||||
);
|
||||
}
|
||||
@@ -1391,7 +1532,7 @@ function WorldEditor({
|
||||
tone="landscape"
|
||||
showInput={false}
|
||||
previewOverlay={<SceneSparringPreview profile={draft} />}
|
||||
footer={(
|
||||
footer={
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<ActionButton
|
||||
@@ -1408,7 +1549,7 @@ function WorldEditor({
|
||||
开局归处会直接作为进入自定义世界时的第一张背景。这里可以单独指定或生成这张背景图。
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
}
|
||||
/>
|
||||
<Field label="玩家原始设定">
|
||||
<TextArea
|
||||
@@ -1475,6 +1616,7 @@ function PlayableNpcEditor({
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [draft, setDraft] = useDraft(npc);
|
||||
const [isAiAssetStudioOpen, setIsAiAssetStudioOpen] = useState(false);
|
||||
const selectedTemplate =
|
||||
PRESET_CHARACTERS.find(
|
||||
(character) => character.id === draft.templateCharacterId,
|
||||
@@ -1493,7 +1635,7 @@ function PlayableNpcEditor({
|
||||
<div className="grid gap-4 rounded-2xl border border-white/8 bg-black/20 p-4 sm:grid-cols-[7rem_minmax(0,1fr)]">
|
||||
<div className="overflow-hidden rounded-2xl border border-white/10 bg-black/30">
|
||||
<img
|
||||
src={selectedTemplate.portrait}
|
||||
src={draft.imageSrc || selectedTemplate.portrait}
|
||||
alt={selectedTemplate.name}
|
||||
className="h-28 w-full object-cover object-top"
|
||||
/>
|
||||
@@ -1511,6 +1653,25 @@ function PlayableNpcEditor({
|
||||
<div className="mt-3 text-sm leading-6 text-zinc-300">
|
||||
{selectedTemplate.description}
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{draft.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}
|
||||
{draft.generatedAnimationSetId ? (
|
||||
<span className="rounded-full border border-amber-300/20 bg-amber-500/10 px-2.5 py-1 text-[10px] text-amber-100">
|
||||
已应用动作
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<ActionButton
|
||||
label="AI生成形象与动作"
|
||||
onClick={() => setIsAiAssetStudioOpen(true)}
|
||||
tone="sky"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -1678,6 +1839,19 @@ function PlayableNpcEditor({
|
||||
onClose();
|
||||
}}
|
||||
/>
|
||||
{isAiAssetStudioOpen ? (
|
||||
<CustomWorldRoleAssetStudioModal
|
||||
role={draft}
|
||||
roleKind="playable"
|
||||
onApply={(nextRole) =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
...nextRole,
|
||||
}))
|
||||
}
|
||||
onClose={() => setIsAiAssetStudioOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</ModalShell>
|
||||
);
|
||||
@@ -1696,6 +1870,7 @@ function StoryNpcEditor({
|
||||
}) {
|
||||
const [draft, setDraft] = useDraft(npc);
|
||||
const [isVisualEditorOpen, setIsVisualEditorOpen] = useState(false);
|
||||
const [isAiAssetStudioOpen, setIsAiAssetStudioOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<ModalShell
|
||||
@@ -1712,6 +1887,7 @@ function StoryNpcEditor({
|
||||
visual={draft.visual}
|
||||
className="aspect-square w-full max-w-[9.5rem]"
|
||||
scale={2.05}
|
||||
preferImageSrc
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 space-y-3">
|
||||
@@ -1729,6 +1905,23 @@ function StoryNpcEditor({
|
||||
onClick={() => setIsVisualEditorOpen(true)}
|
||||
tone="sky"
|
||||
/>
|
||||
<ActionButton
|
||||
label="AI生成形象与动作"
|
||||
onClick={() => setIsAiAssetStudioOpen(true)}
|
||||
tone="sky"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{draft.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}
|
||||
{draft.generatedAnimationSetId ? (
|
||||
<span className="rounded-full border border-amber-300/20 bg-amber-500/10 px-2.5 py-1 text-[10px] text-amber-100">
|
||||
已应用动作
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1892,9 +2085,23 @@ function StoryNpcEditor({
|
||||
onChange={(visual) =>
|
||||
setDraft((current) => ({ ...current, visual }))
|
||||
}
|
||||
onOpenAiStudio={() => setIsAiAssetStudioOpen(true)}
|
||||
onClose={() => setIsVisualEditorOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
{isAiAssetStudioOpen ? (
|
||||
<CustomWorldRoleAssetStudioModal
|
||||
role={draft}
|
||||
roleKind="story"
|
||||
onApply={(nextRole) =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
...nextRole,
|
||||
}))
|
||||
}
|
||||
onClose={() => setIsAiAssetStudioOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</ModalShell>
|
||||
);
|
||||
@@ -1957,7 +2164,9 @@ function LandmarkEditor({
|
||||
|
||||
const updateConnection = (
|
||||
index: number,
|
||||
updater: (connection: CustomWorldSceneConnection) => CustomWorldSceneConnection,
|
||||
updater: (
|
||||
connection: CustomWorldSceneConnection,
|
||||
) => CustomWorldSceneConnection,
|
||||
) => {
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
@@ -1996,7 +2205,9 @@ function LandmarkEditor({
|
||||
const nextLandmarks =
|
||||
mode === 'create'
|
||||
? [...profile.landmarks, draft]
|
||||
: profile.landmarks.map((entry) => (entry.id === draft.id ? draft : entry));
|
||||
: profile.landmarks.map((entry) =>
|
||||
entry.id === draft.id ? draft : entry,
|
||||
);
|
||||
|
||||
onSaveProfile({
|
||||
...profile,
|
||||
@@ -2077,7 +2288,8 @@ function LandmarkEditor({
|
||||
场景内 NPC
|
||||
</div>
|
||||
<div className="mt-2 text-sm leading-6 text-zinc-400">
|
||||
每个场景至少保留 3 个 NPC。可以在这里直接继续新增 NPC,并立即加入当前场景。
|
||||
每个场景至少保留 3 个 NPC。可以在这里直接继续新增
|
||||
NPC,并立即加入当前场景。
|
||||
</div>
|
||||
</div>
|
||||
<ActionButton
|
||||
@@ -2179,11 +2391,7 @@ function LandmarkEditor({
|
||||
编辑当前场景与其他场景之间的相对位置关系。保存时会自动同步反向连线,避免地图断开。
|
||||
</div>
|
||||
</div>
|
||||
<ActionButton
|
||||
label="新增连接"
|
||||
onClick={addConnection}
|
||||
tone="sky"
|
||||
/>
|
||||
<ActionButton label="新增连接" onClick={addConnection} tone="sky" />
|
||||
</div>
|
||||
<div className="mt-3 space-y-3">
|
||||
{draft.connections.length > 0 ? (
|
||||
@@ -2214,7 +2422,8 @@ function LandmarkEditor({
|
||||
onChange={(value) =>
|
||||
updateConnection(index, (current) => ({
|
||||
...current,
|
||||
relativePosition: value as CustomWorldSceneConnection['relativePosition'],
|
||||
relativePosition:
|
||||
value as CustomWorldSceneConnection['relativePosition'],
|
||||
}))
|
||||
}
|
||||
options={CUSTOM_WORLD_SCENE_RELATIVE_POSITION_OPTIONS.map(
|
||||
@@ -2246,7 +2455,8 @@ function LandmarkEditor({
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
connections: current.connections.filter(
|
||||
(_item, connectionIndex) => connectionIndex !== index,
|
||||
(_item, connectionIndex) =>
|
||||
connectionIndex !== index,
|
||||
),
|
||||
}))
|
||||
}
|
||||
@@ -2309,7 +2519,9 @@ function LandmarkEditor({
|
||||
setDraftStoryNpcs((current) =>
|
||||
npcEditorState.mode === 'create'
|
||||
? [...current, nextNpc]
|
||||
: current.map((item) => (item.id === nextNpc.id ? nextNpc : item)),
|
||||
: current.map((item) =>
|
||||
item.id === nextNpc.id ? nextNpc : item,
|
||||
),
|
||||
);
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
@@ -2428,7 +2640,9 @@ function createPlayableNpc(
|
||||
};
|
||||
}
|
||||
|
||||
function createStoryNpc(profile: Pick<CustomWorldProfile, 'storyNpcs'>): CustomWorldNpc {
|
||||
function createStoryNpc(
|
||||
profile: Pick<CustomWorldProfile, 'storyNpcs'>,
|
||||
): CustomWorldNpc {
|
||||
const seed = Date.now() + profile.storyNpcs.length;
|
||||
const npc = {
|
||||
id: createEntryId(
|
||||
@@ -2638,15 +2852,15 @@ export function CustomWorldEntityEditorModal({
|
||||
}
|
||||
|
||||
if (target.mode === 'create') {
|
||||
return (
|
||||
<LandmarkEditor
|
||||
profile={profile}
|
||||
landmark={createLandmark(profile)}
|
||||
mode="create"
|
||||
onSaveProfile={onProfileChange}
|
||||
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);
|
||||
|
||||
@@ -23,12 +23,19 @@ import {
|
||||
type MedievalRace,
|
||||
sanitizeCustomWorldNpcVisual,
|
||||
} from '../data/medievalNpcVisuals';
|
||||
import { type CustomWorldNpc, type CustomWorldNpcVisual } from '../types';
|
||||
import {
|
||||
type CustomWorldNpc,
|
||||
type CustomWorldNpcVisual,
|
||||
type CustomWorldProfile,
|
||||
} from '../types';
|
||||
import { buildDefaultCustomWorldNpcVisual } from './customWorldNpcVisualDefaults';
|
||||
import { HostileNpcAnimator } from './HostileNpcAnimator';
|
||||
import { MedievalNpcAnimator } from './MedievalNpcAnimator';
|
||||
|
||||
type EditableNpcSource = Pick<CustomWorldNpc, 'id' | 'name' | 'role' | 'description'>
|
||||
type EditableNpcSource = Pick<
|
||||
CustomWorldNpc,
|
||||
'id' | 'name' | 'role' | 'description' | 'imageSrc'
|
||||
>
|
||||
& Partial<
|
||||
Pick<
|
||||
CustomWorldNpc,
|
||||
@@ -291,25 +298,37 @@ function ActionButton({
|
||||
|
||||
export function CustomWorldNpcPortrait({
|
||||
npc,
|
||||
profile,
|
||||
visual,
|
||||
className = '',
|
||||
scale = 2.05,
|
||||
preferImageSrc = false,
|
||||
}: {
|
||||
npc: EditableNpcSource;
|
||||
profile?: CustomWorldProfile | null;
|
||||
visual?: CustomWorldNpcVisual;
|
||||
className?: string;
|
||||
scale?: number;
|
||||
preferImageSrc?: boolean;
|
||||
}) {
|
||||
const previewSpec = buildPreviewSpec(npc, visual ? sanitizeCustomWorldNpcVisual(visual) : undefined);
|
||||
const monsterPreset = visual
|
||||
? null
|
||||
: resolveCustomWorldNpcMonsterPreset(npc);
|
||||
: resolveCustomWorldNpcMonsterPreset(npc, undefined, profile ?? null);
|
||||
const preferredImageSrc =
|
||||
preferImageSrc && npc.imageSrc?.trim() ? npc.imageSrc.trim() : '';
|
||||
|
||||
return (
|
||||
<div className={`relative overflow-hidden rounded-2xl border border-white/10 bg-[radial-gradient(circle_at_top,rgba(56,189,248,0.16),transparent_48%),linear-gradient(180deg,rgba(19,24,39,0.96),rgba(8,10,17,0.92))] ${className}`}>
|
||||
<div className="absolute inset-0 opacity-10 [background-image:linear-gradient(rgba(255,255,255,0.16)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.16)_1px,transparent_1px)] [background-size:16px_16px]" />
|
||||
<div className="relative flex h-full min-h-[7rem] items-center justify-center p-3">
|
||||
{monsterPreset ? (
|
||||
{preferredImageSrc ? (
|
||||
<img
|
||||
src={preferredImageSrc}
|
||||
alt={npc.name}
|
||||
className="h-full w-full object-contain drop-shadow-[0_12px_18px_rgba(0,0,0,0.38)]"
|
||||
/>
|
||||
) : monsterPreset ? (
|
||||
<div
|
||||
className="origin-center drop-shadow-[0_12px_18px_rgba(0,0,0,0.38)]"
|
||||
style={{
|
||||
@@ -333,11 +352,13 @@ export function CustomWorldNpcPortrait({
|
||||
|
||||
export function CustomWorldNpcVisualEditor({
|
||||
npc,
|
||||
profile,
|
||||
value,
|
||||
onChange,
|
||||
onAiGenerate,
|
||||
}: {
|
||||
npc: EditableNpcSource;
|
||||
profile?: CustomWorldProfile | null;
|
||||
value?: CustomWorldNpcVisual;
|
||||
onChange: (value: CustomWorldNpcVisual) => void;
|
||||
onAiGenerate: () => void;
|
||||
@@ -411,6 +432,7 @@ export function CustomWorldNpcVisualEditor({
|
||||
<div className="mx-auto w-full max-w-[9.5rem] space-y-3">
|
||||
<CustomWorldNpcPortrait
|
||||
npc={npc}
|
||||
profile={profile}
|
||||
visual={effectiveVisual}
|
||||
className="aspect-square"
|
||||
scale={2.05}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { type ReactNode,useMemo, useState } from 'react';
|
||||
|
||||
import { normalizeCustomWorldLandmarks } from '../data/customWorldSceneGraph';
|
||||
import { Character, CustomWorldProfile } from '../types';
|
||||
import { getNineSliceStyle, UI_CHROME } from '../uiAssets';
|
||||
import { CustomWorldEntityCatalog, type ResultTab } from './CustomWorldEntityCatalog';
|
||||
@@ -16,11 +17,6 @@ interface CustomWorldResultViewProps {
|
||||
onEditSetting: () => void;
|
||||
onRegenerate: () => void;
|
||||
onContinueExpand?: () => void;
|
||||
onRegeneratePlayableNpc?: (id: string) => void;
|
||||
onRegenerateStoryNpc?: (id: string) => void;
|
||||
onRegenerateLandmark?: (id: string) => void;
|
||||
onRegenerateStoryExpansion?: () => void;
|
||||
onRegenerateLandmarkNetwork?: () => void;
|
||||
onSave: () => void;
|
||||
onProfileChange: (profile: CustomWorldProfile) => void;
|
||||
}
|
||||
@@ -66,6 +62,46 @@ function getCreateLabelByTab(activeTab: ResultTab) {
|
||||
return '';
|
||||
}
|
||||
|
||||
function removeStoryNpcsFromProfile(
|
||||
profile: CustomWorldProfile,
|
||||
ids: string[],
|
||||
) {
|
||||
const idSet = new Set(ids);
|
||||
const nextStoryNpcs = profile.storyNpcs.filter((npc) => !idSet.has(npc.id));
|
||||
|
||||
return {
|
||||
...profile,
|
||||
storyNpcs: nextStoryNpcs,
|
||||
landmarks: normalizeCustomWorldLandmarks({
|
||||
landmarks: profile.landmarks.map((landmark) => ({
|
||||
...landmark,
|
||||
sceneNpcIds: landmark.sceneNpcIds.filter((npcId) => !idSet.has(npcId)),
|
||||
})),
|
||||
storyNpcs: nextStoryNpcs,
|
||||
}),
|
||||
} satisfies CustomWorldProfile;
|
||||
}
|
||||
|
||||
function removeLandmarksFromProfile(profile: CustomWorldProfile, ids: string[]) {
|
||||
const idSet = new Set(ids);
|
||||
const nextLandmarks = profile.landmarks.filter(
|
||||
(landmark) => !idSet.has(landmark.id),
|
||||
);
|
||||
|
||||
return {
|
||||
...profile,
|
||||
landmarks: normalizeCustomWorldLandmarks({
|
||||
landmarks: nextLandmarks.map((landmark) => ({
|
||||
...landmark,
|
||||
connections: landmark.connections.filter(
|
||||
(connection) => !idSet.has(connection.targetLandmarkId),
|
||||
),
|
||||
})),
|
||||
storyNpcs: profile.storyNpcs,
|
||||
}),
|
||||
} satisfies CustomWorldProfile;
|
||||
}
|
||||
|
||||
export function CustomWorldResultView({
|
||||
profile,
|
||||
previewCharacters,
|
||||
@@ -77,11 +113,6 @@ export function CustomWorldResultView({
|
||||
onEditSetting,
|
||||
onRegenerate: triggerRegenerate,
|
||||
onContinueExpand,
|
||||
onRegeneratePlayableNpc,
|
||||
onRegenerateStoryNpc,
|
||||
onRegenerateLandmark,
|
||||
onRegenerateStoryExpansion,
|
||||
onRegenerateLandmarkNetwork,
|
||||
onSave,
|
||||
onProfileChange,
|
||||
}: CustomWorldResultViewProps) {
|
||||
@@ -101,6 +132,16 @@ export function CustomWorldResultView({
|
||||
triggerRegenerate();
|
||||
};
|
||||
|
||||
const handleDeleteStoryNpcs = (ids: string[]) => {
|
||||
if (ids.length === 0) return;
|
||||
onProfileChange(removeStoryNpcsFromProfile(profile, ids));
|
||||
};
|
||||
|
||||
const handleDeleteLandmarks = (ids: string[]) => {
|
||||
if (ids.length === 0) return;
|
||||
onProfileChange(removeLandmarksFromProfile(profile, ids));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div className="mb-4 flex justify-start">
|
||||
@@ -122,11 +163,8 @@ export function CustomWorldResultView({
|
||||
onActiveTabChange={setActiveTab}
|
||||
onEditTarget={setEditorTarget}
|
||||
onProfileChange={onProfileChange}
|
||||
onRegeneratePlayableNpc={onRegeneratePlayableNpc}
|
||||
onRegenerateStoryNpc={onRegenerateStoryNpc}
|
||||
onRegenerateLandmark={onRegenerateLandmark}
|
||||
onRegenerateStoryExpansion={onRegenerateStoryExpansion}
|
||||
onRegenerateLandmarkNetwork={onRegenerateLandmarkNetwork}
|
||||
onDeleteStoryNpcs={handleDeleteStoryNpcs}
|
||||
onDeleteLandmarks={handleDeleteLandmarks}
|
||||
createActionLabel={createLabel}
|
||||
onCreateAction={createTarget ? () => setEditorTarget(createTarget) : undefined}
|
||||
/>
|
||||
|
||||
987
src/components/CustomWorldRoleAssetStudioModal.tsx
Normal file
987
src/components/CustomWorldRoleAssetStudioModal.tsx
Normal file
@@ -0,0 +1,987 @@
|
||||
import {
|
||||
CheckCircle2,
|
||||
Film,
|
||||
ImagePlus,
|
||||
RefreshCcw,
|
||||
} from 'lucide-react';
|
||||
import { type ChangeEvent, type ReactNode, useMemo, useState } from 'react';
|
||||
|
||||
import { PRESET_CHARACTERS } from '../data/characterPresets';
|
||||
import {
|
||||
AnimationState,
|
||||
type CustomWorldNpc,
|
||||
type CustomWorldPlayableNpc,
|
||||
} from '../types';
|
||||
import { CharacterAnimator } from './CharacterAnimator';
|
||||
import {
|
||||
buildAnimationClipFromVideoSource,
|
||||
type DraftAnimationClip,
|
||||
readFileAsDataUrl,
|
||||
} from './preset-editor/characterAssetStudioModel';
|
||||
import {
|
||||
type CharacterAnimationDraftPayload,
|
||||
type CharacterAnimationGenerationPayload,
|
||||
type CharacterVisualDraft,
|
||||
type CharacterVisualSourceMode,
|
||||
generateCharacterAnimationDraft,
|
||||
generateCharacterVisualCandidates,
|
||||
publishCharacterAnimationAssets,
|
||||
publishCharacterVisualAsset,
|
||||
} from './preset-editor/characterAssetStudioPersistence';
|
||||
|
||||
type EditableCustomWorldRole = CustomWorldPlayableNpc | CustomWorldNpc;
|
||||
|
||||
type CustomWorldAiActionConfig = {
|
||||
animation: AnimationState;
|
||||
label: string;
|
||||
templateId: string;
|
||||
fps: number;
|
||||
frameCount: number;
|
||||
durationSeconds: number;
|
||||
loop: boolean;
|
||||
};
|
||||
|
||||
const VISUAL_SOURCE_OPTIONS: Array<{
|
||||
label: string;
|
||||
value: Exclude<CharacterVisualSourceMode, 'upload'>;
|
||||
}> = [
|
||||
{ label: '纯文生主图', value: 'text-to-image' },
|
||||
{ label: '参考图生主图', value: 'image-to-image' },
|
||||
];
|
||||
|
||||
const CORE_ACTIONS: CustomWorldAiActionConfig[] = [
|
||||
{
|
||||
animation: AnimationState.IDLE,
|
||||
label: '待机',
|
||||
templateId: 'idle',
|
||||
fps: 8,
|
||||
frameCount: 8,
|
||||
durationSeconds: 4,
|
||||
loop: true,
|
||||
},
|
||||
{
|
||||
animation: AnimationState.RUN,
|
||||
label: '奔跑',
|
||||
templateId: 'run',
|
||||
fps: 12,
|
||||
frameCount: 8,
|
||||
durationSeconds: 4,
|
||||
loop: true,
|
||||
},
|
||||
{
|
||||
animation: AnimationState.ATTACK,
|
||||
label: '攻击',
|
||||
templateId: 'attack_slash',
|
||||
fps: 12,
|
||||
frameCount: 8,
|
||||
durationSeconds: 3,
|
||||
loop: false,
|
||||
},
|
||||
{
|
||||
animation: AnimationState.HURT,
|
||||
label: '受击',
|
||||
templateId: 'hurt',
|
||||
fps: 10,
|
||||
frameCount: 6,
|
||||
durationSeconds: 3,
|
||||
loop: false,
|
||||
},
|
||||
{
|
||||
animation: AnimationState.DIE,
|
||||
label: '死亡',
|
||||
templateId: 'die',
|
||||
fps: 8,
|
||||
frameCount: 8,
|
||||
durationSeconds: 4,
|
||||
loop: false,
|
||||
},
|
||||
];
|
||||
|
||||
function ModalShell({
|
||||
title,
|
||||
subtitle,
|
||||
onClose,
|
||||
disableClose = false,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
onClose: () => void;
|
||||
disableClose?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-end justify-center bg-black/78 p-0 backdrop-blur-sm sm:items-center sm:p-4"
|
||||
onClick={disableClose ? undefined : onClose}
|
||||
>
|
||||
<div
|
||||
className="flex h-[92vh] w-full max-w-6xl flex-col overflow-hidden rounded-t-[1.75rem] border border-white/10 bg-[linear-gradient(180deg,rgba(19,24,39,0.98),rgba(8,10,17,0.96))] shadow-[0_24px_80px_rgba(0,0,0,0.6)] sm:h-auto sm:max-h-[min(92vh,58rem)] sm:rounded-[1.75rem]"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4 border-b border-white/10 px-4 py-4 sm:px-5">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold text-white">
|
||||
{title}
|
||||
</div>
|
||||
{subtitle ? (
|
||||
<div className="mt-1 text-xs leading-6 text-zinc-400">
|
||||
{subtitle}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={disableClose}
|
||||
className={`rounded-full border border-white/10 bg-black/20 px-3 py-2 text-xs text-zinc-300 transition-colors hover:text-white ${disableClose ? 'cursor-not-allowed opacity-45' : ''}`}
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-4 sm:p-5">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="space-y-4 rounded-3xl border border-white/10 bg-black/20 p-4">
|
||||
<div className="text-[11px] font-bold tracking-[0.18em] text-zinc-200">
|
||||
{title}
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<label className="block">
|
||||
<div className="mb-2 text-[11px] font-bold tracking-[0.14em] text-zinc-300">
|
||||
{label}
|
||||
</div>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectInput({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
options: Array<{ value: string; label: string }>;
|
||||
}) {
|
||||
return (
|
||||
<select
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
className="w-full rounded-2xl border border-white/10 bg-black/25 px-4 py-3 text-sm text-zinc-100 outline-none transition-colors focus:border-sky-300/35"
|
||||
>
|
||||
{options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
function TextArea({
|
||||
value,
|
||||
onChange,
|
||||
rows = 4,
|
||||
placeholder,
|
||||
readOnly = false,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
rows?: number;
|
||||
placeholder?: string;
|
||||
readOnly?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<textarea
|
||||
rows={rows}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
readOnly={readOnly}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
className="w-full rounded-2xl border border-white/10 bg-black/25 px-4 py-3 text-sm leading-7 text-zinc-100 outline-none transition-colors placeholder:text-zinc-500 focus:border-sky-300/35"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({
|
||||
tone,
|
||||
children,
|
||||
}: {
|
||||
tone: 'green' | 'amber' | 'zinc';
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const toneClassName = {
|
||||
green: 'border-emerald-400/30 bg-emerald-500/10 text-emerald-100',
|
||||
amber: 'border-amber-400/30 bg-amber-500/10 text-amber-100',
|
||||
zinc: 'border-white/10 bg-black/20 text-zinc-300',
|
||||
}[tone];
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex rounded-full border px-2.5 py-1 text-[11px] ${toneClassName}`}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionButton({
|
||||
icon,
|
||||
label,
|
||||
onClick,
|
||||
disabled = false,
|
||||
tone = 'default',
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
tone?: 'default' | 'sky' | 'green';
|
||||
}) {
|
||||
const toneClassName =
|
||||
tone === 'green'
|
||||
? 'border-emerald-400/30 bg-emerald-500/10 text-emerald-100 hover:bg-emerald-500/20'
|
||||
: tone === 'sky'
|
||||
? 'border-sky-300/22 bg-sky-500/12 text-sky-50 hover:border-sky-200/40 hover:text-white'
|
||||
: 'border-white/12 bg-black/20 text-zinc-200 hover:border-white/22 hover:text-white';
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={`inline-flex items-center gap-2 rounded-full border px-4 py-2 text-sm font-semibold transition-colors ${toneClassName} ${disabled ? 'cursor-not-allowed opacity-45' : ''}`}
|
||||
>
|
||||
{icon}
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function buildRoleCharacterBrief(
|
||||
role: EditableCustomWorldRole,
|
||||
templateLabel?: string,
|
||||
) {
|
||||
return [
|
||||
`角色名称:${role.name}`,
|
||||
`角色头衔:${role.title}`,
|
||||
`世界身份:${role.role}`,
|
||||
role.description ? `角色描述:${role.description}` : '',
|
||||
role.backstory ? `角色背景:${role.backstory}` : '',
|
||||
role.personality ? `角色性格:${role.personality}` : '',
|
||||
role.motivation ? `角色动机:${role.motivation}` : '',
|
||||
role.combatStyle ? `战斗风格:${role.combatStyle}` : '',
|
||||
role.tags.length > 0 ? `角色标签:${role.tags.join('、')}` : '',
|
||||
templateLabel ? `参考模板:${templateLabel}` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function mergeRole<T extends EditableCustomWorldRole>(
|
||||
role: T,
|
||||
patch: Partial<T>,
|
||||
) {
|
||||
return {
|
||||
...role,
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
export function CustomWorldRoleAssetStudioModal({
|
||||
role,
|
||||
roleKind,
|
||||
onApply,
|
||||
onClose,
|
||||
}: {
|
||||
role: EditableCustomWorldRole;
|
||||
roleKind: 'playable' | 'story';
|
||||
onApply: (nextRole: EditableCustomWorldRole) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [sourceMode, setSourceMode] =
|
||||
useState<Exclude<CharacterVisualSourceMode, 'upload'>>(
|
||||
role.imageSrc ? 'image-to-image' : 'text-to-image',
|
||||
);
|
||||
const [visualPromptText, setVisualPromptText] = useState('');
|
||||
const [referenceImageDataUrls, setReferenceImageDataUrls] = useState<
|
||||
string[]
|
||||
>([]);
|
||||
const [visualDrafts, setVisualDrafts] = useState<CharacterVisualDraft[]>([]);
|
||||
const [selectedVisualDraftId, setSelectedVisualDraftId] = useState('');
|
||||
const [visualStatus, setVisualStatus] = useState<string | null>(null);
|
||||
const [isGeneratingVisuals, setIsGeneratingVisuals] = useState(false);
|
||||
const [isApplyingVisual, setIsApplyingVisual] = useState(false);
|
||||
|
||||
const [selectedAnimation, setSelectedAnimation] = useState<AnimationState>(
|
||||
CORE_ACTIONS[0]?.animation ?? AnimationState.IDLE,
|
||||
);
|
||||
const [animationPromptText, setAnimationPromptText] = useState('');
|
||||
const [draftAnimations, setDraftAnimations] = useState<
|
||||
Partial<Record<AnimationState, DraftAnimationClip>>
|
||||
>({});
|
||||
const [animationStatus, setAnimationStatus] = useState<string | null>(null);
|
||||
const [isGeneratingAnimations, setIsGeneratingAnimations] = useState(false);
|
||||
const [isApplyingAnimations, setIsApplyingAnimations] = useState(false);
|
||||
|
||||
const selectedTemplate =
|
||||
roleKind === 'playable' && 'templateCharacterId' in role && role.templateCharacterId
|
||||
? PRESET_CHARACTERS.find(
|
||||
(character) => character.id === role.templateCharacterId,
|
||||
) ?? null
|
||||
: null;
|
||||
const characterBriefText = useMemo(
|
||||
() =>
|
||||
buildRoleCharacterBrief(
|
||||
role,
|
||||
selectedTemplate
|
||||
? `${selectedTemplate.name} / ${selectedTemplate.title}`
|
||||
: undefined,
|
||||
),
|
||||
[role, selectedTemplate],
|
||||
);
|
||||
const effectiveReferenceImages =
|
||||
referenceImageDataUrls.length > 0
|
||||
? referenceImageDataUrls
|
||||
: role.imageSrc
|
||||
? [role.imageSrc]
|
||||
: [];
|
||||
const selectedVisualDraft =
|
||||
visualDrafts.find((draft) => draft.id === selectedVisualDraftId) ?? null;
|
||||
const previewImageSrc =
|
||||
selectedVisualDraft?.imageSrc ??
|
||||
role.imageSrc ??
|
||||
selectedTemplate?.portrait ??
|
||||
'';
|
||||
const selectedActionConfig =
|
||||
CORE_ACTIONS.find((item) => item.animation === selectedAnimation) ??
|
||||
CORE_ACTIONS[0];
|
||||
const appliedActionCount = CORE_ACTIONS.filter(
|
||||
(item) => role.animationMap?.[item.animation]?.basePath,
|
||||
).length;
|
||||
|
||||
const handleReferenceImageUpload = async (
|
||||
event: ChangeEvent<HTMLInputElement>,
|
||||
) => {
|
||||
const fileList = event.target.files;
|
||||
if (!fileList || fileList.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const uploadedDataUrls = await Promise.all(
|
||||
Array.from(fileList)
|
||||
.slice(0, 4)
|
||||
.map((file) => readFileAsDataUrl(file)),
|
||||
);
|
||||
setReferenceImageDataUrls(uploadedDataUrls);
|
||||
setVisualStatus(`已载入 ${uploadedDataUrls.length} 张参考图。`);
|
||||
event.target.value = '';
|
||||
};
|
||||
|
||||
const handleGenerateVisuals = async () => {
|
||||
setIsGeneratingVisuals(true);
|
||||
setVisualStatus(null);
|
||||
|
||||
try {
|
||||
if (
|
||||
sourceMode === 'image-to-image' &&
|
||||
effectiveReferenceImages.length === 0
|
||||
) {
|
||||
throw new Error('参考图生主图至少需要一张参考图。');
|
||||
}
|
||||
|
||||
const result = await generateCharacterVisualCandidates({
|
||||
characterId: role.id,
|
||||
sourceMode,
|
||||
promptText: visualPromptText,
|
||||
characterBriefText,
|
||||
referenceImageDataUrls: effectiveReferenceImages,
|
||||
candidateCount: 3,
|
||||
imageModel: 'wan2.7-image-pro',
|
||||
size: '1024*1536',
|
||||
});
|
||||
setVisualDrafts(result.drafts);
|
||||
setSelectedVisualDraftId(result.drafts[0]?.id ?? '');
|
||||
setVisualStatus(`已生成 ${result.drafts.length} 个主图候选。`);
|
||||
} catch (error) {
|
||||
setVisualStatus(
|
||||
error instanceof Error ? error.message : '生成角色主图失败。',
|
||||
);
|
||||
} finally {
|
||||
setIsGeneratingVisuals(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleApplyVisual = async () => {
|
||||
if (!selectedVisualDraft) {
|
||||
setVisualStatus('请先选择一个主图候选。');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsApplyingVisual(true);
|
||||
setVisualStatus(null);
|
||||
|
||||
try {
|
||||
const result = await publishCharacterVisualAsset({
|
||||
characterId: role.id,
|
||||
sourceMode,
|
||||
promptText: visualPromptText,
|
||||
selectedPreviewSource: selectedVisualDraft.imageSrc,
|
||||
previewSources: visualDrafts.map((draft) => draft.imageSrc),
|
||||
width: selectedVisualDraft.width,
|
||||
height: selectedVisualDraft.height,
|
||||
updateCharacterOverride: false,
|
||||
});
|
||||
|
||||
onApply(
|
||||
mergeRole(role, {
|
||||
imageSrc: result.portraitPath,
|
||||
generatedVisualAssetId: result.assetId,
|
||||
generatedAnimationSetId: undefined,
|
||||
animationMap: undefined,
|
||||
}),
|
||||
);
|
||||
setDraftAnimations({});
|
||||
setAnimationStatus(null);
|
||||
setVisualStatus('主图已应用到当前角色,可继续生成核心动作。');
|
||||
} catch (error) {
|
||||
setVisualStatus(
|
||||
error instanceof Error ? error.message : '应用角色主图失败。',
|
||||
);
|
||||
} finally {
|
||||
setIsApplyingVisual(false);
|
||||
}
|
||||
};
|
||||
|
||||
const generateActionClip = async (config: CustomWorldAiActionConfig) => {
|
||||
if (!role.imageSrc || !role.generatedVisualAssetId) {
|
||||
throw new Error('请先应用主图,再生成动作。');
|
||||
}
|
||||
|
||||
const result = await generateCharacterAnimationDraft({
|
||||
characterId: role.id,
|
||||
strategy: 'image-to-video',
|
||||
animation: config.animation,
|
||||
promptText: animationPromptText,
|
||||
characterBriefText,
|
||||
actionTemplateId: config.templateId,
|
||||
visualSource: role.imageSrc,
|
||||
referenceImageDataUrls: [],
|
||||
referenceVideoDataUrls: [],
|
||||
frameCount: config.frameCount,
|
||||
fps: config.fps,
|
||||
durationSeconds: config.durationSeconds,
|
||||
loop: config.loop,
|
||||
useChromaKey: true,
|
||||
resolution: '720P',
|
||||
imageSequenceModel: 'wan2.7-image-pro',
|
||||
videoModel: 'wan2.7-i2v',
|
||||
referenceVideoModel: 'wan2.7-r2v',
|
||||
motionTransferModel: 'wan2.2-animate-move',
|
||||
} satisfies CharacterAnimationGenerationPayload);
|
||||
|
||||
if (result.strategy !== 'image-to-video') {
|
||||
throw new Error('当前自定义世界动作工坊只支持图生视频方案。');
|
||||
}
|
||||
|
||||
return buildAnimationClipFromVideoSource(result.previewVideoPath, {
|
||||
animation: config.animation,
|
||||
fps: config.fps,
|
||||
loop: config.loop,
|
||||
frameCount: config.frameCount,
|
||||
applyChromaKey: true,
|
||||
});
|
||||
};
|
||||
|
||||
const handleGenerateSingleAnimation = async () => {
|
||||
if (!selectedActionConfig) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsGeneratingAnimations(true);
|
||||
setAnimationStatus(null);
|
||||
|
||||
try {
|
||||
const clip = await generateActionClip(selectedActionConfig);
|
||||
setDraftAnimations((current) => ({
|
||||
...current,
|
||||
[selectedActionConfig.animation]: clip,
|
||||
}));
|
||||
setAnimationStatus(`${selectedActionConfig.label} 动作草稿已生成。`);
|
||||
} catch (error) {
|
||||
setAnimationStatus(
|
||||
error instanceof Error ? error.message : '生成角色动作失败。',
|
||||
);
|
||||
} finally {
|
||||
setIsGeneratingAnimations(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerateAllAnimations = async () => {
|
||||
setIsGeneratingAnimations(true);
|
||||
setAnimationStatus(null);
|
||||
|
||||
try {
|
||||
const nextDrafts: Partial<Record<AnimationState, DraftAnimationClip>> = {
|
||||
...draftAnimations,
|
||||
};
|
||||
|
||||
for (const config of CORE_ACTIONS) {
|
||||
setAnimationStatus(`正在生成 ${config.label} 动作...`);
|
||||
nextDrafts[config.animation] = await generateActionClip(config);
|
||||
}
|
||||
|
||||
setDraftAnimations(nextDrafts);
|
||||
setAnimationStatus('核心动作草稿已生成,可直接应用到当前角色。');
|
||||
} catch (error) {
|
||||
setAnimationStatus(
|
||||
error instanceof Error ? error.message : '批量生成核心动作失败。',
|
||||
);
|
||||
} finally {
|
||||
setIsGeneratingAnimations(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleApplyAnimations = async () => {
|
||||
if (!role.generatedVisualAssetId) {
|
||||
setAnimationStatus('请先应用主图,再应用动作。');
|
||||
return;
|
||||
}
|
||||
|
||||
const animationEntries = Object.entries(draftAnimations).filter(
|
||||
(entry): entry is [AnimationState, DraftAnimationClip] => Boolean(entry[1]),
|
||||
);
|
||||
|
||||
if (animationEntries.length === 0) {
|
||||
setAnimationStatus('请先生成至少一个核心动作草稿。');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsApplyingAnimations(true);
|
||||
setAnimationStatus(null);
|
||||
|
||||
try {
|
||||
const payload = Object.fromEntries(
|
||||
animationEntries.map(([animation, clip]) => [
|
||||
animation,
|
||||
{
|
||||
framesDataUrls: clip.frames,
|
||||
fps: clip.fps,
|
||||
loop: clip.loop,
|
||||
frameWidth: clip.frameWidth,
|
||||
frameHeight: clip.frameHeight,
|
||||
} satisfies CharacterAnimationDraftPayload,
|
||||
]),
|
||||
);
|
||||
const result = await publishCharacterAnimationAssets({
|
||||
characterId: role.id,
|
||||
visualAssetId: role.generatedVisualAssetId,
|
||||
animations: payload,
|
||||
updateCharacterOverride: false,
|
||||
});
|
||||
|
||||
onApply(
|
||||
mergeRole(role, {
|
||||
generatedAnimationSetId: result.animationSetId,
|
||||
animationMap: {
|
||||
...(role.animationMap ?? {}),
|
||||
...(result.animationMap as NonNullable<
|
||||
EditableCustomWorldRole['animationMap']
|
||||
>),
|
||||
},
|
||||
}),
|
||||
);
|
||||
setAnimationStatus('核心动作已应用到当前角色。');
|
||||
} catch (error) {
|
||||
setAnimationStatus(
|
||||
error instanceof Error ? error.message : '应用角色动作失败。',
|
||||
);
|
||||
} finally {
|
||||
setIsApplyingAnimations(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalShell
|
||||
title="AI 角色资产"
|
||||
subtitle="先应用主图,再走图生视频抽帧生成核心动作。"
|
||||
onClose={onClose}
|
||||
disableClose={
|
||||
isGeneratingVisuals ||
|
||||
isApplyingVisual ||
|
||||
isGeneratingAnimations ||
|
||||
isApplyingAnimations
|
||||
}
|
||||
>
|
||||
<div className="grid gap-5 xl:grid-cols-[minmax(0,1.05fr)_minmax(22rem,0.95fr)]">
|
||||
<div className="space-y-5">
|
||||
<Section title="阶段 A · 主图">
|
||||
<div className="grid gap-4 lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]">
|
||||
<div className="space-y-4">
|
||||
<Field label="主图方式">
|
||||
<SelectInput
|
||||
value={sourceMode}
|
||||
onChange={(value) =>
|
||||
setSourceMode(
|
||||
value as Exclude<CharacterVisualSourceMode, 'upload'>,
|
||||
)
|
||||
}
|
||||
options={VISUAL_SOURCE_OPTIONS}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="形象补充要求">
|
||||
<TextArea
|
||||
value={visualPromptText}
|
||||
onChange={setVisualPromptText}
|
||||
rows={6}
|
||||
placeholder="例如:衣摆更利落、剑柄更明显、整体更像江湖少女剑客。"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="参考图">
|
||||
<label className="block rounded-2xl border border-dashed border-white/12 bg-black/20 px-4 py-4">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
multiple
|
||||
onChange={handleReferenceImageUpload}
|
||||
className="w-full text-xs text-zinc-300 file:mr-3 file:rounded-lg file:border-0 file:bg-sky-500 file:px-3 file:py-1.5 file:text-xs file:font-medium file:text-black"
|
||||
/>
|
||||
</label>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{effectiveReferenceImages.map((imageSrc, index) => (
|
||||
<div
|
||||
key={`${imageSrc}-${index}`}
|
||||
className="h-16 w-16 overflow-hidden rounded-xl border border-white/10 bg-black/25"
|
||||
>
|
||||
<img
|
||||
src={imageSrc}
|
||||
alt={`reference-${index + 1}`}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{effectiveReferenceImages.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-white/10 px-4 py-3 text-xs text-zinc-500">
|
||||
未上传时会只使用当前角色档案生成主图。
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Field>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<ActionButton
|
||||
icon={<ImagePlus className="h-4 w-4" />}
|
||||
label={isGeneratingVisuals ? '生成中...' : '生成主图候选'}
|
||||
onClick={() => void handleGenerateVisuals()}
|
||||
disabled={isGeneratingVisuals}
|
||||
tone="sky"
|
||||
/>
|
||||
<ActionButton
|
||||
icon={<CheckCircle2 className="h-4 w-4" />}
|
||||
label={isApplyingVisual ? '应用中...' : '应用主图'}
|
||||
onClick={() => void handleApplyVisual()}
|
||||
disabled={isApplyingVisual || !selectedVisualDraft}
|
||||
tone="green"
|
||||
/>
|
||||
</div>
|
||||
{visualStatus ? (
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-zinc-300">
|
||||
{visualStatus}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="overflow-hidden rounded-3xl border border-white/10 bg-[radial-gradient(circle_at_top,rgba(56,189,248,0.16),transparent_48%),linear-gradient(180deg,rgba(19,24,39,0.96),rgba(8,10,17,0.92))]">
|
||||
<div className="flex min-h-[20rem] items-center justify-center p-4">
|
||||
{previewImageSrc ? (
|
||||
<img
|
||||
src={previewImageSrc}
|
||||
alt={role.name}
|
||||
className="max-h-[28rem] w-full object-contain"
|
||||
/>
|
||||
) : selectedTemplate ? (
|
||||
<img
|
||||
src={selectedTemplate.portrait}
|
||||
alt={selectedTemplate.name}
|
||||
className="max-h-[20rem] w-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<div className="px-6 text-center text-sm leading-7 text-zinc-500">
|
||||
先生成或选择主图候选。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{visualDrafts.map((draft) => {
|
||||
const isSelected = draft.id === selectedVisualDraftId;
|
||||
return (
|
||||
<button
|
||||
key={draft.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedVisualDraftId(draft.id)}
|
||||
className={`overflow-hidden rounded-2xl border text-left transition-colors ${
|
||||
isSelected
|
||||
? 'border-emerald-400/40 bg-emerald-500/10'
|
||||
: 'border-white/10 bg-black/20 hover:border-white/20'
|
||||
}`}
|
||||
>
|
||||
<div className="aspect-[3/4] overflow-hidden bg-black/30 p-2">
|
||||
<img
|
||||
src={draft.imageSrc}
|
||||
alt={draft.label}
|
||||
className="h-full w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3 px-3 py-3">
|
||||
<div className="text-sm text-white">{draft.label}</div>
|
||||
{isSelected ? (
|
||||
<StatusBadge tone="green">当前选择</StatusBadge>
|
||||
) : null}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{visualDrafts.length === 0 ? (
|
||||
<div className="rounded-2xl border border-dashed border-white/10 bg-black/20 px-4 py-8 text-sm text-zinc-500 sm:col-span-2">
|
||||
主图候选会显示在这里。
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="阶段 B · 核心动作">
|
||||
<div className="grid gap-4 lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]">
|
||||
<div className="space-y-4">
|
||||
<Field label="动作槽位">
|
||||
<SelectInput
|
||||
value={selectedAnimation}
|
||||
onChange={(value) =>
|
||||
setSelectedAnimation(value as AnimationState)
|
||||
}
|
||||
options={CORE_ACTIONS.map((item) => ({
|
||||
value: item.animation,
|
||||
label: item.label,
|
||||
}))}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="动作补充要求">
|
||||
<TextArea
|
||||
value={animationPromptText}
|
||||
onChange={setAnimationPromptText}
|
||||
rows={5}
|
||||
placeholder="例如:剑客攻击更干脆,收招更稳;奔跑时衣摆不要飘得过大。"
|
||||
/>
|
||||
</Field>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<ActionButton
|
||||
icon={<RefreshCcw className="h-4 w-4" />}
|
||||
label={
|
||||
isGeneratingAnimations
|
||||
? '生成中...'
|
||||
: `生成${selectedActionConfig?.label ?? '当前'}动作`
|
||||
}
|
||||
onClick={() => void handleGenerateSingleAnimation()}
|
||||
disabled={isGeneratingAnimations || !role.imageSrc}
|
||||
tone="sky"
|
||||
/>
|
||||
<ActionButton
|
||||
icon={<Film className="h-4 w-4" />}
|
||||
label={
|
||||
isGeneratingAnimations ? '生成中...' : '生成核心动作'
|
||||
}
|
||||
onClick={() => void handleGenerateAllAnimations()}
|
||||
disabled={isGeneratingAnimations || !role.imageSrc}
|
||||
/>
|
||||
<ActionButton
|
||||
icon={<CheckCircle2 className="h-4 w-4" />}
|
||||
label={isApplyingAnimations ? '应用中...' : '应用动作'}
|
||||
onClick={() => void handleApplyAnimations()}
|
||||
disabled={isApplyingAnimations}
|
||||
tone="green"
|
||||
/>
|
||||
</div>
|
||||
{animationStatus ? (
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-zinc-300">
|
||||
{animationStatus}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-3xl border border-white/10 bg-black/20 p-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<StatusBadge tone="amber">
|
||||
已应用动作 {appliedActionCount}/{CORE_ACTIONS.length}
|
||||
</StatusBadge>
|
||||
{role.generatedVisualAssetId ? (
|
||||
<StatusBadge tone="green">主图已应用</StatusBadge>
|
||||
) : (
|
||||
<StatusBadge tone="zinc">待应用主图</StatusBadge>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
||||
{CORE_ACTIONS.map((item) => {
|
||||
const hasDraft = Boolean(draftAnimations[item.animation]);
|
||||
const isApplied = Boolean(
|
||||
role.animationMap?.[item.animation]?.basePath,
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={item.animation}
|
||||
className={`rounded-2xl border px-4 py-4 ${
|
||||
item.animation === selectedAnimation
|
||||
? 'border-sky-300/30 bg-sky-500/10'
|
||||
: 'border-white/10 bg-black/20'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-sm font-semibold text-white">
|
||||
{item.label}
|
||||
</div>
|
||||
{hasDraft ? (
|
||||
<StatusBadge tone="green">草稿已生成</StatusBadge>
|
||||
) : isApplied ? (
|
||||
<StatusBadge tone="amber">已应用</StatusBadge>
|
||||
) : (
|
||||
<StatusBadge tone="zinc">待生成</StatusBadge>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 text-xs leading-6 text-zinc-500">
|
||||
图生视频抽帧
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-3xl border border-white/10 bg-[linear-gradient(180deg,rgba(19,24,39,0.96),rgba(8,10,17,0.92))] p-4">
|
||||
<div className="mb-3 text-[11px] font-bold tracking-[0.18em] text-zinc-300">
|
||||
当前角色预览
|
||||
</div>
|
||||
<div className="flex items-center justify-center rounded-2xl border border-white/10 bg-black/20 p-4">
|
||||
{roleKind === 'playable' && selectedTemplate ? (
|
||||
<div className="h-[220px] w-[220px]">
|
||||
<CharacterAnimator
|
||||
state={selectedAnimation}
|
||||
character={{
|
||||
...selectedTemplate,
|
||||
id: role.id,
|
||||
name: role.name,
|
||||
title: role.title,
|
||||
portrait: role.imageSrc || selectedTemplate.portrait,
|
||||
generatedVisualAssetId: role.generatedVisualAssetId,
|
||||
generatedAnimationSetId:
|
||||
role.generatedAnimationSetId,
|
||||
animationMap: role.animationMap
|
||||
? {
|
||||
...(selectedTemplate.animationMap ?? {}),
|
||||
...role.animationMap,
|
||||
}
|
||||
: selectedTemplate.animationMap,
|
||||
}}
|
||||
className="h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
) : previewImageSrc ? (
|
||||
<img
|
||||
src={previewImageSrc}
|
||||
alt={role.name}
|
||||
className="max-h-[16rem] w-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<div className="px-4 text-sm text-zinc-500">
|
||||
应用主图后,这里会显示当前角色。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
|
||||
<div className="space-y-5">
|
||||
<Section title="当前角色档案">
|
||||
<div className="rounded-3xl border border-white/10 bg-black/20 p-4">
|
||||
<div className="text-lg font-semibold text-white">{role.name}</div>
|
||||
<div className="mt-1 text-sm text-zinc-400">
|
||||
{role.title} / {role.role}
|
||||
</div>
|
||||
<div className="mt-4 space-y-3 text-sm leading-7 text-zinc-300">
|
||||
{role.description ? <div>{role.description}</div> : null}
|
||||
{role.combatStyle ? <div>战斗风格:{role.combatStyle}</div> : null}
|
||||
{role.tags.length > 0 ? <div>标签:{role.tags.join('、')}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
<Field label="自动提示词依据">
|
||||
<TextArea
|
||||
value={characterBriefText}
|
||||
onChange={() => {}}
|
||||
rows={14}
|
||||
placeholder=""
|
||||
readOnly
|
||||
/>
|
||||
</Field>
|
||||
</Section>
|
||||
|
||||
<Section title="当前进度">
|
||||
<div className="grid gap-3">
|
||||
<div className="flex items-center justify-between rounded-2xl border border-white/10 bg-black/20 px-4 py-3">
|
||||
<div className="text-sm text-zinc-200">主图状态</div>
|
||||
{role.generatedVisualAssetId ? (
|
||||
<StatusBadge tone="green">已应用</StatusBadge>
|
||||
) : (
|
||||
<StatusBadge tone="zinc">待生成</StatusBadge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-2xl border border-white/10 bg-black/20 px-4 py-3">
|
||||
<div className="text-sm text-zinc-200">核心动作状态</div>
|
||||
{appliedActionCount > 0 ? (
|
||||
<StatusBadge tone="amber">
|
||||
已应用 {appliedActionCount}/{CORE_ACTIONS.length}
|
||||
</StatusBadge>
|
||||
) : (
|
||||
<StatusBadge tone="zinc">待生成</StatusBadge>
|
||||
)}
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-xs leading-6 text-zinc-500">
|
||||
当前面板只保留主图和图生视频抽帧这条生产链,不提供视频预览、抽帧编辑、修帧和导出步骤。
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
</ModalShell>
|
||||
);
|
||||
}
|
||||
@@ -14,11 +14,11 @@ import {
|
||||
} from '../hooks/useStoryGeneration';
|
||||
import {
|
||||
type Character,
|
||||
type CustomWorldProfile,
|
||||
type CompanionRenderState,
|
||||
type GameState,
|
||||
type StoryMoment,
|
||||
type StoryOption,
|
||||
type WorldType,
|
||||
} from '../types';
|
||||
import {CHROME_ICONS, getNineSliceStyle, TAB_ICONS, UI_CHROME} from '../uiAssets';
|
||||
import {CharacterSelectionFlow} from './game-shell/CharacterSelectionFlow';
|
||||
@@ -58,7 +58,7 @@ interface GameShellEntryProps {
|
||||
handleContinueGame: () => void;
|
||||
handleStartNewGame: () => void;
|
||||
handleSaveAndExit: () => void;
|
||||
handleWorldSelect: (type: WorldType, customWorldProfile?: GameState['customWorldProfile']) => void;
|
||||
handleCustomWorldSelect: (customWorldProfile: CustomWorldProfile) => void;
|
||||
handleBackToWorldSelect: () => void;
|
||||
handleCharacterSelect: (character: Character) => void;
|
||||
}
|
||||
@@ -211,7 +211,7 @@ export function GameShell({session, story, entry, companions, audio}: GameShellP
|
||||
handleContinueGame,
|
||||
handleStartNewGame,
|
||||
handleSaveAndExit,
|
||||
handleWorldSelect,
|
||||
handleCustomWorldSelect,
|
||||
handleBackToWorldSelect,
|
||||
handleCharacterSelect,
|
||||
} = entry;
|
||||
@@ -430,7 +430,7 @@ export function GameShell({session, story, entry, companions, audio}: GameShellP
|
||||
hasSavedGame={hasSavedGame}
|
||||
handleContinueGame={handleContinueGame}
|
||||
handleStartNewGame={handleStartNewGame}
|
||||
handleWorldSelect={handleWorldSelect}
|
||||
handleCustomWorldSelect={handleCustomWorldSelect}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -150,7 +150,10 @@ function TradeQuantityStepper({
|
||||
|
||||
export function NpcModals({ gameState, npcUi }: NpcModalsProps) {
|
||||
const [tradeDetail, setTradeDetail] = useState<TradeDetailState>(null);
|
||||
const currencyName = getCurrencyName(gameState.worldType);
|
||||
const currencyName = getCurrencyName(
|
||||
gameState.worldType,
|
||||
gameState.customWorldProfile,
|
||||
);
|
||||
const tradeModal = npcUi.tradeModal;
|
||||
const tradeNpcState = tradeModal
|
||||
? gameState.npcStates[getNpcEncounterKey(tradeModal.encounter)]
|
||||
|
||||
@@ -1135,84 +1135,53 @@ export function AdventurePanelOverlays({
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-3 scrollbar-hide">
|
||||
{(() => {
|
||||
if (!activeGoalQuest) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const currentTaskCard = buildCurrentTaskCardCopy({
|
||||
goalStack,
|
||||
goalPulse,
|
||||
journeyBeat,
|
||||
sceneName: statistics.currentSceneName,
|
||||
});
|
||||
if (!currentTaskCard) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<TaskTemplateCard
|
||||
eyebrow={currentTaskCard.eyebrow}
|
||||
title={currentTaskCard.title}
|
||||
description={currentTaskCard.description}
|
||||
condition={currentTaskCard.condition}
|
||||
progress={currentTaskCard.progress}
|
||||
reward={activeGoalQuest?.reward ?? null}
|
||||
onRewardItemSelect={
|
||||
activeGoalQuest
|
||||
? itemId => selectQuestRewardItem(activeGoalQuest, itemId)
|
||||
: null
|
||||
}
|
||||
tone="main"
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
|
||||
{quests.length > 0 ? (
|
||||
<div className={`${activeGoalQuest ? 'mt-3' : ''} space-y-2`}>
|
||||
<div className="space-y-2">
|
||||
{sortedQuests.map(quest => (
|
||||
<button
|
||||
<div
|
||||
key={quest.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedQuestId(quest.id)}
|
||||
className="w-full rounded-2xl border border-white/8 bg-black/20 px-3 py-3 text-left transition hover:border-white/15"
|
||||
className="rounded-2xl border border-white/8 bg-black/20 transition hover:border-white/15 focus-within:border-white/15"
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="text-[10px] tracking-[0.2em] text-zinc-500">
|
||||
{goalStack.activeGoal?.sourceKind === 'quest' && goalStack.activeGoal.sourceId === quest.id
|
||||
? '当前主任务'
|
||||
: '任务'}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedQuestId(quest.id)}
|
||||
className="w-full px-3 pt-3 text-left focus:outline-none"
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="text-[10px] tracking-[0.2em] text-zinc-500">任务</div>
|
||||
<span className={`rounded-full px-2 py-0.5 text-[10px] ${
|
||||
isQuestReadyToClaim(quest)
|
||||
? 'border border-emerald-400/20 bg-emerald-500/10 text-emerald-100'
|
||||
: quest.status === 'turned_in'
|
||||
? 'border border-white/10 bg-white/8 text-zinc-200'
|
||||
: 'border border-sky-400/20 bg-sky-500/10 text-sky-100'
|
||||
}`}>
|
||||
{getQuestStatusLabel(quest.status)}
|
||||
</span>
|
||||
</div>
|
||||
<span className={`rounded-full px-2 py-0.5 text-[10px] ${
|
||||
isQuestReadyToClaim(quest)
|
||||
? 'border border-emerald-400/20 bg-emerald-500/10 text-emerald-100'
|
||||
: quest.status === 'turned_in'
|
||||
? 'border border-white/10 bg-white/8 text-zinc-200'
|
||||
: 'border border-sky-400/20 bg-sky-500/10 text-sky-100'
|
||||
}`}>
|
||||
{getQuestStatusLabel(quest.status)}
|
||||
</span>
|
||||
<div className="mt-2 text-sm font-semibold text-white">
|
||||
{formatTaskTitle(quest.title)}
|
||||
</div>
|
||||
<div className="mt-2 text-xs leading-relaxed text-zinc-400">
|
||||
<span className="text-zinc-500">任务描述:</span>
|
||||
{quest.description || quest.narrativeBinding?.playerHook || quest.summary}
|
||||
</div>
|
||||
<div className="mt-1 text-xs leading-relaxed text-zinc-300">
|
||||
<span className="text-zinc-500">达成条件:</span>
|
||||
{buildQuestConditionText(quest, worldType)}
|
||||
</div>
|
||||
<div className="mt-2 text-[11px] leading-relaxed text-zinc-500">
|
||||
委托人:{quest.issuerNpcName}
|
||||
{` · 任务进度:${getQuestProgressText(quest)}`}
|
||||
</div>
|
||||
</button>
|
||||
<div className="px-3 pb-3">
|
||||
<QuestRewardIconStrip
|
||||
reward={quest.reward}
|
||||
onSelectItem={itemId => selectQuestRewardItem(quest, itemId)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 text-sm font-semibold text-white">
|
||||
{formatTaskTitle(quest.title)}
|
||||
</div>
|
||||
<div className="mt-2 text-xs leading-relaxed text-zinc-400">
|
||||
<span className="text-zinc-500">任务描述:</span>
|
||||
{quest.description || quest.narrativeBinding?.playerHook || quest.summary}
|
||||
</div>
|
||||
<div className="mt-1 text-xs leading-relaxed text-zinc-300">
|
||||
<span className="text-zinc-500">达成条件:</span>
|
||||
{buildQuestConditionText(quest, worldType)}
|
||||
</div>
|
||||
<div className="mt-2 text-[11px] leading-relaxed text-zinc-500">
|
||||
委托人:{quest.issuerNpcName}
|
||||
{` · 任务进度:${getQuestProgressText(quest)}`}
|
||||
</div>
|
||||
<QuestRewardIconStrip
|
||||
reward={quest.reward}
|
||||
onSelectItem={itemId => selectQuestRewardItem(quest, itemId)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -10,10 +10,10 @@ import type {
|
||||
} from '../../hooks/useStoryGeneration';
|
||||
import type {
|
||||
CompanionRenderState,
|
||||
CustomWorldProfile,
|
||||
GameState,
|
||||
StoryMoment,
|
||||
StoryOption,
|
||||
WorldType,
|
||||
} from '../../types';
|
||||
import { UI_CHROME } from '../../uiAssets';
|
||||
import type { GameCanvasEntitySelection } from '../GameCanvas';
|
||||
@@ -51,7 +51,7 @@ export function GameShellMainContent({
|
||||
hasSavedGame,
|
||||
handleContinueGame,
|
||||
handleStartNewGame,
|
||||
handleWorldSelect,
|
||||
handleCustomWorldSelect,
|
||||
handleBackToWorldSelect,
|
||||
handleCharacterSelect,
|
||||
displayedOptions,
|
||||
@@ -88,7 +88,7 @@ export function GameShellMainContent({
|
||||
hasSavedGame: boolean;
|
||||
handleContinueGame: () => void;
|
||||
handleStartNewGame: () => void;
|
||||
handleWorldSelect: (type: WorldType, customWorldProfile?: GameState['customWorldProfile']) => void;
|
||||
handleCustomWorldSelect: (customWorldProfile: CustomWorldProfile) => void;
|
||||
handleBackToWorldSelect: () => void;
|
||||
handleCharacterSelect: (character: NonNullable<GameState['playerCharacter']>) => void;
|
||||
displayedOptions: StoryOption[];
|
||||
@@ -132,7 +132,7 @@ export function GameShellMainContent({
|
||||
hasSavedGame={hasSavedGame}
|
||||
handleContinueGame={handleContinueGame}
|
||||
handleStartNewGame={handleStartNewGame}
|
||||
handleWorldSelect={handleWorldSelect}
|
||||
handleCustomWorldSelect={handleCustomWorldSelect}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ export function GameShellRuntime({session, story, entry, companions, audio}: Gam
|
||||
handleContinueGame,
|
||||
handleStartNewGame,
|
||||
handleSaveAndExit,
|
||||
handleWorldSelect,
|
||||
handleCustomWorldSelect,
|
||||
handleBackToWorldSelect,
|
||||
handleCharacterSelect,
|
||||
} = entry;
|
||||
@@ -228,7 +228,7 @@ export function GameShellRuntime({session, story, entry, companions, audio}: Gam
|
||||
hasSavedGame={hasSavedGame}
|
||||
handleContinueGame={handleContinueGame}
|
||||
handleStartNewGame={handleStartNewGame}
|
||||
handleWorldSelect={handleWorldSelect}
|
||||
handleCustomWorldSelect={handleCustomWorldSelect}
|
||||
handleBackToWorldSelect={handleBackToWorldSelect}
|
||||
handleCharacterSelect={handleCharacterSelect}
|
||||
displayedOptions={displayedOptions}
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
upsertSavedCustomWorldProfile,
|
||||
} from '../../data/customWorldLibrary';
|
||||
import { resolveCustomWorldCampSceneImage } from '../../data/customWorldVisuals';
|
||||
import { getScenePreset } from '../../data/scenePresets';
|
||||
import {
|
||||
type CustomWorldGenerationProgress,
|
||||
generateCustomWorldProfile,
|
||||
@@ -25,7 +24,6 @@ import {
|
||||
type CustomWorldGenerationMode,
|
||||
type CustomWorldProfile,
|
||||
type GameState,
|
||||
WorldType,
|
||||
} from '../../types';
|
||||
import {
|
||||
CHROME_ICONS,
|
||||
@@ -45,8 +43,6 @@ export type SelectionStage =
|
||||
| 'custom-world-generating'
|
||||
| 'custom-world-result';
|
||||
|
||||
type WorldOnlineCounts = Partial<Record<WorldType, number>>;
|
||||
|
||||
type PreGameSelectionFlowProps = {
|
||||
selectionStage: SelectionStage;
|
||||
setSelectionStage: (stage: SelectionStage) => void;
|
||||
@@ -54,10 +50,7 @@ type PreGameSelectionFlowProps = {
|
||||
hasSavedGame: boolean;
|
||||
handleContinueGame: () => void;
|
||||
handleStartNewGame: () => void;
|
||||
handleWorldSelect: (
|
||||
type: WorldType,
|
||||
customWorldProfile?: GameState['customWorldProfile'],
|
||||
) => void;
|
||||
handleCustomWorldSelect: (customWorldProfile: CustomWorldProfile) => void;
|
||||
};
|
||||
|
||||
const DEVELOPER_TEAM_MESSAGE =
|
||||
@@ -68,32 +61,6 @@ const START_SCREEN_CONTACTS = [
|
||||
{ label: '微信', value: 'bzh253518756' },
|
||||
] as const;
|
||||
|
||||
const WORLD_OPTIONS = [
|
||||
{
|
||||
id: WorldType.WUXIA,
|
||||
name: '武侠',
|
||||
subtitle: '刀剑江湖',
|
||||
icon: WORLD_SELECT_ICONS.wuxia,
|
||||
texture: UI_CHROME.worldButtonWuxia,
|
||||
},
|
||||
{
|
||||
id: WorldType.XIANXIA,
|
||||
name: '仙侠',
|
||||
subtitle: '云灵仙境',
|
||||
icon: WORLD_SELECT_ICONS.xianxia,
|
||||
texture: UI_CHROME.worldButtonXianxia,
|
||||
},
|
||||
] as const;
|
||||
|
||||
function generateWorldOnlineCounts(): WorldOnlineCounts {
|
||||
const roll = (base: number) =>
|
||||
Math.max(100, Math.min(200, base + Math.floor(Math.random() * 19) - 9));
|
||||
return {
|
||||
[WorldType.WUXIA]: roll(146),
|
||||
[WorldType.XIANXIA]: roll(173),
|
||||
};
|
||||
}
|
||||
|
||||
function buildLockedSeedNameSets(profile: CustomWorldProfile) {
|
||||
const lockedCharacterNames = new Set(
|
||||
profile.creatorIntent?.keyCharacters
|
||||
@@ -168,7 +135,7 @@ export function PreGameSelectionFlow({
|
||||
hasSavedGame,
|
||||
handleContinueGame,
|
||||
handleStartNewGame,
|
||||
handleWorldSelect,
|
||||
handleCustomWorldSelect,
|
||||
}: PreGameSelectionFlowProps) {
|
||||
const [generatedCustomWorldProfile, setGeneratedCustomWorldProfile] =
|
||||
useState<GameState['customWorldProfile']>(null);
|
||||
@@ -176,9 +143,6 @@ export function PreGameSelectionFlow({
|
||||
CustomWorldProfile[]
|
||||
>(() => readSavedCustomWorldProfiles());
|
||||
const [showDeveloperTeamModal, setShowDeveloperTeamModal] = useState(false);
|
||||
const [worldOnlineCounts, setWorldOnlineCounts] = useState<WorldOnlineCounts>(
|
||||
() => generateWorldOnlineCounts(),
|
||||
);
|
||||
const [showCustomWorldModal, setShowCustomWorldModal] = useState(false);
|
||||
const [customWorldCreatorIntent, setCustomWorldCreatorIntent] =
|
||||
useState<CustomWorldCreatorIntent>(() =>
|
||||
@@ -200,23 +164,6 @@ export function PreGameSelectionFlow({
|
||||
[generatedCustomWorldProfile],
|
||||
);
|
||||
|
||||
const worldCards = useMemo(
|
||||
() =>
|
||||
WORLD_OPTIONS.map((world, index) => ({
|
||||
...world,
|
||||
sceneImage:
|
||||
getScenePreset(world.id, index + 1)?.imageSrc ??
|
||||
getScenePreset(world.id, 0)?.imageSrc ??
|
||||
'',
|
||||
featureIcon:
|
||||
world.id === WorldType.WUXIA
|
||||
? '/Icons/03_Torch.png'
|
||||
: '/Icons/19_Mana_potion.png',
|
||||
onlineCount: worldOnlineCounts[world.id] ?? 0,
|
||||
})),
|
||||
[worldOnlineCounts],
|
||||
);
|
||||
|
||||
const savedCustomWorldCards = useMemo(
|
||||
() =>
|
||||
savedCustomWorldProfiles.map((profile) => {
|
||||
@@ -255,12 +202,6 @@ export function PreGameSelectionFlow({
|
||||
return customWorldCreatorIntent.rawSettingText.trim();
|
||||
}, [customWorldCreatorIntent]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!gameState.worldType && selectionStage === 'world') {
|
||||
setWorldOnlineCounts(generateWorldOnlineCounts());
|
||||
}
|
||||
}, [gameState.worldType, selectionStage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
selectionStage === 'custom-world-result' &&
|
||||
@@ -343,7 +284,7 @@ export function PreGameSelectionFlow({
|
||||
return;
|
||||
}
|
||||
|
||||
handleWorldSelect(WorldType.CUSTOM, generatedCustomWorldProfile);
|
||||
handleCustomWorldSelect(generatedCustomWorldProfile);
|
||||
setGeneratedCustomWorldProfile(null);
|
||||
setCustomWorldError(null);
|
||||
setCustomWorldProgress(null);
|
||||
@@ -454,147 +395,6 @@ export function PreGameSelectionFlow({
|
||||
);
|
||||
};
|
||||
|
||||
const regeneratePlayableNpc = async (id: string) => {
|
||||
await regenerateFromCurrentProfile(
|
||||
(currentProfile, regeneratedProfile) => {
|
||||
const targetIndex = currentProfile.playableNpcs.findIndex(
|
||||
(entry) => entry.id === id,
|
||||
);
|
||||
if (targetIndex < 0) {
|
||||
return currentProfile;
|
||||
}
|
||||
const nextNpc =
|
||||
regeneratedProfile.playableNpcs[targetIndex] ??
|
||||
regeneratedProfile.playableNpcs.find(
|
||||
(entry) =>
|
||||
entry.name === currentProfile.playableNpcs[targetIndex]?.name,
|
||||
);
|
||||
if (!nextNpc) {
|
||||
return currentProfile;
|
||||
}
|
||||
|
||||
return {
|
||||
...currentProfile,
|
||||
playableNpcs: currentProfile.playableNpcs.map((entry, index) =>
|
||||
index === targetIndex ? nextNpc : entry,
|
||||
),
|
||||
};
|
||||
},
|
||||
{
|
||||
confirmMessage: '确认重新生成这个可扮演角色吗?当前角色的 AI 生成内容会被替换。',
|
||||
generationMode: 'full',
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const regenerateStoryNpc = async (id: string) => {
|
||||
await regenerateFromCurrentProfile(
|
||||
(currentProfile, regeneratedProfile) => {
|
||||
const targetIndex = currentProfile.storyNpcs.findIndex(
|
||||
(entry) => entry.id === id,
|
||||
);
|
||||
if (targetIndex < 0) {
|
||||
return currentProfile;
|
||||
}
|
||||
const nextNpc =
|
||||
regeneratedProfile.storyNpcs[targetIndex] ??
|
||||
regeneratedProfile.storyNpcs.find(
|
||||
(entry) => entry.name === currentProfile.storyNpcs[targetIndex]?.name,
|
||||
);
|
||||
if (!nextNpc) {
|
||||
return currentProfile;
|
||||
}
|
||||
|
||||
const nextStoryNpcs = currentProfile.storyNpcs.map((entry, index) =>
|
||||
index === targetIndex ? nextNpc : entry,
|
||||
);
|
||||
|
||||
return {
|
||||
...currentProfile,
|
||||
storyNpcs: nextStoryNpcs,
|
||||
landmarks: currentProfile.landmarks.map((landmark) => ({
|
||||
...landmark,
|
||||
sceneNpcIds: landmark.sceneNpcIds.map((npcId) =>
|
||||
npcId === id ? nextNpc.id : npcId,
|
||||
),
|
||||
})),
|
||||
};
|
||||
},
|
||||
{
|
||||
confirmMessage: '确认重新生成这个场景角色吗?当前角色的 AI 生成内容会被替换。',
|
||||
generationMode: 'full',
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const regenerateLandmark = async (id: string) => {
|
||||
await regenerateFromCurrentProfile(
|
||||
(currentProfile, regeneratedProfile) => {
|
||||
const targetIndex = currentProfile.landmarks.findIndex(
|
||||
(entry) => entry.id === id,
|
||||
);
|
||||
if (targetIndex < 0) {
|
||||
return currentProfile;
|
||||
}
|
||||
const nextLandmark =
|
||||
regeneratedProfile.landmarks[targetIndex] ??
|
||||
regeneratedProfile.landmarks.find(
|
||||
(entry) => entry.name === currentProfile.landmarks[targetIndex]?.name,
|
||||
);
|
||||
if (!nextLandmark) {
|
||||
return currentProfile;
|
||||
}
|
||||
|
||||
return {
|
||||
...currentProfile,
|
||||
landmarks: currentProfile.landmarks.map((entry, index) =>
|
||||
index === targetIndex ? nextLandmark : entry,
|
||||
),
|
||||
};
|
||||
},
|
||||
{
|
||||
confirmMessage: '确认重新生成这个关键地点吗?当前场景的 AI 生成内容会被替换。',
|
||||
generationMode: 'full',
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const regenerateStoryExpansion = async () => {
|
||||
await regenerateFromCurrentProfile(
|
||||
(currentProfile, regeneratedProfile) => ({
|
||||
...currentProfile,
|
||||
storyNpcs: regeneratedProfile.storyNpcs,
|
||||
}),
|
||||
{
|
||||
confirmMessage:
|
||||
'确认重新生成长尾场景角色吗?已锁定锚点会保留,其余场景角色会被新的生成结果替换。',
|
||||
generationMode: 'full',
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const regenerateLandmarkNetwork = async () => {
|
||||
await regenerateFromCurrentProfile(
|
||||
(currentProfile, regeneratedProfile) => ({
|
||||
...currentProfile,
|
||||
landmarks: currentProfile.landmarks.map((landmark, index) => ({
|
||||
...landmark,
|
||||
sceneNpcIds:
|
||||
regeneratedProfile.landmarks[index]?.sceneNpcIds ??
|
||||
landmark.sceneNpcIds,
|
||||
connections:
|
||||
regeneratedProfile.landmarks[index]?.connections ??
|
||||
landmark.connections,
|
||||
})),
|
||||
}),
|
||||
{
|
||||
confirmMessage:
|
||||
'确认重新生成场景网络吗?已锁定场景名称与描述会保留,但 NPC 分布和连接关系会按最新结果刷新。',
|
||||
generationMode: 'full',
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const createCustomWorld = async () => {
|
||||
if (isGeneratingCustomWorld) {
|
||||
return;
|
||||
@@ -800,7 +600,7 @@ export function PreGameSelectionFlow({
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<div className="text-sm font-bold tracking-[0.2em] text-zinc-400">
|
||||
选择世界
|
||||
自定义世界
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -814,67 +614,14 @@ export function PreGameSelectionFlow({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto pr-1">
|
||||
<div className="min-h-0 flex-1 overflow-y-auto pr-1">
|
||||
<div className="grid gap-3 pb-1 md:grid-cols-2 xl:grid-cols-3">
|
||||
{worldCards.map((world) => (
|
||||
<button
|
||||
key={world.id}
|
||||
type="button"
|
||||
onClick={() => handleWorldSelect(world.id)}
|
||||
className="pixel-nine-slice pixel-pressable order-2 relative flex min-h-[12.5rem] flex-col items-start justify-between overflow-hidden text-left"
|
||||
style={getNineSliceStyle(world.texture, {
|
||||
paddingX: 18,
|
||||
paddingY: 16,
|
||||
})}
|
||||
>
|
||||
{world.sceneImage && (
|
||||
<img
|
||||
src={world.sceneImage}
|
||||
alt={world.subtitle}
|
||||
className="absolute inset-0 h-full w-full object-cover opacity-25"
|
||||
style={{ imageRendering: 'pixelated' }}
|
||||
/>
|
||||
)}
|
||||
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(8,10,14,0.12),rgba(8,10,14,0.82))]" />
|
||||
<div className="absolute right-3 top-3 flex h-9 w-9 items-center justify-center rounded-full border border-white/10 bg-black/25">
|
||||
<PixelIcon
|
||||
src={world.featureIcon}
|
||||
className="h-5 w-5 opacity-95"
|
||||
/>
|
||||
</div>
|
||||
<div className="relative z-10 flex h-full w-full flex-col">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="rounded-full border border-white/10 bg-black/24 px-3 py-1 text-[10px] tracking-[0.2em] text-zinc-100">
|
||||
{world.name}
|
||||
</div>
|
||||
<PixelIcon
|
||||
src={world.icon}
|
||||
className="h-10 w-10 opacity-95"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-auto">
|
||||
<div className="text-3xl font-black text-white">
|
||||
{world.subtitle}
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<span className="rounded-full border border-emerald-400/20 bg-emerald-500/10 px-3 py-1 text-[10px] text-emerald-100">
|
||||
在线 {world.onlineCount}
|
||||
</span>
|
||||
<span className="rounded-full border border-white/10 bg-black/24 px-2.5 py-1 text-[10px] text-zinc-100">
|
||||
推荐
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
|
||||
{savedCustomWorldCards.map((world) => (
|
||||
<div key={world.id} className="order-1 relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
handleWorldSelect(WorldType.CUSTOM, world.profile)
|
||||
handleCustomWorldSelect(world.profile)
|
||||
}
|
||||
className="pixel-nine-slice pixel-pressable relative flex min-h-[12.5rem] w-full flex-col items-start justify-between overflow-hidden text-left"
|
||||
style={getNineSliceStyle(world.texture, {
|
||||
@@ -1013,21 +760,6 @@ export function PreGameSelectionFlow({
|
||||
onContinueExpand={() => {
|
||||
void continueExpandCustomWorld();
|
||||
}}
|
||||
onRegeneratePlayableNpc={(id) => {
|
||||
void regeneratePlayableNpc(id);
|
||||
}}
|
||||
onRegenerateStoryNpc={(id) => {
|
||||
void regenerateStoryNpc(id);
|
||||
}}
|
||||
onRegenerateLandmark={(id) => {
|
||||
void regenerateLandmark(id);
|
||||
}}
|
||||
onRegenerateStoryExpansion={() => {
|
||||
void regenerateStoryExpansion();
|
||||
}}
|
||||
onRegenerateLandmarkNetwork={() => {
|
||||
void regenerateLandmarkNetwork();
|
||||
}}
|
||||
onSave={saveGeneratedCustomWorld}
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
@@ -9,11 +9,11 @@ import type {
|
||||
} from '../../hooks/useStoryGeneration';
|
||||
import type {
|
||||
Character,
|
||||
CustomWorldProfile,
|
||||
CompanionRenderState,
|
||||
GameState,
|
||||
StoryMoment,
|
||||
StoryOption,
|
||||
WorldType,
|
||||
} from '../../types';
|
||||
|
||||
export interface GameShellSessionProps {
|
||||
@@ -46,7 +46,7 @@ export interface GameShellEntryProps {
|
||||
handleContinueGame: () => void;
|
||||
handleStartNewGame: () => void;
|
||||
handleSaveAndExit: () => void;
|
||||
handleWorldSelect: (type: WorldType, customWorldProfile?: GameState['customWorldProfile']) => void;
|
||||
handleCustomWorldSelect: (customWorldProfile: CustomWorldProfile) => void;
|
||||
handleBackToWorldSelect: () => void;
|
||||
handleCharacterSelect: (character: Character) => void;
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ export type CharacterVisualGenerationPayload = {
|
||||
characterId: string;
|
||||
sourceMode: Exclude<CharacterVisualSourceMode, 'upload'>;
|
||||
promptText: string;
|
||||
characterBriefText?: string;
|
||||
referenceImageDataUrls: string[];
|
||||
candidateCount: number;
|
||||
imageModel: string;
|
||||
@@ -57,6 +58,7 @@ export type CharacterVisualPublishPayload = {
|
||||
previewSources: string[];
|
||||
width: number;
|
||||
height: number;
|
||||
updateCharacterOverride?: boolean;
|
||||
};
|
||||
|
||||
export type CharacterAnimationGenerationPayload = {
|
||||
@@ -64,6 +66,8 @@ export type CharacterAnimationGenerationPayload = {
|
||||
strategy: CharacterAnimationStrategy;
|
||||
animation: string;
|
||||
promptText: string;
|
||||
characterBriefText?: string;
|
||||
actionTemplateId?: string;
|
||||
visualSource: string;
|
||||
referenceImageDataUrls: string[];
|
||||
referenceVideoDataUrls: string[];
|
||||
@@ -243,6 +247,7 @@ export async function publishCharacterAnimationAssets(payload: {
|
||||
characterId: string;
|
||||
visualAssetId: string;
|
||||
animations: Record<string, CharacterAnimationDraftPayload>;
|
||||
updateCharacterOverride?: boolean;
|
||||
}) {
|
||||
const response = await fetch(CHARACTER_ANIMATION_PUBLISH_API_PATH, {
|
||||
method: 'POST',
|
||||
@@ -259,6 +264,7 @@ export async function publishCharacterAnimationAssets(payload: {
|
||||
ok: true;
|
||||
animationSetId: string;
|
||||
overrideMap: Record<string, unknown>;
|
||||
animationMap: Record<string, unknown>;
|
||||
saveMessage: string;
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user