This commit is contained in:
2026-04-21 19:18:26 +08:00
parent 4372ab5be1
commit 48957311bc
78 changed files with 643 additions and 3801 deletions

View File

@@ -1,65 +0,0 @@
import { AnimatePresence, motion } from 'motion/react';
import { CHROME_ICONS, getNineSliceStyle, UI_CHROME } from '../uiAssets';
import { PixelIcon } from './PixelIcon';
interface DeveloperTeamModalProps {
isOpen: boolean;
message: string;
onClose: () => void;
}
export function DeveloperTeamModal({
isOpen,
message,
onClose,
}: DeveloperTeamModalProps) {
return (
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[74] flex items-center justify-center bg-black/78 p-3 sm:p-4 backdrop-blur-sm"
onClick={onClose}
>
<motion.div
initial={{ opacity: 0, scale: 0.96, y: 10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.96, y: 10 }}
transition={{ duration: 0.18, ease: 'easeOut' }}
className="pixel-nine-slice pixel-modal-shell flex max-h-[min(92vh,48rem)] w-full max-w-[min(96vw,42rem)] flex-col overflow-hidden shadow-[0_28px_90px_rgba(0,0,0,0.58)]"
style={getNineSliceStyle(UI_CHROME.modalPanel)}
onClick={event => event.stopPropagation()}
>
<div className="flex items-center justify-between gap-3 border-b border-white/10 px-4 py-3 sm:px-5 sm:py-4">
<div className="min-w-0">
<div className="mt-1 text-sm font-semibold text-white">{'\u5f00\u53d1\u56e2\u961f'}</div>
</div>
<button
type="button"
onClick={onClose}
className="rounded-full border border-white/10 bg-black/20 p-2 text-zinc-400 transition-colors hover:text-white"
aria-label="关闭开发团队弹窗"
>
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-4 sm:p-5">
<div
className="pixel-nine-slice pixel-panel flex flex-col items-center gap-5"
style={getNineSliceStyle(UI_CHROME.panel, { paddingX: 16, paddingY: 16 })}
>
<div className="whitespace-pre-line text-center text-sm leading-7 text-zinc-100">
{message}
</div>
</div>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}

View File

@@ -1,33 +0,0 @@
import {lazy, Suspense} from 'react';
import type {SkillEffectPreviewProps} from './SkillEffectPreview';
const SkillEffectPreview = lazy(async () => {
const module = await import('./SkillEffectPreview');
return {
default: module.SkillEffectPreview,
};
});
function SkillEffectPreviewFallback() {
return (
<div className="rounded-2xl border border-white/10 bg-black/20 p-4">
<div className="mb-3 space-y-2">
<div className="h-4 w-28 rounded bg-white/10" />
<div className="h-3 w-40 rounded bg-white/5" />
</div>
<div className="overflow-hidden rounded-2xl border border-white/10 bg-black">
<div className="h-[300px] animate-pulse bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.08),transparent_42%),linear-gradient(180deg,rgba(255,255,255,0.05),rgba(255,255,255,0.02))]" />
</div>
</div>
);
}
export function LazySkillEffectPreview(props: SkillEffectPreviewProps) {
return (
<Suspense fallback={<SkillEffectPreviewFallback />}>
<SkillEffectPreview {...props} />
</Suspense>
);
}

View File

@@ -1,176 +0,0 @@
import {
buildBodyPath,
buildMedievalAtlasSpec,
buildRaceAssetPath,
clampMedievalAtlasFrame,
getMedievalAtlasOptions,
getMedievalPoseOptions,
MEDIEVAL_BODY_COLORS,
type MedievalAtlasSourceType,
type MedievalNpcVisualOverride,
type MedievalRace,
} from '../data/medievalNpcVisuals';
import type { Encounter } from '../types';
import { type NpcLayoutConfig, type NpcLayoutPart } from './npcVisualShared';
export type GearSourceType = 'none' | MedievalAtlasSourceType;
export type EditableNpcVisualState = {
race: MedievalRace;
bodyColor: string;
headIndex: number;
hairColorIndex: number;
hairStyleFrame: number;
facialHairEnabled: boolean;
facialHairColorIndex: number;
facialHairStyleFrame: number;
headgearType: GearSourceType;
headgearFile: string;
headgearFrame: number;
mainHandType: GearSourceType;
mainHandFile: string;
mainHandFrame: number;
offHandType: GearSourceType;
offHandFile: string;
offHandFrame: number;
};
export type EditorNpcOption = {
encounter: Encounter;
sceneNames: string[];
};
const NPC_LAYOUT_PARTS: NpcLayoutPart[] = [
'body',
'head',
'facialHair',
'hair',
'headgear',
'hand',
'mainHand',
'offHand',
];
export function sanitizeFrameSelection(
type: GearSourceType,
file: string,
frame: number,
usage: 'headgear' | 'mainHand' | 'offHand',
) {
if (type === 'none' || !file) return 0;
const poseOptions = getMedievalPoseOptions(type, file, usage);
if (poseOptions.length === 0) return 0;
if (poseOptions.some(option => option.value === frame)) {
return clampMedievalAtlasFrame(type, file, frame);
}
const firstOption = poseOptions[0];
return firstOption ? firstOption.value : 0;
}
export function getDefaultFileForType(type: GearSourceType) {
if (type === 'none') return '';
return getMedievalAtlasOptions(type)[0]?.file ?? '';
}
export function getDefaultFrameForSelection(
type: GearSourceType,
file: string,
usage: 'headgear' | 'mainHand' | 'offHand',
) {
if (type === 'none' || !file) return 0;
return getMedievalPoseOptions(type, file, usage)[0]?.value ?? 0;
}
export function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
export function isNpcLayoutConfig(value: unknown): value is NpcLayoutConfig {
return (
isRecord(value)
&& NPC_LAYOUT_PARTS.every(part => {
const coordinate = value[part];
return (
isRecord(coordinate)
&& typeof coordinate.x === 'number'
&& Number.isFinite(coordinate.x)
&& typeof coordinate.y === 'number'
&& Number.isFinite(coordinate.y)
);
})
);
}
export function buildOverrideFromEditorState(
state: EditableNpcVisualState,
): MedievalNpcVisualOverride {
return {
race: state.race,
bodySrc: buildBodyPath(
state.bodyColor as (typeof MEDIEVAL_BODY_COLORS)[number],
),
headSrc: buildRaceAssetPath(state.race, 'head', state.headIndex),
hairSrc: buildRaceAssetPath(state.race, 'hair', state.hairColorIndex),
handSrc: buildRaceAssetPath(state.race, 'hand', 1),
facialHairSrc: state.facialHairEnabled
? buildRaceAssetPath(state.race, 'facialHair', state.facialHairColorIndex)
: undefined,
headgear:
state.headgearType === 'none'
? undefined
: buildMedievalAtlasSpec(
state.headgearType,
state.headgearFile,
sanitizeFrameSelection(
state.headgearType,
state.headgearFile,
state.headgearFrame,
'headgear',
),
),
mainHand:
state.mainHandType === 'none'
? undefined
: buildMedievalAtlasSpec(
state.mainHandType,
state.mainHandFile,
sanitizeFrameSelection(
state.mainHandType,
state.mainHandFile,
state.mainHandFrame,
'mainHand',
),
),
offHand:
state.offHandType === 'none'
? undefined
: buildMedievalAtlasSpec(
state.offHandType,
state.offHandFile,
sanitizeFrameSelection(
state.offHandType,
state.offHandFile,
state.offHandFrame,
'offHand',
),
),
bodyFrames: [0, 1, 2, 3],
headFrame: 0,
hairFrame: state.hairStyleFrame,
handFrame: 0,
facialHairFrame: state.facialHairEnabled
? state.facialHairStyleFrame
: undefined,
};
}
export function buildNpcVisualSavePayload(
overrideMap: Record<string, MedievalNpcVisualOverride>,
npcId: string,
editorState: EditableNpcVisualState,
) {
return {
...overrideMap,
[npcId]: buildOverrideFromEditorState(editorState),
};
}

View File

@@ -1,107 +0,0 @@
import { describe, expect, it, vi } from 'vitest';
import type { MedievalNpcVisualOverride } from '../data/medievalNpcVisuals';
import type { EditableNpcVisualState } from './npcVisualEditorModel';
import {
NPC_LAYOUT_CONFIG_API_PATH,
NPC_VISUAL_OVERRIDES_API_PATH,
persistNpcLayoutConfig,
persistNpcVisualOverrides,
} from './npcVisualEditorPersistence';
import type { NpcLayoutConfig } from './npcVisualShared';
function createEditorState(): EditableNpcVisualState {
return {
race: 'human',
bodyColor: 'black',
headIndex: 1,
hairColorIndex: 1,
hairStyleFrame: 0,
facialHairEnabled: false,
facialHairColorIndex: 1,
facialHairStyleFrame: 0,
headgearType: 'none',
headgearFile: '',
headgearFrame: 0,
mainHandType: 'none',
mainHandFile: '',
mainHandFrame: 0,
offHandType: 'none',
offHandFile: '',
offHandFrame: 0,
};
}
function createExistingOverride(): MedievalNpcVisualOverride {
return {
race: 'elf',
bodySrc: '/body.png',
headSrc: '/head.png',
hairSrc: '/hair.png',
handSrc: '/hand.png',
bodyFrames: [0, 1, 2, 3],
headFrame: 0,
hairFrame: 1,
handFrame: 0,
};
}
function createLayoutDraft(): NpcLayoutConfig {
return {
body: { x: 0, y: 0 },
head: { x: 1, y: 2 },
facialHair: { x: 3, y: 4 },
hair: { x: 5, y: 6 },
headgear: { x: 7, y: 8 },
hand: { x: 9, y: 10 },
mainHand: { x: 11, y: 12 },
offHand: { x: 13, y: 14 },
};
}
describe('npcVisualEditorPersistence', () => {
it('persists merged npc visual overrides and returns the writeback payload', async () => {
const saveJson = vi.fn(async () => undefined);
const result = await persistNpcVisualOverrides({
overrideMap: {
existing: createExistingOverride(),
},
npcId: 'npc-1',
editorState: createEditorState(),
saveJson,
});
expect(saveJson).toHaveBeenCalledWith(
NPC_VISUAL_OVERRIDES_API_PATH,
expect.objectContaining({
existing: createExistingOverride(),
'npc-1': expect.objectContaining({
race: 'human',
bodyFrames: [0, 1, 2, 3],
}),
}),
'保存角色形象覆盖配置失败',
);
expect(result.nextOverrideMap.existing).toEqual(createExistingOverride());
expect(result.nextOverrideMap['npc-1']).toEqual(expect.objectContaining({ race: 'human' }));
expect(result.saveMessage).toContain('npcVisualOverrides.json');
});
it('persists layout config with a cloned payload for local writeback', async () => {
const saveJson = vi.fn(async () => undefined);
const layoutDraft = createLayoutDraft();
const result = await persistNpcLayoutConfig({
layoutDraft,
saveJson,
});
expect(saveJson).toHaveBeenCalledWith(
NPC_LAYOUT_CONFIG_API_PATH,
expect.objectContaining(layoutDraft),
'保存角色布局配置失败',
);
expect(result.nextLayout).toEqual(layoutDraft);
expect(result.nextLayout).not.toBe(layoutDraft);
expect(result.saveMessage).toContain('角色布局');
});
});

View File

@@ -1,65 +0,0 @@
import type { MedievalNpcVisualOverride } from '../data/medievalNpcVisuals';
import {
buildEditorJsonApiPath,
EDITOR_JSON_RESOURCE_IDS,
} from '../editor/shared/editorApiClient';
import { saveJsonObject } from '../editor/shared/jsonClient';
import {
buildNpcVisualSavePayload,
type EditableNpcVisualState,
} from './npcVisualEditorModel';
import { cloneNpcLayoutConfig, type NpcLayoutConfig } from './npcVisualShared';
export const NPC_VISUAL_OVERRIDES_API_PATH = buildEditorJsonApiPath(
EDITOR_JSON_RESOURCE_IDS.npcVisualOverrides,
);
export const NPC_LAYOUT_CONFIG_API_PATH = buildEditorJsonApiPath(
EDITOR_JSON_RESOURCE_IDS.npcLayoutConfig,
);
type SaveEditorJsonFn = typeof saveJsonObject;
export async function persistNpcVisualOverrides(params: {
overrideMap: Record<string, MedievalNpcVisualOverride>;
npcId: string;
editorState: EditableNpcVisualState;
saveJson?: SaveEditorJsonFn;
}) {
const {
overrideMap,
npcId,
editorState,
saveJson = saveJsonObject,
} = params;
const nextOverrideMap = buildNpcVisualSavePayload(overrideMap, npcId, editorState);
await saveJson(
NPC_VISUAL_OVERRIDES_API_PATH,
nextOverrideMap,
'保存角色形象覆盖配置失败',
);
return {
nextOverrideMap,
saveMessage: '已将角色形象覆盖配置保存到 src/data/npcVisualOverrides.json。',
};
}
export async function persistNpcLayoutConfig(params: {
layoutDraft: NpcLayoutConfig;
saveJson?: SaveEditorJsonFn;
}) {
const { layoutDraft, saveJson = saveJsonObject } = params;
const nextLayout = cloneNpcLayoutConfig(layoutDraft);
await saveJson(
NPC_LAYOUT_CONFIG_API_PATH,
nextLayout,
'保存角色布局配置失败',
);
return {
nextLayout,
saveMessage: '已保存共享角色布局配置。',
};
}

View File

@@ -1,4 +0,0 @@
export {
RpgCreationRoleAssetStudioModal,
type RpgCreationRoleAssetStudioModalProps,
} from './RpgCreationRoleAssetStudioModal';

View File

@@ -1 +0,0 @@
export { SectionPanel as default } from './RpgCreationEntityEditorShared';

View File

@@ -1,4 +0,0 @@
export {
RpgCreationEntityEditorModal,
type RpgCreationEntityEditorModalProps,
} from './RpgCreationEntityEditorModal';

View File

@@ -1,4 +0,0 @@
export {
RpgCreationResultView,
type RpgCreationResultViewProps,
} from './RpgCreationResultView';

View File

@@ -17,6 +17,7 @@ import {
upsertRpgWorldProfile,
} from '../../services/rpg-creation';
import type { AuthUser } from '../../services/authService';
import { ApiClientError } from '../../services/apiClient';
import {
clearRpgProfileBrowseHistory as clearProfileBrowseHistory,
deleteRpgEntryWorldProfile,
@@ -517,6 +518,7 @@ beforeEach(() => {
vi.mocked(createRpgCreationSession).mockResolvedValue({
session: mockSession,
});
vi.mocked(getRpgCreationSession).mockResolvedValue(mockSession);
vi.mocked(listRpgCreationWorks).mockResolvedValue([]);
vi.mocked(executeRpgCreationAction).mockResolvedValue({
operation: {
@@ -669,6 +671,67 @@ test('create tab resumes agent workspace when draft has no compiled result yet',
expect(screen.queryByText('世界档案')).toBeNull();
});
test('opening a compiled draft with a missing agent session falls back to create hub', async () => {
const user = userEvent.setup();
vi.mocked(listRpgCreationWorks)
.mockResolvedValueOnce([
{
workId: 'draft:custom-world-agent-session-missing',
sourceType: 'agent_session',
status: 'draft',
title: '潮雾列岛',
subtitle: '世界底稿已生成',
summary: '这是一份已经整理过首版结果页的草稿。',
coverImageSrc: null,
coverRenderMode: 'image',
coverCharacterImageSrcs: [],
updatedAt: '2026-04-20T11:00:00.000Z',
publishedAt: null,
stage: 'object_refining',
stageLabel: '整理关键对象',
playableNpcCount: 1,
landmarkCount: 1,
roleVisualReadyCount: 0,
roleAnimationReadyCount: 0,
roleAssetSummaryLabel: null,
sessionId: 'custom-world-agent-session-missing',
profileId: null,
canResume: true,
canEnterWorld: false,
},
])
.mockResolvedValueOnce([]);
vi.mocked(getRpgCreationSession).mockRejectedValueOnce(
new ApiClientError({
message: 'custom world agent session not found',
status: 404,
code: 'NOT_FOUND',
}),
);
render(<TestWrapper withAuth />);
await openCreationHub(user);
await user.click(screen.getByRole('button', { name: //u }));
await waitFor(() => {
expect(
screen.getByText(
'这份共创草稿已失效,已为你返回创作中心,请重新开始创作。',
),
).toBeTruthy();
});
expect(window.location.search).toBe('');
expect(listRpgCreationWorks).toHaveBeenCalledTimes(2);
expect(screen.getByText('还没有作品')).toBeTruthy();
expect(
screen.queryByText('Agent工作区custom-world-agent-session-missing'),
).toBeNull();
});
test('clicking a public work while logged out routes through requireAuth', async () => {
const user = userEvent.setup();
const requireAuth = vi.fn();

View File

@@ -16,6 +16,7 @@ import {
publishRpgEntryWorldProfile,
unpublishRpgEntryWorldProfile,
} from '../../services/rpg-entry';
import { ApiClientError } from '../../services/apiClient';
import type { CustomWorldProfile } from '../../types';
import {
normalizeRpgEntryAgentBackedProfile,
@@ -79,6 +80,14 @@ type UseRpgEntryLibraryDetailParams = {
markAutoSavedProfile: (profile: CustomWorldProfile) => void;
};
function isMissingRpgEntryAgentSessionError(error: unknown) {
return (
error instanceof ApiClientError &&
error.status === 404 &&
error.code === 'NOT_FOUND'
);
}
/**
* 负责平台详情、创作作品入口和结果页打开路径。
* 平台壳层只消费“打开哪个面板”的结果,不再自己拼接恢复流程细节。
@@ -218,7 +227,6 @@ export function useRpgEntryLibraryDetail(
const handleOpenCreationWork = useCallback(
async (work: CustomWorldWorkSummary) => {
if (work.status === 'draft' && work.sessionId) {
persistAgentUiState(work.sessionId, null);
setCustomWorldError(null);
setCustomWorldAutoSaveError(null);
setCustomWorldAutoSaveState('idle');
@@ -228,33 +236,57 @@ export function useRpgEntryLibraryDetail(
const shouldOpenAgentWorkspace =
work.playableNpcCount <= 0 && work.landmarkCount <= 0;
if (shouldOpenAgentWorkspace) {
// 仅八锚点未整理成底稿时才恢复 Agent 对话工作区。
suppressAgentDraftResultAutoOpen();
setGeneratedCustomWorldProfile(null);
setCustomWorldResultViewSource(null);
try {
if (shouldOpenAgentWorkspace) {
// 仅八锚点未整理成底稿时才恢复 Agent 对话工作区。
suppressAgentDraftResultAutoOpen();
persistAgentUiState(work.sessionId, null);
setGeneratedCustomWorldProfile(null);
setCustomWorldResultViewSource(null);
setPlatformTabToCreate();
setSelectionStage('agent-workspace');
return;
}
releaseAgentDraftResultAutoOpenSuppression();
const latestSession = await syncAgentSessionSnapshot(work.sessionId);
const nextProfile = buildDraftResultProfile(latestSession);
if (!nextProfile) {
persistAgentUiState(work.sessionId, null);
setPlatformError('当前草稿还没有可编辑的结果页数据,请先继续补齐锚点。');
setPlatformTabToCreate();
setSelectionStage('agent-workspace');
return;
}
persistAgentUiState(work.sessionId, null);
setGeneratedCustomWorldProfile(
normalizeRpgEntryAgentBackedProfile(nextProfile),
);
setCustomWorldResultViewSource('agent-draft');
setPlatformTabToCreate();
setSelectionStage('agent-workspace');
setSelectionStage('custom-world-result');
return;
} catch (error) {
if (isMissingRpgEntryAgentSessionError(error)) {
// 失效会话不能继续保留在恢复状态里,否则刷新后会重复命中同一个坏 session。
persistAgentUiState(null, null);
setGeneratedCustomWorldProfile(null);
setCustomWorldResultViewSource(null);
await refreshCustomWorldWorks().catch(() => []);
setPlatformError(
'这份共创草稿已失效,已为你返回创作中心,请重新开始创作。',
);
} else {
setPlatformError(
resolveRpgEntryErrorMessage(error, '读取创作草稿失败。'),
);
}
setPlatformTabToCreate();
setSelectionStage('platform');
return;
}
releaseAgentDraftResultAutoOpenSuppression();
const latestSession = await syncAgentSessionSnapshot(work.sessionId);
const nextProfile = buildDraftResultProfile(latestSession);
if (!nextProfile) {
setPlatformError('当前草稿还没有可编辑的结果页数据,请先继续补齐锚点。');
setPlatformTabToCreate();
setSelectionStage('agent-workspace');
return;
}
setGeneratedCustomWorldProfile(
normalizeRpgEntryAgentBackedProfile(nextProfile),
);
setCustomWorldResultViewSource('agent-draft');
setPlatformTabToCreate();
setSelectionStage('custom-world-result');
return;
}
if (!work.profileId) {