@@ -27,13 +27,13 @@ function createCharacter(): Character {
|
||||
} as Character;
|
||||
}
|
||||
|
||||
test('adventure panel treats negative affinity updates as relationship change system messages', () => {
|
||||
test('adventure panel renders system turns without special relationship labels', () => {
|
||||
const currentStory: StoryMoment = {
|
||||
text: '你们的语气忽然冷了下来。',
|
||||
displayMode: 'dialogue',
|
||||
dialogue: [
|
||||
{ speaker: 'npc', speakerName: '柳无声', text: '这件事你最好别再追问。' },
|
||||
{ speaker: 'system', text: '关系转冷 好感 -2', affinityDelta: -2 },
|
||||
{ speaker: 'system', text: '这轮交谈先在这里收束。' },
|
||||
],
|
||||
options: [],
|
||||
};
|
||||
@@ -102,8 +102,9 @@ test('adventure panel treats negative affinity updates as relationship change sy
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain('关系变化');
|
||||
expect(html).toContain('关系转冷 好感 -2');
|
||||
expect(html).toContain('系统');
|
||||
expect(html).toContain('这轮交谈先在这里收束。');
|
||||
expect(html).not.toContain('关系变化');
|
||||
});
|
||||
|
||||
test('adventure panel shows current act label and remaining turns for limited hostile npc chat', () => {
|
||||
|
||||
@@ -157,9 +157,13 @@ test('adventure panel shows npc chat custom input and exit button in chat mode',
|
||||
dialogue: [
|
||||
{ speaker: 'player', text: '你刚才那句话是什么意思?' },
|
||||
{ speaker: 'npc', speakerName: '柳无声', text: '意思是这件事还没结束。' },
|
||||
{ speaker: 'system', text: '关系升温 好感 +3', affinityDelta: 3 },
|
||||
],
|
||||
options: [optionA, optionB, optionC],
|
||||
npcAffinityEffect: {
|
||||
eventId: 'effect-liu-1',
|
||||
npcId: 'npc-liu',
|
||||
delta: 3,
|
||||
},
|
||||
npcChatState: {
|
||||
npcId: 'npc-liu',
|
||||
npcName: '柳无声',
|
||||
@@ -178,6 +182,7 @@ test('adventure panel shows npc chat custom input and exit button in chat mode',
|
||||
expect(html).toContain('输入你想对 TA 说的话');
|
||||
expect(html).toContain('发送');
|
||||
expect(html).not.toContain('换一换');
|
||||
expect(html).not.toContain('关系升温');
|
||||
});
|
||||
|
||||
test('adventure panel hides custom input and shows quest offer actions during npc quest offer mode', () => {
|
||||
@@ -243,3 +248,19 @@ test('adventure panel hides custom input and shows quest offer actions during np
|
||||
expect(html).not.toContain('发送');
|
||||
expect(html).not.toContain('输入你想对 TA 说的话');
|
||||
});
|
||||
|
||||
test('adventure panel renders narrative story text without italics and hides option detail text', () => {
|
||||
const option = createOption('idle_observe_signs', '观察风里残下的痕迹');
|
||||
option.detailText = '这段说明不应该继续出现在 UI 里。';
|
||||
const currentStory: StoryMoment = {
|
||||
text: '风从桥洞里灌过来,你把注意力重新放回脚下与前路。',
|
||||
options: [option],
|
||||
};
|
||||
|
||||
const html = renderPanel(currentStory, [option]);
|
||||
|
||||
expect(html).toContain('font-serif');
|
||||
expect(html).not.toContain('italic');
|
||||
expect(html).toContain('text-[15px]');
|
||||
expect(html).not.toContain('这段说明不应该继续出现在 UI 里。');
|
||||
});
|
||||
|
||||
@@ -174,9 +174,7 @@ function getDialogueTurnBubbleClass(
|
||||
turn: NonNullable<StoryMoment['dialogue']>[number],
|
||||
) {
|
||||
if (turn.speaker === 'system') {
|
||||
return turn.affinityDelta && turn.affinityDelta > 0
|
||||
? 'border-rose-400/30 bg-rose-500/12 text-rose-50'
|
||||
: 'border-white/12 bg-white/[0.06] text-zinc-100';
|
||||
return 'border-white/12 bg-white/[0.06] text-zinc-100';
|
||||
}
|
||||
|
||||
if (turn.speaker === 'player') {
|
||||
@@ -212,7 +210,7 @@ function getDialogueTurnLabel(
|
||||
turn: NonNullable<StoryMoment['dialogue']>[number],
|
||||
) {
|
||||
if (turn.speaker === 'system') {
|
||||
return typeof turn.affinityDelta === 'number' ? '关系变化' : '系统';
|
||||
return '系统';
|
||||
}
|
||||
|
||||
if (turn.speaker === 'player') {
|
||||
@@ -1107,7 +1105,7 @@ export function AdventurePanel({
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="font-serif text-sm italic leading-relaxed text-zinc-300">
|
||||
<p className="font-serif text-[15px] leading-7 text-zinc-200 sm:text-base">
|
||||
{currentStory.text}
|
||||
</p>
|
||||
)}
|
||||
@@ -1192,9 +1190,6 @@ export function AdventurePanel({
|
||||
hasDeferredAdventureOptions &&
|
||||
isContinueAdventureOption(option);
|
||||
const optionDisabled = option.disabled === true;
|
||||
const compactOptionDetailText = option.disabledReason
|
||||
? option.disabledReason
|
||||
: getCompactOptionDetailText(option);
|
||||
|
||||
if (isDeferredContinueOption) {
|
||||
return (
|
||||
@@ -1210,7 +1205,7 @@ export function AdventurePanel({
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span
|
||||
className={`text-xs ${getOptionActionTextClass(option)}`}
|
||||
className={`text-sm sm:text-[15px] ${getOptionActionTextClass(option)}`}
|
||||
>
|
||||
{option.actionText}
|
||||
</span>
|
||||
@@ -1237,7 +1232,7 @@ export function AdventurePanel({
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span
|
||||
className={`${isNpcChatMode ? 'text-sm sm:text-[15px]' : 'text-xs'} ${getOptionActionTextClass(option)}`}
|
||||
className={`text-sm sm:text-[15px] ${getOptionActionTextClass(option)}`}
|
||||
>
|
||||
{option.actionText}
|
||||
</span>
|
||||
@@ -1246,11 +1241,6 @@ export function AdventurePanel({
|
||||
className="h-3 w-3 opacity-70 transition-opacity group-hover:opacity-100"
|
||||
/>
|
||||
</div>
|
||||
{!isNpcChatMode && compactOptionDetailText && (
|
||||
<div className="mt-1 text-[10px] leading-relaxed text-zinc-500">
|
||||
{compactOptionDetailText}
|
||||
</div>
|
||||
)}
|
||||
{!isNpcChatMode && option.goalAffordance?.label && (
|
||||
<div
|
||||
className={`mt-1 text-[10px] ${getOptionGoalAffordanceClass(option)}`}
|
||||
|
||||
@@ -20,7 +20,13 @@ import {
|
||||
import { resolveCustomWorldCampScene } from '../services/customWorldCamp';
|
||||
import { resolveCustomWorldCoverPresentation } from '../services/customWorldCover';
|
||||
import { normalizeCustomWorldCreatorIntent } from '../services/customWorldCreatorIntent';
|
||||
import { AnimationState, Character, CustomWorldProfile } from '../types';
|
||||
import {
|
||||
AnimationState,
|
||||
Character,
|
||||
CustomWorldProfile,
|
||||
type SceneActBlueprint,
|
||||
type SceneChapterBlueprint,
|
||||
} from '../types';
|
||||
import { CharacterAnimator } from './CharacterAnimator';
|
||||
import { CustomWorldCoverArtwork } from './CustomWorldCoverArtwork';
|
||||
import type { CustomWorldEditorTarget } from './CustomWorldEntityEditorModal';
|
||||
@@ -233,6 +239,104 @@ function PendingEntityCard({
|
||||
);
|
||||
}
|
||||
|
||||
function resolveSceneEntrySceneChapters(params: {
|
||||
sceneChapters: CustomWorldProfile['sceneChapterBlueprints'];
|
||||
sceneId: string;
|
||||
sceneName: string;
|
||||
}) {
|
||||
const sceneChapters = params.sceneChapters ?? [];
|
||||
const normalizedSceneId = params.sceneId.trim();
|
||||
const normalizedSceneName = params.sceneName.trim();
|
||||
|
||||
const directMatches = sceneChapters.filter(
|
||||
(chapter) => chapter.sceneId.trim() === normalizedSceneId,
|
||||
);
|
||||
if (directMatches.length > 0) {
|
||||
return directMatches;
|
||||
}
|
||||
|
||||
const linkedMatches = sceneChapters.filter((chapter) =>
|
||||
chapter.linkedLandmarkIds.some(
|
||||
(landmarkId) => landmarkId.trim() === normalizedSceneId,
|
||||
),
|
||||
);
|
||||
if (linkedMatches.length > 0) {
|
||||
return linkedMatches;
|
||||
}
|
||||
|
||||
return sceneChapters.filter((chapter) => {
|
||||
const chapterTitle = chapter.title.trim();
|
||||
return (
|
||||
chapterTitle === normalizedSceneName ||
|
||||
chapter.summary.includes(normalizedSceneName) ||
|
||||
chapter.acts.some(
|
||||
(act) =>
|
||||
act.title.includes(normalizedSceneName) ||
|
||||
act.summary.includes(normalizedSceneName),
|
||||
)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function buildSceneActParticipantText(
|
||||
act: SceneActBlueprint,
|
||||
roleById: Map<
|
||||
string,
|
||||
| CustomWorldProfile['playableNpcs'][number]
|
||||
| CustomWorldProfile['storyNpcs'][number]
|
||||
>,
|
||||
) {
|
||||
const primaryRoleName = roleById.get(act.primaryNpcId)?.name?.trim() || '';
|
||||
const supportRoleNames = act.encounterNpcIds
|
||||
.filter((roleId) => roleId !== act.primaryNpcId)
|
||||
.map((roleId) => roleById.get(roleId)?.name?.trim() || '')
|
||||
.filter(Boolean);
|
||||
|
||||
return compactTextList([
|
||||
primaryRoleName ? `主角色:${primaryRoleName}` : '',
|
||||
supportRoleNames.length > 0
|
||||
? `相遇角色:${supportRoleNames.join('、')}`
|
||||
: '',
|
||||
]).join(';');
|
||||
}
|
||||
|
||||
function buildSceneChapterSearchText(
|
||||
sceneChapters: SceneChapterBlueprint[],
|
||||
roleById: Map<
|
||||
string,
|
||||
| CustomWorldProfile['playableNpcs'][number]
|
||||
| CustomWorldProfile['storyNpcs'][number]
|
||||
>,
|
||||
) {
|
||||
return sceneChapters
|
||||
.flatMap((chapter) => [
|
||||
chapter.title,
|
||||
chapter.summary,
|
||||
...chapter.acts.flatMap((act) => [
|
||||
act.title,
|
||||
act.summary,
|
||||
act.actGoal,
|
||||
act.transitionHook,
|
||||
buildSceneActParticipantText(act, roleById),
|
||||
]),
|
||||
])
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
function resolveSceneCardImage(params: {
|
||||
sceneImageSrc?: string | null;
|
||||
sceneChapters: SceneChapterBlueprint[];
|
||||
}) {
|
||||
const firstActImageSrc =
|
||||
params.sceneChapters
|
||||
.flatMap((chapter) => chapter.acts)
|
||||
.map((act) => act.backgroundImageSrc?.trim() || '')
|
||||
.find(Boolean) || '';
|
||||
|
||||
return firstActImageSrc || params.sceneImageSrc?.trim() || '';
|
||||
}
|
||||
|
||||
function CatalogCard({
|
||||
title,
|
||||
description,
|
||||
@@ -370,6 +474,10 @@ function resolvePlayableRolePreviewImage(
|
||||
role: CustomWorldProfile['playableNpcs'][number],
|
||||
previewCharacter: Character | null,
|
||||
) {
|
||||
if (role.imageSrc?.trim()) {
|
||||
return role.imageSrc;
|
||||
}
|
||||
|
||||
if (previewCharacter?.portrait?.trim()) {
|
||||
return previewCharacter.portrait;
|
||||
}
|
||||
@@ -378,10 +486,6 @@ function resolvePlayableRolePreviewImage(
|
||||
return previewCharacter.avatar;
|
||||
}
|
||||
|
||||
if (role.imageSrc?.trim()) {
|
||||
return role.imageSrc;
|
||||
}
|
||||
|
||||
const template = role.templateCharacterId
|
||||
? ROLE_TEMPLATE_CHARACTERS.find(
|
||||
(character) => character.id === role.templateCharacterId,
|
||||
@@ -796,6 +900,16 @@ export function CustomWorldEntityCatalog({
|
||||
() => new Map(profile.storyNpcs.map((npc) => [npc.id, npc])),
|
||||
[profile.storyNpcs],
|
||||
);
|
||||
const roleById = useMemo(
|
||||
() =>
|
||||
new Map(
|
||||
[...profile.playableNpcs, ...profile.storyNpcs].map((role) => [
|
||||
role.id,
|
||||
role,
|
||||
]),
|
||||
),
|
||||
[profile.playableNpcs, profile.storyNpcs],
|
||||
);
|
||||
const landmarkById = useMemo(
|
||||
() => new Map(profile.landmarks.map((landmark) => [landmark.id, landmark])),
|
||||
[profile.landmarks],
|
||||
@@ -876,22 +990,53 @@ export function CustomWorldEntityCatalog({
|
||||
[profile.creatorIntent],
|
||||
);
|
||||
const filteredSceneEntries = useMemo(() => {
|
||||
const openingSceneChapters = resolveSceneEntrySceneChapters({
|
||||
sceneChapters: profile.sceneChapterBlueprints,
|
||||
sceneId: resolvedCampScene.id,
|
||||
sceneName: resolvedCampScene.name,
|
||||
});
|
||||
const openingSceneEntry = {
|
||||
id: 'custom-world-opening-scene',
|
||||
id: resolvedCampScene.id,
|
||||
kind: 'camp' as const,
|
||||
name: resolvedCampScene.name,
|
||||
description: resolvedCampScene.description,
|
||||
imageSrc: resolvedCampImageSrc,
|
||||
searchText: buildOpeningSceneSearchText(profile, resolvedCampScene),
|
||||
imageSrc: resolveSceneCardImage({
|
||||
sceneImageSrc: resolvedCampImageSrc,
|
||||
sceneChapters: openingSceneChapters,
|
||||
}),
|
||||
sceneChapters: openingSceneChapters,
|
||||
searchText: [
|
||||
buildOpeningSceneSearchText(profile, resolvedCampScene),
|
||||
buildSceneChapterSearchText(openingSceneChapters, roleById),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' '),
|
||||
};
|
||||
const landmarkEntries = filteredLandmarks.map((landmark) => ({
|
||||
id: landmark.id,
|
||||
kind: 'landmark' as const,
|
||||
name: landmark.name,
|
||||
description: landmark.description,
|
||||
imageSrc: landmarkImageById.get(landmark.id) ?? landmark.imageSrc,
|
||||
searchText: buildLandmarkSearchText(landmark, storyNpcById, landmarkById),
|
||||
}));
|
||||
const landmarkEntries = profile.landmarks.map((landmark) => {
|
||||
const sceneChapters = resolveSceneEntrySceneChapters({
|
||||
sceneChapters: profile.sceneChapterBlueprints,
|
||||
sceneId: landmark.id,
|
||||
sceneName: landmark.name,
|
||||
});
|
||||
|
||||
return {
|
||||
id: landmark.id,
|
||||
kind: 'landmark' as const,
|
||||
name: landmark.name,
|
||||
description: landmark.description,
|
||||
imageSrc: resolveSceneCardImage({
|
||||
sceneImageSrc: landmarkImageById.get(landmark.id) ?? landmark.imageSrc,
|
||||
sceneChapters,
|
||||
}),
|
||||
sceneChapters,
|
||||
searchText: [
|
||||
buildLandmarkSearchText(landmark, storyNpcById, landmarkById),
|
||||
buildSceneChapterSearchText(sceneChapters, roleById),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' '),
|
||||
};
|
||||
});
|
||||
const recentEntries = landmarkEntries.filter((entry) =>
|
||||
recentLandmarkIdSet.has(entry.id),
|
||||
);
|
||||
@@ -909,13 +1054,13 @@ export function CustomWorldEntityCatalog({
|
||||
);
|
||||
}, [
|
||||
deferredSearch,
|
||||
filteredLandmarks,
|
||||
landmarkById,
|
||||
landmarkImageById,
|
||||
profile,
|
||||
recentLandmarkIdSet,
|
||||
resolvedCampImageSrc,
|
||||
resolvedCampScene,
|
||||
roleById,
|
||||
storyNpcById,
|
||||
]);
|
||||
|
||||
@@ -1281,7 +1426,13 @@ export function CustomWorldEntityCatalog({
|
||||
})
|
||||
}
|
||||
media={
|
||||
previewCharacter ? (
|
||||
role.imageSrc?.trim() ? (
|
||||
<img
|
||||
src={role.imageSrc}
|
||||
alt={role.name}
|
||||
className="h-full w-full object-cover object-top"
|
||||
/>
|
||||
) : previewCharacter ? (
|
||||
<CharacterAnimator
|
||||
state={AnimationState.RUN}
|
||||
character={previewCharacter}
|
||||
@@ -1414,51 +1565,48 @@ export function CustomWorldEntityCatalog({
|
||||
<EmptyState title="当前没有符合搜索条件的场景。" />
|
||||
) : (
|
||||
filteredSceneEntries.map((scene, index) => (
|
||||
<div
|
||||
<CatalogCard
|
||||
key={buildFallbackRenderKey(
|
||||
scene.id,
|
||||
`scene-entry-${index}-${scene.name.trim() || scene.kind}`,
|
||||
)}
|
||||
>
|
||||
<CatalogCard
|
||||
title={scene.name}
|
||||
description={
|
||||
scene.kind === 'camp'
|
||||
? `开局场景 · ${scene.description}`
|
||||
: scene.description
|
||||
}
|
||||
badge={
|
||||
scene.kind === 'landmark' && recentLandmarkIdSet.has(scene.id) ? (
|
||||
<NewBadge />
|
||||
) : null
|
||||
}
|
||||
isSelectionMode={scene.kind === 'landmark' && isBulkDeleteMode}
|
||||
isSelected={
|
||||
scene.kind === 'landmark' &&
|
||||
selectedBulkIds.includes(scene.id)
|
||||
}
|
||||
onClick={() =>
|
||||
scene.kind === 'camp'
|
||||
? onEditTarget({ kind: 'camp' })
|
||||
: isBulkDeleteMode
|
||||
? toggleBulkSelected(scene.id)
|
||||
: onEditTarget({
|
||||
kind: 'landmark',
|
||||
mode: 'edit',
|
||||
id: scene.id,
|
||||
})
|
||||
}
|
||||
media={
|
||||
<ImageFrame
|
||||
src={scene.imageSrc}
|
||||
alt={scene.name}
|
||||
fallbackLabel={scene.name.slice(0, 4) || '场景'}
|
||||
tone="landscape"
|
||||
/>
|
||||
}
|
||||
disabled={scene.kind === 'camp' && isBulkDeleteMode}
|
||||
/>
|
||||
</div>
|
||||
title={scene.name}
|
||||
description={
|
||||
scene.kind === 'camp'
|
||||
? `开局场景 · ${scene.description}`
|
||||
: scene.description
|
||||
}
|
||||
badge={
|
||||
scene.kind === 'landmark' && recentLandmarkIdSet.has(scene.id) ? (
|
||||
<NewBadge />
|
||||
) : null
|
||||
}
|
||||
isSelectionMode={scene.kind === 'landmark' && isBulkDeleteMode}
|
||||
isSelected={
|
||||
scene.kind === 'landmark' &&
|
||||
selectedBulkIds.includes(scene.id)
|
||||
}
|
||||
onClick={() =>
|
||||
scene.kind === 'camp'
|
||||
? onEditTarget({ kind: 'camp' })
|
||||
: isBulkDeleteMode
|
||||
? toggleBulkSelected(scene.id)
|
||||
: onEditTarget({
|
||||
kind: 'landmark',
|
||||
mode: 'edit',
|
||||
id: scene.id,
|
||||
})
|
||||
}
|
||||
media={
|
||||
<ImageFrame
|
||||
src={scene.imageSrc}
|
||||
alt={scene.name}
|
||||
fallbackLabel={scene.name.slice(0, 4) || '场景'}
|
||||
tone="landscape"
|
||||
/>
|
||||
}
|
||||
disabled={scene.kind === 'camp' && isBulkDeleteMode}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
type CustomWorldEditorTarget,
|
||||
CustomWorldEntityEditorModal,
|
||||
} from './CustomWorldEntityEditorModal';
|
||||
import * as customWorldCoverAssetService from '../services/customWorldCoverAssetService';
|
||||
|
||||
vi.mock('../data/characterPresets', async () => {
|
||||
const actual = await vi.importActual<typeof import('../data/characterPresets')>(
|
||||
@@ -65,10 +66,6 @@ vi.mock('./game-shell/GameShellRuntime', () => ({
|
||||
|
||||
vi.mock('./asset-studio/characterAssetWorkflowPersistence', () => ({
|
||||
fetchCharacterWorkflowCache: vi.fn().mockResolvedValue({ cache: null }),
|
||||
generateCharacterPromptBundle: vi.fn().mockResolvedValue({
|
||||
visualPromptText: '自动生成的形象提示词',
|
||||
animationPromptText: '自动生成的动作提示词',
|
||||
}),
|
||||
saveCharacterWorkflowCache: vi.fn().mockResolvedValue(undefined),
|
||||
generateCharacterVisualCandidates: vi.fn(),
|
||||
publishCharacterVisualAsset: vi.fn(),
|
||||
@@ -76,6 +73,11 @@ vi.mock('./asset-studio/characterAssetWorkflowPersistence', () => ({
|
||||
publishCharacterAnimationAssets: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../services/customWorldCoverAssetService', () => ({
|
||||
generateCustomWorldCoverImage: vi.fn(),
|
||||
uploadCustomWorldCoverImage: vi.fn(),
|
||||
}));
|
||||
|
||||
function createBackstoryReveal() {
|
||||
return {
|
||||
publicSummary: '公开背景',
|
||||
@@ -261,10 +263,19 @@ function CampEditorFlowHarness() {
|
||||
const [profile, setProfile] = useState<CustomWorldProfile>({
|
||||
...createProfileWithLandmark(),
|
||||
camp: {
|
||||
id: 'custom-scene-camp',
|
||||
name: '潮灯居',
|
||||
description: '玩家最初落脚的旧灯塔内院。',
|
||||
dangerLevel: 'medium',
|
||||
imageSrc: '/generated-custom-world-scenes/original-camp.png',
|
||||
sceneNpcIds: ['story-1', 'story-2', 'story-3'],
|
||||
connections: [
|
||||
{
|
||||
targetLandmarkId: 'landmark-1',
|
||||
relativePosition: 'north',
|
||||
summary: '北侧通往沉钟栈桥。',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const [target, setTarget] = useState<CustomWorldEditorTarget | null>({
|
||||
@@ -273,6 +284,9 @@ function CampEditorFlowHarness() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<pre data-testid="camp-profile-json" className="hidden">
|
||||
{JSON.stringify(profile)}
|
||||
</pre>
|
||||
<CustomWorldEntityCatalog
|
||||
profile={profile}
|
||||
previewCharacters={[]}
|
||||
@@ -293,6 +307,44 @@ function CampEditorFlowHarness() {
|
||||
);
|
||||
}
|
||||
|
||||
function CoverEditorFlowHarness() {
|
||||
const [profile, setProfile] = useState<CustomWorldProfile>({
|
||||
...createProfileWithLandmark(),
|
||||
cover: {
|
||||
sourceType: 'default',
|
||||
imageSrc: null,
|
||||
characterRoleIds: ['playable-1'],
|
||||
},
|
||||
});
|
||||
const [target, setTarget] = useState<CustomWorldEditorTarget | null>({
|
||||
kind: 'cover',
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<pre data-testid="cover-profile-json" className="hidden">
|
||||
{JSON.stringify(profile)}
|
||||
</pre>
|
||||
<CustomWorldEntityEditorModal
|
||||
profile={profile}
|
||||
target={target}
|
||||
onClose={() => setTarget(null)}
|
||||
onProfileChange={setProfile}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function readCoverHarnessProfile() {
|
||||
const content = screen.getByTestId('cover-profile-json').textContent;
|
||||
return JSON.parse(content || '{}') as CustomWorldProfile;
|
||||
}
|
||||
|
||||
function readCampHarnessProfile() {
|
||||
const content = screen.getByTestId('camp-profile-json').textContent;
|
||||
return JSON.parse(content || '{}') as CustomWorldProfile;
|
||||
}
|
||||
|
||||
test('playable角色打开AI工坊后不会自动关闭', async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleClose = vi.fn();
|
||||
@@ -506,6 +558,14 @@ test('场景图片保存后会同步更新编辑页和场景列表', async () =>
|
||||
'/generated-custom-world-scenes/original-scene.png',
|
||||
);
|
||||
|
||||
const firstActCard = getSceneActCard(0);
|
||||
await user.click(within(firstActCard).getByRole('button', { name: '配置背景' }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('配置幕背景:第1幕')).toBeTruthy();
|
||||
});
|
||||
expect(screen.queryByText('场景图片')).toBeNull();
|
||||
expect(screen.queryByText('场景内 NPC')).toBeNull();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'AI生成' }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('智能生成:沉钟栈桥')).toBeTruthy();
|
||||
@@ -523,22 +583,29 @@ test('场景图片保存后会同步更新编辑页和场景列表', async () =>
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('img', { name: '场景图片' }).getAttribute('src')).toBe(
|
||||
expect(screen.getByRole('img', { name: '第1幕背景预览' }).getAttribute('src')).toBe(
|
||||
'/generated-custom-world-scenes/updated-scene.png',
|
||||
);
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /保存修改/u }));
|
||||
await user.click(screen.getByRole('button', { name: '保存背景' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('img', { name: '场景图片' })).toBeNull();
|
||||
expect(screen.queryByText('配置幕背景:第1幕')).toBeNull();
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /保存修改/u }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('img', { name: '沉钟栈桥' }).getAttribute('src')).toBe(
|
||||
'/generated-custom-world-scenes/updated-scene.png',
|
||||
);
|
||||
});
|
||||
|
||||
const savedProfile = readLandmarkHarnessProfile();
|
||||
expect(savedProfile.landmarks[0]?.imageSrc).toBe(
|
||||
'/generated-custom-world-scenes/updated-scene.png',
|
||||
);
|
||||
});
|
||||
|
||||
test('开局场景图片保存后会同步更新编辑页和场景列表', async () => {
|
||||
@@ -562,6 +629,14 @@ test('开局场景图片保存后会同步更新编辑页和场景列表', async
|
||||
'/generated-custom-world-scenes/original-camp.png',
|
||||
);
|
||||
|
||||
const firstActCard = getSceneActCard(0);
|
||||
await user.click(within(firstActCard).getByRole('button', { name: '配置背景' }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('配置幕背景:第1幕')).toBeTruthy();
|
||||
});
|
||||
expect(screen.queryByText('场景图片')).toBeNull();
|
||||
expect(screen.queryByText('场景内 NPC')).toBeNull();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'AI生成' }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('智能生成:潮灯居')).toBeTruthy();
|
||||
@@ -579,22 +654,80 @@ test('开局场景图片保存后会同步更新编辑页和场景列表', async
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('img', { name: '场景图片' }).getAttribute('src')).toBe(
|
||||
expect(screen.getByRole('img', { name: '第1幕背景预览' }).getAttribute('src')).toBe(
|
||||
'/generated-custom-world-scenes/updated-camp.png',
|
||||
);
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /保存修改/u }));
|
||||
await user.click(screen.getByRole('button', { name: '保存背景' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('img', { name: '场景图片' })).toBeNull();
|
||||
expect(screen.queryByText('配置幕背景:第1幕')).toBeNull();
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /保存修改/u }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('img', { name: '潮灯居' }).getAttribute('src')).toBe(
|
||||
'/generated-custom-world-scenes/updated-camp.png',
|
||||
);
|
||||
});
|
||||
|
||||
const savedProfile = readCampHarnessProfile();
|
||||
expect(savedProfile.camp?.imageSrc).toBe(
|
||||
'/generated-custom-world-scenes/updated-camp.png',
|
||||
);
|
||||
});
|
||||
|
||||
test('开局场景在场景配置面板中与普通场景使用同级参数并可保存', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<CampEditorFlowHarness />);
|
||||
|
||||
expect(screen.getByText('多幕配置')).toBeTruthy();
|
||||
expect(screen.getByText('场景连接关系')).toBeTruthy();
|
||||
expect(screen.queryByText('场景图片')).toBeNull();
|
||||
expect(screen.queryByText('场景内 NPC')).toBeNull();
|
||||
expect(screen.getAllByTestId('scene-act-card')).toHaveLength(3);
|
||||
|
||||
const firstActCard = getSceneActCard(0);
|
||||
await user.click(within(firstActCard).getAllByTestId('scene-act-slot-button')[0]!);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('配置角色:第1幕 · 主角色槽位')).toBeTruthy();
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /闻雪汀/u }));
|
||||
await user.click(screen.getByRole('button', { name: '保存角色' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('配置角色:第1幕 · 主角色槽位')).toBeNull();
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /保存修改/u }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('编辑场景:潮灯居')).toBeNull();
|
||||
});
|
||||
|
||||
const savedProfile = readCampHarnessProfile();
|
||||
const openingSceneChapter = savedProfile.sceneChapterBlueprints?.find(
|
||||
(entry) => entry.sceneId === 'custom-scene-camp',
|
||||
);
|
||||
|
||||
expect(savedProfile.camp?.sceneNpcIds).toHaveLength(3);
|
||||
expect(savedProfile.camp?.sceneNpcIds).toEqual(
|
||||
expect.arrayContaining(['story-1', 'story-2', 'story-3']),
|
||||
);
|
||||
expect(savedProfile.camp?.connections).toEqual([
|
||||
{
|
||||
targetLandmarkId: 'landmark-1',
|
||||
relativePosition: 'north',
|
||||
summary: '北侧通往沉钟栈桥。',
|
||||
},
|
||||
]);
|
||||
expect(openingSceneChapter).toBeTruthy();
|
||||
expect(openingSceneChapter?.acts[0]?.encounterNpcIds[0]).toBe('story-2');
|
||||
expect(openingSceneChapter?.linkedLandmarkIds).toContain('custom-scene-camp');
|
||||
});
|
||||
|
||||
test('场景编辑器会在场景内展示槽位化多幕配置并保存', async () => {
|
||||
@@ -636,7 +769,7 @@ test('场景编辑器会在场景内展示槽位化多幕配置并保存', async
|
||||
expect(screen.getByText('配置角色:第1幕 · 主角色槽位')).toBeTruthy();
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /闻雪汀[\s\S]*选择/u }));
|
||||
await user.click(screen.getByRole('button', { name: /闻雪汀/u }));
|
||||
await user.click(screen.getByRole('button', { name: '保存角色' }));
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -679,7 +812,7 @@ test('场景多幕支持新增删除和调序', async () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('配置角色:第2幕 · 主角色槽位')).toBeTruthy();
|
||||
});
|
||||
await user.click(screen.getByRole('button', { name: /谢孤灯[\s\S]*选择/u }));
|
||||
await user.click(screen.getByRole('button', { name: /谢孤灯/u }));
|
||||
await user.click(screen.getByRole('button', { name: '保存角色' }));
|
||||
await user.click(within(secondActCard).getByRole('button', { name: '下移' }));
|
||||
|
||||
@@ -721,3 +854,83 @@ test('场景幕预览会打开当前幕运行时面板', async () => {
|
||||
expect(screen.queryByText('幕预览运行时')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
test('作品封面上传会先进入 16:9 裁剪面板再提交到后端', async () => {
|
||||
const uploadMock = vi
|
||||
.mocked(customWorldCoverAssetService.uploadCustomWorldCoverImage)
|
||||
.mockResolvedValue({
|
||||
imageSrc: '/generated-custom-world-covers/world-1/uploaded/cover.webp',
|
||||
assetId: 'custom-cover-upload-1',
|
||||
sourceType: 'uploaded',
|
||||
});
|
||||
|
||||
class MockFileReader {
|
||||
result: string | null = null;
|
||||
error: Error | null = null;
|
||||
onload: null | (() => void) = null;
|
||||
onerror: null | (() => void) = null;
|
||||
|
||||
readAsDataURL() {
|
||||
this.result =
|
||||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO7+7aQAAAAASUVORK5CYII=';
|
||||
this.onload?.();
|
||||
}
|
||||
}
|
||||
|
||||
class MockImage {
|
||||
onload: null | (() => void) = null;
|
||||
onerror: null | (() => void) = null;
|
||||
naturalWidth = 1920;
|
||||
naturalHeight = 1080;
|
||||
|
||||
set src(_value: string) {
|
||||
this.onload?.();
|
||||
}
|
||||
}
|
||||
|
||||
vi.stubGlobal('FileReader', MockFileReader as unknown as typeof FileReader);
|
||||
vi.stubGlobal('Image', MockImage as unknown as typeof Image);
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<CoverEditorFlowHarness />);
|
||||
|
||||
const input = document.querySelector('input[type="file"]') as HTMLInputElement | null;
|
||||
expect(input).toBeTruthy();
|
||||
if (!input) {
|
||||
throw new Error('未找到封面上传输入框');
|
||||
}
|
||||
|
||||
const file = new File(['cover'], 'cover.png', { type: 'image/png' });
|
||||
await user.upload(input, file);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('裁剪上传封面')).toBeTruthy();
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '确认裁剪并上传' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(uploadMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
const uploadPayload = uploadMock.mock.calls[0]?.[0];
|
||||
expect(uploadPayload?.worldName).toBe('潮雾群岛');
|
||||
expect(uploadPayload?.cropRect.width).toBeGreaterThan(0);
|
||||
expect(uploadPayload?.cropRect.height).toBeGreaterThan(0);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('裁剪上传封面')).toBeNull();
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /保存/u }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('编辑作品封面')).toBeNull();
|
||||
});
|
||||
|
||||
const savedProfile = readCoverHarnessProfile();
|
||||
expect(savedProfile.cover?.sourceType).toBe('uploaded');
|
||||
expect(savedProfile.cover?.imageSrc).toBe(
|
||||
'/generated-custom-world-covers/world-1/uploaded/cover.webp',
|
||||
);
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,11 +14,11 @@ interface CustomWorldGenerationViewProps {
|
||||
onRetry: () => void;
|
||||
onInterrupt?: () => void;
|
||||
backLabel?: string;
|
||||
settingActionLabel?: string;
|
||||
settingActionLabel?: string | null;
|
||||
retryLabel?: string;
|
||||
interruptLabel?: string;
|
||||
settingTitle?: string;
|
||||
settingDescription?: string;
|
||||
settingDescription?: string | null;
|
||||
progressTitle?: string;
|
||||
activeBadgeLabel?: string;
|
||||
pausedBadgeLabel?: string;
|
||||
@@ -80,6 +80,11 @@ export function CustomWorldGenerationView({
|
||||
const progressValue = getProgressPercentage(progress);
|
||||
const steps = progress?.steps ?? [];
|
||||
const hasStructuredAnchors = anchorEntries.length > 0;
|
||||
// 允许不同生成场景按需隐藏第二模块的说明和次级返回动作。
|
||||
const normalizedSettingActionLabel = settingActionLabel?.trim() ?? '';
|
||||
const normalizedSettingDescription = settingDescription?.trim() ?? '';
|
||||
const hasSettingActionLabel = normalizedSettingActionLabel.length > 0;
|
||||
const hasSettingDescription = normalizedSettingDescription.length > 0;
|
||||
const estimatedWaitText =
|
||||
progress?.estimatedRemainingMs != null
|
||||
? `预计还需 ${formatDuration(progress.estimatedRemainingMs)}`
|
||||
@@ -207,13 +212,15 @@ export function CustomWorldGenerationView({
|
||||
<div className="mt-4 flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:justify-end">
|
||||
{!isGenerating ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEditSetting}
|
||||
className="platform-button platform-button--ghost min-h-0 rounded-full px-4 py-2 text-sm"
|
||||
>
|
||||
{settingActionLabel}
|
||||
</button>
|
||||
{hasSettingActionLabel ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEditSetting}
|
||||
className="platform-button platform-button--ghost min-h-0 rounded-full px-4 py-2 text-sm"
|
||||
>
|
||||
{normalizedSettingActionLabel}
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRetry}
|
||||
@@ -240,18 +247,22 @@ export function CustomWorldGenerationView({
|
||||
<div className="text-[11px] font-bold tracking-[0.2em] text-[var(--platform-cool-text)]">
|
||||
{settingTitle}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-zinc-400">
|
||||
{settingDescription}
|
||||
</div>
|
||||
{hasSettingDescription ? (
|
||||
<div className="mt-1 text-sm text-zinc-400">
|
||||
{normalizedSettingDescription}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEditSetting}
|
||||
disabled={isGenerating}
|
||||
className={`platform-button platform-button--ghost min-h-0 px-3 py-1.5 text-[11px] ${isGenerating ? 'cursor-not-allowed opacity-40' : ''}`}
|
||||
>
|
||||
{settingActionLabel}
|
||||
</button>
|
||||
{hasSettingActionLabel ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEditSetting}
|
||||
disabled={isGenerating}
|
||||
className={`platform-button platform-button--ghost min-h-0 px-3 py-1.5 text-[11px] ${isGenerating ? 'cursor-not-allowed opacity-40' : ''}`}
|
||||
>
|
||||
{normalizedSettingActionLabel}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{hasStructuredAnchors ? (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
|
||||
@@ -220,6 +220,33 @@ const baseProfile = {
|
||||
connections: [],
|
||||
},
|
||||
],
|
||||
sceneChapterBlueprints: [
|
||||
{
|
||||
id: 'scene-chapter-1',
|
||||
sceneId: 'landmark-1',
|
||||
title: '沉钟栈桥章节',
|
||||
summary: '围绕沉钟栈桥推进的三幕结构。',
|
||||
linkedThreadIds: [],
|
||||
linkedLandmarkIds: ['landmark-1'],
|
||||
acts: [
|
||||
{
|
||||
id: 'scene-act-1',
|
||||
sceneId: 'landmark-1',
|
||||
title: '潮声逼近',
|
||||
summary: '第一幕先把潮声与旧钟压上来。',
|
||||
stageCoverage: ['opening'],
|
||||
backgroundImageSrc: '/generated-custom-world-scenes/scene-act-1.png',
|
||||
backgroundAssetId: 'scene-asset-1',
|
||||
encounterNpcIds: ['story-1'],
|
||||
primaryNpcId: 'story-1',
|
||||
linkedThreadIds: [],
|
||||
advanceRule: 'after_primary_contact',
|
||||
actGoal: '接住首幕压力',
|
||||
transitionHook: '继续逼近钟楼深处。',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
creatorIntent: null,
|
||||
anchorPack: null,
|
||||
lockState: null,
|
||||
@@ -278,7 +305,7 @@ test('clicking新增可扮演角色 shows pending item, disables button, and mar
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('云止')).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: /云止/u })).toBeTruthy();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -304,3 +331,70 @@ test('world basic setting renders eight anchor fields and hides legacy parsed/so
|
||||
expect(screen.getByText(/被海雾反复改写航路的群岛世界/u)).toBeTruthy();
|
||||
expect(screen.getByText(/沉钟异动和旧案灭口是同一条线/u)).toBeTruthy();
|
||||
});
|
||||
|
||||
test('playable tab prefers generated portrait over runtime preview placeholder', async () => {
|
||||
const user = userEvent.setup();
|
||||
const profile = {
|
||||
...baseProfile,
|
||||
playableNpcs: [
|
||||
{
|
||||
...createPlayableRole('playable-portrait', '云止'),
|
||||
imageSrc: '/generated-characters/playable-portrait/master.png',
|
||||
generatedVisualAssetId: 'visual-playable-portrait',
|
||||
},
|
||||
],
|
||||
} as CustomWorldProfile;
|
||||
|
||||
render(
|
||||
<CustomWorldResultView
|
||||
profile={profile}
|
||||
previewCharacters={[
|
||||
{
|
||||
id: 'playable-portrait',
|
||||
name: '云止',
|
||||
title: '同行者',
|
||||
description: '预览角色',
|
||||
backstory: '预览背景',
|
||||
personality: '预览性格',
|
||||
portrait: '/template/portrait.png',
|
||||
avatar: '/template/avatar.png',
|
||||
assetFolder: 'test',
|
||||
assetVariant: 'Hero',
|
||||
combatTags: [],
|
||||
skills: [],
|
||||
adventureOpenings: {},
|
||||
} as never,
|
||||
]}
|
||||
isGenerating={false}
|
||||
progress={0}
|
||||
progressLabel=""
|
||||
error={null}
|
||||
onBack={() => {}}
|
||||
onProfileChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /可扮演角色/u }));
|
||||
|
||||
const portrait = screen.getByRole('img', { name: '云止' });
|
||||
expect((portrait as HTMLImageElement).getAttribute('src')).toBe(
|
||||
'/generated-characters/playable-portrait/master.png',
|
||||
);
|
||||
expect(screen.getByText('已生成主图')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('landmark tab uses first act image as scene card preview and keeps chapter details out of list', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<ResultViewHarness />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /场景\s*2/u }));
|
||||
|
||||
expect(screen.queryByText('沉钟栈桥章节')).toBeNull();
|
||||
expect(screen.queryByText('潮声逼近')).toBeNull();
|
||||
|
||||
const sceneImage = screen.getByRole('img', { name: '沉钟栈桥' });
|
||||
expect((sceneImage as HTMLImageElement).getAttribute('src')).toBe(
|
||||
'/generated-custom-world-scenes/scene-act-1.png',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -54,6 +54,159 @@ type PendingGeneratedEntity = {
|
||||
|
||||
type RecentGeneratedIds = Record<EntityGenerationKind, string[]>;
|
||||
|
||||
type CustomWorldAssetDebugEntry = {
|
||||
id: string;
|
||||
label: string;
|
||||
imageSrc: string;
|
||||
kind: 'playable' | 'story' | 'landmark' | 'scene-act';
|
||||
};
|
||||
|
||||
type AssetDebugLoadStatus = 'loading' | 'loaded' | 'error';
|
||||
|
||||
const CUSTOM_WORLD_ASSET_DEBUG_QUERY_KEY = 'debugCustomWorldAssets';
|
||||
const CUSTOM_WORLD_ASSET_DEBUG_STORAGE_KEY =
|
||||
'genarrative.debug.customWorldAssets';
|
||||
|
||||
function shouldEnableCustomWorldAssetDebugPanel() {
|
||||
if (!import.meta.env.DEV || typeof window === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
if (searchParams.get(CUSTOM_WORLD_ASSET_DEBUG_QUERY_KEY) === '1') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (
|
||||
window.localStorage.getItem(CUSTOM_WORLD_ASSET_DEBUG_STORAGE_KEY) === '1'
|
||||
);
|
||||
}
|
||||
|
||||
function collectCustomWorldAssetDebugEntries(
|
||||
profile: CustomWorldProfile,
|
||||
): CustomWorldAssetDebugEntry[] {
|
||||
const playableEntries = profile.playableNpcs
|
||||
.map((role) => {
|
||||
const imageSrc = role.imageSrc?.trim() || '';
|
||||
if (!imageSrc) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: `playable:${role.id}`,
|
||||
label: `${role.name}主形象`,
|
||||
imageSrc,
|
||||
kind: 'playable' as const,
|
||||
};
|
||||
})
|
||||
.filter(
|
||||
(entry): entry is CustomWorldAssetDebugEntry => Boolean(entry),
|
||||
);
|
||||
const storyEntries = profile.storyNpcs
|
||||
.map((role) => {
|
||||
const imageSrc = role.imageSrc?.trim() || '';
|
||||
if (!imageSrc) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: `story:${role.id}`,
|
||||
label: `${role.name}场景角色主图`,
|
||||
imageSrc,
|
||||
kind: 'story' as const,
|
||||
};
|
||||
})
|
||||
.filter(
|
||||
(entry): entry is CustomWorldAssetDebugEntry => Boolean(entry),
|
||||
);
|
||||
const landmarkEntries = profile.landmarks
|
||||
.map((landmark) => {
|
||||
const imageSrc = landmark.imageSrc?.trim() || '';
|
||||
if (!imageSrc) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: `landmark:${landmark.id}`,
|
||||
label: `${landmark.name}场景主图`,
|
||||
imageSrc,
|
||||
kind: 'landmark' as const,
|
||||
};
|
||||
})
|
||||
.filter(
|
||||
(entry): entry is CustomWorldAssetDebugEntry => Boolean(entry),
|
||||
);
|
||||
const sceneActEntries =
|
||||
profile.sceneChapterBlueprints?.flatMap((chapter) =>
|
||||
chapter.acts
|
||||
.map((act) => {
|
||||
const imageSrc = act.backgroundImageSrc?.trim() || '';
|
||||
if (!imageSrc) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: `scene-act:${chapter.id}:${act.id}`,
|
||||
label: `${chapter.title || chapter.sceneId} / ${act.title}幕图`,
|
||||
imageSrc,
|
||||
kind: 'scene-act' as const,
|
||||
};
|
||||
})
|
||||
.filter(
|
||||
(entry): entry is CustomWorldAssetDebugEntry => Boolean(entry),
|
||||
),
|
||||
) ?? [];
|
||||
|
||||
return [
|
||||
...playableEntries,
|
||||
...storyEntries,
|
||||
...landmarkEntries,
|
||||
...sceneActEntries,
|
||||
];
|
||||
}
|
||||
|
||||
function resolveAssetDebugStatusLabel(status: AssetDebugLoadStatus | undefined) {
|
||||
if (status === 'loaded') {
|
||||
return '已加载';
|
||||
}
|
||||
if (status === 'error') {
|
||||
return '加载失败';
|
||||
}
|
||||
return '检测中';
|
||||
}
|
||||
|
||||
function resolveAssetDebugSummary(profile: CustomWorldProfile) {
|
||||
return [
|
||||
{
|
||||
label: '可扮演角色主图',
|
||||
value: `${profile.playableNpcs.filter((role) => Boolean(role.imageSrc?.trim())).length}/${profile.playableNpcs.length}`,
|
||||
},
|
||||
{
|
||||
label: '场景角色主图',
|
||||
value: `${profile.storyNpcs.filter((role) => Boolean(role.imageSrc?.trim())).length}/${profile.storyNpcs.length}`,
|
||||
},
|
||||
{
|
||||
label: '场景主图',
|
||||
value: `${profile.landmarks.filter((landmark) => Boolean(landmark.imageSrc?.trim())).length}/${profile.landmarks.length}`,
|
||||
},
|
||||
{
|
||||
label: '分幕图',
|
||||
value: `${profile.sceneChapterBlueprints?.reduce(
|
||||
(sum, chapter) =>
|
||||
sum +
|
||||
chapter.acts.filter((act) => Boolean(act.backgroundImageSrc?.trim()))
|
||||
.length,
|
||||
0,
|
||||
) ?? 0}/${
|
||||
profile.sceneChapterBlueprints?.reduce(
|
||||
(sum, chapter) => sum + chapter.acts.length,
|
||||
0,
|
||||
) ?? 0
|
||||
}`,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function SmallButton({
|
||||
onClick,
|
||||
children,
|
||||
@@ -236,6 +389,22 @@ export function CustomWorldResultView({
|
||||
null,
|
||||
);
|
||||
const pendingProgressTimerRef = useRef<number | null>(null);
|
||||
const assetDebugEnabled = useMemo(
|
||||
() => shouldEnableCustomWorldAssetDebugPanel(),
|
||||
[],
|
||||
);
|
||||
const assetDebugEntries = useMemo(
|
||||
() =>
|
||||
assetDebugEnabled ? collectCustomWorldAssetDebugEntries(profile) : [],
|
||||
[assetDebugEnabled, profile],
|
||||
);
|
||||
const assetDebugSummary = useMemo(
|
||||
() => (assetDebugEnabled ? resolveAssetDebugSummary(profile) : []),
|
||||
[assetDebugEnabled, profile],
|
||||
);
|
||||
const [assetDebugStatusMap, setAssetDebugStatusMap] = useState<
|
||||
Record<string, AssetDebugLoadStatus>
|
||||
>({});
|
||||
|
||||
const createTarget = useMemo(
|
||||
() => getCreateTargetByTab(activeTab),
|
||||
@@ -254,6 +423,59 @@ export function CustomWorldResultView({
|
||||
|
||||
useEffect(() => () => stopPendingProgressTimer(), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!assetDebugEnabled) {
|
||||
setAssetDebugStatusMap({});
|
||||
return;
|
||||
}
|
||||
|
||||
if (assetDebugEntries.length === 0) {
|
||||
setAssetDebugStatusMap({});
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const cleanupList: Array<() => void> = [];
|
||||
|
||||
setAssetDebugStatusMap(
|
||||
Object.fromEntries(
|
||||
assetDebugEntries.map((entry) => [entry.id, 'loading' as const]),
|
||||
),
|
||||
);
|
||||
|
||||
assetDebugEntries.forEach((entry) => {
|
||||
const image = new Image();
|
||||
const updateStatus = (status: AssetDebugLoadStatus) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
setAssetDebugStatusMap((current) => {
|
||||
if (current[entry.id] === status) {
|
||||
return current;
|
||||
}
|
||||
return {
|
||||
...current,
|
||||
[entry.id]: status,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
image.onload = () => updateStatus('loaded');
|
||||
image.onerror = () => updateStatus('error');
|
||||
image.src = entry.imageSrc;
|
||||
cleanupList.push(() => {
|
||||
image.onload = null;
|
||||
image.onerror = null;
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
cleanupList.forEach((cleanup) => cleanup());
|
||||
};
|
||||
}, [assetDebugEnabled, assetDebugEntries]);
|
||||
|
||||
const startPendingProgress = (kind: EntityGenerationKind) => {
|
||||
stopPendingProgressTimer();
|
||||
setPendingGeneratedEntity(createPendingGeneratedEntity(kind));
|
||||
@@ -445,6 +667,77 @@ export function CustomWorldResultView({
|
||||
{localGenerationError}
|
||||
</div>
|
||||
) : null}
|
||||
{assetDebugEnabled ? (
|
||||
<div className="platform-surface platform-surface--soft mt-3 px-3.5 py-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-xs font-bold tracking-[0.16em] text-white">
|
||||
资产诊断
|
||||
</div>
|
||||
<div className="mt-1 text-xs leading-6 text-zinc-500">
|
||||
仅开发模式显示,用来核对结果页当前拿到的图片字段和实际加载状态。
|
||||
</div>
|
||||
</div>
|
||||
<div className="platform-pill platform-pill--neutral px-2.5 py-1 text-[10px]">
|
||||
{assetDebugEntries.length}项
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-2 gap-2 xl:grid-cols-4">
|
||||
{assetDebugSummary.map((entry) => (
|
||||
<div
|
||||
key={entry.label}
|
||||
className="platform-subpanel rounded-2xl px-3 py-2"
|
||||
>
|
||||
<div className="text-[11px] text-zinc-500">{entry.label}</div>
|
||||
<div className="mt-1 text-sm font-semibold text-white">
|
||||
{entry.value}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-3 space-y-2">
|
||||
{assetDebugEntries.length > 0 ? (
|
||||
assetDebugEntries.map((entry) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="platform-subpanel rounded-2xl px-3 py-2"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold text-white">
|
||||
{entry.label}
|
||||
</div>
|
||||
<div className="mt-1 break-all text-[11px] leading-5 text-zinc-400">
|
||||
{entry.imageSrc}
|
||||
</div>
|
||||
</div>
|
||||
<div className="platform-pill platform-pill--neutral px-2.5 py-1 text-[10px]">
|
||||
{resolveAssetDebugStatusLabel(
|
||||
assetDebugStatusMap[entry.id],
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<a
|
||||
href={entry.imageSrc}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
aria-label={`打开 ${entry.label}`}
|
||||
className="text-xs font-semibold text-amber-200 underline decoration-white/20 underline-offset-2"
|
||||
>
|
||||
打开原图
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="platform-subpanel rounded-2xl px-3 py-3 text-sm text-zinc-400">
|
||||
当前结果页 profile 里没有拿到任何可诊断的图片地址。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-4 flex flex-col gap-3">
|
||||
{profile.generationStatus === 'key_only' ? (
|
||||
|
||||
@@ -283,6 +283,7 @@ export function GameShell({session, story, entry, companions, audio}: GameShellP
|
||||
!gameState.playerCharacter;
|
||||
const collapseTopStage = gameState.currentScene === 'Selection';
|
||||
const shouldHideStoryOptions = sceneTransitionPhase !== 'idle';
|
||||
const visibleStoryForRender = visibleCurrentStory;
|
||||
|
||||
const dialogueIndicator = useMemo(() => {
|
||||
if (!isLoading || visibleCurrentStory?.displayMode !== 'dialogue' || visibleGameState.currentEncounter?.kind !== 'npc') {
|
||||
@@ -431,6 +432,7 @@ export function GameShell({session, story, entry, companions, audio}: GameShellP
|
||||
companions={canvasCompanionRenderStates}
|
||||
npcStates={visibleGameState.npcStates}
|
||||
dialogueIndicator={dialogueIndicator}
|
||||
npcAffinityEffect={visibleStoryForRender?.npcAffinityEffect ?? null}
|
||||
onEntitySelect={setSelectedSceneEntity}
|
||||
onSceneNameClick={() => setIsMapOpen(true)}
|
||||
sceneTransitionPhase={sceneTransitionPhase}
|
||||
@@ -485,7 +487,7 @@ export function GameShell({session, story, entry, companions, audio}: GameShellP
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{visibleGameState.playerCharacter && visibleCurrentStory && (
|
||||
{visibleGameState.playerCharacter && visibleStoryForRender && (
|
||||
<motion.div key="story-flow" initial={{opacity: 0}} animate={{opacity: 1}} className="flex h-full min-h-0 flex-col">
|
||||
<div className="story-top-tabs mb-3 grid grid-cols-3 gap-2 sm:gap-3">
|
||||
<button
|
||||
@@ -562,7 +564,7 @@ export function GameShell({session, story, entry, companions, audio}: GameShellP
|
||||
<Suspense fallback={<PanelLoadingFallback label="正在加载冒险面板" />}>
|
||||
<AdventurePanel
|
||||
aiError={aiError}
|
||||
currentStory={visibleCurrentStory}
|
||||
currentStory={visibleStoryForRender}
|
||||
isLoading={isLoading}
|
||||
displayedOptions={displayedOptions}
|
||||
hideOptions={shouldHideStoryOptions}
|
||||
|
||||
@@ -6,8 +6,6 @@ import { fetchJson } from '../../editor/shared/jsonClient';
|
||||
|
||||
export const CHARACTER_VISUAL_GENERATE_API_PATH =
|
||||
ASSET_API_PATHS.characterVisualGenerate;
|
||||
export const CHARACTER_PROMPT_BUNDLE_GENERATE_API_PATH =
|
||||
ASSET_API_PATHS.characterPromptBundleGenerate;
|
||||
export const CHARACTER_WORKFLOW_CACHE_API_PATH =
|
||||
ASSET_API_PATHS.characterWorkflowCache;
|
||||
export const CHARACTER_VISUAL_PUBLISH_API_PATH =
|
||||
@@ -47,29 +45,6 @@ export type CharacterVisualDraft = {
|
||||
height: number;
|
||||
};
|
||||
|
||||
export type CharacterPromptBundlePayload = {
|
||||
roleKind: 'playable' | 'story';
|
||||
characterName: string;
|
||||
roleTitle?: string;
|
||||
roleLabel?: string;
|
||||
description?: string;
|
||||
backstory?: string;
|
||||
personality?: string;
|
||||
motivation?: string;
|
||||
combatStyle?: string;
|
||||
tags?: string[];
|
||||
characterBriefText: string;
|
||||
};
|
||||
|
||||
export type CharacterPromptBundleResult = {
|
||||
ok: true;
|
||||
visualPromptText: string;
|
||||
animationPromptText: string;
|
||||
scenePromptText: string;
|
||||
source: 'llm' | 'fallback';
|
||||
model: string | null;
|
||||
};
|
||||
|
||||
export type CharacterAssetWorkflowCache = {
|
||||
characterId: string;
|
||||
visualPromptText: string;
|
||||
@@ -174,16 +149,6 @@ export async function generateCharacterVisualCandidates(
|
||||
}>(CHARACTER_VISUAL_GENERATE_API_PATH, payload, '生成角色主形象失败');
|
||||
}
|
||||
|
||||
export async function generateCharacterPromptBundle(
|
||||
payload: CharacterPromptBundlePayload,
|
||||
) {
|
||||
return postApiJson<CharacterPromptBundleResult>(
|
||||
CHARACTER_PROMPT_BUNDLE_GENERATE_API_PATH,
|
||||
payload,
|
||||
'生成默认提示词失败',
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchCharacterWorkflowCache(characterId: string) {
|
||||
return fetchJson<{
|
||||
ok: true;
|
||||
|
||||
@@ -44,7 +44,7 @@ export function CustomWorldAgentComposer({
|
||||
|
||||
return (
|
||||
<div className="shrink-0">
|
||||
<div className="platform-remap-surface relative">
|
||||
<div className="platform-remap-surface platform-subpanel relative rounded-[1.5rem] p-1.5">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={text}
|
||||
@@ -58,13 +58,13 @@ export function CustomWorldAgentComposer({
|
||||
rows={2}
|
||||
disabled={disabled}
|
||||
placeholder="输入消息"
|
||||
className="min-h-[5.5rem] w-full resize-none rounded-[1.35rem] border border-white/10 bg-[#111318]/92 px-4 pb-11 pr-18 pt-2.5 text-sm leading-5.5 text-white outline-none transition focus:border-emerald-300/35 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
className="platform-input min-h-[5.5rem] resize-none rounded-[1.2rem] pb-12 pr-20 pt-3 text-sm leading-5.5 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={submit}
|
||||
disabled={disabled || !text.trim()}
|
||||
className="absolute bottom-2.5 right-2.5 rounded-full border border-emerald-300/20 bg-emerald-500/10 px-3 py-1.5 text-xs font-medium text-emerald-100 transition hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
|
||||
className="platform-button platform-button--primary absolute bottom-3 right-3 h-9 min-h-0 rounded-full px-3 py-1.5 text-xs disabled:cursor-not-allowed disabled:opacity-45"
|
||||
>
|
||||
发送
|
||||
</button>
|
||||
|
||||
@@ -4,11 +4,11 @@ type CustomWorldAgentHeaderProps = {
|
||||
|
||||
export function CustomWorldAgentHeader({ onBack }: CustomWorldAgentHeaderProps) {
|
||||
return (
|
||||
<div className="platform-remap-surface flex items-center rounded-[1.5rem] border border-white/10 bg-[#111318]/95 px-4 py-3">
|
||||
<div className="platform-remap-surface platform-subpanel flex items-center rounded-[1.5rem] px-4 py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-[11px] text-zinc-300 transition hover:text-white"
|
||||
className="platform-button platform-button--ghost h-9 min-h-0 rounded-full px-3 py-1.5 text-[11px]"
|
||||
>
|
||||
返回
|
||||
</button>
|
||||
|
||||
@@ -35,37 +35,41 @@ export function CustomWorldAgentOperationBanner({
|
||||
const isFailed = visibleOperation.status === 'failed';
|
||||
const isRunning =
|
||||
visibleOperation.status === 'running' || visibleOperation.status === 'queued';
|
||||
// 操作横幅直接复用平台状态横幅,亮暗主题都从同一套 token 取色。
|
||||
const bannerToneClass = isFailed
|
||||
? 'platform-banner--danger'
|
||||
: isRunning
|
||||
? 'platform-banner--info'
|
||||
: 'platform-banner--success';
|
||||
const progressFillStyle = isFailed
|
||||
? { background: 'linear-gradient(90deg, #fb7185 0%, #f43f5e 100%)' }
|
||||
: isRunning
|
||||
? { background: 'var(--platform-button-primary-fill)' }
|
||||
: { background: 'linear-gradient(90deg, #86efac 0%, #34d399 100%)' };
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`platform-remap-surface rounded-[1.4rem] border px-4 py-4 ${
|
||||
isFailed
|
||||
? 'border-rose-400/20 bg-[#111318]/95'
|
||||
: isRunning
|
||||
? 'border-emerald-300/20 bg-[#111318]/95'
|
||||
: 'border-emerald-300/20 bg-[#111318]/95'
|
||||
}`}
|
||||
className={`platform-remap-surface platform-banner rounded-[1.4rem] px-4 py-4 ${bannerToneClass}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-sm font-semibold text-white">
|
||||
<div className="text-sm font-semibold">
|
||||
{visibleOperation.phaseLabel}
|
||||
</div>
|
||||
<div className="text-xs text-zinc-300">
|
||||
<div className="text-xs opacity-80">
|
||||
{Math.max(0, Math.min(100, Math.round(visibleOperation.progress)))}%
|
||||
</div>
|
||||
</div>
|
||||
{visibleOperation.error ? (
|
||||
<div className="mt-2 text-sm text-zinc-200">
|
||||
<div className="mt-2 text-sm opacity-90">
|
||||
{visibleOperation.error}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-3 h-2 overflow-hidden rounded-full bg-white/10">
|
||||
<div className="platform-progress-track mt-3 h-2 overflow-hidden rounded-full">
|
||||
<div
|
||||
className={`h-full rounded-full transition-[width] duration-300 ${
|
||||
isFailed ? 'bg-rose-300' : 'bg-emerald-300'
|
||||
}`}
|
||||
className="h-full rounded-full transition-[width] duration-300"
|
||||
style={{
|
||||
width: `${Math.max(8, Math.min(100, visibleOperation.progress))}%`,
|
||||
...progressFillStyle,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -37,9 +37,9 @@ export function CustomWorldAgentThread({
|
||||
}, [messages, streamingReplyText, isStreamingReply]);
|
||||
|
||||
return (
|
||||
<div className="platform-remap-surface flex h-full min-h-0 flex-1 flex-col overflow-y-auto px-1 py-2 sm:px-2">
|
||||
<div className="platform-remap-surface platform-subpanel flex h-full min-h-0 flex-1 flex-col overflow-y-auto rounded-[1.75rem] px-2 py-3 sm:px-3">
|
||||
{messages.length === 0 ? (
|
||||
<div className="m-auto text-sm text-zinc-400">
|
||||
<div className="m-auto text-sm text-[var(--platform-text-soft)]">
|
||||
暂无消息
|
||||
</div>
|
||||
) : (
|
||||
@@ -47,6 +47,12 @@ export function CustomWorldAgentThread({
|
||||
{messages.map((message, index) => {
|
||||
const isUser = message.role === 'user';
|
||||
const isSystem = message.role === 'system';
|
||||
// 聊天气泡统一映射到平台主题 token,避免亮色主题继续透出历史深色底。
|
||||
const bubbleToneClass = isUser
|
||||
? 'border border-[var(--platform-cool-border)] bg-[var(--platform-cool-bg)] text-[var(--platform-text-strong)]'
|
||||
: isSystem
|
||||
? 'border border-[var(--platform-warm-border)] bg-[var(--platform-warm-bg)] text-[var(--platform-warm-text)]'
|
||||
: 'platform-subpanel text-[var(--platform-text-strong)]';
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -56,13 +62,7 @@ export function CustomWorldAgentThread({
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`max-w-[90%] rounded-[1.4rem] px-4 py-3 text-sm leading-7 break-words sm:max-w-[82%] ${
|
||||
isUser
|
||||
? 'border border-white/10 bg-white/10 text-zinc-50'
|
||||
: isSystem
|
||||
? 'border border-amber-300/16 bg-amber-500/10 text-amber-50'
|
||||
: 'border border-white/10 bg-white/6 text-zinc-100'
|
||||
}`}
|
||||
className={`max-w-[90%] rounded-[1.4rem] px-4 py-3 text-sm leading-7 break-words sm:max-w-[82%] ${bubbleToneClass}`}
|
||||
>
|
||||
<div className="whitespace-pre-wrap">{message.text}</div>
|
||||
{!isUser &&
|
||||
@@ -74,7 +74,7 @@ export function CustomWorldAgentThread({
|
||||
key={`recommended-reply-${replyIndex}-${reply}`}
|
||||
type="button"
|
||||
onClick={() => onRecommendedReply?.(reply)}
|
||||
className="rounded-[0.95rem] border border-white/10 bg-white/5 px-2.5 py-1.5 text-left text-[11px] leading-4.5 text-zinc-200 transition hover:border-emerald-300/25 hover:text-white"
|
||||
className="platform-button platform-button--ghost min-h-0 justify-start rounded-[0.95rem] px-2.5 py-1.5 text-left text-[11px] leading-4.5 whitespace-normal"
|
||||
>
|
||||
{reply}
|
||||
</button>
|
||||
@@ -87,17 +87,17 @@ export function CustomWorldAgentThread({
|
||||
})}
|
||||
{isStreamingReply ? (
|
||||
<div className="flex justify-start">
|
||||
<div className="max-w-[90%] rounded-[1.4rem] border border-white/10 bg-white/6 px-4 py-3 text-sm leading-7 text-zinc-100 sm:max-w-[82%]">
|
||||
<div className="platform-subpanel max-w-[90%] rounded-[1.4rem] px-4 py-3 text-sm leading-7 text-[var(--platform-text-strong)] sm:max-w-[82%]">
|
||||
{streamingReplyText ? (
|
||||
<div className="whitespace-pre-wrap">
|
||||
{streamingReplyText}
|
||||
<span className="ml-1 inline-block h-4 w-1 animate-pulse rounded-full bg-emerald-200/80 align-[-2px]" />
|
||||
<span className="ml-1 inline-block h-4 w-1 animate-pulse rounded-full bg-[var(--platform-cool-text)] align-[-2px]" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5 py-1">
|
||||
<span className="h-2 w-2 animate-pulse rounded-full bg-zinc-300/70 [animation-delay:-0.2s]" />
|
||||
<span className="h-2 w-2 animate-pulse rounded-full bg-zinc-300/70 [animation-delay:-0.1s]" />
|
||||
<span className="h-2 w-2 animate-pulse rounded-full bg-zinc-300/70" />
|
||||
<span className="h-2 w-2 animate-pulse rounded-full bg-[var(--platform-text-soft)] [animation-delay:-0.2s]" />
|
||||
<span className="h-2 w-2 animate-pulse rounded-full bg-[var(--platform-text-soft)] [animation-delay:-0.1s]" />
|
||||
<span className="h-2 w-2 animate-pulse rounded-full bg-[var(--platform-text-soft)]" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -42,7 +42,7 @@ export function CustomWorldAgentWorkspace({
|
||||
}: CustomWorldAgentWorkspaceProps) {
|
||||
if (!session) {
|
||||
return (
|
||||
<div className="platform-remap-surface mx-auto flex h-full w-full max-w-4xl items-center justify-center rounded-[1.75rem] border border-white/10 bg-black/20 px-6 py-8 text-center text-sm text-zinc-400">
|
||||
<div className="platform-remap-surface platform-subpanel mx-auto flex h-full w-full max-w-4xl items-center justify-center rounded-[1.75rem] px-6 py-8 text-center text-sm text-[var(--platform-text-soft)]">
|
||||
正在恢复
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -46,36 +46,42 @@ export function EightAnchorProgressBar({
|
||||
const normalizedProgress = clampProgress(progressPercent);
|
||||
const isCompleted = normalizedProgress >= 100;
|
||||
const canQuickFill = currentTurn >= 2;
|
||||
const progressFillStyle = isCompleted
|
||||
? { background: 'linear-gradient(90deg, #86efac 0%, #34d399 100%)' }
|
||||
: { background: 'var(--platform-button-primary-fill)' };
|
||||
|
||||
return (
|
||||
<div className="platform-remap-surface rounded-[1.75rem] border border-white/10 bg-[#111318]/95 p-4">
|
||||
<div className="platform-remap-surface platform-subpanel rounded-[1.75rem] p-4">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-xs font-semibold tracking-[0.14em] text-zinc-300">
|
||||
<div className="text-xs font-semibold tracking-[0.14em] text-[var(--platform-text-base)]">
|
||||
创作进度
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-zinc-400">
|
||||
<div className="mt-1 text-sm text-[var(--platform-text-soft)]">
|
||||
{resolveProgressHint(normalizedProgress)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-lg font-semibold text-white">
|
||||
<div className="text-lg font-semibold text-[var(--platform-text-strong)]">
|
||||
{normalizedProgress}%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-3 overflow-hidden rounded-full bg-white/8">
|
||||
<div className="platform-progress-track h-3 overflow-hidden rounded-full">
|
||||
<div
|
||||
className="h-full rounded-full bg-[linear-gradient(90deg,#d8ffd9_0%,#6ee7b7_45%,#34d399_100%)] transition-[width] duration-500"
|
||||
style={{ width: `${Math.max(6, normalizedProgress)}%` }}
|
||||
className="h-full rounded-full transition-[width] duration-500"
|
||||
style={{
|
||||
width: `${Math.max(6, normalizedProgress)}%`,
|
||||
...progressFillStyle,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSummaryClick}
|
||||
disabled={disabled}
|
||||
className="rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-zinc-200 transition hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
|
||||
className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-xs disabled:cursor-not-allowed disabled:opacity-45"
|
||||
>
|
||||
总结当前设定
|
||||
</button>
|
||||
@@ -84,7 +90,7 @@ export function EightAnchorProgressBar({
|
||||
type="button"
|
||||
onClick={onGenerateDraft}
|
||||
disabled={disabled}
|
||||
className="flex min-h-[3rem] items-center justify-center rounded-[1.1rem] border border-emerald-300/25 bg-emerald-500/12 px-4 py-3 text-sm font-semibold text-emerald-50 transition hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
|
||||
className="platform-button platform-button--primary min-h-[3rem] rounded-[1.1rem] px-4 py-3 text-sm disabled:cursor-not-allowed disabled:opacity-45"
|
||||
>
|
||||
生成游戏设定草稿
|
||||
</button>
|
||||
@@ -93,7 +99,7 @@ export function EightAnchorProgressBar({
|
||||
type="button"
|
||||
onClick={onQuickFill}
|
||||
disabled={disabled}
|
||||
className="rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-zinc-200 transition hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
|
||||
className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-xs disabled:cursor-not-allowed disabled:opacity-45"
|
||||
>
|
||||
补全剩余设定
|
||||
</button>
|
||||
|
||||
128
src/components/game-canvas/GameCanvasEntityLayer.test.tsx
Normal file
128
src/components/game-canvas/GameCanvasEntityLayer.test.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
AnimationState,
|
||||
type Character,
|
||||
type Encounter,
|
||||
type SceneHostileNpc,
|
||||
} from '../../types';
|
||||
import { GameCanvasEntityLayer } from './GameCanvasEntityLayer';
|
||||
|
||||
function createCharacter(): Character {
|
||||
return {
|
||||
id: 'hero',
|
||||
name: '沈行',
|
||||
title: '试剑客',
|
||||
description: '测试主角',
|
||||
backstory: '测试背景',
|
||||
avatar: '/hero.png',
|
||||
portrait: '/hero.png',
|
||||
assetFolder: 'hero',
|
||||
assetVariant: 'default',
|
||||
attributes: {
|
||||
strength: 10,
|
||||
agility: 10,
|
||||
intelligence: 8,
|
||||
spirit: 9,
|
||||
},
|
||||
personality: 'calm',
|
||||
skills: [],
|
||||
adventureOpenings: {},
|
||||
} as Character;
|
||||
}
|
||||
|
||||
function createEncounter(overrides: Partial<Encounter> = {}): Encounter {
|
||||
return {
|
||||
id: 'npc-liu',
|
||||
kind: 'npc',
|
||||
npcName: '柳无声',
|
||||
npcDescription: '桥口旧识',
|
||||
npcAvatar: '/npc-liu.png',
|
||||
context: '断桥',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createHostileNpc(overrides: Partial<SceneHostileNpc> = {}): SceneHostileNpc {
|
||||
return {
|
||||
id: 'npc-liu',
|
||||
name: '柳无声',
|
||||
action: '对峙',
|
||||
description: '桥口旧识',
|
||||
animation: 'idle',
|
||||
xMeters: 3,
|
||||
yOffset: 0,
|
||||
facing: 'left',
|
||||
attackRange: 1,
|
||||
speed: 1,
|
||||
hp: 10,
|
||||
maxHp: 10,
|
||||
encounter: createEncounter(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderEntityLayer(effectNpcId: string | null) {
|
||||
return renderToStaticMarkup(
|
||||
<GameCanvasEntityLayer
|
||||
companions={[]}
|
||||
currentScenePreset={null}
|
||||
sceneTransitionToken={0}
|
||||
isSceneTransitionEntering={false}
|
||||
isSceneTransitionExiting={false}
|
||||
transitionSweepPx={320}
|
||||
sceneTransitionExitDurationS={0.2}
|
||||
sceneTransitionEntryDurationS={0.2}
|
||||
companionAnchorLeft="10%"
|
||||
companionAnchorBottom="20%"
|
||||
playerBottomOffsetPx={0}
|
||||
sceneTransitionPhase="idle"
|
||||
inBattle={false}
|
||||
onEntitySelect={null}
|
||||
playerLeft="20%"
|
||||
playerCharacter={createCharacter()}
|
||||
playerHp={100}
|
||||
playerMaxHp={100}
|
||||
effectivePlayerFacing="right"
|
||||
effectivePlayerAnimationState={AnimationState.IDLE}
|
||||
shouldShowPlayerDialogueIcon={false}
|
||||
dialogueIndicator={null}
|
||||
npcAffinityEffect={
|
||||
effectNpcId
|
||||
? {
|
||||
eventId: 'effect-1',
|
||||
npcId: effectNpcId,
|
||||
delta: 3,
|
||||
}
|
||||
: null
|
||||
}
|
||||
sceneCombatants={[createHostileNpc()]}
|
||||
monsters={[]}
|
||||
getHostileNpcOuterLeft={() => '70%'}
|
||||
groundBottom="18%"
|
||||
stageLiftPx={68}
|
||||
encounter={null}
|
||||
sideAnchor="15%"
|
||||
cameraAnchorX={0}
|
||||
monsterAnchorMeters={3.2}
|
||||
playerX={0}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('GameCanvasEntityLayer', () => {
|
||||
it('renders affinity effect on the matching hostile npc', () => {
|
||||
const html = renderEntityLayer('npc-liu');
|
||||
|
||||
expect(html).toContain('data-testid="npc-affinity-effect-npc-liu"');
|
||||
expect(html).toContain('aria-label="好感度变化 +3"');
|
||||
});
|
||||
|
||||
it('does not render affinity effect on a different npc', () => {
|
||||
const html = renderEntityLayer('npc-other');
|
||||
|
||||
expect(html).not.toContain('npc-affinity-effect-npc-liu');
|
||||
expect(html).not.toContain('好感度变化 +3');
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import {HostileNpcAnimator} from '../HostileNpcAnimator';
|
||||
import {MedievalNpcAnimator} from '../MedievalNpcAnimator';
|
||||
import {getRenderableNpcFacing} from '../npcRenderUtils';
|
||||
import {NpcAffinityEffectBadge} from './NpcAffinityEffectBadge';
|
||||
import {
|
||||
DialogueBubbleIcon,
|
||||
type GameCanvasEntitySelection,
|
||||
@@ -66,6 +67,11 @@ interface GameCanvasEntityLayerProps {
|
||||
showEncounter: boolean;
|
||||
activeSpeaker?: 'player' | 'npc' | null;
|
||||
} | null;
|
||||
npcAffinityEffect?: {
|
||||
eventId: string;
|
||||
npcId: string;
|
||||
delta: number;
|
||||
} | null;
|
||||
sceneCombatants: SceneHostileNpc[];
|
||||
monsters: MonsterSpriteConfig[];
|
||||
getHostileNpcOuterLeft: (hostileNpc: SceneHostileNpc) => string;
|
||||
@@ -101,6 +107,7 @@ export function GameCanvasEntityLayer({
|
||||
effectivePlayerAnimationState,
|
||||
shouldShowPlayerDialogueIcon,
|
||||
dialogueIndicator = null,
|
||||
npcAffinityEffect = null,
|
||||
sceneCombatants,
|
||||
monsters,
|
||||
getHostileNpcOuterLeft,
|
||||
@@ -326,6 +333,10 @@ export function GameCanvasEntityLayer({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* 聊天好感变化要挂在当前角色形象上,而不是消息区里。 */}
|
||||
{npcAffinityEffect?.npcId === (npcEncounter.id ?? npcEncounter.npcName) ? (
|
||||
<NpcAffinityEffectBadge effect={npcAffinityEffect} />
|
||||
) : null}
|
||||
</SceneEntityButton>
|
||||
</div>
|
||||
);
|
||||
@@ -436,6 +447,10 @@ export function GameCanvasEntityLayer({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* 和平相遇态同样沿用角色形象上的好感浮出特效。 */}
|
||||
{npcAffinityEffect?.npcId === (encounter.id ?? encounter.npcName) ? (
|
||||
<NpcAffinityEffectBadge effect={npcAffinityEffect} />
|
||||
) : null}
|
||||
</SceneEntityButton>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -39,6 +39,7 @@ export function GameCanvasRuntime({
|
||||
activeCombatEffects = [],
|
||||
companions = [],
|
||||
dialogueIndicator = null,
|
||||
npcAffinityEffect = null,
|
||||
onEntitySelect = null,
|
||||
onSceneNameClick = null,
|
||||
sceneTransitionPhase = 'idle',
|
||||
@@ -192,6 +193,7 @@ export function GameCanvasRuntime({
|
||||
effectivePlayerAnimationState={effectivePlayerAnimationState}
|
||||
shouldShowPlayerDialogueIcon={shouldShowPlayerDialogueIcon}
|
||||
dialogueIndicator={dialogueIndicator}
|
||||
npcAffinityEffect={npcAffinityEffect}
|
||||
sceneCombatants={sceneHostileNpcs}
|
||||
monsters={monsters}
|
||||
getHostileNpcOuterLeft={getHostileNpcOuterLeft}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
Encounter,
|
||||
SceneHostileNpc,
|
||||
ScenePresetInfo,
|
||||
StoryNpcAffinityEffect,
|
||||
StoryEngineMemoryState,
|
||||
WorldType,
|
||||
} from '../../types';
|
||||
@@ -54,6 +55,7 @@ export interface GameCanvasProps {
|
||||
showEncounter: boolean;
|
||||
activeSpeaker?: 'player' | 'npc' | null;
|
||||
} | null;
|
||||
npcAffinityEffect?: StoryNpcAffinityEffect | null;
|
||||
onEntitySelect?: ((entity: GameCanvasEntitySelection) => void) | null;
|
||||
onSceneNameClick?: (() => void) | null;
|
||||
sceneTransitionPhase?: 'idle' | 'exiting' | 'entering';
|
||||
@@ -68,6 +70,10 @@ export const ENTITY_CONTAINER_REM = 7;
|
||||
export const ROLE_CHARACTER_FRAME_CLASS = 'flex h-28 w-28 items-end justify-center overflow-visible';
|
||||
export const ROLE_CHARACTER_SPRITE_CLASS = 'h-full w-full scale-[1.32] origin-bottom';
|
||||
export const GENERIC_NPC_SCENE_SCALE = 1.72;
|
||||
const DEFAULT_IMAGE_STYLE: React.CSSProperties = {
|
||||
imageRendering: 'pixelated',
|
||||
objectPosition: 'center bottom',
|
||||
};
|
||||
export const DEFAULT_COMBAT_HP_TOP_PX = -18;
|
||||
export const CHARACTER_NPC_COMBAT_HP_TOP_PX = -2;
|
||||
export const GENERIC_NPC_COMBAT_HP_TOP_PX = 94;
|
||||
|
||||
59
src/components/game-canvas/NpcAffinityEffectBadge.tsx
Normal file
59
src/components/game-canvas/NpcAffinityEffectBadge.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { Heart } from 'lucide-react';
|
||||
import { motion } from 'motion/react';
|
||||
|
||||
import type { StoryNpcAffinityEffect } from '../../types';
|
||||
|
||||
interface NpcAffinityEffectBadgeProps {
|
||||
effect: StoryNpcAffinityEffect;
|
||||
}
|
||||
|
||||
/**
|
||||
* 聊天结算后的好感度浮出特效。
|
||||
* 仅负责表现层,不承担任何数值计算。
|
||||
*/
|
||||
export function NpcAffinityEffectBadge({
|
||||
effect,
|
||||
}: NpcAffinityEffectBadgeProps) {
|
||||
const isPositive = effect.delta > 0;
|
||||
const deltaText = `${effect.delta > 0 ? '+' : ''}${effect.delta}`;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={effect.eventId}
|
||||
initial={{ opacity: 0, y: 24, scale: 0.8 }}
|
||||
animate={{ opacity: [0, 1, 1, 0], y: [24, -8, -26, -44], scale: [0.8, 1.08, 1, 0.92] }}
|
||||
transition={{ duration: 1.45, ease: 'easeOut' }}
|
||||
className="pointer-events-none absolute -top-14 left-1/2 z-[12] flex -translate-x-1/2 items-center gap-1 rounded-full border px-2.5 py-1 shadow-[0_10px_24px_rgba(0,0,0,0.35)] backdrop-blur-[2px]"
|
||||
data-testid={`npc-affinity-effect-${effect.npcId}`}
|
||||
aria-label={`好感度变化 ${deltaText}`}
|
||||
>
|
||||
{isPositive ? (
|
||||
<>
|
||||
<div className="absolute inset-0 rounded-full bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.3),transparent_60%)]" />
|
||||
<div className="absolute -inset-1 rounded-full bg-rose-400/18 blur-md" />
|
||||
<div className="relative flex items-center gap-1 text-rose-50">
|
||||
<Heart className="h-3.5 w-3.5 fill-current" />
|
||||
<span className="text-xs font-semibold tracking-[0.08em]">
|
||||
{deltaText}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="absolute inset-0 rounded-full bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.18),transparent_60%)]" />
|
||||
<div className="absolute -inset-1 rounded-full bg-slate-400/15 blur-md" />
|
||||
<div className="relative text-xs font-semibold tracking-[0.08em] text-slate-100">
|
||||
{deltaText}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div
|
||||
className={`absolute inset-0 rounded-full border ${
|
||||
isPositive
|
||||
? 'border-rose-200/45 bg-rose-500/18'
|
||||
: 'border-slate-200/35 bg-slate-700/30'
|
||||
}`}
|
||||
/>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -56,7 +56,7 @@ const DESKTOP_PAGE_STAGE_CLASS =
|
||||
function SectionHeader({ title, detail }: { title: string; detail: string }) {
|
||||
return (
|
||||
<div className="mb-3">
|
||||
<div className="text-[10px] font-semibold tracking-[0.26em] text-zinc-500">
|
||||
<div className="text-[10px] font-semibold tracking-[0.26em] text-[var(--platform-text-soft)]">
|
||||
{detail}
|
||||
</div>
|
||||
<div className="mt-1 text-base font-semibold text-[var(--platform-text-strong)]">
|
||||
@@ -69,7 +69,7 @@ function SectionHeader({ title, detail }: { title: string; detail: string }) {
|
||||
function EmptyShelf({ text }: { text: string }) {
|
||||
return (
|
||||
<div
|
||||
className={`${PANEL_SURFACE_CLASS} rounded-[1.35rem] px-4 py-3 text-sm leading-6 text-zinc-300`}
|
||||
className={`${PANEL_SURFACE_CLASS} rounded-[1.35rem] px-4 py-3 text-sm leading-6 text-[var(--platform-text-base)]`}
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
@@ -101,7 +101,7 @@ function SaveArchivePreview({
|
||||
)}
|
||||
<div className="absolute inset-0 bg-[var(--platform-card-overlay-soft)]" />
|
||||
<div className="absolute inset-x-0 bottom-0 px-2.5 py-2">
|
||||
<span className="inline-flex max-w-full items-center rounded-full border border-white/15 bg-black/24 px-2.5 py-1 text-[9px] font-semibold tracking-[0.14em] text-white/88">
|
||||
<span className="inline-flex max-w-full items-center rounded-full border border-white/15 bg-black/24 px-2.5 py-1 text-[9px] font-semibold tracking-[0.14em] text-[var(--platform-text-strong)]">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
@@ -162,15 +162,15 @@ function WorldCard({
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-auto">
|
||||
<div className="line-clamp-1 text-xl font-black text-white">
|
||||
<div className="line-clamp-1 text-xl font-black text-[var(--platform-text-strong)]">
|
||||
{entry.worldName}
|
||||
</div>
|
||||
{entry.subtitle ? (
|
||||
<div className="mt-1 line-clamp-1 text-[11px] tracking-[0.16em] text-zinc-300/85">
|
||||
<div className="mt-1 line-clamp-1 text-[11px] tracking-[0.16em] text-[color:color-mix(in_srgb,var(--platform-text-base)_85%,transparent)]">
|
||||
{entry.subtitle}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-2 line-clamp-2 text-xs leading-5 text-zinc-200/90">
|
||||
<div className="mt-2 line-clamp-2 text-xs leading-5 text-[color:color-mix(in_srgb,var(--platform-text-base)_90%,transparent)]">
|
||||
{entry.summaryText || '等待补充世界摘要。'}
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
@@ -249,28 +249,28 @@ function CreationLibraryCard({
|
||||
>
|
||||
{statusLabel}
|
||||
</span>
|
||||
<span className="inline-flex min-w-0 max-w-full items-center rounded-full border border-white/12 bg-black/18 px-2 py-1 text-[10px] font-medium text-zinc-300">
|
||||
<span className="inline-flex min-w-0 max-w-full items-center rounded-full border border-white/12 bg-black/18 px-2 py-1 text-[10px] font-medium text-[var(--platform-text-base)]">
|
||||
<span className="truncate">{metaLabel}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-auto min-w-0">
|
||||
<div className="line-clamp-2 break-words text-base font-black leading-[1.15] text-white sm:text-[1.12rem]">
|
||||
<div className="line-clamp-2 break-words text-base font-black leading-[1.15] text-[var(--platform-text-strong)] sm:text-[1.12rem]">
|
||||
{entry.worldName}
|
||||
</div>
|
||||
{entry.subtitle ? (
|
||||
<div className="mt-1 line-clamp-1 break-words text-[11px] tracking-[0.08em] text-zinc-300/84">
|
||||
<div className="mt-1 line-clamp-1 break-words text-[11px] tracking-[0.08em] text-[color:color-mix(in_srgb,var(--platform-text-base)_84%,transparent)]">
|
||||
{entry.subtitle}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-2 line-clamp-3 break-words text-[11px] leading-5 text-zinc-200/88 sm:text-xs">
|
||||
<div className="mt-2 line-clamp-3 break-words text-[11px] leading-5 text-[color:color-mix(in_srgb,var(--platform-text-base)_88%,transparent)] sm:text-xs">
|
||||
{summaryText}
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-1.5">
|
||||
<span className="inline-flex min-w-0 max-w-full items-center rounded-full border border-white/12 bg-black/18 px-2 py-1 text-[10px] font-semibold tracking-[0.1em] text-zinc-100/90">
|
||||
<span className="inline-flex min-w-0 max-w-full items-center rounded-full border border-white/12 bg-black/18 px-2 py-1 text-[10px] font-semibold tracking-[0.1em] text-[color:color-mix(in_srgb,var(--platform-text-strong)_90%,transparent)]">
|
||||
<span className="truncate">{primaryTag}</span>
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1 text-[11px] font-semibold text-zinc-200">
|
||||
<span className="inline-flex items-center gap-1 text-[11px] font-semibold text-[var(--platform-text-base)]">
|
||||
<span>
|
||||
{entry.visibility === 'published' ? '进入世界' : '继续创作'}
|
||||
</span>
|
||||
@@ -306,21 +306,21 @@ function SaveArchiveCard({
|
||||
<div className="relative z-10 flex h-full w-full flex-col gap-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="platform-pill platform-pill--cool">ARCHIVE</span>
|
||||
<span className="rounded-full border border-white/10 bg-black/18 px-2.5 py-1 text-[11px] font-medium text-zinc-300">
|
||||
<span className="rounded-full border border-white/10 bg-black/18 px-2.5 py-1 text-[11px] font-medium text-[var(--platform-text-base)]">
|
||||
{loading ? '恢复中' : formatSnapshotTime(entry.lastPlayedAt)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 items-stretch gap-3 sm:gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="line-clamp-2 break-words text-[1.15rem] font-black leading-tight text-white sm:text-xl">
|
||||
<div className="line-clamp-2 break-words text-[1.15rem] font-black leading-tight text-[var(--platform-text-strong)] sm:text-xl">
|
||||
{entry.worldName}
|
||||
</div>
|
||||
{entry.subtitle ? (
|
||||
<div className="mt-1 line-clamp-1 break-words text-sm text-zinc-300">
|
||||
<div className="mt-1 line-clamp-1 break-words text-sm text-[var(--platform-text-base)]">
|
||||
{entry.subtitle}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-2 line-clamp-3 break-words text-xs leading-5 text-zinc-400 sm:text-sm">
|
||||
<div className="mt-2 line-clamp-3 break-words text-xs leading-5 text-[var(--platform-text-soft)] sm:text-sm">
|
||||
{summaryText}
|
||||
</div>
|
||||
<div className="mt-4 inline-flex items-center gap-1.5 text-xs font-semibold text-zinc-200">
|
||||
@@ -356,13 +356,11 @@ function PlatformTabButton({
|
||||
onClick={onClick}
|
||||
className={`platform-bottom-nav__button ${active ? 'platform-bottom-nav__button--active' : ''}`}
|
||||
>
|
||||
<span className="flex flex-col items-center justify-center gap-1">
|
||||
<span className="platform-bottom-nav__button-content">
|
||||
<span className="platform-bottom-nav__icon-shell">
|
||||
<Icon className="platform-bottom-nav__icon h-[1.05rem] w-[1.05rem]" />
|
||||
</span>
|
||||
<span className="platform-bottom-nav__label text-[11px] font-semibold tracking-[0.18em]">
|
||||
{label}
|
||||
<Icon className="platform-bottom-nav__icon" />
|
||||
</span>
|
||||
<span className="platform-bottom-nav__label">{label}</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
@@ -425,14 +423,14 @@ function DesktopTrendingItem({
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 text-[10px] tracking-[0.18em] text-zinc-500">
|
||||
<div className="flex items-center gap-2 text-[10px] tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
<span>{`${rank}`.padStart(2, '0')}</span>
|
||||
<span>{formatPlatformWorldTime(entry.publishedAt)}</span>
|
||||
</div>
|
||||
<div className="mt-2 line-clamp-1 text-lg font-semibold text-[var(--platform-text-strong)]">
|
||||
{entry.worldName}
|
||||
</div>
|
||||
<div className="mt-1 line-clamp-2 text-sm leading-6 text-zinc-300/86">
|
||||
<div className="mt-1 line-clamp-2 text-sm leading-6 text-[color:color-mix(in_srgb,var(--platform-text-base)_86%,transparent)]">
|
||||
{entry.summaryText || entry.subtitle || '等待补充世界摘要。'}
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
@@ -453,7 +451,7 @@ function DesktopTrendingItem({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ChevronRight className="h-4 w-4 shrink-0 text-zinc-500" />
|
||||
<ChevronRight className="h-4 w-4 shrink-0 text-[var(--platform-text-soft)]" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -584,7 +582,7 @@ function ProfileStatCard({
|
||||
onClick={onClick ? () => onClick(cardKey) : undefined}
|
||||
className="platform-subpanel rounded-[1.35rem] px-4 py-3 text-left transition hover:border-[var(--platform-surface-hover-border)] hover:bg-[var(--platform-button-secondary-fill)]"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-zinc-400">
|
||||
<div className="flex items-center gap-2 text-[var(--platform-text-soft)]">
|
||||
<Icon className="h-4 w-4" />
|
||||
<span className="text-[11px] tracking-[0.16em]">{label}</span>
|
||||
</div>
|
||||
@@ -598,8 +596,8 @@ function ProfileStatCard({
|
||||
function ProfileStatCardSkeleton() {
|
||||
return (
|
||||
<div className="platform-subpanel rounded-[1.35rem] px-4 py-3">
|
||||
<div className="h-4 w-20 animate-pulse rounded-full bg-white/10" />
|
||||
<div className="mt-3 h-7 w-16 animate-pulse rounded-full bg-white/12" />
|
||||
<div className="h-4 w-20 animate-pulse rounded-full bg-[var(--platform-subpanel-border)]" />
|
||||
<div className="mt-3 h-7 w-16 animate-pulse rounded-full bg-[var(--platform-line-soft)]" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -748,7 +746,7 @@ export function PlatformHomeView({
|
||||
}}
|
||||
className={`${HERO_SURFACE_CLASS} relative block w-full overflow-hidden px-[18px] py-4 text-left`}
|
||||
>
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(255,255,255,0.18),transparent_30%),radial-gradient(circle_at_right,rgba(255,205,178,0.18),transparent_28%),linear-gradient(135deg,rgba(255,47,112,0.92),rgba(255,136,104,0.9))]" />
|
||||
<div className="absolute inset-0 bg-[var(--platform-hero-overlay-strong)]" />
|
||||
<div className="relative z-10 flex min-h-[10rem] flex-col justify-between">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<span className="platform-pill platform-pill--warm">
|
||||
@@ -776,7 +774,7 @@ export function PlatformHomeView({
|
||||
</button>
|
||||
|
||||
{platformError ? (
|
||||
<div className="rounded-2xl border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100">
|
||||
<div className="rounded-2xl border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-700">
|
||||
{platformError}
|
||||
</div>
|
||||
) : null}
|
||||
@@ -833,7 +831,7 @@ export function PlatformHomeView({
|
||||
onClick={onOpenCreateTypePicker}
|
||||
className={`${HERO_SURFACE_CLASS} relative block w-full overflow-hidden px-[18px] py-4 text-left`}
|
||||
>
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(255,255,255,0.16),transparent_38%),radial-gradient(circle_at_right,rgba(255,201,172,0.18),transparent_30%),linear-gradient(180deg,rgba(255,90,141,0.88),rgba(255,144,105,0.88))]" />
|
||||
<div className="absolute inset-0 bg-[var(--platform-hero-overlay-strong)]" />
|
||||
<div className="relative z-10 flex min-h-[10rem] flex-col justify-between">
|
||||
<span className="platform-pill platform-pill--cool w-fit">
|
||||
CREATE
|
||||
@@ -887,7 +885,7 @@ export function PlatformHomeView({
|
||||
{authUi?.user ? (
|
||||
<>
|
||||
{saveError ? (
|
||||
<div className="rounded-2xl border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100">
|
||||
<div className="rounded-2xl border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-700">
|
||||
{saveError}
|
||||
</div>
|
||||
) : null}
|
||||
@@ -1060,7 +1058,7 @@ export function PlatformHomeView({
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3 text-[11px] text-zinc-500">
|
||||
<div className="mt-3 text-[11px] text-[var(--platform-text-soft)]">
|
||||
{dashboardError
|
||||
? dashboardError
|
||||
: `更新于 ${formatDashboardUpdatedAt(profileDashboard?.updatedAt)}`}
|
||||
@@ -1090,10 +1088,12 @@ export function PlatformHomeView({
|
||||
<div className="text-base font-semibold text-[var(--platform-text-strong)]">
|
||||
设置
|
||||
</div>
|
||||
<div className="text-xs text-zinc-400">主题与账号</div>
|
||||
<div className="text-xs text-[var(--platform-text-soft)]">
|
||||
主题与账号
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 text-zinc-500" />
|
||||
<ChevronRight className="h-4 w-4 text-[var(--platform-text-soft)]" />
|
||||
</button>
|
||||
</section>
|
||||
</>
|
||||
@@ -1121,7 +1121,7 @@ export function PlatformHomeView({
|
||||
activeTab === 'home' ? (
|
||||
<div className={DESKTOP_PAGE_STAGE_CLASS}>
|
||||
{platformError ? (
|
||||
<div className="rounded-[1.5rem] border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100">
|
||||
<div className="rounded-[1.5rem] border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-700">
|
||||
{platformError}
|
||||
</div>
|
||||
) : null}
|
||||
@@ -1147,8 +1147,7 @@ export function PlatformHomeView({
|
||||
className="absolute inset-0 h-full w-full object-cover opacity-34"
|
||||
/>
|
||||
) : null}
|
||||
<div className="absolute inset-0 bg-[linear-gradient(115deg,rgba(255,31,111,0.94),rgba(255,109,104,0.8)_52%,rgba(255,164,124,0.9))]" />
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(255,255,255,0.18),transparent_28%),radial-gradient(circle_at_78%_24%,rgba(255,208,178,0.18),transparent_20%)]" />
|
||||
<div className="absolute inset-0 bg-[var(--platform-hero-overlay-strong)]" />
|
||||
<div className="relative z-10 flex min-h-[24rem] flex-col justify-between">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<span className="platform-pill platform-pill--warm">
|
||||
@@ -1198,8 +1197,8 @@ export function PlatformHomeView({
|
||||
) : null}
|
||||
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(255,255,255,0.04),rgba(91,24,46,0.34))]" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-3 py-2 text-[11px] text-zinc-300/82">
|
||||
<span className="text-zinc-500">
|
||||
<div className="flex items-center gap-2 px-3 py-2 text-[11px] text-[color:color-mix(in_srgb,var(--platform-text-base)_82%,transparent)]">
|
||||
<span className="text-[var(--platform-text-soft)]">
|
||||
{`${index + 1}`.padStart(2, '0')}
|
||||
</span>
|
||||
<span className="line-clamp-1">
|
||||
@@ -1286,17 +1285,17 @@ export function PlatformHomeView({
|
||||
<span className="platform-pill platform-pill--cool">
|
||||
{hasSavedGame ? 'SAVE POINT' : 'START HERE'}
|
||||
</span>
|
||||
<ArrowRight className="h-4 w-4 text-zinc-400" />
|
||||
<ArrowRight className="h-4 w-4 text-[var(--platform-text-soft)]" />
|
||||
</div>
|
||||
<div className="mt-4 text-2xl font-semibold text-white">
|
||||
<div className="mt-4 text-2xl font-semibold text-[var(--platform-text-strong)]">
|
||||
{hasSavedGame ? snapshotWorldName : '从这里开启新的创作'}
|
||||
</div>
|
||||
<div className="mt-2 text-sm leading-7 text-zinc-300/84">
|
||||
<div className="mt-2 text-sm leading-7 text-[color:color-mix(in_srgb,var(--platform-text-base)_84%,transparent)]">
|
||||
{hasSavedGame
|
||||
? `当前角色:${snapshotCharacterName}`
|
||||
: '快速进入自定义世界创作,继续补齐设定、角色与核心冲突。'}
|
||||
</div>
|
||||
<div className="mt-3 line-clamp-3 text-sm leading-6 text-zinc-400">
|
||||
<div className="mt-3 line-clamp-3 text-sm leading-6 text-[var(--platform-text-soft)]">
|
||||
{hasSavedGame
|
||||
? snapshotDigest
|
||||
: '先生成一版可玩的世界底稿,再继续编辑并发布。'}
|
||||
@@ -1305,7 +1304,7 @@ export function PlatformHomeView({
|
||||
</button>
|
||||
|
||||
<div className="mt-5">
|
||||
<div className="text-[10px] font-semibold tracking-[0.24em] text-zinc-500">
|
||||
<div className="text-[10px] font-semibold tracking-[0.24em] text-[var(--platform-text-soft)]">
|
||||
{desktopLibraryPreview.length > 0
|
||||
? '最近作品'
|
||||
: historyEntries.length > 0
|
||||
@@ -1325,10 +1324,10 @@ export function PlatformHomeView({
|
||||
className="platform-desktop-trending-item flex w-full items-center justify-between gap-3 px-4 py-4 text-left"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="line-clamp-1 text-base font-semibold text-white">
|
||||
<div className="line-clamp-1 text-base font-semibold text-[var(--platform-text-strong)]">
|
||||
{entry.worldName}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-zinc-400">
|
||||
<div className="mt-1 text-sm text-[var(--platform-text-soft)]">
|
||||
{entry.visibility === 'published'
|
||||
? `发布于 ${formatPlatformWorldTime(entry.publishedAt)}`
|
||||
: '草稿待完善'}
|
||||
@@ -1366,10 +1365,10 @@ export function PlatformHomeView({
|
||||
className="platform-desktop-trending-item flex w-full items-center justify-between gap-3 px-4 py-4 text-left"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="line-clamp-1 text-base font-semibold text-white">
|
||||
<div className="line-clamp-1 text-base font-semibold text-[var(--platform-text-strong)]">
|
||||
{entry.worldName}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-zinc-400">
|
||||
<div className="mt-1 text-sm text-[var(--platform-text-soft)]">
|
||||
作者:{entry.authorDisplayName}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1434,7 +1433,7 @@ export function PlatformHomeView({
|
||||
paddingBottom: 'calc(env(safe-area-inset-bottom) + 0.2rem)',
|
||||
}}
|
||||
>
|
||||
<div className="platform-bottom-nav grid h-14 grid-cols-4 gap-1 rounded-[1.2rem] px-1 py-1">
|
||||
<div className="platform-bottom-nav grid grid-cols-4">
|
||||
<PlatformTabButton
|
||||
active={activeTab === 'home'}
|
||||
label="首页"
|
||||
@@ -1468,7 +1467,7 @@ export function PlatformHomeView({
|
||||
<div className="platform-desktop-topbar flex items-center gap-4 px-5 py-4">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-5">
|
||||
<PlatformBrandLogo className="shrink-0" decorative />
|
||||
<div className="platform-desktop-search flex min-w-0 max-w-[34rem] flex-1 items-center gap-3 px-4 py-3 text-zinc-400">
|
||||
<div className="platform-desktop-search flex min-w-0 max-w-[34rem] flex-1 items-center gap-3 px-4 py-3 text-[var(--platform-text-soft)]">
|
||||
<Search className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate text-sm">
|
||||
搜索世界、角色、主题或灵感
|
||||
@@ -1503,7 +1502,7 @@ export function PlatformHomeView({
|
||||
<span className="block truncate text-sm font-semibold text-[var(--platform-text-strong)]">
|
||||
{authUi?.user?.displayName || '进入账户'}
|
||||
</span>
|
||||
<span className="block truncate text-xs text-zinc-400">
|
||||
<span className="block truncate text-xs text-[var(--platform-text-soft)]">
|
||||
{authUi?.user ? publicUserCode : '登录后同步创作与进度'}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
@@ -110,7 +110,7 @@ export function PlatformWorldDetailView({
|
||||
className="absolute bottom-0 right-2 h-32 w-32 object-contain opacity-25"
|
||||
/>
|
||||
) : null}
|
||||
<div className="absolute inset-0 bg-[linear-gradient(125deg,rgba(255,31,111,0.78),rgba(255,138,115,0.52)_48%,rgba(255,255,255,0.08)_100%)]" />
|
||||
<div className="absolute inset-0 bg-[var(--platform-hero-overlay-strong)]" />
|
||||
<div className="relative z-10">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="platform-pill platform-pill--warm">
|
||||
@@ -151,12 +151,12 @@ export function PlatformWorldDetailView({
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-[1.2fr_0.8fr]">
|
||||
<div className="platform-surface platform-surface--soft px-4 py-3.5">
|
||||
<div className="text-[10px] tracking-[0.22em] text-zinc-500">
|
||||
<div className="text-[10px] tracking-[0.22em] text-[var(--platform-text-soft)]">
|
||||
世界信息
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-2 gap-3 text-sm text-zinc-100 sm:grid-cols-4">
|
||||
<div className="mt-3 grid grid-cols-2 gap-3 text-sm text-[var(--platform-text-strong)] sm:grid-cols-4">
|
||||
<div className="platform-subpanel rounded-xl px-3 py-3">
|
||||
<div className="text-[10px] tracking-[0.18em] text-zinc-500">
|
||||
<div className="text-[10px] tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
可玩角色
|
||||
</div>
|
||||
<div className="mt-2 text-lg font-bold">
|
||||
@@ -164,7 +164,7 @@ export function PlatformWorldDetailView({
|
||||
</div>
|
||||
</div>
|
||||
<div className="platform-subpanel rounded-xl px-3 py-3">
|
||||
<div className="text-[10px] tracking-[0.18em] text-zinc-500">
|
||||
<div className="text-[10px] tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
地标
|
||||
</div>
|
||||
<div className="mt-2 text-lg font-bold">
|
||||
@@ -172,7 +172,7 @@ export function PlatformWorldDetailView({
|
||||
</div>
|
||||
</div>
|
||||
<div className="platform-subpanel rounded-xl px-3 py-3">
|
||||
<div className="text-[10px] tracking-[0.18em] text-zinc-500">
|
||||
<div className="text-[10px] tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
阵营
|
||||
</div>
|
||||
<div className="mt-2 text-lg font-bold">
|
||||
@@ -180,7 +180,7 @@ export function PlatformWorldDetailView({
|
||||
</div>
|
||||
</div>
|
||||
<div className="platform-subpanel rounded-xl px-3 py-3">
|
||||
<div className="text-[10px] tracking-[0.18em] text-zinc-500">
|
||||
<div className="text-[10px] tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
冲突
|
||||
</div>
|
||||
<div className="mt-2 text-lg font-bold">
|
||||
@@ -190,19 +190,19 @@ export function PlatformWorldDetailView({
|
||||
</div>
|
||||
|
||||
<div className="mt-5">
|
||||
<div className="text-[10px] tracking-[0.22em] text-zinc-500">
|
||||
<div className="text-[10px] tracking-[0.22em] text-[var(--platform-text-soft)]">
|
||||
关键角色
|
||||
</div>
|
||||
<div className="mt-3 grid gap-3 sm:grid-cols-3">
|
||||
{previewCharacters.map((character, index) => (
|
||||
<div
|
||||
key={character.id || `preview-character-${index}`}
|
||||
className="platform-subpanel rounded-2xl px-3 py-3"
|
||||
className="platform-subpanel rounded-2xl px-3 py-3"
|
||||
>
|
||||
<div className="line-clamp-1 text-sm font-bold text-white">
|
||||
<div className="line-clamp-1 text-sm font-bold text-[var(--platform-text-strong)]">
|
||||
{character.title}
|
||||
</div>
|
||||
<div className="mt-1 line-clamp-2 text-xs leading-5 text-zinc-300">
|
||||
<div className="mt-1 line-clamp-2 text-xs leading-5 text-[var(--platform-text-base)]">
|
||||
{character.description}
|
||||
</div>
|
||||
</div>
|
||||
@@ -211,19 +211,19 @@ export function PlatformWorldDetailView({
|
||||
</div>
|
||||
|
||||
<div className="mt-5">
|
||||
<div className="text-[10px] tracking-[0.22em] text-zinc-500">
|
||||
<div className="text-[10px] tracking-[0.22em] text-[var(--platform-text-soft)]">
|
||||
关键场景
|
||||
</div>
|
||||
<div className="mt-3 grid gap-3 sm:grid-cols-3">
|
||||
{previewLandmarks.map((landmark, index) => (
|
||||
<div
|
||||
key={landmark.id || `preview-landmark-${index}`}
|
||||
className="platform-subpanel rounded-2xl px-3 py-3"
|
||||
className="platform-subpanel rounded-2xl px-3 py-3"
|
||||
>
|
||||
<div className="line-clamp-1 text-sm font-bold text-white">
|
||||
<div className="line-clamp-1 text-sm font-bold text-[var(--platform-text-strong)]">
|
||||
{landmark.name}
|
||||
</div>
|
||||
<div className="mt-1 line-clamp-2 text-xs leading-5 text-zinc-300">
|
||||
<div className="mt-1 line-clamp-2 text-xs leading-5 text-[var(--platform-text-base)]">
|
||||
{landmark.description}
|
||||
</div>
|
||||
</div>
|
||||
@@ -233,7 +233,7 @@ export function PlatformWorldDetailView({
|
||||
</div>
|
||||
|
||||
<div className="platform-surface platform-surface--soft px-4 py-3.5">
|
||||
<div className="text-[10px] tracking-[0.22em] text-zinc-500">
|
||||
<div className="text-[10px] tracking-[0.22em] text-[var(--platform-text-soft)]">
|
||||
操作
|
||||
</div>
|
||||
<div className="mt-4 flex flex-col gap-3">
|
||||
@@ -275,7 +275,7 @@ export function PlatformWorldDetailView({
|
||||
) : null}
|
||||
</div>
|
||||
{error ? (
|
||||
<div className="mt-4 rounded-2xl border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100">
|
||||
<div className="mt-4 rounded-2xl border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-700">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -483,7 +483,8 @@ test('starting draft generation leaves the agent workspace and shows the generat
|
||||
expect(await screen.findByText('世界草稿生成进度')).toBeTruthy();
|
||||
expect(screen.queryByText(/Agent工作区/u)).toBeNull();
|
||||
expect(screen.getAllByText('生成世界底稿').length).toBeGreaterThan(0);
|
||||
expect(screen.getByText('当前锚点信息')).toBeTruthy();
|
||||
expect(screen.getByText('当前世界信息')).toBeTruthy();
|
||||
expect(screen.queryByText('回到工作区')).toBeNull();
|
||||
expect(screen.getByText('世界承诺')).toBeTruthy();
|
||||
expect(screen.getByText(/灯塔与禁航令共同决定谁能穿过死潮/u)).toBeTruthy();
|
||||
expect(screen.queryByText('先告诉我你想做一个怎样的 RPG 世界。')).toBeNull();
|
||||
|
||||
@@ -43,9 +43,7 @@ import {
|
||||
readCustomWorldAgentUiState,
|
||||
writeCustomWorldAgentUiState,
|
||||
} from '../../services/customWorldAgentUiState';
|
||||
import {
|
||||
buildCustomWorldCreatorIntentFoundationText,
|
||||
} from '../../services/customWorldCreatorIntent';
|
||||
import { buildCustomWorldCreatorIntentFoundationText } from '../../services/customWorldCreatorIntent';
|
||||
import {
|
||||
hasPendingPlatformBrowseHistoryMigration,
|
||||
markPlatformBrowseHistoryMigrated,
|
||||
@@ -169,7 +167,7 @@ function normalizeAgentBackedProfile(profile: CustomWorldProfile) {
|
||||
function LazyPanelFallback({ label }: { label: string }) {
|
||||
return (
|
||||
<div className="flex h-full min-h-0 items-center justify-center">
|
||||
<div className="platform-subpanel rounded-2xl px-5 py-4 text-sm text-zinc-300">
|
||||
<div className="platform-subpanel rounded-2xl px-5 py-4 text-sm text-[var(--platform-text-base)]">
|
||||
{label}
|
||||
</div>
|
||||
</div>
|
||||
@@ -405,9 +403,8 @@ export function PreGameSelectionFlow({
|
||||
hasPendingPlatformBrowseHistoryMigration(authUi?.user) &&
|
||||
localHistoryEntries.length > 0
|
||||
) {
|
||||
nextEntries = await syncProfileBrowseHistory(
|
||||
localHistoryEntries,
|
||||
);
|
||||
nextEntries =
|
||||
await syncProfileBrowseHistory(localHistoryEntries);
|
||||
markPlatformBrowseHistoryMigrated(authUi?.user);
|
||||
}
|
||||
|
||||
@@ -472,12 +469,17 @@ export function PreGameSelectionFlow({
|
||||
} else if (isAuthenticated) {
|
||||
setSaveEntries([]);
|
||||
setSaveError(
|
||||
resolveErrorMessage(saveArchivesResult.reason, '读取存档列表失败。'),
|
||||
resolveErrorMessage(
|
||||
saveArchivesResult.reason,
|
||||
'读取存档列表失败。',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const nextPlatformBootstrapUserId = authUi?.user?.id ?? null;
|
||||
if (platformTabBootstrapUserIdRef.current !== nextPlatformBootstrapUserId) {
|
||||
if (
|
||||
platformTabBootstrapUserIdRef.current !== nextPlatformBootstrapUserId
|
||||
) {
|
||||
platformTabBootstrapUserIdRef.current = nextPlatformBootstrapUserId;
|
||||
if (!initialAgentUiStateRef.current.activeSessionId) {
|
||||
setPlatformTab(
|
||||
@@ -1333,7 +1335,7 @@ export function PreGameSelectionFlow({
|
||||
>
|
||||
{isDetailLoading || !selectedDetailEntry ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="platform-subpanel rounded-2xl px-5 py-4 text-sm text-zinc-300">
|
||||
<div className="platform-subpanel rounded-2xl px-5 py-4 text-sm text-[var(--platform-text-base)]">
|
||||
{detailError || '正在读取作品详情...'}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1419,7 +1421,7 @@ export function PreGameSelectionFlow({
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="platform-subpanel rounded-2xl px-5 py-4 text-sm text-zinc-300">
|
||||
<div className="platform-subpanel rounded-2xl px-5 py-4 text-sm text-[var(--platform-text-base)]">
|
||||
{isLoadingAgentSession
|
||||
? '正在准备 Agent 共创工作区...'
|
||||
: creationTypeError || '正在恢复创作工作区...'}
|
||||
@@ -1452,14 +1454,10 @@ export function PreGameSelectionFlow({
|
||||
onRetry={retryAgentDraftGeneration}
|
||||
onInterrupt={undefined}
|
||||
backLabel="返回工作区"
|
||||
settingActionLabel="回到工作区"
|
||||
settingActionLabel={null}
|
||||
retryLabel="重新生成草稿"
|
||||
settingTitle="当前锚点信息"
|
||||
settingDescription={
|
||||
isAgentDraftGenerationView
|
||||
? '将按当前八锚点结构编译第一版世界底稿与草稿卡。'
|
||||
: undefined
|
||||
}
|
||||
settingTitle="当前世界信息"
|
||||
settingDescription={null}
|
||||
progressTitle={
|
||||
isAgentDraftGenerationView ? '世界草稿生成进度' : undefined
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user