Simplify custom world result editing controls

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

View File

@@ -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>
))
)}

View File

@@ -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);

View File

@@ -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}

View File

@@ -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}
/>

View 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>
);
}

View File

@@ -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}
/>
)}

View File

@@ -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)]

View File

@@ -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>
) : (

View File

@@ -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}
/>
)}

View File

@@ -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}

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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;
};
}