1
This commit is contained in:
@@ -26,10 +26,10 @@ import { RESOLVED_ENTITY_X_METERS } from '../../data/sceneEncounterPreviews';
|
||||
import { buildEncounterFromSceneNpc } from '../../data/scenePresets';
|
||||
import { EDITOR_ITEM_CATALOG_API_PATH } from '../../editor/shared/editorApiClient';
|
||||
import { fetchJson } from '../../editor/shared/jsonClient';
|
||||
import { useCombatFlow } from '../../hooks/useCombatFlow';
|
||||
import { useNpcInteractionFlow } from '../../hooks/useNpcInteractionFlow';
|
||||
import { useRpgRuntimeStory } from '../../hooks/rpg-runtime-story/useRpgRuntimeStory';
|
||||
import { useRpgSessionBootstrap } from '../../hooks/rpg-session/useRpgSessionBootstrap';
|
||||
import { useCombatFlow } from '../../hooks/useCombatFlow';
|
||||
import { useNpcInteractionFlow } from '../../hooks/useNpcInteractionFlow';
|
||||
import { buildSkillActionPrompt } from '../../prompts/customWorldEntityActionPrompts';
|
||||
import type { CustomWorldSceneImageResult } from '../../services/aiTypes';
|
||||
import { resolveCustomWorldCampScene } from '../../services/customWorldCamp';
|
||||
@@ -37,18 +37,22 @@ import {
|
||||
buildDefaultCustomWorldCoverProfile,
|
||||
resolveCustomWorldCoverPresentation,
|
||||
} from '../../services/customWorldCover';
|
||||
import {
|
||||
getCustomWorldFoundationAnchorContent,
|
||||
parseFoundationTagText,
|
||||
type CustomWorldFoundationEntryId,
|
||||
} from '../../services/customWorldFoundationEntries';
|
||||
import { createEmptyCustomWorldCreatorIntent } from '../../services/customWorldCreatorIntent';
|
||||
import {
|
||||
type CustomWorldCoverAssetResult,
|
||||
generateCustomWorldCoverImage,
|
||||
uploadCustomWorldCoverImage,
|
||||
} from '../../services/customWorldCoverAssetService';
|
||||
import { rpgCreationAssetClient } from '../../services/rpg-creation/rpgCreationAssetClient';
|
||||
import { createEmptyCustomWorldCreatorIntent } from '../../services/customWorldCreatorIntent';
|
||||
import {
|
||||
type CustomWorldFoundationEntryId,
|
||||
getCustomWorldFoundationAnchorContent,
|
||||
parseFoundationTagText,
|
||||
} from '../../services/customWorldFoundationEntries';
|
||||
import {
|
||||
rpgCreationAssetClient,
|
||||
type RpgCreationHistoryAsset,
|
||||
type RpgCreationHistoryAssetKind,
|
||||
} from '../../services/rpg-creation/rpgCreationAssetClient';
|
||||
import { createEmptyStoryEngineMemoryState } from '../../services/storyEngine/visibilityEngine';
|
||||
import {
|
||||
AnimationState,
|
||||
@@ -81,23 +85,16 @@ import {
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
import { CharacterAnimator } from '../CharacterAnimator';
|
||||
import { CustomWorldCoverArtwork } from '../CustomWorldCoverArtwork';
|
||||
import { buildDefaultCustomWorldNpcVisual } from '../customWorldNpcVisualDefaults';
|
||||
import {
|
||||
CustomWorldNpcPortrait,
|
||||
CustomWorldNpcVisualEditor,
|
||||
} from '../CustomWorldNpcVisualEditor';
|
||||
import { RpgCreationRoleAssetStudioModal } from '../rpg-creation-asset-studio/RpgCreationRoleAssetStudioModal';
|
||||
import { CustomWorldNpcPortrait } from '../CustomWorldNpcVisualEditor';
|
||||
import {
|
||||
RoleCharacterSprite,
|
||||
SceneEncounterNpcSprite,
|
||||
} from '../game-canvas/GameCanvasShared';
|
||||
import { PixelIcon } from '../PixelIcon';
|
||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
import { RpgCreationRoleAssetStudioModal } from '../rpg-creation-asset-studio/RpgCreationRoleAssetStudioModal';
|
||||
import { RpgRuntimeShell } from '../rpg-runtime-shell';
|
||||
import {
|
||||
createLandmarkDraft,
|
||||
createPlayableNpcDraft,
|
||||
createStoryNpcDraft,
|
||||
resolveEditableLandmark,
|
||||
resolveEditablePlayableNpc,
|
||||
resolveEditableStoryNpc,
|
||||
@@ -135,9 +132,9 @@ function getAnimationPreviewFrameStyle(
|
||||
}
|
||||
|
||||
const [
|
||||
BACKSTORY_UNLOCK_AFFINITY_EASED,
|
||||
BACKSTORY_UNLOCK_AFFINITY_FRIENDLY,
|
||||
BACKSTORY_UNLOCK_AFFINITY_TRUSTED,
|
||||
,
|
||||
,
|
||||
,
|
||||
BACKSTORY_UNLOCK_AFFINITY_CLOSE,
|
||||
] = AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS;
|
||||
|
||||
@@ -211,10 +208,6 @@ function dedupeTextValues(values: Array<string | null | undefined>) {
|
||||
];
|
||||
}
|
||||
|
||||
function compactTextList(values: Array<string | null | undefined>) {
|
||||
return values.map((value) => value?.trim() ?? '').filter(Boolean);
|
||||
}
|
||||
|
||||
function moveArrayItem<T>(values: T[], fromIndex: number, toIndex: number) {
|
||||
if (
|
||||
fromIndex < 0 ||
|
||||
@@ -572,6 +565,8 @@ function sanitizeSceneChapterBlueprint(params: {
|
||||
actGoal: currentAct?.actGoal?.trim() || fallbackAct.actGoal,
|
||||
transitionHook:
|
||||
currentAct?.transitionHook?.trim() || fallbackAct.transitionHook,
|
||||
backgroundAssetId:
|
||||
currentAct?.backgroundAssetId?.trim() || fallbackAct.backgroundAssetId,
|
||||
} satisfies SceneActBlueprint;
|
||||
});
|
||||
|
||||
@@ -618,7 +613,7 @@ function resolveSceneCompatibilityImageSrc(params: {
|
||||
const firstActImageSrc =
|
||||
params.chapter.acts[0]?.backgroundImageSrc?.trim() || '';
|
||||
|
||||
// 中文注释:创作侧只暴露一张场景显示图,列表、幕卡片和背景配置弹层都从这里取图,避免同一场景在不同层级显示不同图片。
|
||||
// 中文注释:场景卡片只读取当前幕已保存图片;场景主图只给没有幕图的旧草稿兜底,不能反向覆盖每一幕。
|
||||
return firstActImageSrc || currentImageSrc || resolvedImageSrc || undefined;
|
||||
}
|
||||
|
||||
@@ -1047,7 +1042,7 @@ function ModalShell({
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={`platform-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' : `platform-ui-shell platform-theme ${platformThemeClass}`} ${panelClassName} sm:rounded-[1.75rem]`}
|
||||
className={`platform-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)] xl:max-h-[min(94vh,64rem)] ${usePixelFont ? 'fusion-pixel-app' : `platform-ui-shell platform-theme ${platformThemeClass}`} ${panelClassName} sm:rounded-[1.75rem]`}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3 border-b border-white/10 px-4 py-4 sm:px-5">
|
||||
@@ -1372,50 +1367,6 @@ function ImagePreview({
|
||||
);
|
||||
}
|
||||
|
||||
function ImageField({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
fallbackLabel,
|
||||
tone = 'square',
|
||||
showInput = true,
|
||||
previewOverlay,
|
||||
footer,
|
||||
}: {
|
||||
label: string;
|
||||
value?: string;
|
||||
onChange: (value: string) => void;
|
||||
fallbackLabel: string;
|
||||
tone?: 'square' | 'landscape';
|
||||
showInput?: boolean;
|
||||
previewOverlay?: ReactNode;
|
||||
footer?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="text-[11px] font-bold tracking-[0.14em] text-zinc-300">
|
||||
{label}
|
||||
</div>
|
||||
<ImagePreview
|
||||
src={value}
|
||||
alt={label}
|
||||
fallbackLabel={fallbackLabel}
|
||||
tone={tone}
|
||||
>
|
||||
{previewOverlay}
|
||||
</ImagePreview>
|
||||
{showInput ? (
|
||||
<TextInput
|
||||
value={value ?? ''}
|
||||
onChange={onChange}
|
||||
placeholder="支持填写项目内图片路径或外链地址"
|
||||
/>
|
||||
) : null}
|
||||
{footer}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionButton({
|
||||
label,
|
||||
onClick,
|
||||
@@ -1457,6 +1408,128 @@ function ActionButton({
|
||||
);
|
||||
}
|
||||
|
||||
function formatHistoryAssetDate(value: string) {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value || '';
|
||||
}
|
||||
return date.toLocaleString('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function HistoryAssetPickerModal({
|
||||
title,
|
||||
kind,
|
||||
tone,
|
||||
onSelect,
|
||||
onClose,
|
||||
}: {
|
||||
title: string;
|
||||
kind: RpgCreationHistoryAssetKind;
|
||||
tone: 'square' | 'landscape';
|
||||
onSelect: (asset: RpgCreationHistoryAsset) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [assets, setAssets] = useState<RpgCreationHistoryAsset[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let isCancelled = false;
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
rpgCreationAssetClient
|
||||
.listHistoryAssets({ kind, limit: 120 })
|
||||
.then((nextAssets) => {
|
||||
if (!isCancelled) {
|
||||
setAssets(nextAssets);
|
||||
}
|
||||
})
|
||||
.catch((loadError) => {
|
||||
if (!isCancelled) {
|
||||
setError(
|
||||
loadError instanceof Error ? loadError.message : '历史素材读取失败。',
|
||||
);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!isCancelled) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
};
|
||||
}, [kind]);
|
||||
|
||||
return (
|
||||
<ModalShell
|
||||
title={title}
|
||||
onClose={onClose}
|
||||
overlayClassName="z-[99]"
|
||||
panelClassName="sm:max-w-5xl"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{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}
|
||||
{isLoading ? (
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-8 text-center text-sm text-zinc-300">
|
||||
读取中...
|
||||
</div>
|
||||
) : assets.length === 0 && !error ? (
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-8 text-center text-sm text-zinc-300">
|
||||
暂无历史素材
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={`grid gap-3 ${
|
||||
tone === 'landscape'
|
||||
? 'sm:grid-cols-2 xl:grid-cols-3'
|
||||
: 'grid-cols-2 sm:grid-cols-3 xl:grid-cols-4'
|
||||
}`}
|
||||
>
|
||||
{assets.map((asset) => (
|
||||
<div
|
||||
key={asset.assetObjectId}
|
||||
className="overflow-hidden rounded-2xl border border-white/10 bg-black/20"
|
||||
>
|
||||
<ImagePreview
|
||||
src={asset.imageSrc}
|
||||
alt={asset.ownerLabel}
|
||||
fallbackLabel="素材"
|
||||
tone={tone}
|
||||
/>
|
||||
<div className="space-y-2 px-3 py-3">
|
||||
<div className="truncate text-xs font-semibold text-zinc-100">
|
||||
{asset.ownerLabel || '未记录账号'}
|
||||
</div>
|
||||
<div className="text-[11px] leading-5 text-zinc-400">
|
||||
{formatHistoryAssetDate(asset.createdAt)}
|
||||
</div>
|
||||
<ActionButton
|
||||
label="使用"
|
||||
onClick={() => onSelect(asset)}
|
||||
tone="sky"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ModalShell>
|
||||
);
|
||||
}
|
||||
|
||||
const SCENE_ACT_SLOT_LAYOUTS = [
|
||||
{
|
||||
left: '77%',
|
||||
@@ -2681,12 +2754,14 @@ function SceneImageGenerationModal({
|
||||
profile,
|
||||
landmark,
|
||||
initialPromptText,
|
||||
initialPreviewImageSrc,
|
||||
onApply,
|
||||
onClose,
|
||||
}: {
|
||||
profile: CustomWorldProfile;
|
||||
landmark: CustomWorldLandmark;
|
||||
initialPromptText?: string;
|
||||
initialPreviewImageSrc?: string | null;
|
||||
onApply: (result: CustomWorldSceneImageResult) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
@@ -2704,6 +2779,10 @@ function SceneImageGenerationModal({
|
||||
const [isExitConfirmOpen, setIsExitConfirmOpen] = useState(false);
|
||||
|
||||
const originalImageSrc = useMemo(() => {
|
||||
const initialPreview = initialPreviewImageSrc?.trim() || '';
|
||||
if (initialPreview) {
|
||||
return initialPreview;
|
||||
}
|
||||
const landmarkIndex = profile.landmarks.findIndex(
|
||||
(entry) => entry.id === landmark.id,
|
||||
);
|
||||
@@ -2717,7 +2796,7 @@ function SceneImageGenerationModal({
|
||||
.map((entry) => entry.imageSrc)
|
||||
.filter((imageSrc): imageSrc is string => Boolean(imageSrc)),
|
||||
);
|
||||
}, [landmark, profile]);
|
||||
}, [initialPreviewImageSrc, landmark, profile]);
|
||||
|
||||
const previewImageSrc = latestResult?.imageSrc || originalImageSrc;
|
||||
|
||||
@@ -2944,14 +3023,18 @@ function SceneActBackgroundModal({
|
||||
actLabel: string;
|
||||
currentImageSrc?: string | null;
|
||||
fallbackImageSrc?: string | null;
|
||||
onApply: (imageSrc?: string | null) => void;
|
||||
onApply: (imageSrc?: string | null, assetId?: string | null) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const presetImages = useMemo(() => getAllCustomWorldSceneImages(), []);
|
||||
const [draftImageSrc, setDraftImageSrc] = useDraft(
|
||||
currentImageSrc?.trim() || '',
|
||||
);
|
||||
const [draftAssetId, setDraftAssetId] = useDraft(
|
||||
act.backgroundAssetId?.trim() || '',
|
||||
);
|
||||
const [isAiGenerateOpen, setIsAiGenerateOpen] = useState(false);
|
||||
const [isHistoryPickerOpen, setIsHistoryPickerOpen] = useState(false);
|
||||
const previewImageSrc = draftImageSrc || fallbackImageSrc || '';
|
||||
|
||||
return (
|
||||
@@ -2972,13 +3055,20 @@ function SceneActBackgroundModal({
|
||||
<div className="mt-3 flex flex-wrap gap-3">
|
||||
<ActionButton
|
||||
label="跟随场景主图"
|
||||
onClick={() => setDraftImageSrc('')}
|
||||
onClick={() => {
|
||||
setDraftImageSrc('');
|
||||
setDraftAssetId('');
|
||||
}}
|
||||
tone="sky"
|
||||
/>
|
||||
<ActionButton
|
||||
label="AI生成"
|
||||
onClick={() => setIsAiGenerateOpen(true)}
|
||||
/>
|
||||
<ActionButton
|
||||
label="使用历史素材"
|
||||
onClick={() => setIsHistoryPickerOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3023,7 +3113,7 @@ function SceneActBackgroundModal({
|
||||
<ActionButton
|
||||
label="保存背景"
|
||||
onClick={() => {
|
||||
onApply(draftImageSrc || fallbackImageSrc || undefined);
|
||||
onApply(draftImageSrc || undefined, draftAssetId);
|
||||
onClose();
|
||||
}}
|
||||
tone="sky"
|
||||
@@ -3038,14 +3128,31 @@ function SceneActBackgroundModal({
|
||||
landmark={landmark}
|
||||
initialPromptText={
|
||||
act.backgroundPromptText?.trim() ||
|
||||
compactTextList([act.title, act.summary, act.actGoal]).join(';')
|
||||
landmark.visualDescription?.trim() ||
|
||||
landmark.description.trim() ||
|
||||
landmark.name.trim()
|
||||
}
|
||||
initialPreviewImageSrc={previewImageSrc}
|
||||
onApply={(result) => {
|
||||
setDraftImageSrc(result.imageSrc);
|
||||
setDraftAssetId(result.assetId);
|
||||
}}
|
||||
onClose={() => setIsAiGenerateOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
{isHistoryPickerOpen ? (
|
||||
<HistoryAssetPickerModal
|
||||
title="使用历史素材"
|
||||
kind="scene_image"
|
||||
tone="landscape"
|
||||
onSelect={(asset) => {
|
||||
setDraftImageSrc(asset.imageSrc);
|
||||
setDraftAssetId(asset.assetObjectId);
|
||||
setIsHistoryPickerOpen(false);
|
||||
}}
|
||||
onClose={() => setIsHistoryPickerOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -4704,45 +4811,6 @@ function InitialItemsEditor({
|
||||
);
|
||||
}
|
||||
|
||||
function StoryNpcVisualEditorModal({
|
||||
npc,
|
||||
visual,
|
||||
onChange,
|
||||
onOpenAiStudio,
|
||||
onClose,
|
||||
}: {
|
||||
npc: CustomWorldNpc;
|
||||
visual: NonNullable<CustomWorldNpc['visual']>;
|
||||
onChange: (visual: NonNullable<CustomWorldNpc['visual']>) => void;
|
||||
onOpenAiStudio?: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<ModalShell
|
||||
title={`修改形象:${npc.name}`}
|
||||
subtitle="在独立面板中组合中世纪奇幻角色形象,左侧预览会保持吸顶。"
|
||||
onClose={onClose}
|
||||
panelClassName="sm:max-w-6xl"
|
||||
overlayClassName="z-[99]"
|
||||
>
|
||||
<CustomWorldNpcVisualEditor
|
||||
npc={{
|
||||
id: npc.id,
|
||||
name: npc.name,
|
||||
role: npc.role,
|
||||
description: npc.description,
|
||||
}}
|
||||
value={visual}
|
||||
onChange={onChange}
|
||||
onAiGenerate={() => {
|
||||
onClose();
|
||||
onOpenAiStudio?.();
|
||||
}}
|
||||
/>
|
||||
</ModalShell>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorldEditor({
|
||||
profile,
|
||||
onSave,
|
||||
@@ -4759,6 +4827,7 @@ export function WorldEditor({
|
||||
title="编辑世界信息"
|
||||
subtitle="修改后的内容会直接反映在结果页,并会作为进入世界前的最终档案。"
|
||||
onClose={onClose}
|
||||
panelClassName="sm:max-w-4xl xl:max-w-6xl 2xl:max-w-7xl"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<Field label="世界名称">
|
||||
@@ -4822,6 +4891,24 @@ export function WorldEditor({
|
||||
rows={4}
|
||||
/>
|
||||
</Field>
|
||||
<WorldAttributeSchemaEditor
|
||||
value={draft.attributeSchema}
|
||||
onChange={(attributeSchema) =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
attributeSchema,
|
||||
ownedSettingLayers: current.ownedSettingLayers
|
||||
? {
|
||||
...current.ownedSettingLayers,
|
||||
ruleProfile: {
|
||||
...current.ownedSettingLayers.ruleProfile,
|
||||
attributeSchema,
|
||||
},
|
||||
}
|
||||
: current.ownedSettingLayers,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<SaveBar
|
||||
onClose={onClose}
|
||||
onSave={() => {
|
||||
@@ -4932,6 +5019,110 @@ function applyFoundationDraftToProfile(
|
||||
};
|
||||
}
|
||||
|
||||
function WorldAttributeSchemaEditor({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: CustomWorldProfile['attributeSchema'];
|
||||
onChange: (value: CustomWorldProfile['attributeSchema']) => void;
|
||||
}) {
|
||||
const updateSlot = (
|
||||
slotId: string,
|
||||
patch: Partial<CustomWorldProfile['attributeSchema']['slots'][number]>,
|
||||
) => {
|
||||
onChange({
|
||||
...value,
|
||||
slots: value.slots.map((slot) =>
|
||||
slot.slotId === slotId ? { ...slot, ...patch } : slot,
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionPanel title="角色维度" subtitle={value.schemaName || '世界能力维度'}>
|
||||
<div className="space-y-3">
|
||||
{value.slots.map((slot) => (
|
||||
<div
|
||||
key={slot.slotId}
|
||||
className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3"
|
||||
>
|
||||
<div className="grid gap-3 sm:grid-cols-[10rem_minmax(0,1fr)]">
|
||||
<Field label="维度名称">
|
||||
<TextInput
|
||||
value={slot.name}
|
||||
onChange={(name) => updateSlot(slot.slotId, { name })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="定义">
|
||||
<TextArea
|
||||
value={slot.definition}
|
||||
onChange={(definition) =>
|
||||
updateSlot(slot.slotId, { definition })
|
||||
}
|
||||
rows={2}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-3 sm:grid-cols-2">
|
||||
<Field label="正向信号">
|
||||
<TextArea
|
||||
value={commaText(slot.positiveSignals)}
|
||||
onChange={(text) =>
|
||||
updateSlot(slot.slotId, {
|
||||
positiveSignals: parseCommaText(text),
|
||||
})
|
||||
}
|
||||
rows={2}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="负向信号">
|
||||
<TextArea
|
||||
value={commaText(slot.negativeSignals)}
|
||||
onChange={(text) =>
|
||||
updateSlot(slot.slotId, {
|
||||
negativeSignals: parseCommaText(text),
|
||||
})
|
||||
}
|
||||
rows={2}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-3 sm:grid-cols-3">
|
||||
<Field label="战斗体现">
|
||||
<TextArea
|
||||
value={slot.combatUseText}
|
||||
onChange={(combatUseText) =>
|
||||
updateSlot(slot.slotId, { combatUseText })
|
||||
}
|
||||
rows={2}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="社交体现">
|
||||
<TextArea
|
||||
value={slot.socialUseText}
|
||||
onChange={(socialUseText) =>
|
||||
updateSlot(slot.slotId, { socialUseText })
|
||||
}
|
||||
rows={2}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="探索体现">
|
||||
<TextArea
|
||||
value={slot.explorationUseText}
|
||||
onChange={(explorationUseText) =>
|
||||
updateSlot(slot.slotId, { explorationUseText })
|
||||
}
|
||||
rows={2}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SectionPanel>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorldFoundationEditor({
|
||||
profile,
|
||||
onSave,
|
||||
@@ -4948,7 +5139,7 @@ export function WorldFoundationEditor({
|
||||
<ModalShell
|
||||
title="编辑基本设定"
|
||||
onClose={onClose}
|
||||
panelClassName="sm:max-w-4xl"
|
||||
panelClassName="sm:max-w-5xl xl:max-w-7xl 2xl:max-w-[92rem]"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{FOUNDATION_EDITOR_FIELDS.map((field) => (
|
||||
@@ -5059,12 +5250,12 @@ export function PlayableNpcEditor({
|
||||
}
|
||||
setIsCloseConfirmOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ModalShell
|
||||
title={mode === 'create' ? '新增可扮演角色' : `编辑角色:${npc.name}`}
|
||||
onClose={handleRequestClose}
|
||||
panelClassName="sm:max-w-4xl xl:max-w-6xl 2xl:max-w-7xl"
|
||||
disableClose={isAiAssetStudioOpen || isCloseConfirmOpen}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
@@ -5110,7 +5301,20 @@ export function PlayableNpcEditor({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
) : (
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="text-[11px] font-bold tracking-[0.18em] text-zinc-300">
|
||||
角色形象
|
||||
</div>
|
||||
<ActionButton
|
||||
label="AI生成"
|
||||
onClick={() => setIsAiAssetStudioOpen(true)}
|
||||
tone="sky"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Field label="名称">
|
||||
<TextInput
|
||||
value={draft.name}
|
||||
@@ -5289,8 +5493,8 @@ export function StoryNpcEditor({
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [draft, setDraft] = useDraft(npc);
|
||||
const [isVisualEditorOpen, setIsVisualEditorOpen] = useState(false);
|
||||
const [isAiAssetStudioOpen, setIsAiAssetStudioOpen] = useState(false);
|
||||
const [isHistoryPickerOpen, setIsHistoryPickerOpen] = useState(false);
|
||||
const [isCloseConfirmOpen, setIsCloseConfirmOpen] = useState(false);
|
||||
const initialSnapshot = useMemo(() => JSON.stringify(npc), [npc]);
|
||||
const draftSnapshot = useMemo(() => JSON.stringify(draft), [draft]);
|
||||
@@ -5322,14 +5526,14 @@ export function StoryNpcEditor({
|
||||
}
|
||||
setIsCloseConfirmOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ModalShell
|
||||
title={mode === 'create' ? '新增场景角色' : `编辑场景角色:${npc.name}`}
|
||||
onClose={handleRequestClose}
|
||||
panelClassName="sm:max-w-4xl xl:max-w-6xl 2xl:max-w-7xl"
|
||||
disableClose={
|
||||
isVisualEditorOpen || isAiAssetStudioOpen || isCloseConfirmOpen
|
||||
isHistoryPickerOpen || isAiAssetStudioOpen || isCloseConfirmOpen
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
@@ -5350,8 +5554,8 @@ export function StoryNpcEditor({
|
||||
<div className="min-w-0 space-y-3">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<ActionButton
|
||||
label="基于预设素材修改"
|
||||
onClick={() => setIsVisualEditorOpen(true)}
|
||||
label="使用历史素材"
|
||||
onClick={() => setIsHistoryPickerOpen(true)}
|
||||
tone="sky"
|
||||
/>
|
||||
<ActionButton
|
||||
@@ -5509,23 +5713,22 @@ export function StoryNpcEditor({
|
||||
}}
|
||||
showClose={false}
|
||||
/>
|
||||
{isVisualEditorOpen ? (
|
||||
<StoryNpcVisualEditorModal
|
||||
npc={draft}
|
||||
visual={
|
||||
draft.visual ??
|
||||
buildDefaultCustomWorldNpcVisual({
|
||||
id: draft.id,
|
||||
name: draft.name,
|
||||
role: draft.role,
|
||||
description: draft.description,
|
||||
})
|
||||
}
|
||||
onChange={(visual) =>
|
||||
setDraft((current) => ({ ...current, visual }))
|
||||
}
|
||||
onOpenAiStudio={() => setIsAiAssetStudioOpen(true)}
|
||||
onClose={() => setIsVisualEditorOpen(false)}
|
||||
{isHistoryPickerOpen ? (
|
||||
<HistoryAssetPickerModal
|
||||
title="使用历史素材"
|
||||
kind="character_visual"
|
||||
tone="square"
|
||||
onSelect={(asset) => {
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
imageSrc: asset.imageSrc,
|
||||
generatedVisualAssetId: asset.assetObjectId,
|
||||
generatedAnimationSetId: undefined,
|
||||
animationMap: undefined,
|
||||
}));
|
||||
setIsHistoryPickerOpen(false);
|
||||
}}
|
||||
onClose={() => setIsHistoryPickerOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
{isAiAssetStudioOpen ? (
|
||||
@@ -5935,14 +6138,29 @@ export function LandmarkEditor({
|
||||
}));
|
||||
};
|
||||
|
||||
const updateSceneActSharedBackground = (imageSrc?: string | null) => {
|
||||
const resolvedImageSrc = imageSrc?.trim() || compatibilityImageSrc || '';
|
||||
const updateSceneActBackground = (
|
||||
actIndex: number,
|
||||
imageSrc?: string | null,
|
||||
assetId?: string | null,
|
||||
) => {
|
||||
const resolvedImageSrc = imageSrc?.trim() || '';
|
||||
const normalizedAssetId = assetId?.trim();
|
||||
updateSceneChapterDraft((current) => ({
|
||||
...current,
|
||||
acts: current.acts.map((act) => ({
|
||||
...act,
|
||||
backgroundImageSrc: resolvedImageSrc || undefined,
|
||||
})),
|
||||
acts: current.acts.map((act, currentActIndex) =>
|
||||
currentActIndex === actIndex
|
||||
? {
|
||||
...act,
|
||||
backgroundImageSrc: resolvedImageSrc || undefined,
|
||||
backgroundAssetId:
|
||||
normalizedAssetId !== undefined
|
||||
? normalizedAssetId || undefined
|
||||
: resolvedImageSrc
|
||||
? act.backgroundAssetId
|
||||
: undefined,
|
||||
}
|
||||
: act,
|
||||
),
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -6094,6 +6312,7 @@ export function LandmarkEditor({
|
||||
: `编辑场景:${landmark.name || (isOpeningScene ? '开局场景' : '未命名场景')}`
|
||||
}
|
||||
onClose={handleRequestClose}
|
||||
panelClassName="sm:max-w-5xl xl:max-w-7xl 2xl:max-w-[96rem]"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<Field label="名称">
|
||||
@@ -6196,7 +6415,8 @@ export function LandmarkEditor({
|
||||
<SceneActStagePreview
|
||||
actLabel={actLabel}
|
||||
imageSrc={
|
||||
act.backgroundImageSrc?.trim() || compatibilityImageSrc
|
||||
act.backgroundImageSrc?.trim() ||
|
||||
compatibilityImageSrc
|
||||
}
|
||||
fallbackImageSrc={resolvedDraftImageSrc}
|
||||
previewCharacter={previewPlayableCharacter}
|
||||
@@ -6302,11 +6522,16 @@ export function LandmarkEditor({
|
||||
}
|
||||
act={activeSceneActBackgroundDraft}
|
||||
currentImageSrc={
|
||||
activeSceneActBackgroundDraft.backgroundImageSrc?.trim() ||
|
||||
compatibilityImageSrc
|
||||
activeSceneActBackgroundDraft.backgroundImageSrc?.trim() || ''
|
||||
}
|
||||
fallbackImageSrc={compatibilityImageSrc || resolvedDraftImageSrc}
|
||||
onApply={updateSceneActSharedBackground}
|
||||
onApply={(imageSrc, assetId) =>
|
||||
updateSceneActBackground(
|
||||
activeSceneActBackgroundIndex,
|
||||
imageSrc,
|
||||
assetId,
|
||||
)
|
||||
}
|
||||
onClose={() => setActiveSceneActBackgroundIndex(null)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
Reference in New Issue
Block a user