This commit is contained in:
2026-04-26 20:50:58 +08:00
parent a3a9bfa194
commit 67161bd6d1
142 changed files with 3349 additions and 10674 deletions

View File

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

View File

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

View File

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

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

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

View File

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

View File

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