1
This commit is contained in:
@@ -68,15 +68,19 @@ function Section({
|
||||
badge,
|
||||
actions,
|
||||
children,
|
||||
className = '',
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
badge?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="platform-surface platform-surface--soft px-3.5 py-3">
|
||||
<div
|
||||
className={`platform-surface platform-surface--soft px-3.5 py-3 ${className}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs font-bold tracking-[0.16em] text-white">
|
||||
@@ -220,9 +224,7 @@ function PendingEntityCard({
|
||||
<div className="text-sm font-semibold text-[var(--platform-text-strong)]">
|
||||
{title}
|
||||
</div>
|
||||
<div className="mt-1 text-xs leading-6">
|
||||
{phaseLabel}
|
||||
</div>
|
||||
<div className="mt-1 text-xs leading-6">{phaseLabel}</div>
|
||||
</div>
|
||||
<div className="platform-pill platform-pill--cool px-2.5 py-1 text-[10px]">
|
||||
{Math.round(progress)}%
|
||||
@@ -286,9 +288,11 @@ function buildSceneChapterSearchText(
|
||||
}
|
||||
|
||||
function buildSceneTaskDescriptionText(sceneChapters: SceneChapterBlueprint[]) {
|
||||
return compactTextList(
|
||||
sceneChapters.map((chapter) => chapter.sceneTaskDescription),
|
||||
)[0] ?? '';
|
||||
return (
|
||||
compactTextList(
|
||||
sceneChapters.map((chapter) => chapter.sceneTaskDescription),
|
||||
)[0] ?? ''
|
||||
);
|
||||
}
|
||||
|
||||
function SceneActPreviewStrip({
|
||||
@@ -364,9 +368,7 @@ function CatalogCard({
|
||||
onClick={disabled ? undefined : onClick}
|
||||
aria-disabled={disabled}
|
||||
className={`w-full rounded-[1.3rem] border p-2.5 text-left transition-colors xl:p-3 ${
|
||||
isSelected
|
||||
? 'border-rose-300/35 bg-rose-500/10'
|
||||
: 'platform-subpanel'
|
||||
isSelected ? 'border-rose-300/35 bg-rose-500/10' : 'platform-subpanel'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3 xl:gap-3.5">
|
||||
@@ -388,7 +390,9 @@ function CatalogCard({
|
||||
<div className="mt-1.5 text-sm leading-5 text-zinc-300 xl:line-clamp-2">
|
||||
{description || '暂无描述'}
|
||||
</div>
|
||||
{actions ? <div className="mt-2 flex flex-wrap gap-2">{actions}</div> : null}
|
||||
{actions ? (
|
||||
<div className="mt-2 flex flex-wrap gap-2">{actions}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -402,9 +406,7 @@ function CatalogCard({
|
||||
onClick={disabled ? undefined : onClick}
|
||||
aria-disabled={disabled}
|
||||
className={`w-full rounded-[1.4rem] border p-3 text-left transition-colors ${
|
||||
isSelected
|
||||
? 'border-rose-300/35 bg-rose-500/10'
|
||||
: 'platform-subpanel'
|
||||
isSelected ? 'border-rose-300/35 bg-rose-500/10' : 'platform-subpanel'
|
||||
}`}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
@@ -816,17 +818,19 @@ export function CustomWorldEntityCatalog({
|
||||
return (
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className="h-full min-h-0 space-y-3 overflow-y-auto overscroll-contain pr-1 scrollbar-hide xl:space-y-4 xl:pr-2"
|
||||
className="h-full min-h-0 space-y-3 overflow-y-auto overscroll-contain pr-1 scrollbar-hide xl:space-y-4 xl:pr-2 2xl:space-y-5 2xl:pr-3"
|
||||
>
|
||||
<div className="px-1 pb-1 text-center xl:rounded-[2rem] xl:border xl:border-[var(--platform-subpanel-border)] xl:bg-white/55 xl:px-6 xl:py-4 xl:text-left xl:shadow-[0_18px_70px_rgba(255,79,139,0.08)] xl:backdrop-blur-sm">
|
||||
<div className="px-1 pb-1 text-center xl:flex xl:items-end xl:justify-between xl:gap-6 xl:rounded-[2rem] xl:border xl:border-[var(--platform-subpanel-border)] xl:bg-white/55 xl:px-6 xl:py-3 xl:text-left xl:shadow-[0_18px_70px_rgba(255,79,139,0.08)] xl:backdrop-blur-sm 2xl:px-7">
|
||||
<div className="text-[11px] font-bold tracking-[0.28em] text-zinc-500">
|
||||
世界档案
|
||||
</div>
|
||||
<div className="mt-2 text-3xl font-black text-[var(--platform-text-strong)] sm:text-[2.2rem] xl:mt-1 xl:text-[2rem]">
|
||||
{profile.name}
|
||||
</div>
|
||||
<div className="mt-2 text-sm tracking-[0.18em] text-zinc-400 xl:mt-1 xl:text-xs">
|
||||
{profile.subtitle}
|
||||
<div className="min-w-0 xl:flex xl:flex-1 xl:items-end xl:justify-between xl:gap-5">
|
||||
<div className="mt-2 truncate text-3xl font-black text-[var(--platform-text-strong)] sm:text-[2.2rem] xl:mt-0 xl:text-[2rem] 2xl:text-[2.25rem]">
|
||||
{profile.name}
|
||||
</div>
|
||||
<div className="mt-2 min-w-0 text-sm tracking-[0.18em] text-zinc-400 xl:mt-0 xl:max-w-[34rem] xl:truncate xl:text-right xl:text-xs">
|
||||
{profile.subtitle}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -898,7 +902,7 @@ export function CustomWorldEntityCatalog({
|
||||
</div>
|
||||
|
||||
{activeTab === 'world' ? (
|
||||
<div className="space-y-3 xl:grid xl:grid-cols-[0.8fr_1.2fr] xl:items-start xl:gap-3 xl:space-y-0">
|
||||
<div className="space-y-3 xl:grid xl:grid-cols-[minmax(18rem,0.82fr)_minmax(0,1fr)_minmax(24rem,1.08fr)] xl:items-start xl:gap-3 xl:space-y-0 2xl:gap-4">
|
||||
<Section title="档案规模">
|
||||
<div className="grid grid-cols-3 gap-2 text-center text-[11px] text-zinc-300">
|
||||
<div className="platform-subpanel rounded-xl px-2 py-3">
|
||||
@@ -926,7 +930,7 @@ export function CustomWorldEntityCatalog({
|
||||
title="角色维度"
|
||||
subtitle={profile.attributeSchema?.schemaName}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 xl:grid-cols-2 2xl:grid-cols-3">
|
||||
{attributeSlots.map((slot) => (
|
||||
<div
|
||||
key={slot.slotId}
|
||||
@@ -963,19 +967,20 @@ export function CustomWorldEntityCatalog({
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="space-y-3 text-sm leading-7 text-zinc-300">
|
||||
<p>{profile.summary}</p>
|
||||
<div className="platform-banner platform-banner--warning rounded-2xl px-3 py-3">
|
||||
主线目标:{profile.playerGoal}
|
||||
</div>
|
||||
<div className="platform-subpanel rounded-2xl px-3 py-3">
|
||||
世界基调:{profile.tone}
|
||||
</div>
|
||||
<div className="space-y-3 text-sm leading-7 text-zinc-300">
|
||||
<p>{profile.summary}</p>
|
||||
<div className="platform-banner platform-banner--warning rounded-2xl px-3 py-3">
|
||||
主线目标:{profile.playerGoal}
|
||||
</div>
|
||||
<div className="platform-subpanel rounded-2xl px-3 py-3">
|
||||
世界基调:{profile.tone}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title="基本设定"
|
||||
className="xl:col-span-3"
|
||||
actions={
|
||||
readOnly ? (
|
||||
<SmallButton
|
||||
@@ -1006,14 +1011,16 @@ export function CustomWorldEntityCatalog({
|
||||
</div>
|
||||
{entry.value ? (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{parseFoundationTagText(entry.value).map((tag, index) => (
|
||||
<span
|
||||
key={`${entry.id}-${index}-${tag}`}
|
||||
className="rounded-full border border-white/10 bg-white/[0.06] px-3 py-1 text-xs leading-5 text-zinc-100"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
{parseFoundationTagText(entry.value).map(
|
||||
(tag, index) => (
|
||||
<span
|
||||
key={`${entry.id}-${index}-${tag}`}
|
||||
className="rounded-full border border-white/10 bg-white/[0.06] px-3 py-1 text-xs leading-5 text-zinc-100"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-2 text-sm leading-7 text-zinc-100">
|
||||
@@ -1029,7 +1036,7 @@ export function CustomWorldEntityCatalog({
|
||||
) : null}
|
||||
|
||||
{activeTab === 'playable' ? (
|
||||
<div className="space-y-3 xl:grid xl:grid-cols-2 xl:gap-3 xl:space-y-0 2xl:grid-cols-3">
|
||||
<div className="space-y-3 xl:grid xl:grid-cols-3 xl:gap-3 xl:space-y-0 2xl:grid-cols-4 2xl:gap-4">
|
||||
{pendingGeneratedEntity?.kind === 'playable' ? (
|
||||
<PendingEntityCard
|
||||
title={pendingGeneratedEntity.title}
|
||||
@@ -1060,7 +1067,9 @@ export function CustomWorldEntityCatalog({
|
||||
<CatalogCard
|
||||
title={role.name}
|
||||
description={description || '暂无描述'}
|
||||
badge={recentPlayableIdSet.has(role.id) ? <NewBadge /> : null}
|
||||
badge={
|
||||
recentPlayableIdSet.has(role.id) ? <NewBadge /> : null
|
||||
}
|
||||
isSelectionMode={false}
|
||||
isSelected={false}
|
||||
layout="compact"
|
||||
@@ -1093,9 +1102,9 @@ export function CustomWorldEntityCatalog({
|
||||
className="h-full w-full object-cover object-top"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center bg-[rgba(255,255,255,0.64)] px-3 text-center text-xs font-semibold tracking-[0.16em] text-[var(--platform-text-soft)]">
|
||||
{role.name.slice(0, 4) || '角色'}
|
||||
</div>
|
||||
<div className="flex h-full w-full items-center justify-center bg-[rgba(255,255,255,0.64)] px-3 text-center text-xs font-semibold tracking-[0.16em] text-[var(--platform-text-soft)]">
|
||||
{role.name.slice(0, 4) || '角色'}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
/>
|
||||
@@ -1140,7 +1149,7 @@ export function CustomWorldEntityCatalog({
|
||||
) : null}
|
||||
|
||||
{activeTab === 'story' ? (
|
||||
<div className="space-y-3 xl:grid xl:grid-cols-2 xl:gap-3 xl:space-y-0 2xl:grid-cols-3">
|
||||
<div className="space-y-3 xl:grid xl:grid-cols-3 xl:gap-3 xl:space-y-0 2xl:grid-cols-4 2xl:gap-4">
|
||||
{pendingGeneratedEntity?.kind === 'story' ? (
|
||||
<PendingEntityCard
|
||||
title={pendingGeneratedEntity.title}
|
||||
@@ -1200,7 +1209,7 @@ export function CustomWorldEntityCatalog({
|
||||
) : null}
|
||||
|
||||
{activeTab === 'landmarks' ? (
|
||||
<div className="space-y-3 xl:grid xl:grid-cols-2 xl:gap-3 xl:space-y-0 2xl:grid-cols-3">
|
||||
<div className="space-y-3 xl:grid xl:grid-cols-3 xl:gap-3 xl:space-y-0 2xl:grid-cols-4 2xl:gap-4">
|
||||
{pendingGeneratedEntity?.kind === 'landmark' ? (
|
||||
<PendingEntityCard
|
||||
title={pendingGeneratedEntity.title}
|
||||
@@ -1218,16 +1227,15 @@ export function CustomWorldEntityCatalog({
|
||||
`scene-entry-${index}-${scene.name.trim() || scene.kind}`,
|
||||
)}
|
||||
title={scene.name}
|
||||
description={
|
||||
compactTextList([
|
||||
scene.kind === 'camp'
|
||||
? `开局场景 · ${scene.description}`
|
||||
: scene.description,
|
||||
scene.sceneTaskDescription,
|
||||
]).join(' / ')
|
||||
}
|
||||
description={compactTextList([
|
||||
scene.kind === 'camp'
|
||||
? `开局场景 · ${scene.description}`
|
||||
: scene.description,
|
||||
scene.sceneTaskDescription,
|
||||
]).join(' / ')}
|
||||
badge={
|
||||
scene.kind === 'landmark' && recentLandmarkIdSet.has(scene.id) ? (
|
||||
scene.kind === 'landmark' &&
|
||||
recentLandmarkIdSet.has(scene.id) ? (
|
||||
<NewBadge />
|
||||
) : null
|
||||
}
|
||||
@@ -1270,4 +1278,3 @@ export function CustomWorldEntityCatalog({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { useState } from 'react';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
@@ -191,6 +198,7 @@ function createProfile(): CustomWorldProfile {
|
||||
attributeSchema: {
|
||||
id: 'schema-1',
|
||||
worldId: 'world-1',
|
||||
schemaName: '潮雾六维',
|
||||
schemaVersion: 1,
|
||||
generatedFrom: {
|
||||
worldType: 'WUXIA',
|
||||
@@ -199,7 +207,68 @@ function createProfile(): CustomWorldProfile {
|
||||
tone: '压抑、潮湿、带着未解旧伤。',
|
||||
conflictCore: '旧航道归属',
|
||||
},
|
||||
slots: [],
|
||||
slots: [
|
||||
{
|
||||
slotId: 'axis_a',
|
||||
name: '骨势',
|
||||
definition: '扛住压力并正面推进的底子。',
|
||||
positiveSignals: ['硬顶'],
|
||||
negativeSignals: ['畏缩'],
|
||||
combatUseText: '正面承压与破阵。',
|
||||
socialUseText: '在谈判里稳住立场。',
|
||||
explorationUseText: '穿过危险地形。',
|
||||
},
|
||||
{
|
||||
slotId: 'axis_b',
|
||||
name: '身法',
|
||||
definition: '抢位、转场与把握节奏的能力。',
|
||||
positiveSignals: ['灵动'],
|
||||
negativeSignals: ['迟滞'],
|
||||
combatUseText: '移动换位。',
|
||||
socialUseText: '捕捉话锋。',
|
||||
explorationUseText: '快速穿行。',
|
||||
},
|
||||
{
|
||||
slotId: 'axis_c',
|
||||
name: '眼脉',
|
||||
definition: '看破破绽、拆解局势的能力。',
|
||||
positiveSignals: ['洞察'],
|
||||
negativeSignals: ['误判'],
|
||||
combatUseText: '识破招式。',
|
||||
socialUseText: '辨别谎言。',
|
||||
explorationUseText: '发现线索。',
|
||||
},
|
||||
{
|
||||
slotId: 'axis_d',
|
||||
name: '心焰',
|
||||
definition: '决断、压迫与坚持意志的能力。',
|
||||
positiveSignals: ['果断'],
|
||||
negativeSignals: ['犹疑'],
|
||||
combatUseText: '强行压制。',
|
||||
socialUseText: '立威推进。',
|
||||
explorationUseText: '面对险境不退。',
|
||||
},
|
||||
{
|
||||
slotId: 'axis_e',
|
||||
name: '尘缘',
|
||||
definition: '处理人情、承诺和关系牵引的能力。',
|
||||
positiveSignals: ['守信'],
|
||||
negativeSignals: ['冷漠'],
|
||||
combatUseText: '协作配合。',
|
||||
socialUseText: '建立信任。',
|
||||
explorationUseText: '借助人脉。',
|
||||
},
|
||||
{
|
||||
slotId: 'axis_f',
|
||||
name: '玄息',
|
||||
definition: '调息、稳态和久战的能力。',
|
||||
positiveSignals: ['沉稳'],
|
||||
negativeSignals: ['浮躁'],
|
||||
combatUseText: '续战恢复。',
|
||||
socialUseText: '保持耐心。',
|
||||
explorationUseText: '长线跋涉。',
|
||||
},
|
||||
],
|
||||
},
|
||||
playableNpcs: [createPlayableRole('playable-1', '沈砺')],
|
||||
storyNpcs: [createStoryRole('story-1', '顾潮音')],
|
||||
@@ -684,6 +753,57 @@ test('基本设定目标打开独立编辑面板', () => {
|
||||
expect(screen.queryByText('编辑世界信息')).toBeNull();
|
||||
});
|
||||
|
||||
test('世界信息面板可以编辑六个角色维度信息', async () => {
|
||||
const user = userEvent.setup();
|
||||
const savedProfileRef: { current: CustomWorldProfile | null } = {
|
||||
current: null,
|
||||
};
|
||||
|
||||
render(
|
||||
<RpgCreationEntityEditorModal
|
||||
profile={createProfile()}
|
||||
target={{ kind: 'world' }}
|
||||
onClose={() => {}}
|
||||
onProfileChange={(profile) => {
|
||||
savedProfileRef.current = profile;
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('角色维度')).toBeTruthy();
|
||||
const nameInputs = screen.getAllByLabelText('维度名称');
|
||||
await user.clear(nameInputs[0]!);
|
||||
await user.type(nameInputs[0]!, '潮骨');
|
||||
|
||||
const definitionFields = screen.getAllByLabelText('定义');
|
||||
await user.clear(definitionFields[0]!);
|
||||
await user.type(definitionFields[0]!, '顶住潮压并正面推进的角色底色。');
|
||||
|
||||
const positiveSignalFields = screen.getAllByLabelText('正向信号');
|
||||
fireEvent.change(positiveSignalFields[0]!, {
|
||||
target: { value: '硬顶, 护阵' },
|
||||
});
|
||||
|
||||
const combatFields = screen.getAllByLabelText('战斗体现');
|
||||
await user.clear(combatFields[0]!);
|
||||
await user.type(combatFields[0]!, '正面压线与护住阵脚。');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /保存修改/u }));
|
||||
|
||||
expect(savedProfileRef.current?.attributeSchema.slots[0]?.name).toBe(
|
||||
'潮骨',
|
||||
);
|
||||
expect(savedProfileRef.current?.attributeSchema.slots[0]?.definition).toBe(
|
||||
'顶住潮压并正面推进的角色底色。',
|
||||
);
|
||||
expect(
|
||||
savedProfileRef.current?.attributeSchema.slots[0]?.positiveSignals,
|
||||
).toEqual(['硬顶', '护阵']);
|
||||
expect(savedProfileRef.current?.attributeSchema.slots[0]?.combatUseText).toBe(
|
||||
'正面压线与护住阵脚。',
|
||||
);
|
||||
});
|
||||
|
||||
test('可扮演角色列表使用缩略卡片并点击进入编辑', async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleEditTarget = vi.fn();
|
||||
@@ -821,11 +941,15 @@ test('场景图片保存后会同步更新编辑页和场景列表', async () =>
|
||||
const savedSceneChapter = savedProfile.sceneChapterBlueprints?.find(
|
||||
(entry) => entry.sceneId === 'landmark-1',
|
||||
);
|
||||
expect(
|
||||
savedSceneChapter?.acts.every(
|
||||
(act) => act.backgroundImageSrc === '/generated-custom-world-scenes/updated-scene.png',
|
||||
),
|
||||
).toBe(true);
|
||||
expect(savedSceneChapter?.acts[0]?.backgroundImageSrc).toBe(
|
||||
'/generated-custom-world-scenes/updated-scene.png',
|
||||
);
|
||||
expect(savedSceneChapter?.acts[1]?.backgroundImageSrc).not.toBe(
|
||||
'/generated-custom-world-scenes/updated-scene.png',
|
||||
);
|
||||
expect(savedSceneChapter?.acts[2]?.backgroundImageSrc).not.toBe(
|
||||
'/generated-custom-world-scenes/updated-scene.png',
|
||||
);
|
||||
});
|
||||
|
||||
test('开局场景图片保存后会同步更新编辑页和场景列表', async () => {
|
||||
@@ -899,11 +1023,15 @@ test('开局场景图片保存后会同步更新编辑页和场景列表', async
|
||||
const savedSceneChapter = savedProfile.sceneChapterBlueprints?.find(
|
||||
(entry) => entry.sceneId === 'custom-scene-camp',
|
||||
);
|
||||
expect(
|
||||
savedSceneChapter?.acts.every(
|
||||
(act) => act.backgroundImageSrc === '/generated-custom-world-scenes/updated-camp.png',
|
||||
),
|
||||
).toBe(true);
|
||||
expect(savedSceneChapter?.acts[0]?.backgroundImageSrc).toBe(
|
||||
'/generated-custom-world-scenes/updated-camp.png',
|
||||
);
|
||||
expect(savedSceneChapter?.acts[1]?.backgroundImageSrc).not.toBe(
|
||||
'/generated-custom-world-scenes/updated-camp.png',
|
||||
);
|
||||
expect(savedSceneChapter?.acts[2]?.backgroundImageSrc).not.toBe(
|
||||
'/generated-custom-world-scenes/updated-camp.png',
|
||||
);
|
||||
});
|
||||
|
||||
test('开局场景在场景配置面板中与普通场景使用同级参数并可保存', async () => {
|
||||
@@ -960,6 +1088,8 @@ test('开局场景在场景配置面板中与普通场景使用同级参数并
|
||||
|
||||
test('开局场景列表与详情幕预览复用同一套幕级图片', async () => {
|
||||
const profile = createProfileWithSceneChapters();
|
||||
profile.sceneChapterBlueprints![0]!.acts[1]!.backgroundPromptText =
|
||||
'第二幕专属背景提示';
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
@@ -1003,6 +1133,53 @@ test('开局场景列表与详情幕预览复用同一套幕级图片', async ()
|
||||
);
|
||||
});
|
||||
|
||||
test('开局场景幕背景智能生成复用当前幕图片和幕级提示词', async () => {
|
||||
mockedRpgCreationAssetClient.generateSceneImage.mockClear();
|
||||
mockedRpgCreationAssetClient.generateSceneImage.mockResolvedValue({
|
||||
imageSrc: '/generated-custom-world-scenes/camp-act-2-ai.png',
|
||||
assetId: 'asset-camp-act-2',
|
||||
model: 'wan2.2-t2i-flash',
|
||||
size: '1280*720',
|
||||
taskId: 'task-camp-act-2',
|
||||
prompt: '第二幕专属背景提示',
|
||||
});
|
||||
const profile = createProfileWithSceneChapters();
|
||||
profile.sceneChapterBlueprints![0]!.acts[1]!.backgroundPromptText =
|
||||
'第二幕专属背景提示';
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<RpgCreationEntityEditorModal
|
||||
profile={profile}
|
||||
target={{ kind: 'camp' }}
|
||||
onClose={() => {}}
|
||||
onProfileChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(within(getSceneActCard(1)).getByRole('button', { name: '配置背景' }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('配置幕背景:第2幕')).toBeTruthy();
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'AI生成' }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('智能生成:潮灯居')).toBeTruthy();
|
||||
});
|
||||
|
||||
expect(screen.getByRole('img', { name: '潮灯居' }).getAttribute('src')).toBe(
|
||||
'/generated-custom-world-scenes/camp-act-2.png',
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '开始生成' }));
|
||||
await waitFor(() => {
|
||||
expect(mockedRpgCreationAssetClient.generateSceneImage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
const payload = mockedRpgCreationAssetClient.generateSceneImage.mock.calls[0]?.[0];
|
||||
expect(payload?.userPrompt).toBe('第二幕专属背景提示');
|
||||
});
|
||||
|
||||
test('普通场景世界地图会包含开局场景并高亮当前场景', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {motion} from 'motion/react';
|
||||
import {type ReactNode, useEffect, useMemo, useRef, useState} from 'react';
|
||||
|
||||
import {getCharacterById} from '../../data/characterPresets';
|
||||
import {getFacingTowardPlayer, MONSTERS_BY_WORLD} from '../../data/hostileNpcs';
|
||||
@@ -16,20 +17,24 @@ import {HostileNpcAnimator} from '../HostileNpcAnimator';
|
||||
import {MedievalNpcAnimator} from '../MedievalNpcAnimator';
|
||||
import {getRenderableNpcFacing} from '../npcRenderUtils';
|
||||
import {ResolvedAssetImage} from '../ResolvedAssetImage';
|
||||
import {NpcAffinityEffectBadge} from './NpcAffinityEffectBadge';
|
||||
import {
|
||||
buildCombatFeedbackEvents,
|
||||
type CombatFeedbackEvent,
|
||||
type CombatFeedbackHealthSample,
|
||||
} from './combatFeedback';
|
||||
import {
|
||||
CHARACTER_COMBAT_HP_TOP_PX,
|
||||
DialogueBubbleIcon,
|
||||
type GameCanvasEntitySelection,
|
||||
GENERIC_NPC_SCENE_SCALE,
|
||||
CHARACTER_COMBAT_HP_TOP_PX,
|
||||
getCompanionSlotOffset,
|
||||
getEncounterCharacterBottomOffsetPx,
|
||||
getEncounterCharacterOpponentBottom,
|
||||
getHostileNpcSceneBottomOffsetPx,
|
||||
getMonsterWorldLeft,
|
||||
getNpcCombatHpTop,
|
||||
getSceneNpcVisualBottomOffsetPx,
|
||||
getSceneEntityZIndex,
|
||||
getSceneNpcVisualBottomOffsetPx,
|
||||
HpBar,
|
||||
mapHostileNpcAnimationToCharacterState,
|
||||
MONSTER_RENDER_OFFSETS,
|
||||
@@ -40,6 +45,7 @@ import {
|
||||
SceneEncounterNpcSprite,
|
||||
SceneEntityButton,
|
||||
} from './GameCanvasShared';
|
||||
import {NpcAffinityEffectBadge} from './NpcAffinityEffectBadge';
|
||||
|
||||
type MonsterSpriteConfig = (typeof MONSTERS_BY_WORLD)[WorldType.WUXIA][number];
|
||||
|
||||
@@ -87,6 +93,88 @@ interface GameCanvasEntityLayerProps {
|
||||
playerX: number;
|
||||
}
|
||||
|
||||
function CombatFloatingNumber({
|
||||
event,
|
||||
onDone,
|
||||
}: {
|
||||
event: CombatFeedbackEvent;
|
||||
onDone: (eventId: string) => void;
|
||||
}) {
|
||||
const isHealing = event.delta > 0;
|
||||
const deltaText = `${isHealing ? '+' : ''}${event.delta}`;
|
||||
const colorClass = isHealing ? 'text-emerald-200' : 'text-rose-200';
|
||||
const glowClass = isHealing
|
||||
? 'drop-shadow-[0_0_8px_rgba(52,211,153,0.9)]'
|
||||
: 'drop-shadow-[0_0_8px_rgba(248,113,113,0.9)]';
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={event.id}
|
||||
initial={{opacity: 0, y: 10, scale: 0.76}}
|
||||
animate={{opacity: [0, 1, 1, 0], y: [10, -12, -31, -50], scale: [0.76, 1.22, 1, 0.9]}}
|
||||
transition={{duration: 0.92, ease: 'easeOut'}}
|
||||
onAnimationComplete={() => onDone(event.id)}
|
||||
className={`pointer-events-none absolute -top-16 left-1/2 z-[14] -translate-x-1/2 text-lg font-black leading-none ${colorClass} ${glowClass}`}
|
||||
data-testid={`combat-feedback-${event.targetKey}`}
|
||||
aria-label={`战斗数值 ${deltaText}`}
|
||||
>
|
||||
<span className="[-webkit-text-stroke:1px_rgba(24,24,27,0.76)]">
|
||||
{deltaText}
|
||||
</span>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
function CombatFeedbackNumbers({
|
||||
events,
|
||||
onDone,
|
||||
}: {
|
||||
events: CombatFeedbackEvent[];
|
||||
onDone: (eventId: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{events.map(event => (
|
||||
<CombatFloatingNumber key={event.id} event={event} onDone={onDone} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function getLatestDamageFeedback(events: CombatFeedbackEvent[]) {
|
||||
for (let index = events.length - 1; index >= 0; index -= 1) {
|
||||
const event = events[index];
|
||||
if (event?.delta && event.delta < 0) return event;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function CombatReactiveSpriteFrame({
|
||||
events,
|
||||
facing,
|
||||
className = ROLE_CHARACTER_FRAME_CLASS,
|
||||
children,
|
||||
}: {
|
||||
events: CombatFeedbackEvent[];
|
||||
facing: 'left' | 'right';
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const latestDamage = getLatestDamageFeedback(events);
|
||||
const retreatX = facing === 'right' ? -12 : 12;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className={className}
|
||||
animate={latestDamage ? {x: [0, retreatX, 0]} : {x: 0}}
|
||||
transition={{duration: 0.28, ease: 'easeOut'}}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export function GameCanvasEntityLayer({
|
||||
companions,
|
||||
currentScenePreset,
|
||||
@@ -122,13 +210,79 @@ export function GameCanvasEntityLayer({
|
||||
monsterAnchorMeters,
|
||||
playerX,
|
||||
}: GameCanvasEntityLayerProps) {
|
||||
const [combatFeedbackEvents, setCombatFeedbackEvents] = useState<CombatFeedbackEvent[]>([]);
|
||||
const previousCombatSamplesRef = useRef<Map<string, CombatFeedbackHealthSample> | null>(null);
|
||||
const combatFeedbackSequenceRef = useRef(0);
|
||||
const shouldRenderPeacefulEncounter =
|
||||
Boolean(encounter) && (!inBattle || sceneCombatants.length === 0);
|
||||
const combatHealthSamples = useMemo<CombatFeedbackHealthSample[]>(
|
||||
() => {
|
||||
if (!inBattle) return [];
|
||||
|
||||
return [
|
||||
{key: 'player', kind: 'player', hp: playerHp},
|
||||
...companions.map(companion => ({
|
||||
key: `companion:${companion.npcId}`,
|
||||
kind: 'companion' as const,
|
||||
hp: companion.hp,
|
||||
})),
|
||||
...sceneCombatants.map(hostileNpc => ({
|
||||
key: `hostile:${hostileNpc.id}`,
|
||||
kind: 'hostile' as const,
|
||||
hp: hostileNpc.hp,
|
||||
})),
|
||||
];
|
||||
},
|
||||
[companions, inBattle, playerHp, sceneCombatants],
|
||||
);
|
||||
const combatFeedbackByTarget = useMemo(() => {
|
||||
const feedbackByTarget = new Map<string, CombatFeedbackEvent[]>();
|
||||
combatFeedbackEvents.forEach(event => {
|
||||
feedbackByTarget.set(event.targetKey, [
|
||||
...(feedbackByTarget.get(event.targetKey) ?? []),
|
||||
event,
|
||||
]);
|
||||
});
|
||||
return feedbackByTarget;
|
||||
}, [combatFeedbackEvents]);
|
||||
const removeCombatFeedbackEvent = (eventId: string) => {
|
||||
setCombatFeedbackEvents(events => events.filter(event => event.id !== eventId));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!inBattle) {
|
||||
previousCombatSamplesRef.current = null;
|
||||
setCombatFeedbackEvents([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const previousSamples = previousCombatSamplesRef.current;
|
||||
if (previousSamples) {
|
||||
const result = buildCombatFeedbackEvents(
|
||||
previousSamples,
|
||||
combatHealthSamples,
|
||||
combatFeedbackSequenceRef.current,
|
||||
);
|
||||
|
||||
if (result.events.length > 0) {
|
||||
setCombatFeedbackEvents(events => [...events.slice(-8), ...result.events]);
|
||||
}
|
||||
|
||||
combatFeedbackSequenceRef.current = result.nextSequence;
|
||||
}
|
||||
|
||||
previousCombatSamplesRef.current = new Map(
|
||||
combatHealthSamples.map(sample => [sample.key, sample]),
|
||||
);
|
||||
}, [combatHealthSamples, inBattle]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{companions.map(companion => {
|
||||
const slotOffset = getCompanionSlotOffset(companion.slot);
|
||||
const feedbackTargetKey = `companion:${companion.npcId}`;
|
||||
const feedbackEvents = combatFeedbackByTarget.get(feedbackTargetKey) ?? [];
|
||||
const companionFacing = companion.facing ?? 'right';
|
||||
return (
|
||||
<motion.div
|
||||
key={`${companion.npcId}-${companion.recruitToken ?? 'steady'}-${sceneTransitionToken}`}
|
||||
@@ -172,6 +326,7 @@ export function GameCanvasEntityLayer({
|
||||
ariaLabel={`查看${companion.character.name}详情`}
|
||||
className="relative flex w-28 flex-col items-center"
|
||||
>
|
||||
<CombatFeedbackNumbers events={feedbackEvents} onDone={removeCombatFeedbackEvent} />
|
||||
{inBattle && (
|
||||
<div
|
||||
className="absolute left-1/2 -translate-x-1/2"
|
||||
@@ -180,15 +335,15 @@ export function GameCanvasEntityLayer({
|
||||
<HpBar hp={companion.hp} maxHp={companion.maxHp} tone="emerald" />
|
||||
</div>
|
||||
)}
|
||||
<div className={ROLE_CHARACTER_FRAME_CLASS}>
|
||||
<CombatReactiveSpriteFrame events={feedbackEvents} facing={companionFacing}>
|
||||
<div className={companion.hp <= 0 ? 'opacity-45 grayscale' : undefined}>
|
||||
<RoleCharacterSprite
|
||||
state={sceneTransitionPhase === 'idle' ? companion.animationState : AnimationState.RUN}
|
||||
character={companion.character}
|
||||
facing={sceneTransitionPhase === 'idle' ? (companion.facing ?? 'right') : 'right'}
|
||||
facing={sceneTransitionPhase === 'idle' ? companionFacing : 'right'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CombatReactiveSpriteFrame>
|
||||
</SceneEntityButton>
|
||||
</div>
|
||||
</div>
|
||||
@@ -217,6 +372,10 @@ export function GameCanvasEntityLayer({
|
||||
}}
|
||||
>
|
||||
<div className="relative">
|
||||
<CombatFeedbackNumbers
|
||||
events={combatFeedbackByTarget.get('player') ?? []}
|
||||
onDone={removeCombatFeedbackEvent}
|
||||
/>
|
||||
{inBattle && (
|
||||
<div
|
||||
className="absolute left-1/2 -translate-x-1/2"
|
||||
@@ -231,7 +390,10 @@ export function GameCanvasEntityLayer({
|
||||
className="relative block"
|
||||
>
|
||||
<div className="relative">
|
||||
<div className={ROLE_CHARACTER_FRAME_CLASS}>
|
||||
<CombatReactiveSpriteFrame
|
||||
events={combatFeedbackByTarget.get('player') ?? []}
|
||||
facing={effectivePlayerFacing}
|
||||
>
|
||||
{playerCharacter && (
|
||||
<RoleCharacterSprite
|
||||
state={effectivePlayerAnimationState}
|
||||
@@ -239,7 +401,7 @@ export function GameCanvasEntityLayer({
|
||||
facing={effectivePlayerFacing}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</CombatReactiveSpriteFrame>
|
||||
</div>
|
||||
{shouldShowPlayerDialogueIcon && (
|
||||
<div className="absolute -top-9 right-1">
|
||||
@@ -270,6 +432,8 @@ export function GameCanvasEntityLayer({
|
||||
npcCharacter ? npcEncounter?.characterId : null,
|
||||
npcCharacter ? null : npcEncounter?.monsterPresetId,
|
||||
);
|
||||
const feedbackTargetKey = `hostile:${hostileNpc.id}`;
|
||||
const feedbackEvents = combatFeedbackByTarget.get(feedbackTargetKey) ?? [];
|
||||
const hostileNpcBottomOffsetPx =
|
||||
npcMonsterConfig
|
||||
? getHostileNpcSceneBottomOffsetPx(npcMonsterConfig)
|
||||
@@ -303,6 +467,7 @@ export function GameCanvasEntityLayer({
|
||||
ariaLabel={`查看${hostileNpc.name}详情`}
|
||||
className="relative flex w-28 flex-col items-center"
|
||||
>
|
||||
<CombatFeedbackNumbers events={feedbackEvents} onDone={removeCombatFeedbackEvent} />
|
||||
{inBattle && (
|
||||
<div
|
||||
className="absolute left-1/2 -translate-x-1/2"
|
||||
@@ -311,7 +476,7 @@ export function GameCanvasEntityLayer({
|
||||
<HpBar hp={hostileNpc.hp} maxHp={hostileNpc.maxHp} tone="rose" />
|
||||
</div>
|
||||
)}
|
||||
<div className={ROLE_CHARACTER_FRAME_CLASS}>
|
||||
<CombatReactiveSpriteFrame events={feedbackEvents} facing={npcSceneSpriteFacing}>
|
||||
{npcCharacter ? (
|
||||
<RoleCharacterSprite
|
||||
state={hostileNpc.characterAnimation ?? mapHostileNpcAnimationToCharacterState(hostileNpc.animation)}
|
||||
@@ -335,7 +500,7 @@ export function GameCanvasEntityLayer({
|
||||
scale={GENERIC_NPC_SCENE_SCALE}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</CombatReactiveSpriteFrame>
|
||||
{dialogueIndicator?.showEncounter && hostileNpc.animation !== 'move' && (
|
||||
<div className="absolute -top-9 left-1">
|
||||
<DialogueBubbleIcon
|
||||
|
||||
62
src/components/game-canvas/combatFeedback.test.ts
Normal file
62
src/components/game-canvas/combatFeedback.test.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildCombatFeedbackEvents,
|
||||
type CombatFeedbackHealthSample,
|
||||
} from './combatFeedback';
|
||||
|
||||
function toSample(key: string, hp: number): CombatFeedbackHealthSample {
|
||||
return {
|
||||
key,
|
||||
kind: key.startsWith('hostile') ? 'hostile' : 'player',
|
||||
hp,
|
||||
};
|
||||
}
|
||||
|
||||
describe('combatFeedback', () => {
|
||||
it('creates red damage and green healing deltas from committed hp changes', () => {
|
||||
const previous = new Map([
|
||||
['player', toSample('player', 20)],
|
||||
['hostile:npc-liu', toSample('hostile:npc-liu', 8)],
|
||||
]);
|
||||
|
||||
const result = buildCombatFeedbackEvents(
|
||||
previous,
|
||||
[
|
||||
toSample('player', 11),
|
||||
toSample('hostile:npc-liu', 9),
|
||||
],
|
||||
4,
|
||||
);
|
||||
|
||||
expect(result.events).toEqual([
|
||||
{
|
||||
id: 'player:5',
|
||||
targetKey: 'player',
|
||||
kind: 'player',
|
||||
delta: -9,
|
||||
},
|
||||
{
|
||||
id: 'hostile:npc-liu:6',
|
||||
targetKey: 'hostile:npc-liu',
|
||||
kind: 'hostile',
|
||||
delta: 1,
|
||||
},
|
||||
]);
|
||||
expect(result.nextSequence).toBe(6);
|
||||
});
|
||||
|
||||
it('ignores first render samples and unchanged hp', () => {
|
||||
const result = buildCombatFeedbackEvents(
|
||||
new Map([['player', toSample('player', 20)]]),
|
||||
[
|
||||
toSample('player', 20),
|
||||
toSample('companion:npc-chen', 6),
|
||||
],
|
||||
0,
|
||||
);
|
||||
|
||||
expect(result.events).toEqual([]);
|
||||
expect(result.nextSequence).toBe(0);
|
||||
});
|
||||
});
|
||||
44
src/components/game-canvas/combatFeedback.ts
Normal file
44
src/components/game-canvas/combatFeedback.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
export type CombatFeedbackTargetKind = 'player' | 'companion' | 'hostile';
|
||||
|
||||
export interface CombatFeedbackHealthSample {
|
||||
key: string;
|
||||
kind: CombatFeedbackTargetKind;
|
||||
hp: number;
|
||||
}
|
||||
|
||||
export interface CombatFeedbackEvent {
|
||||
id: string;
|
||||
targetKey: string;
|
||||
kind: CombatFeedbackTargetKind;
|
||||
delta: number;
|
||||
}
|
||||
|
||||
export function buildCombatFeedbackEvents(
|
||||
previousSamples: Map<string, CombatFeedbackHealthSample>,
|
||||
currentSamples: CombatFeedbackHealthSample[],
|
||||
sequence: number,
|
||||
) {
|
||||
let nextSequence = sequence;
|
||||
const events: CombatFeedbackEvent[] = [];
|
||||
|
||||
currentSamples.forEach(sample => {
|
||||
const previous = previousSamples.get(sample.key);
|
||||
if (!previous) return;
|
||||
|
||||
const delta = sample.hp - previous.hp;
|
||||
if (delta === 0) return;
|
||||
|
||||
nextSequence += 1;
|
||||
events.push({
|
||||
id: `${sample.key}:${nextSequence}`,
|
||||
targetKey: sample.key,
|
||||
kind: sample.kind,
|
||||
delta,
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
events,
|
||||
nextSequence,
|
||||
};
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -29,9 +29,17 @@ export interface RpgCreationResultViewProps {
|
||||
onOpenCoverEditor?: () => void;
|
||||
onPublishWorld?: () => Promise<void> | void;
|
||||
onTestWorld?: () => void;
|
||||
onDeleteEntities?: (kind: 'story' | 'landmark', ids: string[]) => Promise<void> | void;
|
||||
onDeleteEntities?: (
|
||||
kind: 'story' | 'landmark',
|
||||
ids: string[],
|
||||
) => Promise<void> | void;
|
||||
onGenerateEntity?:
|
||||
| ((kind: EntityGenerationKind) => Promise<{ profile?: CustomWorldProfile | null } | void> | { profile?: CustomWorldProfile | null } | void)
|
||||
| ((
|
||||
kind: EntityGenerationKind,
|
||||
) =>
|
||||
| Promise<{ profile?: CustomWorldProfile | null } | void>
|
||||
| { profile?: CustomWorldProfile | null }
|
||||
| void)
|
||||
| undefined;
|
||||
onProfileChange: (profile: CustomWorldProfile) => void;
|
||||
readOnly?: boolean;
|
||||
@@ -126,7 +134,7 @@ export function RpgCreationResultView({
|
||||
: handleDeleteLandmarks;
|
||||
|
||||
return (
|
||||
<div className="platform-remap-surface flex h-full min-h-0 flex-col">
|
||||
<div className="platform-remap-surface mx-auto flex h-full min-h-0 w-full flex-col xl:max-w-[min(100%,98rem)] xl:px-1 2xl:max-w-[min(100%,112rem)]">
|
||||
<RpgCreationResultHeader
|
||||
autoSaveState={autoSaveState}
|
||||
backLabel={backLabel}
|
||||
@@ -150,7 +158,9 @@ export function RpgCreationResultView({
|
||||
: createLabel
|
||||
}
|
||||
onCreateAction={
|
||||
readOnly || (compactAgentResultMode && !onGenerateEntity) || !createTarget
|
||||
readOnly ||
|
||||
(compactAgentResultMode && !onGenerateEntity) ||
|
||||
!createTarget
|
||||
? undefined
|
||||
: () => {
|
||||
if (activeTab === 'playable') {
|
||||
@@ -168,9 +178,7 @@ export function RpgCreationResultView({
|
||||
setEditorTarget(createTarget);
|
||||
}
|
||||
}
|
||||
createActionDisabled={Boolean(
|
||||
isGenerating || pendingGeneratedEntity,
|
||||
)}
|
||||
createActionDisabled={Boolean(isGenerating || pendingGeneratedEntity)}
|
||||
pendingGeneratedEntity={pendingGeneratedEntity}
|
||||
recentGeneratedIds={recentGeneratedIds}
|
||||
readOnly={readOnly}
|
||||
@@ -206,7 +214,12 @@ export function RpgCreationResultView({
|
||||
publishBlockers.length <= 0 &&
|
||||
qualityFindings.some((entry) => entry.severity === 'warning') ? (
|
||||
<div className="platform-banner platform-banner--info mt-3 rounded-2xl text-sm leading-6">
|
||||
发布后仍有 {qualityFindings.filter((entry) => entry.severity === 'warning').length} 条 warning 可继续优化。
|
||||
发布后仍有{' '}
|
||||
{
|
||||
qualityFindings.filter((entry) => entry.severity === 'warning')
|
||||
.length
|
||||
}{' '}
|
||||
条 warning 可继续优化。
|
||||
</div>
|
||||
) : null}
|
||||
{!error && localGenerationError ? (
|
||||
@@ -214,7 +227,9 @@ export function RpgCreationResultView({
|
||||
{localGenerationError}
|
||||
</div>
|
||||
) : null}
|
||||
{assetDebugEnabled ? <RpgCreationAssetDebugPanel profile={profile} /> : null}
|
||||
{assetDebugEnabled ? (
|
||||
<RpgCreationAssetDebugPanel profile={profile} />
|
||||
) : null}
|
||||
|
||||
<RpgCreationResultActionBar
|
||||
editActionLabel={editActionLabel}
|
||||
|
||||
Reference in New Issue
Block a user